'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()); } /** Entity type alias map for the feed labels. Most types humanize fine * via `humanizeFieldName`, but a few read awkwardly ("Residential * Client" is clearer than the raw enum, notes flatten to their parent). */ const ENTITY_TYPE_LABELS: Record = { residential_client: 'Residential client', residential_interest: 'Residential interest', berth_tenancy: 'Berth tenancy', berth_maintenance_log: 'Berth maintenance', berth_recommendation: 'Berth recommendation', client_note: 'Client note', yacht_note: 'Yacht note', company_note: 'Company note', interest_note: 'Interest note', interest_qualification: 'Interest qualification', document_send: 'Document send', document_folder: 'Document folder', document_template: 'Document template', documentTemplate: 'Document template', form_template: 'Form template', report_template: 'Report template', email_account: 'Email account', email_message: 'Email message', user_email_change: 'Email change', custom_field_definition: 'Custom field', custom_field_values: 'Custom field', expense_export: 'Expense export', gdpr_export: 'GDPR export', qualification_criterion: 'Qualification criterion', website_submission: 'Website submission', webhook_inbound: 'Inbound webhook', webhook_delivery: 'Webhook delivery', audit_log: 'Audit log', portal_user: 'Portal user', portal_session: 'Portal session', portal_auth_token: 'Portal token', client_contact: 'Client contact', clientContact: 'Client contact', clientAddress: 'Client address', companyAddress: 'Company address', clientRelationship: 'Client relationship', company_membership: 'Company membership', crm_invite: 'CRM invite', queue_job: 'Queue job', super_admin: 'Super admin', }; function humanizeEntityType(type: string): string { return ENTITY_TYPE_LABELS[type] ?? humanizeFieldName(type); } /** 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. */} · {humanizeEntityType(item.entityType)} ) : ( // No resolvable label - either the entity was // deleted or the type isn't in the server-side // resolver yet. Either way we never expose a // UUID fragment: it reads as noise to the rep // and leaks an internal identifier. {humanizeEntityType(item.entityType)} {item.entityId ? ( (removed) ) : null} )}

{diffLine ? (

{diffLine}

) : null}

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

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