Files
edr-platform/apps/edr-freight-web/backoffice/src/shared/pages/audit-log/audit-log-page.tsx
natib21 22cf8eabd1 fix ui
2026-07-14 07:59:17 +00:00

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>
);
}