123 lines
3.9 KiB
TypeScript
123 lines
3.9 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useEffect, useState } from 'react';
|
||
|
|
import { useQuery } from '@tanstack/react-query';
|
||
|
|
import { formatDistanceToNow } from 'date-fns';
|
||
|
|
|
||
|
|
import { apiFetch } from '@/lib/api/client';
|
||
|
|
|
||
|
|
interface AuditRow {
|
||
|
|
id: string;
|
||
|
|
action: string;
|
||
|
|
entityType: string;
|
||
|
|
entityId: string | null;
|
||
|
|
fieldChanged: string | null;
|
||
|
|
oldValue: unknown;
|
||
|
|
newValue: unknown;
|
||
|
|
metadata: Record<string, unknown> | null;
|
||
|
|
createdAt: string;
|
||
|
|
actor: { id: string; email: string; name: string | null } | null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const ACTION_LABELS: Record<string, string> = {
|
||
|
|
create: 'Created',
|
||
|
|
update: 'Updated',
|
||
|
|
delete: 'Deleted',
|
||
|
|
archive: 'Archived',
|
||
|
|
restore: 'Restored',
|
||
|
|
merge: 'Merged',
|
||
|
|
revert: 'Reverted',
|
||
|
|
};
|
||
|
|
|
||
|
|
function formatAction(action: string): string {
|
||
|
|
return ACTION_LABELS[action] ?? action.charAt(0).toUpperCase() + action.slice(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatField(field: string | null): string | null {
|
||
|
|
if (!field) return null;
|
||
|
|
return field.replace(/_/g, ' ');
|
||
|
|
}
|
||
|
|
|
||
|
|
function summarize(row: AuditRow): string {
|
||
|
|
const verb = formatAction(row.action);
|
||
|
|
const field = formatField(row.fieldChanged);
|
||
|
|
if (field) return `${verb} ${field}`;
|
||
|
|
return verb;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface Props {
|
||
|
|
endpoint: string; // e.g. /api/v1/clients/{id}/activity
|
||
|
|
emptyText?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function EntityActivityFeed({ endpoint, emptyText = 'No activity yet.' }: Props) {
|
||
|
|
const [now, setNow] = useState(() => Date.now());
|
||
|
|
|
||
|
|
// Re-render every minute so "x minutes ago" stays accurate without a
|
||
|
|
// refetch.
|
||
|
|
useEffect(() => {
|
||
|
|
const t = setInterval(() => setNow(Date.now()), 60_000);
|
||
|
|
return () => clearInterval(t);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const { data, isLoading, error } = useQuery({
|
||
|
|
queryKey: ['entity-activity', endpoint],
|
||
|
|
queryFn: () => apiFetch<{ data: AuditRow[] }>(endpoint),
|
||
|
|
staleTime: 30_000,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (isLoading) {
|
||
|
|
return <div className="text-sm text-muted-foreground py-6">Loading activity…</div>;
|
||
|
|
}
|
||
|
|
if (error) {
|
||
|
|
return (
|
||
|
|
<div className="text-sm text-red-600 py-6">
|
||
|
|
Failed to load activity: {error instanceof Error ? error.message : 'unknown error'}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const rows = data?.data ?? [];
|
||
|
|
if (rows.length === 0) {
|
||
|
|
return <div className="text-sm text-muted-foreground py-6">{emptyText}</div>;
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<ol className="relative border-l border-muted-foreground/20 ml-3 pl-6 space-y-4 py-2">
|
||
|
|
{rows.map((row) => {
|
||
|
|
const created = new Date(row.createdAt);
|
||
|
|
const ago = formatDistanceToNow(created, { addSuffix: true });
|
||
|
|
// touch `now` so the closure participates in the minute re-render
|
||
|
|
void now;
|
||
|
|
const actor = row.actor?.name || row.actor?.email || 'System';
|
||
|
|
return (
|
||
|
|
<li key={row.id} className="relative">
|
||
|
|
<span className="absolute -left-[31px] top-1 h-2.5 w-2.5 rounded-full bg-primary/70 ring-2 ring-background" />
|
||
|
|
<div className="text-sm">
|
||
|
|
<span className="font-medium">{summarize(row)}</span>
|
||
|
|
<span className="text-muted-foreground"> · {actor}</span>
|
||
|
|
</div>
|
||
|
|
<div className="text-xs text-muted-foreground" title={created.toISOString()}>
|
||
|
|
{ago}
|
||
|
|
</div>
|
||
|
|
{row.fieldChanged && (row.oldValue !== null || row.newValue !== null) ? (
|
||
|
|
<div className="mt-1 text-xs space-x-2">
|
||
|
|
{row.oldValue !== null && row.oldValue !== undefined ? (
|
||
|
|
<span className="line-through text-muted-foreground">
|
||
|
|
{String(JSON.stringify(row.oldValue)).slice(0, 80)}
|
||
|
|
</span>
|
||
|
|
) : null}
|
||
|
|
{row.newValue !== null && row.newValue !== undefined ? (
|
||
|
|
<span className="text-foreground">
|
||
|
|
→ {String(JSON.stringify(row.newValue)).slice(0, 80)}
|
||
|
|
</span>
|
||
|
|
) : null}
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
</li>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</ol>
|
||
|
|
);
|
||
|
|
}
|