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:
@@ -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."
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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’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."
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user