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:
Matt Ciaccio
2026-05-06 14:57:51 +02:00
parent d19b74b935
commit 8cdee99310
11 changed files with 362 additions and 5 deletions

View File

@@ -3,6 +3,7 @@
import { type DetailTab } from '@/components/shared/detail-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { TagBadge } from '@/components/shared/tag-badge';
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
import { BerthReservationsTab } from './berth-reservations-tab';
import { BerthInterestsTab } from './berth-interests-tab';
import { BerthInterestPulse } from './berth-interest-pulse';
@@ -250,7 +251,12 @@ export function buildBerthTabs(berth: BerthData): DetailTab[] {
{
id: 'activity',
label: 'Activity',
content: <StubTab label="Activity" />,
content: (
<EntityActivityFeed
endpoint={`/api/v1/berths/${berth.id}/activity`}
emptyText="No activity recorded for this berth yet."
/>
),
},
];
}

View File

@@ -16,6 +16,7 @@ import { ClientCompaniesTab } from '@/components/clients/client-companies-tab';
import { ClientReservationsTab } from '@/components/clients/client-reservations-tab';
import { ContactsEditor } from '@/components/clients/contacts-editor';
import { AddressesEditor, type Address } from '@/components/shared/addresses-editor';
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
import { apiFetch } from '@/lib/api/client';
type ClientPatchField =
@@ -280,9 +281,10 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
id: 'activity',
label: 'Activity',
content: (
<div className="text-center py-12 text-muted-foreground">
<p>Activity log coming soon.</p>
</div>
<EntityActivityFeed
endpoint={`/api/v1/clients/${clientId}/activity`}
emptyText="No activity recorded for this client yet."
/>
),
},
];

View File

@@ -9,6 +9,7 @@ import { InlineCountryField } from '@/components/shared/inline-country-field';
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list';
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
import { CompanyMembersTab } from '@/components/companies/company-members-tab';
import { CompanyOwnedYachtsTab } from '@/components/companies/company-owned-yachts-tab';
import { AddressesEditor, type Address } from '@/components/shared/addresses-editor';
@@ -238,5 +239,15 @@ export function getCompanyTabs({
<NotesList entityType="companies" entityId={companyId} currentUserId={currentUserId} />
),
},
{
id: 'activity',
label: 'Activity',
content: (
<EntityActivityFeed
endpoint={`/api/v1/companies/${companyId}/activity`}
emptyText="No activity recorded for this company yet."
/>
),
},
];
}

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

View File

@@ -7,6 +7,7 @@ import type { DetailTab } from '@/components/shared/detail-layout';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list';
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
import { ReservationList, type ReservationRow } from '@/components/reservations/reservation-list';
import { YachtOwnershipHistory } from '@/components/yachts/yacht-ownership-history';
import { apiFetch } from '@/lib/api/client';
@@ -225,7 +226,15 @@ function YachtInterestsTab({ yachtId }: { yachtId: string }) {
if (isLoading) return <p className="text-sm text-muted-foreground">Loading</p>;
if (interests.length === 0) {
return <p className="text-sm text-muted-foreground">No interests linked to this yacht.</p>;
return (
<div className="rounded-md border border-dashed bg-muted/30 p-6 text-center">
<p className="text-sm font-medium">No interests linked to this yacht</p>
<p className="text-xs text-muted-foreground mt-1">
Interests for this yacht will appear here once a sales rep links them. Add an interest
from the Interests tab on the owner&rsquo;s client page.
</p>
</div>
);
}
return (
@@ -298,5 +307,15 @@ export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions
label: 'Notes',
content: <NotesList entityType="yachts" entityId={yachtId} currentUserId={currentUserId} />,
},
{
id: 'activity',
label: 'Activity',
content: (
<EntityActivityFeed
endpoint={`/api/v1/yachts/${yachtId}/activity`}
emptyText="No activity recorded for this yacht yet."
/>
),
},
];
}