Extends Phase 3 from the M43 commit to yacht detail: - New /api/v1/yachts/[id]/field-history endpoint joins through interests.yachtId (no schema migration needed) and filters to 'yacht.%' paths so client-scoped overrides on the same interest don't bleed into the yacht surface. - FieldHistoryScope.type accepts 'yacht'; provider URL routing generalised to /api/v1/<type>s/<id>/field-history. - yacht-tabs OverviewTab wrapped in the provider; Name + the three ft-dimension rows get historyPath wired (m-dimension rows skipped — they're a unit-converted view of the same source value, and the supplemental writer only ever stores ft). Addresses tab on Client detail intentionally left unwired — would need AddressesEditor (a shared component) to surface icons per row, which is more than the 5-min scope. 1454/1454 vitest, tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
168 lines
5.5 KiB
TypeScript
168 lines
5.5 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* Inline field-history surface for detail pages.
|
|
*
|
|
* The pattern:
|
|
*
|
|
* 1. Wrap the detail page (or just the section containing editable
|
|
* fields) in <FieldHistoryProvider scope={{ type, id }} />. The
|
|
* provider fires a single GET for every recorded override, keyed
|
|
* by entity, and exposes a Map<fieldPath, HistoryRow[]> via context.
|
|
*
|
|
* 2. Pass `historyPath` to any InlineEditableField that maps to a
|
|
* bindable path (see src/lib/templates/bindable-fields.ts). The
|
|
* field renders a small clock icon next to the value when at least
|
|
* one history row exists for that path. Click → popover with the
|
|
* reverse-chronological diff list.
|
|
*
|
|
* Fields without `historyPath`, or fields whose path has zero history
|
|
* rows, render nothing extra — the surface is purely additive.
|
|
*/
|
|
|
|
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { Clock } from 'lucide-react';
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
|
|
import { apiFetch } from '@/lib/api/client';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { getBindableField } from '@/lib/templates/bindable-fields';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
export interface FieldHistoryScope {
|
|
type: 'interest' | 'client' | 'yacht';
|
|
id: string;
|
|
}
|
|
|
|
export interface FieldHistoryRow {
|
|
id: string;
|
|
fieldPath: string;
|
|
oldValue: unknown;
|
|
newValue: unknown;
|
|
source: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
interface ContextValue {
|
|
byPath: Map<string, FieldHistoryRow[]>;
|
|
isLoading: boolean;
|
|
}
|
|
|
|
const FieldHistoryContext = createContext<ContextValue>({
|
|
byPath: new Map(),
|
|
isLoading: false,
|
|
});
|
|
|
|
interface ProviderProps {
|
|
scope: FieldHistoryScope | null;
|
|
children: ReactNode;
|
|
}
|
|
|
|
export function FieldHistoryProvider({ scope, children }: ProviderProps) {
|
|
const { data, isLoading } = useQuery({
|
|
enabled: scope !== null,
|
|
queryKey: ['field-history', scope?.type, scope?.id],
|
|
queryFn: async () => {
|
|
if (!scope) return [] as FieldHistoryRow[];
|
|
const url = `/api/v1/${scope.type}s/${scope.id}/field-history`;
|
|
const res = await apiFetch<{ data: FieldHistoryRow[] }>(url);
|
|
return res.data;
|
|
},
|
|
// Field history is small + read-mostly. 30s is plenty fresh for the
|
|
// common case where the rep edits a field and wants to see the
|
|
// history reflect immediately — react-query refetches on focus.
|
|
staleTime: 30_000,
|
|
});
|
|
|
|
const byPath = useMemo(() => {
|
|
const map = new Map<string, FieldHistoryRow[]>();
|
|
for (const row of data ?? []) {
|
|
const arr = map.get(row.fieldPath) ?? [];
|
|
arr.push(row);
|
|
map.set(row.fieldPath, arr);
|
|
}
|
|
return map;
|
|
}, [data]);
|
|
|
|
return (
|
|
<FieldHistoryContext.Provider value={{ byPath, isLoading }}>
|
|
{children}
|
|
</FieldHistoryContext.Provider>
|
|
);
|
|
}
|
|
|
|
interface IconProps {
|
|
fieldPath: string;
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* Renders nothing when the field has no history. When at least one
|
|
* override row exists, renders a small clock button that opens a
|
|
* popover with the diff timeline.
|
|
*/
|
|
export function FieldHistoryIcon({ fieldPath, className }: IconProps) {
|
|
const { byPath } = useContext(FieldHistoryContext);
|
|
const rows = byPath.get(fieldPath);
|
|
if (!rows || rows.length === 0) return null;
|
|
const meta = getBindableField(fieldPath);
|
|
const label = meta?.label ?? fieldPath;
|
|
|
|
return (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={cn('h-5 w-5 text-muted-foreground hover:text-foreground', className)}
|
|
aria-label={`Field history for ${label}`}
|
|
title={`${rows.length} override${rows.length === 1 ? '' : 's'}`}
|
|
>
|
|
<Clock className="h-3 w-3" aria-hidden />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="start" className="w-80 p-0">
|
|
<div className="border-b px-3 py-2">
|
|
<div className="text-xs font-medium">{label}</div>
|
|
<div className="text-[10px] text-muted-foreground">
|
|
{rows.length} override{rows.length === 1 ? '' : 's'}
|
|
</div>
|
|
</div>
|
|
<ScrollArea className="max-h-72">
|
|
<ul className="divide-y text-xs">
|
|
{rows.map((r) => (
|
|
<li key={r.id} className="px-3 py-2 space-y-1">
|
|
<div className="flex items-baseline justify-between gap-2">
|
|
<span className="font-medium">{formatSource(r.source)}</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{formatDistanceToNow(new Date(r.createdAt), { addSuffix: true })}
|
|
</span>
|
|
</div>
|
|
<div className="text-muted-foreground">
|
|
<span className="line-through">{formatValue(r.oldValue)}</span>
|
|
<span className="mx-1">→</span>
|
|
<span className="text-foreground">{formatValue(r.newValue)}</span>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</ScrollArea>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
function formatValue(v: unknown): string {
|
|
if (v === null || v === undefined) return '(empty)';
|
|
if (typeof v === 'string') return v.length > 80 ? `${v.slice(0, 77)}…` : v;
|
|
return JSON.stringify(v);
|
|
}
|
|
|
|
function formatSource(source: string): string {
|
|
if (source === 'supplemental_form') return 'Supplemental info form';
|
|
return source;
|
|
}
|