feat(activity): per-entity Activity timeline (clients/yachts/companies/berths)
Until now only the global /admin/audit page surfaced audit_logs. Each
entity detail page either lacked the Activity tab entirely or rendered
"Activity log coming soon" text.
- entity-activity.service.loadEntityActivity wraps searchAuditLogs
with actor-email resolution; reused by all 5 endpoints.
- New endpoints: /api/v1/{clients,yachts,companies,berths,interests}/[id]/activity,
each gated on the per-entity .view permission and tenant-checked
against ctx.portId.
- EntityActivityFeed renders a timeline with action verb ("Updated",
"Archived"), actor name, relative time, and field old→new diff.
- client-tabs, yacht-tabs, company-tabs, berth-tabs now mount the feed
on their Activity tab. Interest already has the richer
InterestTimeline component.
- yacht-tabs YachtInterestsTab also gets a friendlier empty state with
guidance copy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
122
src/components/shared/entity-activity-feed.tsx
Normal file
122
src/components/shared/entity-activity-feed.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user