feat(uat-batch): M43 follow-up — yacht detail field history
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>
This commit is contained in:
@@ -33,7 +33,7 @@ import { getBindableField } from '@/lib/templates/bindable-fields';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface FieldHistoryScope {
|
||||
type: 'interest' | 'client';
|
||||
type: 'interest' | 'client' | 'yacht';
|
||||
id: string;
|
||||
}
|
||||
|
||||
@@ -67,10 +67,7 @@ export function FieldHistoryProvider({ scope, children }: ProviderProps) {
|
||||
queryKey: ['field-history', scope?.type, scope?.id],
|
||||
queryFn: async () => {
|
||||
if (!scope) return [] as FieldHistoryRow[];
|
||||
const url =
|
||||
scope.type === 'interest'
|
||||
? `/api/v1/interests/${scope.id}/field-history`
|
||||
: `/api/v1/clients/${scope.id}/field-history`;
|
||||
const url = `/api/v1/${scope.type}s/${scope.id}/field-history`;
|
||||
const res = await apiFetch<{ data: FieldHistoryRow[] }>(url);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useParams } from 'next/navigation';
|
||||
|
||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
||||
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
|
||||
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
|
||||
@@ -80,11 +81,25 @@ function useYachtPatch(yachtId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function EditableRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
function EditableRow({
|
||||
label,
|
||||
children,
|
||||
historyPath,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
/** When set, renders a clock icon (when at least one override row
|
||||
* exists for this path on the surrounding FieldHistoryProvider scope)
|
||||
* that opens the field-history popover. */
|
||||
historyPath?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center">
|
||||
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
|
||||
<dd className="flex-1 min-w-0">{children}</dd>
|
||||
<dd className="flex-1 min-w-0 flex items-center gap-1">
|
||||
<div className="flex-1 min-w-0">{children}</div>
|
||||
{historyPath ? <FieldHistoryIcon fieldPath={historyPath} /> : null}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -152,114 +167,116 @@ function OverviewTab({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Identity */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Identity</h3>
|
||||
<dl>
|
||||
<EditableRow label="Name">
|
||||
<InlineEditableField value={yacht.name} onSave={save('name')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Hull Number">
|
||||
<InlineEditableField value={yacht.hullNumber} onSave={save('hullNumber')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Registration">
|
||||
<InlineEditableField value={yacht.registration} onSave={save('registration')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Flag">
|
||||
<InlineEditableField value={yacht.flag} onSave={save('flag')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Year Built">
|
||||
<InlineEditableField
|
||||
value={yacht.yearBuilt?.toString() ?? null}
|
||||
onSave={save('yearBuilt', yearTransform)}
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Status">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={STATUS_OPTIONS}
|
||||
value={yacht.status}
|
||||
onSave={save('status')}
|
||||
/>
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
<FieldHistoryProvider scope={{ type: 'yacht', id: yachtId }}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Identity */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Identity</h3>
|
||||
<dl>
|
||||
<EditableRow label="Name" historyPath="yacht.name">
|
||||
<InlineEditableField value={yacht.name} onSave={save('name')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Hull Number">
|
||||
<InlineEditableField value={yacht.hullNumber} onSave={save('hullNumber')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Registration">
|
||||
<InlineEditableField value={yacht.registration} onSave={save('registration')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Flag">
|
||||
<InlineEditableField value={yacht.flag} onSave={save('flag')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Year Built">
|
||||
<InlineEditableField
|
||||
value={yacht.yearBuilt?.toString() ?? null}
|
||||
onSave={save('yearBuilt', yearTransform)}
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Status">
|
||||
<InlineEditableField
|
||||
variant="select"
|
||||
options={STATUS_OPTIONS}
|
||||
value={yacht.status}
|
||||
onSave={save('status')}
|
||||
/>
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Build */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Build</h3>
|
||||
<dl>
|
||||
<EditableRow label="Builder">
|
||||
<InlineEditableField value={yacht.builder} onSave={save('builder')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Model">
|
||||
<InlineEditableField value={yacht.model} onSave={save('model')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Hull Material">
|
||||
<InlineEditableField value={yacht.hullMaterial} onSave={save('hullMaterial')} />
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
{/* Build */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Build</h3>
|
||||
<dl>
|
||||
<EditableRow label="Builder">
|
||||
<InlineEditableField value={yacht.builder} onSave={save('builder')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Model">
|
||||
<InlineEditableField value={yacht.model} onSave={save('model')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Hull Material">
|
||||
<InlineEditableField value={yacht.hullMaterial} onSave={save('hullMaterial')} />
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Dimensions (ft) */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Dimensions (ft)</h3>
|
||||
<dl>
|
||||
<EditableRow label="Length (ft)">
|
||||
<InlineEditableField value={yacht.lengthFt} onSave={saveDimension('lengthFt')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Width (ft)">
|
||||
<InlineEditableField value={yacht.widthFt} onSave={saveDimension('widthFt')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Draft (ft)">
|
||||
<InlineEditableField value={yacht.draftFt} onSave={saveDimension('draftFt')} />
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
{/* Dimensions (ft) */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Dimensions (ft)</h3>
|
||||
<dl>
|
||||
<EditableRow label="Length (ft)" historyPath="yacht.lengthFt">
|
||||
<InlineEditableField value={yacht.lengthFt} onSave={saveDimension('lengthFt')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Width (ft)" historyPath="yacht.widthFt">
|
||||
<InlineEditableField value={yacht.widthFt} onSave={saveDimension('widthFt')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Draft (ft)" historyPath="yacht.draftFt">
|
||||
<InlineEditableField value={yacht.draftFt} onSave={saveDimension('draftFt')} />
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Dimensions (m) */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Dimensions (m)</h3>
|
||||
<dl>
|
||||
<EditableRow label="Length (m)">
|
||||
<InlineEditableField value={yacht.lengthM} onSave={saveDimension('lengthM')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Width (m)">
|
||||
<InlineEditableField value={yacht.widthM} onSave={saveDimension('widthM')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Draft (m)">
|
||||
<InlineEditableField value={yacht.draftM} onSave={saveDimension('draftM')} />
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
{/* Dimensions (m) */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Dimensions (m)</h3>
|
||||
<dl>
|
||||
<EditableRow label="Length (m)">
|
||||
<InlineEditableField value={yacht.lengthM} onSave={saveDimension('lengthM')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Width (m)">
|
||||
<InlineEditableField value={yacht.widthM} onSave={saveDimension('widthM')} />
|
||||
</EditableRow>
|
||||
<EditableRow label="Draft (m)">
|
||||
<InlineEditableField value={yacht.draftM} onSave={saveDimension('draftM')} />
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Notes — threaded list (parity with clients/interests/companies).
|
||||
{/* Notes — threaded list (parity with clients/interests/companies).
|
||||
The legacy single-field `yacht.notes` column stays in schema
|
||||
for the EOI/contract merge-field path; OverviewTab no longer
|
||||
exposes it for editing here. */}
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<h3 className="text-sm font-medium mb-2">Notes</h3>
|
||||
<NotesList
|
||||
entityType="yachts"
|
||||
entityId={yachtId}
|
||||
currentUserId={currentUserId}
|
||||
parentInvalidateKey={['yachts', yachtId]}
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<h3 className="text-sm font-medium mb-2">Notes</h3>
|
||||
<NotesList
|
||||
entityType="yachts"
|
||||
entityId={yachtId}
|
||||
currentUserId={currentUserId}
|
||||
parentInvalidateKey={['yachts', yachtId]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InlineTagEditor
|
||||
heading="Tags"
|
||||
wrapperClassName="md:col-span-2"
|
||||
endpoint={`/api/v1/yachts/${yachtId}/tags`}
|
||||
currentTags={yacht.tags ?? []}
|
||||
invalidateKey={['yachts', yachtId]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InlineTagEditor
|
||||
heading="Tags"
|
||||
wrapperClassName="md:col-span-2"
|
||||
endpoint={`/api/v1/yachts/${yachtId}/tags`}
|
||||
currentTags={yacht.tags ?? []}
|
||||
invalidateKey={['yachts', yachtId]}
|
||||
/>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<RemindersInline yachtId={yachtId} />
|
||||
<div className="md:col-span-2">
|
||||
<RemindersInline yachtId={yachtId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FieldHistoryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user