From 52493801e0f4abef74007f212b5c9694aa81dcf9 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 22 May 2026 12:57:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(uat-batch):=20M43=20follow-up=20=E2=80=94?= =?UTF-8?q?=20yacht=20detail=20field=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/s//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) --- .../api/v1/yachts/[id]/field-history/route.ts | 50 ++++ src/components/shared/field-history.tsx | 7 +- src/components/yachts/yacht-tabs.tsx | 217 ++++++++++-------- 3 files changed, 169 insertions(+), 105 deletions(-) create mode 100644 src/app/api/v1/yachts/[id]/field-history/route.ts diff --git a/src/app/api/v1/yachts/[id]/field-history/route.ts b/src/app/api/v1/yachts/[id]/field-history/route.ts new file mode 100644 index 00000000..bcce18e5 --- /dev/null +++ b/src/app/api/v1/yachts/[id]/field-history/route.ts @@ -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); + } + }), +); diff --git a/src/components/shared/field-history.tsx b/src/components/shared/field-history.tsx index fcf27242..33a552b5 100644 --- a/src/components/shared/field-history.tsx +++ b/src/components/shared/field-history.tsx @@ -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; }, diff --git a/src/components/yachts/yacht-tabs.tsx b/src/components/yachts/yacht-tabs.tsx index c1d69c52..31a2b9a2 100644 --- a/src/components/yachts/yacht-tabs.tsx +++ b/src/components/yachts/yacht-tabs.tsx @@ -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 (
{label}
-
{children}
+
+
{children}
+ {historyPath ? : null} +
); } @@ -152,114 +167,116 @@ function OverviewTab({ }; return ( -
- {/* Identity */} -
-

Identity

-
- - - - - - - - - - - - - - - - - - -
-
+ +
+ {/* Identity */} +
+

Identity

+
+ + + + + + + + + + + + + + + + + + +
+
- {/* Build */} -
-

Build

-
- - - - - - - - - -
-
+ {/* Build */} +
+

Build

+
+ + + + + + + + + +
+
- {/* Dimensions (ft) */} -
-

Dimensions (ft)

-
- - - - - - - - - -
-
+ {/* Dimensions (ft) */} +
+

Dimensions (ft)

+
+ + + + + + + + + +
+
- {/* Dimensions (m) */} -
-

Dimensions (m)

-
- - - - - - - - - -
-
+ {/* Dimensions (m) */} +
+

Dimensions (m)

+
+ + + + + + + + + +
+
- {/* 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. */} -
-

Notes

- +

Notes

+ +
+ + -
- - -
- +
+ +
-
+ ); }