mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 10:58:14 +00:00
995 lines
37 KiB
TypeScript
995 lines
37 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useMemo, useCallback } from "react";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
CardTitle,
|
|
CardDescription,
|
|
} from "@/shared/common/ui/card";
|
|
import {
|
|
Table,
|
|
TableHeader,
|
|
TableRow,
|
|
TableHead,
|
|
TableBody,
|
|
TableCell,
|
|
} from "@/shared/common/ui/table";
|
|
import { Button } from "@/shared/common/ui/button";
|
|
import { Badge } from "@/shared/common/ui/badge";
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/common/ui/avatar";
|
|
import { Download, RefreshCw, Filter, X, Search } from "lucide-react";
|
|
import { format } from "date-fns";
|
|
import { toast } from "sonner";
|
|
|
|
import { ActivityCard } from "@/shared/components/activity-log/activity-card";
|
|
import { ActivityFilters } from "@/shared/components/activity-log/activity-filters";
|
|
import { ActivityStats } from "@/shared/components/activity-log/activity-stats";
|
|
import {
|
|
ExportModal,
|
|
ExportOptions,
|
|
} from "@/shared/components/activity-log/export-modal";
|
|
|
|
import { listAuditLogCommandsByModule } from "@/shared/services/audit/audit.api";
|
|
import { cn } from "@/shared/lib/utils";
|
|
import { useTranslation } from "react-i18next";
|
|
export interface ActivityLog {
|
|
id: string;
|
|
action: string;
|
|
description: string;
|
|
performedBy: {
|
|
name: string;
|
|
email: string;
|
|
avatar?: string;
|
|
department?: string;
|
|
};
|
|
timestamp: string;
|
|
module: string;
|
|
resourceId?: string;
|
|
resourceName?: string;
|
|
status: "success" | "failure" | "pending" | "warning";
|
|
details?: string;
|
|
ipAddress?: string;
|
|
location?: string;
|
|
severity: "low" | "medium" | "high" | "critical";
|
|
}
|
|
|
|
type AuditLogPageProps = {
|
|
moduleKey: string;
|
|
title?: string;
|
|
subtitle?: string;
|
|
initialPage?: number;
|
|
limit?: number;
|
|
unitId?: string;
|
|
organizationId?: string;
|
|
};
|
|
|
|
function safeString(v: unknown) {
|
|
if (v === null || v === undefined) return "";
|
|
if (typeof v === "string") return v;
|
|
try {
|
|
return JSON.stringify(v);
|
|
} catch {
|
|
return String(v);
|
|
}
|
|
}
|
|
|
|
function pickFileInfoFromChanges(
|
|
changes?: Array<{ field?: string; to?: any; from?: any }>,
|
|
) {
|
|
if (!changes?.length) return undefined;
|
|
const fileInfoChange = changes.find((c) => c?.field === "fileInfo");
|
|
return fileInfoChange?.to || fileInfoChange?.from;
|
|
}
|
|
|
|
function pickResourceNameFromChanges(
|
|
changes?: Array<{ field?: string; to?: any; from?: any }>,
|
|
) {
|
|
const fileInfo = pickFileInfoFromChanges(changes);
|
|
const fileName =
|
|
fileInfo?.originalname || fileInfo?.fileName || fileInfo?.contentType;
|
|
return fileName ? String(fileName) : undefined;
|
|
}
|
|
|
|
function pickPerformedByFromChanges(
|
|
changes?: Array<{ field?: string; to?: any; from?: any }> | null,
|
|
auditLog?: any,
|
|
): ActivityLog["performedBy"] {
|
|
const SYSTEM: ActivityLog["performedBy"] = {
|
|
name: "System",
|
|
email: "system@audit",
|
|
};
|
|
|
|
const au = auditLog?.user;
|
|
if (au) {
|
|
const email = typeof au.email === "string" ? au.email : undefined;
|
|
const name =
|
|
typeof au.name === "string"
|
|
? au.name
|
|
: typeof au.name?.en === "string"
|
|
? au.name.en
|
|
: typeof au.name?.am === "string"
|
|
? au.name.am
|
|
: undefined;
|
|
|
|
if (email || name) {
|
|
return {
|
|
name: name || (email ? email.split("@")[0] : SYSTEM.name),
|
|
email: email || SYSTEM.email,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (!changes?.length) return SYSTEM;
|
|
|
|
const userInfoChange = changes.find((c) => c?.field === "userInfo");
|
|
const u = userInfoChange?.to || userInfoChange?.from;
|
|
|
|
const email = typeof u?.email === "string" ? u.email : undefined;
|
|
const name =
|
|
typeof u?.name === "string"
|
|
? u.name
|
|
: typeof u?.name?.en === "string"
|
|
? u.name.en
|
|
: typeof u?.name?.am === "string"
|
|
? u.name.am
|
|
: undefined;
|
|
|
|
return {
|
|
name: name || (email ? email.split("@")[0] : SYSTEM.name),
|
|
email: email || SYSTEM.email,
|
|
};
|
|
}
|
|
function mapQueryMethodToStatus(_method?: string): ActivityLog["status"] {
|
|
return "success";
|
|
}
|
|
|
|
function mapQueryMethodToSeverity(method?: string): ActivityLog["severity"] {
|
|
switch ((method || "").toUpperCase()) {
|
|
case "DELETE":
|
|
return "high";
|
|
case "INSERT":
|
|
return "medium";
|
|
case "UPDATE":
|
|
return "low";
|
|
default:
|
|
return "low";
|
|
}
|
|
}
|
|
|
|
function buildDescription(entityName?: string, queryMethod?: string) {
|
|
const e = entityName || "entity";
|
|
const m = (queryMethod || "ACTION").toUpperCase();
|
|
if (m === "UPDATE") return `Updated ${e}`;
|
|
if (m === "INSERT") return `Created ${e}`;
|
|
if (m === "DELETE") return `Deleted ${e}`;
|
|
return `${m} on ${e}`;
|
|
}
|
|
|
|
function buildDetails(
|
|
changes?: Array<{ field?: string; from?: any; to?: any }>,
|
|
) {
|
|
if (!changes?.length) return undefined;
|
|
|
|
const lines = changes.slice(0, 8).map((c) => {
|
|
const field = c?.field || "field";
|
|
|
|
if (field === "userInfo") {
|
|
const fromEmail = c?.from?.email ? String(c.from.email) : "—";
|
|
const toEmail = c?.to?.email ? String(c.to.email) : "—";
|
|
const fromName =
|
|
c?.from?.name?.en || c?.from?.name?.am || c?.from?.name || "—";
|
|
const toName = c?.to?.name?.en || c?.to?.name?.am || c?.to?.name || "—";
|
|
return `userInfo: ${fromName} (${fromEmail}) → ${toName} (${toEmail})`;
|
|
}
|
|
|
|
const from = c?.from === null ? "null" : safeString(c?.from);
|
|
const to = c?.to === null ? "null" : safeString(c?.to);
|
|
return `${field}: ${from} → ${to}`;
|
|
});
|
|
|
|
const more = changes.length > 8 ? `\n... +${changes.length - 8} more` : "";
|
|
return lines.join("\n") + more;
|
|
}
|
|
|
|
export default function AuditLogPageShared({
|
|
moduleKey,
|
|
title = "Activity Log",
|
|
subtitle = "Monitor and track audit events across the platform",
|
|
initialPage = 1,
|
|
limit = 10,
|
|
unitId,
|
|
organizationId,
|
|
}: AuditLogPageProps) {
|
|
const pageBg = "bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100";
|
|
const surface = "bg-white dark:bg-gray-900";
|
|
const surfaceSoft = "bg-gray-50 dark:bg-gray-950";
|
|
const border = "border-gray-200 dark:border-gray-800";
|
|
const borderDashed = "border-gray-200 dark:border-gray-800";
|
|
const textMuted = "text-gray-600 dark:text-gray-300";
|
|
const textSubtle = "text-gray-500 dark:text-gray-400";
|
|
const textStrong = "text-gray-900 dark:text-gray-100";
|
|
const hoverSoft = "hover:bg-gray-100 dark:hover:bg-gray-800";
|
|
const primaryBtn =
|
|
"bg-gray-900 hover:bg-gray-800 dark:bg-gray-100 dark:hover:bg-gray-200 text-white dark:text-gray-900";
|
|
const primaryIcon = "text-gray-900 dark:text-gray-100";
|
|
|
|
const getSeverityColor = (severity: string) => {
|
|
switch (severity) {
|
|
case "critical":
|
|
return "bg-red-100 text-red-800 border-red-300 dark:bg-red-950 dark:text-red-300 dark:border-red-800";
|
|
case "high":
|
|
return "bg-orange-100 text-orange-800 border-orange-300 dark:bg-orange-950 dark:text-orange-300 dark:border-orange-800";
|
|
case "medium":
|
|
return "bg-yellow-100 text-yellow-800 border-yellow-300 dark:bg-yellow-950 dark:text-yellow-300 dark:border-yellow-800";
|
|
case "low":
|
|
return "bg-green-100 text-green-800 border-green-300 dark:bg-green-950 dark:text-green-300 dark:border-green-800";
|
|
default:
|
|
return "bg-gray-100 text-gray-800 border-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700";
|
|
}
|
|
};
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case "success":
|
|
return "bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-300";
|
|
case "failure":
|
|
return "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300";
|
|
case "warning":
|
|
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-950 dark:text-yellow-300";
|
|
case "pending":
|
|
return "bg-primary-100 text-primary-800 dark:bg-primary-950 dark:text-primary-300";
|
|
default:
|
|
return "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300";
|
|
}
|
|
};
|
|
|
|
const { t } = useTranslation();
|
|
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [moduleFilter, setModuleFilter] = useState("all");
|
|
const [actionFilter, setActionFilter] = useState("all");
|
|
const [statusFilter, setStatusFilter] = useState("all");
|
|
const [severityFilter, setSeverityFilter] = useState("all");
|
|
const [dateRange, setDateRange] = useState<{ from?: Date; to?: Date }>({});
|
|
const [viewMode, setViewMode] = useState<"card" | "table">("card");
|
|
const [exportModalOpen, setExportModalOpen] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [filtersOpenMobile, setFiltersOpenMobile] = useState(false);
|
|
|
|
const [allLogs, setAllLogs] = useState<ActivityLog[]>([]);
|
|
const [filteredLogs, setFilteredLogs] = useState<ActivityLog[]>([]);
|
|
const [totalCount, setTotalCount] = useState(0);
|
|
const [currentPage, setCurrentPage] = useState(initialPage);
|
|
|
|
const fetchAuditLogs = useCallback(async () => {
|
|
if (!moduleKey) {
|
|
toast.error(t("auditLog.page.fetchKeyError"));
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const res = await listAuditLogCommandsByModule(
|
|
moduleKey,
|
|
{
|
|
skip: (currentPage - 1) * limit,
|
|
take: limit,
|
|
},
|
|
{
|
|
unitId,
|
|
organizationId,
|
|
},
|
|
);
|
|
|
|
const items = res?.items || [];
|
|
|
|
const mapped: ActivityLog[] = items.map((it: any) => {
|
|
const entityName = it?.entityName ? String(it.entityName) : moduleKey;
|
|
const queryMethod = it?.queryMethod ? String(it.queryMethod) : "UPDATE";
|
|
const changes = Array.isArray(it?.changes) ? it.changes : [];
|
|
|
|
return {
|
|
id: it?.id ? String(it.id) : crypto.randomUUID(),
|
|
action: queryMethod.toUpperCase(),
|
|
description: buildDescription(entityName, queryMethod),
|
|
performedBy: pickPerformedByFromChanges(changes, it?.auditLog),
|
|
timestamp: it?.createdAt
|
|
? String(it.createdAt)
|
|
: new Date().toISOString(),
|
|
module: entityName,
|
|
resourceName: pickResourceNameFromChanges(changes),
|
|
status: mapQueryMethodToStatus(queryMethod),
|
|
details: buildDetails(changes),
|
|
severity: mapQueryMethodToSeverity(queryMethod),
|
|
};
|
|
});
|
|
|
|
mapped.sort(
|
|
(a, b) =>
|
|
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
|
|
);
|
|
|
|
setAllLogs(mapped);
|
|
setTotalCount(res?.count || 0);
|
|
toast.success(t("auditLog.page.fetchSuccess"));
|
|
} catch (e: any) {
|
|
console.error(e);
|
|
toast.error(t("auditLog.page.fetchError"), {
|
|
description: e?.message || t("auditLog.page.fetchError"),
|
|
duration: 5000,
|
|
});
|
|
setAllLogs([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [moduleKey, currentPage, limit, unitId, organizationId, t]);
|
|
|
|
useEffect(() => {
|
|
fetchAuditLogs();
|
|
}, [fetchAuditLogs]);
|
|
|
|
const modules = useMemo(
|
|
() => Array.from(new Set(allLogs.map((log) => log.module))).sort(),
|
|
[allLogs],
|
|
);
|
|
|
|
const actions = useMemo(
|
|
() => Array.from(new Set(allLogs.map((log) => log.action))).sort(),
|
|
[allLogs],
|
|
);
|
|
|
|
const stats = useMemo(() => {
|
|
const totalActivities = filteredLogs.length;
|
|
const successCount = filteredLogs.filter(
|
|
(log) => log.status === "success",
|
|
).length;
|
|
const successRate =
|
|
totalActivities > 0
|
|
? Math.round((successCount / totalActivities) * 100)
|
|
: 0;
|
|
const uniqueUsers = new Set(
|
|
filteredLogs.map((log) => log.performedBy.email),
|
|
).size;
|
|
const avgDailyActivities = Math.round(totalActivities / 30);
|
|
|
|
const actionCounts: Record<string, number> = {};
|
|
filteredLogs.forEach((log) => {
|
|
actionCounts[log.action] = (actionCounts[log.action] || 0) + 1;
|
|
});
|
|
const topActions = Object.entries(actionCounts)
|
|
.map(([action, count]) => ({ action, count }))
|
|
.sort((a, b) => b.count - a.count)
|
|
.slice(0, 5);
|
|
|
|
return {
|
|
totalActivities,
|
|
successRate,
|
|
avgDailyActivities,
|
|
uniqueUsers,
|
|
topActions,
|
|
};
|
|
}, [filteredLogs]);
|
|
|
|
const activeFiltersCount = useMemo(() => {
|
|
let n = 0;
|
|
if (searchQuery.trim()) n++;
|
|
if (moduleFilter !== "all") n++;
|
|
if (actionFilter !== "all") n++;
|
|
if (statusFilter !== "all") n++;
|
|
if (severityFilter !== "all") n++;
|
|
if (dateRange.from || dateRange.to) n++;
|
|
return n;
|
|
}, [
|
|
searchQuery,
|
|
moduleFilter,
|
|
actionFilter,
|
|
statusFilter,
|
|
severityFilter,
|
|
dateRange,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
setFilteredLogs(allLogs);
|
|
}, [allLogs]);
|
|
|
|
const clearFilters = useCallback(() => {
|
|
setSearchQuery("");
|
|
setModuleFilter("all");
|
|
setActionFilter("all");
|
|
setStatusFilter("all");
|
|
setSeverityFilter("all");
|
|
setDateRange({});
|
|
}, []);
|
|
|
|
const handleExport = (options: ExportOptions) => {
|
|
toast.success(
|
|
t("auditLog.export.success", {
|
|
count: filteredLogs.length,
|
|
format: options.format.toUpperCase(),
|
|
}),
|
|
);
|
|
|
|
const data = {
|
|
logs: filteredLogs,
|
|
exportOptions: options,
|
|
exportedAt: new Date().toISOString(),
|
|
};
|
|
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
|
type: `application/${options.format === "json" ? "json" : "octet-stream"}`,
|
|
});
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `${options.fileName}.${options.format}`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const MobileRow = ({ log }: { log: ActivityLog }) => {
|
|
return (
|
|
<div className={cn("rounded-lg border p-4 shadow-sm", border, surface)}>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline" className="font-medium">
|
|
{log.action}
|
|
</Badge>
|
|
<Badge
|
|
variant="outline"
|
|
className={cn(
|
|
"text-xs font-medium",
|
|
getSeverityColor(log.severity),
|
|
)}>
|
|
{t(`auditLog.severity.${log.severity}`)}
|
|
</Badge>
|
|
<Badge
|
|
className={cn(
|
|
"text-xs font-medium",
|
|
getStatusColor(log.status),
|
|
)}>
|
|
{t(`auditLog.status.${log.status}`)}
|
|
</Badge>
|
|
</div>
|
|
|
|
<div className={cn("mt-2 font-medium truncate", textStrong)}>
|
|
{log.description}
|
|
</div>
|
|
|
|
{log.resourceName ? (
|
|
<div className={cn("mt-1 text-sm truncate", textMuted)}>
|
|
{log.resourceName}
|
|
</div>
|
|
) : null}
|
|
|
|
<div
|
|
className={cn(
|
|
"mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs",
|
|
textSubtle,
|
|
)}>
|
|
<span className="whitespace-nowrap">
|
|
{format(new Date(log.timestamp), "MMM d, yyyy")}
|
|
</span>
|
|
<span className="whitespace-nowrap">
|
|
{format(new Date(log.timestamp), "HH:mm:ss")}
|
|
</span>
|
|
<span className="inline-flex items-center gap-2 min-w-0">
|
|
<span className={cn(textSubtle)}>•</span>
|
|
<Badge variant="secondary" className="truncate max-w-[160px]">
|
|
{log.module}
|
|
</Badge>
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mt-3 flex items-center gap-3 min-w-0">
|
|
<Avatar className="h-8 w-8 shrink-0">
|
|
{log.performedBy.avatar ? (
|
|
<AvatarImage src={log.performedBy.avatar} />
|
|
) : null}
|
|
<AvatarFallback>
|
|
{log.performedBy.name.charAt(0).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="min-w-0">
|
|
<div className={cn("text-sm font-medium truncate", textStrong)}>
|
|
{log.performedBy.name === "System"
|
|
? t("auditLog.page.systemUser")
|
|
: log.performedBy.name}
|
|
</div>
|
|
<div className={cn("text-xs truncate", textSubtle)}>
|
|
{log.performedBy.email}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-9 w-9 p-0 shrink-0"
|
|
onClick={() => {
|
|
toast.message(`Audit ID: ${log.id}`, {
|
|
description: log.details || log.description,
|
|
});
|
|
}}
|
|
aria-label={t("auditLog.page.viewDetails")}
|
|
title={t("auditLog.page.viewDetails")}>
|
|
•••
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className={cn("min-h-screen px-3 py-4 sm:p-4 md:p-6", pageBg)}>
|
|
<div className="max-w-7xl mx-auto space-y-6">
|
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
|
<div className="min-w-0">
|
|
<h1
|
|
className={cn(
|
|
"text-2xl sm:text-3xl font-bold truncate",
|
|
textStrong,
|
|
)}>
|
|
{title}
|
|
</h1>
|
|
<p className={cn("mt-1 text-sm sm:text-base", textMuted)}>
|
|
{subtitle}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3">
|
|
<Button
|
|
variant="outline"
|
|
onClick={fetchAuditLogs}
|
|
disabled={loading}
|
|
className="flex items-center justify-center gap-2 w-full sm:w-auto">
|
|
<RefreshCw
|
|
className={cn("h-4 w-4", loading ? "animate-spin" : "")}
|
|
/>
|
|
{t("auditLog.page.refresh")}
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={() => setExportModalOpen(true)}
|
|
className="flex items-center justify-center gap-2 w-full sm:w-auto"
|
|
disabled={filteredLogs.length === 0}>
|
|
<Download className="h-4 w-4" />
|
|
{t("auditLog.page.export")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<ActivityStats {...stats} />
|
|
|
|
<Card className={cn("border shadow-sm", border, surface)}>
|
|
<CardHeader className="pb-4">
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
<div className="min-w-0">
|
|
<CardTitle className={cn("text-lg font-semibold", textStrong)}>
|
|
{t("auditLog.page.activeLogs")}
|
|
</CardTitle>
|
|
|
|
<CardDescription className={cn("text-sm", textMuted)}>
|
|
{t("auditLog.page.activitiesFound", {
|
|
count: filteredLogs.length,
|
|
})}
|
|
{dateRange.from && (
|
|
<span className="ml-2">
|
|
• {format(dateRange.from, "MMM d, yyyy")}
|
|
{dateRange.to &&
|
|
` - ${format(dateRange.to, "MMM d, yyyy")}`}
|
|
</span>
|
|
)}
|
|
</CardDescription>
|
|
</div>
|
|
|
|
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-end lg:w-auto">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setFiltersOpenMobile((v) => !v)}
|
|
className="flex w-full items-center justify-center gap-2 sm:w-auto lg:hidden">
|
|
<Filter className="h-4 w-4" />
|
|
{t("auditLog.filters.title")}
|
|
{activeFiltersCount ? (
|
|
<span className="ml-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-gray-900 px-1.5 text-[11px] font-semibold text-white dark:bg-gray-100 dark:text-gray-900">
|
|
{activeFiltersCount}
|
|
</span>
|
|
) : null}
|
|
</Button>
|
|
|
|
<div className="grid w-full grid-cols-2 gap-2 sm:flex sm:w-auto sm:items-center">
|
|
<Button
|
|
variant={viewMode === "card" ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setViewMode("card")}
|
|
className={cn(
|
|
"flex w-full items-center justify-center gap-2 sm:w-auto",
|
|
viewMode === "card" && primaryBtn,
|
|
)}>
|
|
<span className="hidden sm:inline">
|
|
{t("auditLog.page.cardView")}
|
|
</span>
|
|
<span className="sm:hidden">
|
|
{t("auditLog.page.cards")}
|
|
</span>
|
|
</Button>
|
|
|
|
<Button
|
|
variant={viewMode === "table" ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setViewMode("table")}
|
|
className={cn(
|
|
"flex w-full items-center justify-center gap-2 sm:w-auto",
|
|
viewMode === "table" && primaryBtn,
|
|
)}>
|
|
<span className="hidden sm:inline">
|
|
{t("auditLog.page.tableView")}
|
|
</span>
|
|
<span className="sm:hidden">
|
|
{t("auditLog.page.table")}
|
|
</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent>
|
|
<div className="hidden lg:block">
|
|
<ActivityFilters
|
|
searchQuery={searchQuery}
|
|
onSearchChange={setSearchQuery}
|
|
moduleFilter={moduleFilter}
|
|
onModuleChange={setModuleFilter}
|
|
actionFilter={actionFilter}
|
|
onActionChange={setActionFilter}
|
|
statusFilter={statusFilter}
|
|
onStatusChange={setStatusFilter}
|
|
dateRange={dateRange}
|
|
onDateRangeChange={setDateRange}
|
|
modules={modules}
|
|
actions={actions}
|
|
onClearFilters={clearFilters}
|
|
/>
|
|
</div>
|
|
|
|
<div className="lg:hidden">
|
|
<div className="mb-3">
|
|
<div className="relative">
|
|
<Search
|
|
className={cn(
|
|
"pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2",
|
|
textSubtle,
|
|
)}
|
|
/>
|
|
<input
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder={t("auditLog.page.searchActivity")}
|
|
className={cn(
|
|
"w-full h-10 rounded-md border pl-9 pr-10 text-sm outline-none",
|
|
border,
|
|
surface,
|
|
textStrong,
|
|
"placeholder:text-gray-400 dark:placeholder:text-gray-500",
|
|
"focus:ring-2 focus:ring-gray-900/10 dark:focus:ring-gray-100/10 focus:border-gray-300 dark:focus:border-gray-700",
|
|
)}
|
|
/>
|
|
{searchQuery ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => setSearchQuery("")}
|
|
className={cn(
|
|
"absolute right-2 top-1/2 -translate-y-1/2 rounded p-1",
|
|
textSubtle,
|
|
hoverSoft,
|
|
)}
|
|
aria-label={t("auditLog.filters.clear")}>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{filtersOpenMobile ? (
|
|
<div
|
|
className={cn("mb-4 rounded-lg border p-3", border, surface)}>
|
|
<ActivityFilters
|
|
searchQuery={searchQuery}
|
|
onSearchChange={setSearchQuery}
|
|
moduleFilter={moduleFilter}
|
|
onModuleChange={setModuleFilter}
|
|
actionFilter={actionFilter}
|
|
onActionChange={setActionFilter}
|
|
statusFilter={statusFilter}
|
|
onStatusChange={setStatusFilter}
|
|
dateRange={dateRange}
|
|
onDateRangeChange={setDateRange}
|
|
modules={modules}
|
|
actions={actions}
|
|
onClearFilters={clearFilters}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
|
|
{activeFiltersCount ? (
|
|
<div
|
|
className={cn(
|
|
"mb-3 flex flex-col gap-2 text-sm sm:flex-row sm:items-center sm:justify-between",
|
|
textMuted,
|
|
)}>
|
|
<div className="truncate">
|
|
<span className="font-semibold">{activeFiltersCount}</span>{" "}
|
|
{t("auditLog.page.activeFilter", {
|
|
count: activeFiltersCount,
|
|
})}
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={clearFilters}
|
|
className="h-8 w-full sm:w-auto">
|
|
{t("auditLog.page.clear")}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="text-center">
|
|
<RefreshCw
|
|
className={cn("h-8 w-8 animate-spin mx-auto", textSubtle)}
|
|
/>
|
|
<p className={cn("mt-2", textMuted)}>
|
|
{t("auditLog.page.loadingAc")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : filteredLogs.length === 0 ? (
|
|
<div
|
|
className={cn(
|
|
"text-center py-12 border-2 border-dashed rounded-lg",
|
|
borderDashed,
|
|
surface,
|
|
)}>
|
|
<h3 className={cn("text-lg font-semibold mb-2", textStrong)}>
|
|
{t("auditLog.page.noActivities")}
|
|
</h3>
|
|
<p className={cn("max-w-md mx-auto", textMuted)}>
|
|
{t("auditLog.page.tryAdjusting")}
|
|
</p>
|
|
<Button
|
|
variant="outline"
|
|
onClick={clearFilters}
|
|
className="mt-4">
|
|
{t("auditLog.filters.clear")}
|
|
</Button>
|
|
</div>
|
|
) : viewMode === "card" ? (
|
|
<div className="mt-6 space-y-4">
|
|
{filteredLogs.map((log) => (
|
|
<ActivityCard key={log.id} activity={log} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="mt-6">
|
|
<div className="space-y-3 md:hidden">
|
|
{filteredLogs.map((log) => (
|
|
<MobileRow key={log.id} log={log} />
|
|
))}
|
|
</div>
|
|
|
|
<div
|
|
className={cn(
|
|
"hidden md:block rounded-lg border overflow-hidden",
|
|
border,
|
|
surface,
|
|
)}>
|
|
<div className="w-full overflow-x-auto">
|
|
<Table className="min-w-[900px] lg:min-w-[1100px]">
|
|
<TableHeader className={surfaceSoft}>
|
|
<TableRow>
|
|
<TableHead className="w-[180px]">
|
|
{t("auditLog.table.time")}
|
|
</TableHead>
|
|
<TableHead>{t("auditLog.table.user")}</TableHead>
|
|
<TableHead>{t("auditLog.table.action")}</TableHead>
|
|
<TableHead className="w-[300px]">
|
|
{t("auditLog.table.description")}
|
|
</TableHead>
|
|
<TableHead>{t("auditLog.table.module")}</TableHead>
|
|
<TableHead>{t("auditLog.table.severity")}</TableHead>
|
|
<TableHead>{t("auditLog.table.status")}</TableHead>
|
|
<TableHead className="w-[72px]" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
|
|
<TableBody>
|
|
{filteredLogs.map((log) => (
|
|
<TableRow key={log.id} className={hoverSoft}>
|
|
<TableCell className="font-medium">
|
|
<div className="flex flex-col">
|
|
<span className={cn("text-sm", textStrong)}>
|
|
{format(
|
|
new Date(log.timestamp),
|
|
"MMM d, yyyy",
|
|
)}
|
|
</span>
|
|
<span className={cn("text-xs", textSubtle)}>
|
|
{format(new Date(log.timestamp), "HH:mm:ss")}
|
|
</span>
|
|
</div>
|
|
</TableCell>
|
|
|
|
<TableCell>
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<Avatar className="h-8 w-8 shrink-0">
|
|
{log.performedBy.avatar ? (
|
|
<AvatarImage src={log.performedBy.avatar} />
|
|
) : null}
|
|
<AvatarFallback>
|
|
{log.performedBy.name
|
|
.charAt(0)
|
|
.toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
|
|
<div className="min-w-0">
|
|
<div
|
|
className={cn(
|
|
"font-medium truncate max-w-[260px] xl:max-w-[320px]",
|
|
textStrong,
|
|
)}>
|
|
{log.performedBy.name === "System"
|
|
? t("auditLog.page.systemUser")
|
|
: log.performedBy.name}
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
"text-xs truncate max-w-[260px] xl:max-w-[320px]",
|
|
textSubtle,
|
|
)}>
|
|
{log.performedBy.email}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
|
|
<TableCell>
|
|
<Badge variant="outline" className="font-medium">
|
|
{log.action}
|
|
</Badge>
|
|
</TableCell>
|
|
|
|
<TableCell className="hidden lg:table-cell">
|
|
<div className="min-w-0">
|
|
<div
|
|
className={cn(
|
|
"font-medium truncate",
|
|
textStrong,
|
|
)}>
|
|
{log.description}
|
|
</div>
|
|
{log.resourceName ? (
|
|
<div
|
|
className={cn(
|
|
"text-sm mt-1 truncate",
|
|
textMuted,
|
|
)}>
|
|
{log.resourceName}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</TableCell>
|
|
|
|
<TableCell className="hidden lg:table-cell">
|
|
<Badge
|
|
variant="secondary"
|
|
className="truncate max-w-[180px]"
|
|
title={log.module}>
|
|
{log.module}
|
|
</Badge>
|
|
</TableCell>
|
|
|
|
<TableCell>
|
|
<Badge
|
|
variant="outline"
|
|
className={cn(
|
|
"text-xs font-medium",
|
|
getSeverityColor(log.severity),
|
|
)}>
|
|
{t(`auditLog.severity.${log.severity}`)}
|
|
</Badge>
|
|
</TableCell>
|
|
|
|
<TableCell>
|
|
<Badge
|
|
className={cn(
|
|
"text-xs font-medium",
|
|
getStatusColor(log.status),
|
|
)}>
|
|
{t(`auditLog.status.${log.status}`)}
|
|
</Badge>
|
|
</TableCell>
|
|
|
|
<TableCell>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 p-0"
|
|
onClick={() => {
|
|
toast.message(`Audit ID: ${log.id}`, {
|
|
description: log.details || log.description,
|
|
});
|
|
}}
|
|
aria-label={t("auditLog.page.viewDetails")}
|
|
title={t("auditLog.page.viewDetails")}>
|
|
•••
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{filteredLogs.length > 0 ? (
|
|
<div
|
|
className={cn(
|
|
"mt-6 flex flex-col gap-3 text-sm sm:flex-row sm:items-center sm:justify-between",
|
|
textMuted,
|
|
)}>
|
|
<div>
|
|
{t("auditLog.page.showing")}{" "}
|
|
<span className="font-semibold">{filteredLogs.length}</span>{" "}
|
|
{t("auditLog.page.of")}{" "}
|
|
<span className="font-semibold">{totalCount}</span>{" "}
|
|
{t("auditLog.page.totalActivities")}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2 sm:flex sm:items-center sm:gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={currentPage === 1}
|
|
onClick={() => setCurrentPage(currentPage - 1)}
|
|
className="w-full sm:w-auto">
|
|
{t("auditLog.page.prev")}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={filteredLogs.length < limit}
|
|
onClick={() => setCurrentPage(currentPage + 1)}
|
|
className="w-full sm:w-auto">
|
|
{t("auditLog.page.next")}
|
|
</Button>
|
|
</div>
|
|
<div className={cn("text-sm", textMuted)}>
|
|
Page {currentPage}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<ExportModal
|
|
open={exportModalOpen}
|
|
onOpenChange={setExportModalOpen}
|
|
onExport={handleExport}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|