Files
pn-new-crm/src/components/shared/entity-activity-feed.tsx

150 lines
5.0 KiB
TypeScript
Raw Normal View History

'use client';
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { formatDistanceToNow } from 'date-fns';
import { apiFetch } from '@/lib/api/client';
feat: round 2 — stage prompts, berth header, EOI inline edit, measurement units Berth surfaces - New compact mooring-chip header (colored plate + status pill, dock-label in tooltip) replaces the redundant "Berth B1 / Sold / B DOCK" stack - Berth list gains a "Latest deal stage" column showing the most-advanced pipeline stage of any active linked interest (server-aggregated, ranks by PIPELINE_STAGES index) - "Linked prospect" Select on the status-change dialog rebuilt as a Command combobox: search, recent-first sort, stage-coloured pills Pipeline UX - Reverting an interest to Open with linked berths now prompts: keep the links, unlink and reset, or cancel. Silent when no berths are linked - Activity feed + entity-activity feed normalise enum field values via STAGE_LABELS / formatSource: "deposit_10pct → contract_sent" reads as "10% Deposit → Contract Sent" EOI generate dialog - Inline-editable rows for client name, nationality (country combobox), and yacht name — pencil affordance saves directly via clients/yachts PATCH - Replaces the single "Edit on client's page" link with two contextual links framed by short copy explaining what's inline vs what needs the canonical page - Backend EoiContext now includes client.id + yacht.id so the dialog can PATCH without an extra round-trip Company form - New "Connections" section lets the rep attach members (clients) and yachts during create. Yacht attach uses the existing transfer endpoint so audit log + ownership history capture the change - Inline "+ New client" / "+ New yacht" buttons open the canonical forms stacked over the company sheet - After save, the form chains to a yacht pull-in prompt (if any attached client owns yachts not yet linked) and an optional "Create interest" step pre-filled with the first attached client Admin - /admin landing gains a searchable index — typed query flattens groups into a result list matching label + description + group title - "Documenso & EOI" card relabelled to "EOI signing service" (consistent with the user-facing language rename from round 1) Measurement units (migration 0053) - interests gains desired_*_m columns + desired_*_unit discriminators so the rep's literal entry (ft OR m) is preserved verbatim instead of being reconstructed from a single canonical column on every render - yachts + berths gain matching *_unit columns alongside their existing ft + m pairs; defaults to 'ft' so legacy rows still render normally - Interest form POST/PATCH now sends both ft + m + unit; computed m is derived from the ft canonical to keep the recommender SQL unchanged Misc - Active-deals tile + topbar type their Link href as `Route` instead of `any` - Unused REPORT_TYPE_LABELS const dropped from generate-report-form - Test fixtures (fill-eoi-form, documenso-payload, public-berths) updated to include the new id + unit fields on the EoiContext / Berth shapes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:28:22 +02:00
import { STAGE_LABELS, formatSource, type PipelineStage } from '@/lib/constants';
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;
feat: round 2 — stage prompts, berth header, EOI inline edit, measurement units Berth surfaces - New compact mooring-chip header (colored plate + status pill, dock-label in tooltip) replaces the redundant "Berth B1 / Sold / B DOCK" stack - Berth list gains a "Latest deal stage" column showing the most-advanced pipeline stage of any active linked interest (server-aggregated, ranks by PIPELINE_STAGES index) - "Linked prospect" Select on the status-change dialog rebuilt as a Command combobox: search, recent-first sort, stage-coloured pills Pipeline UX - Reverting an interest to Open with linked berths now prompts: keep the links, unlink and reset, or cancel. Silent when no berths are linked - Activity feed + entity-activity feed normalise enum field values via STAGE_LABELS / formatSource: "deposit_10pct → contract_sent" reads as "10% Deposit → Contract Sent" EOI generate dialog - Inline-editable rows for client name, nationality (country combobox), and yacht name — pencil affordance saves directly via clients/yachts PATCH - Replaces the single "Edit on client's page" link with two contextual links framed by short copy explaining what's inline vs what needs the canonical page - Backend EoiContext now includes client.id + yacht.id so the dialog can PATCH without an extra round-trip Company form - New "Connections" section lets the rep attach members (clients) and yachts during create. Yacht attach uses the existing transfer endpoint so audit log + ownership history capture the change - Inline "+ New client" / "+ New yacht" buttons open the canonical forms stacked over the company sheet - After save, the form chains to a yacht pull-in prompt (if any attached client owns yachts not yet linked) and an optional "Create interest" step pre-filled with the first attached client Admin - /admin landing gains a searchable index — typed query flattens groups into a result list matching label + description + group title - "Documenso & EOI" card relabelled to "EOI signing service" (consistent with the user-facing language rename from round 1) Measurement units (migration 0053) - interests gains desired_*_m columns + desired_*_unit discriminators so the rep's literal entry (ft OR m) is preserved verbatim instead of being reconstructed from a single canonical column on every render - yachts + berths gain matching *_unit columns alongside their existing ft + m pairs; defaults to 'ft' so legacy rows still render normally - Interest form POST/PATCH now sends both ft + m + unit; computed m is derived from the ft canonical to keep the recommender SQL unchanged Misc - Active-deals tile + topbar type their Link href as `Route` instead of `any` - Unused REPORT_TYPE_LABELS const dropped from generate-report-form - Test fixtures (fill-eoi-form, documenso-payload, public-berths) updated to include the new id + unit fields on the EoiContext / Berth shapes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:28:22 +02:00
return field
.replace(/_/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.toLowerCase();
}
/** Resolve enum-typed values to their human-readable label so the row reads
* "10% Deposit" instead of "deposit_10pct". Returns the raw value for any
* unrecognised field/value. */
function formatValueForField(field: string | null, value: unknown): string {
if (value === null || value === undefined) return '';
if (field) {
const f = field.replace(/_/g, '').toLowerCase();
if (typeof value === 'string') {
if (f === 'pipelinestage' || f === 'stage') {
return STAGE_LABELS[value as PipelineStage] ?? value.replace(/_/g, ' ');
}
if (f === 'source') return formatSource(value) ?? value;
if (f === 'leadcategory' || f === 'category' || f === 'outcome') {
return value.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
}
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return JSON.stringify(value);
}
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">
feat: round 2 — stage prompts, berth header, EOI inline edit, measurement units Berth surfaces - New compact mooring-chip header (colored plate + status pill, dock-label in tooltip) replaces the redundant "Berth B1 / Sold / B DOCK" stack - Berth list gains a "Latest deal stage" column showing the most-advanced pipeline stage of any active linked interest (server-aggregated, ranks by PIPELINE_STAGES index) - "Linked prospect" Select on the status-change dialog rebuilt as a Command combobox: search, recent-first sort, stage-coloured pills Pipeline UX - Reverting an interest to Open with linked berths now prompts: keep the links, unlink and reset, or cancel. Silent when no berths are linked - Activity feed + entity-activity feed normalise enum field values via STAGE_LABELS / formatSource: "deposit_10pct → contract_sent" reads as "10% Deposit → Contract Sent" EOI generate dialog - Inline-editable rows for client name, nationality (country combobox), and yacht name — pencil affordance saves directly via clients/yachts PATCH - Replaces the single "Edit on client's page" link with two contextual links framed by short copy explaining what's inline vs what needs the canonical page - Backend EoiContext now includes client.id + yacht.id so the dialog can PATCH without an extra round-trip Company form - New "Connections" section lets the rep attach members (clients) and yachts during create. Yacht attach uses the existing transfer endpoint so audit log + ownership history capture the change - Inline "+ New client" / "+ New yacht" buttons open the canonical forms stacked over the company sheet - After save, the form chains to a yacht pull-in prompt (if any attached client owns yachts not yet linked) and an optional "Create interest" step pre-filled with the first attached client Admin - /admin landing gains a searchable index — typed query flattens groups into a result list matching label + description + group title - "Documenso & EOI" card relabelled to "EOI signing service" (consistent with the user-facing language rename from round 1) Measurement units (migration 0053) - interests gains desired_*_m columns + desired_*_unit discriminators so the rep's literal entry (ft OR m) is preserved verbatim instead of being reconstructed from a single canonical column on every render - yachts + berths gain matching *_unit columns alongside their existing ft + m pairs; defaults to 'ft' so legacy rows still render normally - Interest form POST/PATCH now sends both ft + m + unit; computed m is derived from the ft canonical to keep the recommender SQL unchanged Misc - Active-deals tile + topbar type their Link href as `Route` instead of `any` - Unused REPORT_TYPE_LABELS const dropped from generate-report-form - Test fixtures (fill-eoi-form, documenso-payload, public-berths) updated to include the new id + unit fields on the EoiContext / Berth shapes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:28:22 +02:00
{formatValueForField(row.fieldChanged, row.oldValue).slice(0, 80)}
</span>
) : null}
{row.newValue !== null && row.newValue !== undefined ? (
<span className="text-foreground">
feat: round 2 — stage prompts, berth header, EOI inline edit, measurement units Berth surfaces - New compact mooring-chip header (colored plate + status pill, dock-label in tooltip) replaces the redundant "Berth B1 / Sold / B DOCK" stack - Berth list gains a "Latest deal stage" column showing the most-advanced pipeline stage of any active linked interest (server-aggregated, ranks by PIPELINE_STAGES index) - "Linked prospect" Select on the status-change dialog rebuilt as a Command combobox: search, recent-first sort, stage-coloured pills Pipeline UX - Reverting an interest to Open with linked berths now prompts: keep the links, unlink and reset, or cancel. Silent when no berths are linked - Activity feed + entity-activity feed normalise enum field values via STAGE_LABELS / formatSource: "deposit_10pct → contract_sent" reads as "10% Deposit → Contract Sent" EOI generate dialog - Inline-editable rows for client name, nationality (country combobox), and yacht name — pencil affordance saves directly via clients/yachts PATCH - Replaces the single "Edit on client's page" link with two contextual links framed by short copy explaining what's inline vs what needs the canonical page - Backend EoiContext now includes client.id + yacht.id so the dialog can PATCH without an extra round-trip Company form - New "Connections" section lets the rep attach members (clients) and yachts during create. Yacht attach uses the existing transfer endpoint so audit log + ownership history capture the change - Inline "+ New client" / "+ New yacht" buttons open the canonical forms stacked over the company sheet - After save, the form chains to a yacht pull-in prompt (if any attached client owns yachts not yet linked) and an optional "Create interest" step pre-filled with the first attached client Admin - /admin landing gains a searchable index — typed query flattens groups into a result list matching label + description + group title - "Documenso & EOI" card relabelled to "EOI signing service" (consistent with the user-facing language rename from round 1) Measurement units (migration 0053) - interests gains desired_*_m columns + desired_*_unit discriminators so the rep's literal entry (ft OR m) is preserved verbatim instead of being reconstructed from a single canonical column on every render - yachts + berths gain matching *_unit columns alongside their existing ft + m pairs; defaults to 'ft' so legacy rows still render normally - Interest form POST/PATCH now sends both ft + m + unit; computed m is derived from the ft canonical to keep the recommender SQL unchanged Misc - Active-deals tile + topbar type their Link href as `Route` instead of `any` - Unused REPORT_TYPE_LABELS const dropped from generate-report-form - Test fixtures (fill-eoi-form, documenso-payload, public-berths) updated to include the new id + unit fields on the EoiContext / Berth shapes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:28:22 +02:00
{formatValueForField(row.fieldChanged, row.newValue).slice(0, 80)}
</span>
) : null}
</div>
) : null}
</li>
);
})}
</ol>
);
}