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:
50
src/app/api/v1/yachts/[id]/field-history/route.ts
Normal file
50
src/app/api/v1/yachts/[id]/field-history/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,12 +167,13 @@ function OverviewTab({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<FieldHistoryProvider scope={{ type: 'yacht', id: yachtId }}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* Identity */}
|
{/* Identity */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-sm font-medium mb-2">Identity</h3>
|
<h3 className="text-sm font-medium mb-2">Identity</h3>
|
||||||
<dl>
|
<dl>
|
||||||
<EditableRow label="Name">
|
<EditableRow label="Name" historyPath="yacht.name">
|
||||||
<InlineEditableField value={yacht.name} onSave={save('name')} />
|
<InlineEditableField value={yacht.name} onSave={save('name')} />
|
||||||
</EditableRow>
|
</EditableRow>
|
||||||
<EditableRow label="Hull Number">
|
<EditableRow label="Hull Number">
|
||||||
@@ -206,13 +222,13 @@ function OverviewTab({
|
|||||||
<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>
|
||||||
@@ -260,6 +276,7 @@ function OverviewTab({
|
|||||||
<RemindersInline yachtId={yachtId} />
|
<RemindersInline yachtId={yachtId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</FieldHistoryProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user