'use client'; import { useQuery } from '@tanstack/react-query'; import { formatDistanceToNow } from 'date-fns'; import Link from 'next/link'; import { useParams } from 'next/navigation'; import { apiFetch } from '@/lib/api/client'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { CardSkeleton } from '@/components/shared/loading-skeleton'; import { usePermissions } from '@/hooks/use-permissions'; import { WidgetErrorBoundary } from './widget-error-boundary'; import { STAGE_LABELS, PIPELINE_STAGES, LEGACY_STAGE_REMAP, formatSource, type PipelineStage, } from '@/lib/constants'; interface ActivityItem { id: string; action: string; entityType: string; entityId: string | null; /** Server-resolved human label (client name, yacht name, …) when the * underlying entity still exists. Falls back to the id prefix in the UI. */ label: string | null; userId: string | null; /** Server-resolved actor display name (from user_profiles). When null, * the actor row no longer exists — render falls back to a "Unknown * user" sentinel rather than the raw UUID prefix. */ actorName: string | null; fieldChanged: string | null; /** For user-FK diff rows (assignedTo, ownerId, etc.) the service * already replaces these with display names. Non-user-FK rows pass * through verbatim. */ oldValue: unknown; newValue: unknown; metadata: Record | null; createdAt: string; } /** camelCase / snake_case field name → "Title Case" so the audit log * reads naturally ("fullName" → "Full Name", "phone_number" → "Phone * Number"). Single-word fields stay capitalized. */ function humanizeFieldName(name: string): string { return name .replace(/_/g, ' ') .replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/\b\w/g, (c) => c.toUpperCase()); } /** Map enum-typed field values to their canonical human labels. The audit * log stores raw enum strings (`deposit_10pct`, `lost_other_marina`); the * feed should read like `10% Deposit`, not the wire value. */ function normalizeEnumValue(field: string, value: unknown): unknown { if (typeof value !== 'string') return value; const f = field.replace(/_/g, '').toLowerCase(); if (f === 'pipelinestage' || f === 'stage') { // A2: map legacy 9-stage enum values to their 7-stage equivalents so // historical audit-log rows ("deposit_10pct", "contract_sent", ...) // render as the modern label rather than a humanized raw enum. const modern = (PIPELINE_STAGES as readonly string[]).includes(value) ? (value as PipelineStage) : LEGACY_STAGE_REMAP[value]; if (modern) return STAGE_LABELS[modern]; return humanizeFieldName(value); } if (f === 'source') { return formatSource(value) ?? value; } if (f === 'leadcategory' || f === 'category') { return humanizeFieldName(value); } if (f === 'outcome') { return humanizeFieldName(value); } return value; } /** Render a JSON-ish value as a short, single-line preview. Strings come * through as-is; objects flatten to "k: v, k: v"; arrays compress to a * count; nulls / empty render as em-dash. */ function shortValue(value: unknown, fieldContext?: string): string { if (fieldContext) value = normalizeEnumValue(fieldContext, value); if (value === null || value === undefined || value === '') return '—'; if (typeof value === 'string') return value; if (typeof value === 'number' || typeof value === 'boolean') return String(value); if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? '' : 's'}`; if (typeof value === 'object') { const entries = Object.entries(value as Record); if (entries.length === 0) return '—'; return entries .slice(0, 3) .map( ([k, v]) => `${humanizeFieldName(k)}: ${typeof v === 'string' ? normalizeEnumValue(k, v) : JSON.stringify(v)}`, ) .join(', '); } return String(value); } /** Build a "Field: old → new" diff string for the activity row's second * line. Returns null when there's nothing useful to show. * * Audit logs for updates store the per-field diff inside `oldValue` as * `{ field: { old, new }, … }` (see entity-diff.ts), so that's the * shape we pattern-match first. Falls back to a fieldChanged/old→new * pair when those are present, and finally to a key-by-key compare of * two flat objects in `oldValue` vs `newValue`. */ function buildDiffLine(item: ActivityItem): string | null { // Shape A: oldValue = { field: { old, new }, … } if ( item.action === 'update' && item.oldValue && typeof item.oldValue === 'object' && !Array.isArray(item.oldValue) ) { const diffMap = item.oldValue as Record; const entries = Object.entries(diffMap).filter(([, v]) => { return v && typeof v === 'object' && 'old' in (v as object) && 'new' in (v as object); }); if (entries.length > 0) { return entries .slice(0, 2) .map(([field, v]) => { const { old, new: nextValue } = v as { old: unknown; new: unknown }; return `${humanizeFieldName(field)}: ${shortValue(old, field)} → ${shortValue(nextValue, field)}`; }) .join(' · '); } } // Shape B: single-field change with explicit columns. if (item.fieldChanged) { const field = item.fieldChanged; return `${humanizeFieldName(field)}: ${shortValue(item.oldValue, field)} → ${shortValue(item.newValue, field)}`; } // Shape C: flat oldValue vs flat newValue. if ( item.action === 'update' && item.oldValue && typeof item.oldValue === 'object' && item.newValue && typeof item.newValue === 'object' ) { const oldObj = item.oldValue as Record; const newObj = item.newValue as Record; const keys = Object.keys(oldObj).filter((k) => k in newObj); if (keys.length === 0) return null; return keys .slice(0, 2) .map( (k) => `${humanizeFieldName(k)}: ${shortValue(oldObj[k], k)} → ${shortValue(newObj[k], k)}`, ) .join(' · '); } return null; } const ACTION_VARIANTS: Record = { create: 'default', update: 'secondary', delete: 'destructive', archive: 'outline', restore: 'secondary', }; function ActionBadge({ action }: { action: string }) { const variant = ACTION_VARIANTS[action] ?? 'outline'; return ( {action} ); } function ActivityFeedInner() { const params = useParams<{ portSlug: string }>(); const portSlug = params?.portSlug ?? ''; const { can } = usePermissions(); const canViewAuditLog = can('admin', 'view_audit_log'); const { data, isLoading } = useQuery({ queryKey: ['dashboard', 'activity'], queryFn: () => apiFetch('/api/v1/dashboard/activity'), staleTime: 30_000, retry: 2, }); if (isLoading) { return ; } // A1: permission_denied rows on the activity feed render as a bare // action badge with no entity name (they target `admin.X` with empty // entityId). They're noise for the rep — keep them in the audit log // page but hide them from the dashboard feed. const items = (data ?? []).filter((i) => i.action !== 'permission_denied'); return ( Recent Activity {canViewAuditLog && portSlug ? ( See all ) : null} {items.length === 0 ? (

No recent activity yet - your team's actions (interests created, stages changed, invoices sent) will appear here.

) : (
{items.map((item) => { const diffLine = buildDiffLine(item); return (

{item.label ? ( <> {item.label} {/* M-NEW-2: explicit middle-dot separator. The prior `ml-1.5` was getting collapsed under `truncate` so the label + type rendered as "Test Person 1interest" with no visible space between them. */} · {item.entityType} ) : ( <> {item.entityType} {item.entityId && ( {item.entityId.slice(0, 8)} )} )}

{diffLine ? (

{diffLine}

) : null}

{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}

); })}
)}
); } export function ActivityFeed() { return ( ); }