"use client"; import { useState, useMemo, useCallback } from "react"; import Link from "next/link"; import type { Route } from "next"; import { trpc } from "~/lib/trpc/client.js"; // ─── Constants ────────────────────────────────────────────────────────────── const ACTION_BADGES: Record = { CREATE: { label: "Create", className: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400" }, UPDATE: { label: "Update", className: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400" }, DELETE: { label: "Delete", className: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400" }, SHIFT: { label: "Shift", className: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400" }, IMPORT: { label: "Import", className: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400" }, }; const ENTITY_TYPE_OPTIONS = [ "Project", "Resource", "Allocation", "Blueprint", "Vacation", "Role", "Estimate", "EstimateVersion", "ScopeItem", "DemandLine", "Comment", ]; const ACTION_OPTIONS = ["CREATE", "UPDATE", "DELETE", "SHIFT", "IMPORT"]; const ENTITY_LINKS: Record string> = { Project: (id) => `/projects/${id}`, Resource: (id) => `/resources/${id}`, Allocation: (id) => `/allocations?allocationId=${id}`, Blueprint: (_id) => `/admin/blueprints`, Vacation: (_id) => `/vacations`, Role: (_id) => `/roles`, Estimate: (id) => `/estimates/${id}`, }; // ─── Helpers ──────────────────────────────────────────────────────────────── function relativeTime(date: Date): string { const now = new Date(); const diffMs = now.getTime() - new Date(date).getTime(); const diffSec = Math.floor(diffMs / 1000); const diffMin = Math.floor(diffSec / 60); const diffHr = Math.floor(diffMin / 60); const diffDays = Math.floor(diffHr / 24); if (diffSec < 60) return "just now"; if (diffMin < 60) return `${diffMin}m ago`; if (diffHr < 24) return `${diffHr}h ago`; if (diffDays < 7) return `${diffDays}d ago`; return new Date(date).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }); } function userInitials(name: string | null | undefined, email: string): string { if (name) { const parts = name.trim().split(/\s+/); if (parts.length >= 2) return (parts[0]![0]! + parts[parts.length - 1]![0]!).toUpperCase(); return name.slice(0, 2).toUpperCase(); } return email.slice(0, 2).toUpperCase(); } type DiffEntry = { old: unknown; new: unknown }; type Changes = { before?: Record; after?: Record; diff?: Record; metadata?: Record; }; function parseChanges(changes: unknown): Changes { if (!changes || typeof changes !== "object") return {}; return changes as Changes; } function formatValue(val: unknown): string { if (val === null || val === undefined) return "(empty)"; if (typeof val === "boolean") return val ? "Yes" : "No"; if (typeof val === "object") return JSON.stringify(val); return String(val); } // ─── Sub-components ───────────────────────────────────────────────────────── function ActionBadge({ action }: { action: string }) { const badge = ACTION_BADGES[action] ?? { label: action, className: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" }; return ( {badge.label} ); } function DiffView({ changes }: { changes: Changes }) { const diff = changes.diff; if (!diff || Object.keys(diff).length === 0) { return

No field-level diff available.

; } return (
{Object.entries(diff).map(([field, { old: oldVal, new: newVal }]) => (
{field} {formatValue(oldVal)} {formatValue(newVal)}
))}
); } function ExpandedDiff({ entryId }: { entryId: string }) { const { data, isLoading } = trpc.auditLog.getById.useQuery( { id: entryId }, { staleTime: 300_000 }, ); if (isLoading) { return (
); } const changes = parseChanges((data as any)?.changes); return (
); } function SummaryCards({ summary }: { summary: { byEntityType: Record; total: number } }) { const sorted = useMemo(() => { return Object.entries(summary.byEntityType) .sort((a, b) => b[1] - a[1]) .slice(0, 5); }, [summary.byEntityType]); return (

Total (7d)

{summary.total}

{sorted.map(([type, count]) => (

{type}

{count}

))}
); } // ─── Main Component ───────────────────────────────────────────────────────── export function ActivityLogClient() { // Filters const [entityType, setEntityType] = useState(""); const [action, setAction] = useState(""); const [userId, setUserId] = useState(""); const [search, setSearch] = useState(""); const [startDate, setStartDate] = useState(""); const [endDate, setEndDate] = useState(""); // Expanded entry const [expandedId, setExpandedId] = useState(null); // Summary (last 7 days) const sevenDaysAgo = useMemo(() => { const d = new Date(); d.setDate(d.getDate() - 7); return d; }, []); const { data: summary } = trpc.auditLog.getActivitySummary.useQuery( { startDate: sevenDaysAgo }, { staleTime: 60_000 }, ); // Users for filter dropdown type UserListItem = { id: string; name: string | null; email: string }; const { data: users = [] } = trpc.user.list.useQuery(undefined, { staleTime: 300_000 }) as { data: UserListItem[] }; // Build query input const queryInput = useMemo(() => { const input: Record = { limit: 50 }; if (entityType) input.entityType = entityType; if (action) input.action = action; if (userId) input.userId = userId; if (search) input.search = search; if (startDate) input.startDate = new Date(startDate); if (endDate) input.endDate = new Date(endDate + "T23:59:59"); return input; }, [entityType, action, userId, search, startDate, endDate]); type AuditListPage = { items: Array<{ id: string; entityType: string; entityId: string; action: string; changes: unknown; createdAt: Date; source: string | null; entityName: string | null; summary: string | null; user: { id: string; name: string | null; email: string } | null; }>; nextCursor?: string }; const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage, // Keep as any to avoid tRPC TS depth limits with useInfiniteQuery } = (trpc.auditLog.list.useInfiniteQuery as any)( queryInput, { getNextPageParam: (lastPage: AuditListPage) => lastPage.nextCursor ?? undefined, initialCursor: undefined, staleTime: 30_000, }, ) as { data: { pages: AuditListPage[] } | undefined; isLoading: boolean; fetchNextPage: () => void; hasNextPage: boolean; isFetchingNextPage: boolean; }; const allEntries = useMemo(() => { if (!data) return []; return data.pages.flatMap((page) => page.items); }, [data]); const toggleExpand = useCallback((id: string) => { setExpandedId((prev) => (prev === id ? null : id)); }, []); const totalCount = summary?.total ?? 0; return (
{/* Header */}

Activity Log

{totalCount.toLocaleString()} changes recorded in the last 7 days

{/* Summary Cards */} {summary && } {/* Filter Bar */}
setStartDate(e.target.value)} className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-gray-200" />
setEndDate(e.target.value)} className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-gray-200" />
setSearch(e.target.value)} className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-gray-200" />
{/* Timeline List */}
{isLoading && (
)} {!isLoading && allEntries.length === 0 && (

No activity found

Try adjusting your filters or date range.

)} {allEntries.map((entry) => { const isExpanded = expandedId === entry.id; const entityLink = ENTITY_LINKS[entry.entityType]?.(entry.entityId); return (
{/* Expanded Diff — fetched on demand */} {isExpanded && }
); })} {/* Load More */} {hasNextPage && (
)}
); }