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'; import { cn } from '@/lib/utils';
export interface FieldHistoryScope { export interface FieldHistoryScope {
type: 'interest' | 'client'; type: 'interest' | 'client' | 'yacht';
id: string; id: string;
} }
@@ -67,10 +67,7 @@ export function FieldHistoryProvider({ scope, children }: ProviderProps) {
queryKey: ['field-history', scope?.type, scope?.id], queryKey: ['field-history', scope?.type, scope?.id],
queryFn: async () => { queryFn: async () => {
if (!scope) return [] as FieldHistoryRow[]; if (!scope) return [] as FieldHistoryRow[];
const url = const url = `/api/v1/${scope.type}s/${scope.id}/field-history`;
scope.type === 'interest'
? `/api/v1/interests/${scope.id}/field-history`
: `/api/v1/clients/${scope.id}/field-history`;
const res = await apiFetch<{ data: FieldHistoryRow[] }>(url); const res = await apiFetch<{ data: FieldHistoryRow[] }>(url);
return res.data; return res.data;
}, },

View File

@@ -5,6 +5,7 @@ import { useParams } from 'next/navigation';
import type { DetailTab } from '@/components/shared/detail-layout'; import type { DetailTab } from '@/components/shared/detail-layout';
import { InlineEditableField } from '@/components/shared/inline-editable-field'; 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 { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list'; import { NotesList } from '@/components/shared/notes-list';
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed'; 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 ( return (
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center"> <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> <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> </div>
); );
} }
@@ -152,114 +167,116 @@ function OverviewTab({
}; };
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <FieldHistoryProvider scope={{ type: 'yacht', id: yachtId }}>
{/* Identity */} <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-1"> {/* Identity */}
<h3 className="text-sm font-medium mb-2">Identity</h3> <div className="space-y-1">
<dl> <h3 className="text-sm font-medium mb-2">Identity</h3>
<EditableRow label="Name"> <dl>
<InlineEditableField value={yacht.name} onSave={save('name')} /> <EditableRow label="Name" historyPath="yacht.name">
</EditableRow> <InlineEditableField value={yacht.name} onSave={save('name')} />
<EditableRow label="Hull Number"> </EditableRow>
<InlineEditableField value={yacht.hullNumber} onSave={save('hullNumber')} /> <EditableRow label="Hull Number">
</EditableRow> <InlineEditableField value={yacht.hullNumber} onSave={save('hullNumber')} />
<EditableRow label="Registration"> </EditableRow>
<InlineEditableField value={yacht.registration} onSave={save('registration')} /> <EditableRow label="Registration">
</EditableRow> <InlineEditableField value={yacht.registration} onSave={save('registration')} />
<EditableRow label="Flag"> </EditableRow>
<InlineEditableField value={yacht.flag} onSave={save('flag')} /> <EditableRow label="Flag">
</EditableRow> <InlineEditableField value={yacht.flag} onSave={save('flag')} />
<EditableRow label="Year Built"> </EditableRow>
<InlineEditableField <EditableRow label="Year Built">
value={yacht.yearBuilt?.toString() ?? null} <InlineEditableField
onSave={save('yearBuilt', yearTransform)} value={yacht.yearBuilt?.toString() ?? null}
/> onSave={save('yearBuilt', yearTransform)}
</EditableRow> />
<EditableRow label="Status"> </EditableRow>
<InlineEditableField <EditableRow label="Status">
variant="select" <InlineEditableField
options={STATUS_OPTIONS} variant="select"
value={yacht.status} options={STATUS_OPTIONS}
onSave={save('status')} value={yacht.status}
/> onSave={save('status')}
</EditableRow> />
</dl> </EditableRow>
</div> </dl>
</div>
{/* Build */} {/* Build */}
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Build</h3> <h3 className="text-sm font-medium mb-2">Build</h3>
<dl> <dl>
<EditableRow label="Builder"> <EditableRow label="Builder">
<InlineEditableField value={yacht.builder} onSave={save('builder')} /> <InlineEditableField value={yacht.builder} onSave={save('builder')} />
</EditableRow> </EditableRow>
<EditableRow label="Model"> <EditableRow label="Model">
<InlineEditableField value={yacht.model} onSave={save('model')} /> <InlineEditableField value={yacht.model} onSave={save('model')} />
</EditableRow> </EditableRow>
<EditableRow label="Hull Material"> <EditableRow label="Hull Material">
<InlineEditableField value={yacht.hullMaterial} onSave={save('hullMaterial')} /> <InlineEditableField value={yacht.hullMaterial} onSave={save('hullMaterial')} />
</EditableRow> </EditableRow>
</dl> </dl>
</div> </div>
{/* Dimensions (ft) */} {/* Dimensions (ft) */}
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Dimensions (ft)</h3> <h3 className="text-sm font-medium mb-2">Dimensions (ft)</h3>
<dl> <dl>
<EditableRow label="Length (ft)"> <EditableRow label="Length (ft)" historyPath="yacht.lengthFt">
<InlineEditableField value={yacht.lengthFt} onSave={saveDimension('lengthFt')} /> <InlineEditableField value={yacht.lengthFt} onSave={saveDimension('lengthFt')} />
</EditableRow> </EditableRow>
<EditableRow label="Width (ft)"> <EditableRow label="Width (ft)" historyPath="yacht.widthFt">
<InlineEditableField value={yacht.widthFt} onSave={saveDimension('widthFt')} /> <InlineEditableField value={yacht.widthFt} onSave={saveDimension('widthFt')} />
</EditableRow> </EditableRow>
<EditableRow label="Draft (ft)"> <EditableRow label="Draft (ft)" historyPath="yacht.draftFt">
<InlineEditableField value={yacht.draftFt} onSave={saveDimension('draftFt')} /> <InlineEditableField value={yacht.draftFt} onSave={saveDimension('draftFt')} />
</EditableRow> </EditableRow>
</dl> </dl>
</div> </div>
{/* Dimensions (m) */} {/* Dimensions (m) */}
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Dimensions (m)</h3> <h3 className="text-sm font-medium mb-2">Dimensions (m)</h3>
<dl> <dl>
<EditableRow label="Length (m)"> <EditableRow label="Length (m)">
<InlineEditableField value={yacht.lengthM} onSave={saveDimension('lengthM')} /> <InlineEditableField value={yacht.lengthM} onSave={saveDimension('lengthM')} />
</EditableRow> </EditableRow>
<EditableRow label="Width (m)"> <EditableRow label="Width (m)">
<InlineEditableField value={yacht.widthM} onSave={saveDimension('widthM')} /> <InlineEditableField value={yacht.widthM} onSave={saveDimension('widthM')} />
</EditableRow> </EditableRow>
<EditableRow label="Draft (m)"> <EditableRow label="Draft (m)">
<InlineEditableField value={yacht.draftM} onSave={saveDimension('draftM')} /> <InlineEditableField value={yacht.draftM} onSave={saveDimension('draftM')} />
</EditableRow> </EditableRow>
</dl> </dl>
</div> </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 The legacy single-field `yacht.notes` column stays in schema
for the EOI/contract merge-field path; OverviewTab no longer for the EOI/contract merge-field path; OverviewTab no longer
exposes it for editing here. */} exposes it for editing here. */}
<div className="space-y-1 md:col-span-2"> <div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Notes</h3> <h3 className="text-sm font-medium mb-2">Notes</h3>
<NotesList <NotesList
entityType="yachts" entityType="yachts"
entityId={yachtId} entityId={yachtId}
currentUserId={currentUserId} currentUserId={currentUserId}
parentInvalidateKey={['yachts', yachtId]} 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 <div className="md:col-span-2">
heading="Tags" <RemindersInline yachtId={yachtId} />
wrapperClassName="md:col-span-2" </div>
endpoint={`/api/v1/yachts/${yachtId}/tags`}
currentTags={yacht.tags ?? []}
invalidateKey={['yachts', yachtId]}
/>
<div className="md:col-span-2">
<RemindersInline yachtId={yachtId} />
</div> </div>
</div> </FieldHistoryProvider>
); );
} }