feat(uat-batch): M43 follow-up — yacht detail field history
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m35s
Build & Push Docker Images / build-and-push (push) Has been skipped

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:
2026-05-22 12:57:47 +02:00
parent f6cb733424
commit 52493801e0
3 changed files with 169 additions and 105 deletions

View File

@@ -0,0 +1,50 @@
import { NextResponse } from 'next/server';
import { and, desc, eq, sql } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { interestFieldHistory, interests } from '@/lib/db/schema';
import { errorResponse } from '@/lib/errors';
/**
* GET /api/v1/yachts/[id]/field-history
*
* Returns every supplemental-form override that touched the yacht,
* resolved by joining interest_field_history through interests.yachtId.
* The history table itself doesn't carry a yacht_id column (the writer
* scopes by interest + client only) — joining at read time avoids a
* schema migration just to support this rollup.
*/
export const GET = withAuth(
withPermission('yachts', 'view', async (_req, ctx, params) => {
try {
const rows = await db
.select({
id: interestFieldHistory.id,
fieldPath: interestFieldHistory.fieldPath,
oldValue: interestFieldHistory.oldValue,
newValue: interestFieldHistory.newValue,
source: interestFieldHistory.source,
createdAt: interestFieldHistory.createdAt,
})
.from(interestFieldHistory)
.innerJoin(interests, eq(interests.id, interestFieldHistory.interestId))
.where(
and(
eq(interestFieldHistory.portId, ctx.portId),
eq(interests.yachtId, params.id!),
// Restrict to actually-yacht-scoped paths so the rollup
// doesn't surface "client email changed" rows on yacht detail
// (those overrides came in via a supplemental form attached
// to an interest that happens to link this yacht).
sql`${interestFieldHistory.fieldPath} LIKE 'yacht.%'`,
),
)
.orderBy(desc(interestFieldHistory.createdAt))
.limit(100);
return NextResponse.json({ data: rows });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -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;
},

View File

@@ -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>
);
}