Files
pn-new-crm/src/components/yachts/yacht-tabs.tsx
Matt df8c26d1b3 feat(proxies): CM-9 UI — ProxyCard on client, interest, and yacht detail pages
- shared ProxyCard (view/add/edit/remove point-of-contact) reading each entity's
  /[id]/proxy sub-resource; permission-gated on the entity's edit right
- wired into the client overview, interest overview, and yacht overview tabs

Completes CM-9. tsc clean, lint 0 errors, prod build green, 1638 vitest pass.
Comms send-side wiring (route EOIs/emails through resolveEffectiveProxy) is a
deliberate follow-up — the resolver + data are ready for it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 00:01:08 +02:00

439 lines
15 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { Plus } from 'lucide-react';
import type { DetailTab } from '@/components/shared/detail-layout';
import { Button } from '@/components/ui/button';
import { PermissionGate } from '@/components/shared/permission-gate';
import { TenancyCreateDialog } from '@/components/tenancies/tenancy-create-dialog';
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 { ProxyCard } from '@/components/shared/proxy-card';
import { NotesList } from '@/components/shared/notes-list';
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
import { TenancyList, type TenancyRow } from '@/components/tenancies/tenancy-list';
import { RemindersInline } from '@/components/reminders/reminders-inline';
import { YachtOwnershipHistory } from '@/components/yachts/yacht-ownership-history';
import { feetToMeters, metersToFeet } from '@/components/yachts/yacht-dimensions';
import { apiFetch } from '@/lib/api/client';
import { stageLabel } from '@/lib/constants';
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
type YachtPatchField =
| 'name'
| 'hullNumber'
| 'registration'
| 'flag'
| 'yearBuilt'
| 'builder'
| 'model'
| 'hullMaterial'
| 'lengthFt'
| 'widthFt'
| 'draftFt'
| 'lengthM'
| 'widthM'
| 'draftM'
| 'status'
| 'notes';
const STATUS_OPTIONS = [
{ value: 'active', label: 'Active' },
{ value: 'retired', label: 'Retired' },
{ value: 'sold_away', label: 'Sold away' },
];
interface YachtTabsYacht {
id: string;
name: string;
hullNumber: string | null;
registration: string | null;
flag: string | null;
yearBuilt: number | null;
builder: string | null;
model: string | null;
hullMaterial: string | null;
lengthFt: string | null;
widthFt: string | null;
draftFt: string | null;
lengthM: string | null;
widthM: string | null;
draftM: string | null;
status: string;
notes: string | null;
tags?: Array<{ id: string; name: string; color: string }>;
noteCount?: number;
}
interface YachtTabsOptions {
yachtId: string;
currentUserId?: string;
yacht: YachtTabsYacht;
}
function useYachtPatch(yachtId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (patch: Partial<Record<YachtPatchField, string | number | null>>) =>
apiFetch(`/api/v1/yachts/${yachtId}`, {
method: 'PATCH',
body: patch,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['yachts', yachtId] });
},
});
}
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 flex items-center gap-1">
<div className="flex-1 min-w-0">{children}</div>
{historyPath ? <FieldHistoryIcon fieldPath={historyPath} /> : null}
</dd>
</div>
);
}
function OverviewTab({
yachtId,
yacht,
currentUserId,
}: {
yachtId: string;
yacht: YachtTabsYacht;
currentUserId?: string;
}) {
const mutation = useYachtPatch(yachtId);
const save =
(field: YachtPatchField, transform?: (v: string | null) => string | number | null) =>
async (next: string | null) => {
const value = transform ? transform(next) : next;
await mutation.mutateAsync({ [field]: value });
};
/**
* Bidirectional dimension save: when the rep edits Length/Width/Draft
* in feet, also write the metric counterpart (and vice versa). Avoids
* the "I entered ft but the m row still says '-'" surprise.
*
* If the rep clears a field (next === null), only that side is
* cleared - we never overwrite their other-unit value with a derived
* one, since they may have intentionally entered a more precise
* metric figure.
*/
function saveDimension(
primaryField: 'lengthFt' | 'widthFt' | 'draftFt' | 'lengthM' | 'widthM' | 'draftM',
) {
const isFt = primaryField.endsWith('Ft');
const counterpart = (
isFt ? primaryField.replace('Ft', 'M') : primaryField.replace('M', 'Ft')
) as YachtPatchField;
return async (next: string | null) => {
if (next === null || next === '') {
await mutation.mutateAsync({ [primaryField]: null });
return;
}
const n = Number.parseFloat(next);
if (!Number.isFinite(n)) {
await mutation.mutateAsync({ [primaryField]: next });
return;
}
// Delegate the math to the canonical helpers in yacht-dimensions.ts
// so this surface, the create-form, and the read-side formatter all
// round-trip identically. 4dp precision keeps `1 ft → 0.3048 m →
// 1.0000 ft` (after trimZero → "1"); 2dp lost data on small values.
const converted = isFt ? feetToMeters(next) : metersToFeet(next);
const convertedStr =
converted === null ? '' : converted.toFixed(4).replace(/0+$/, '').replace(/\.$/, '');
await mutation.mutateAsync({
[primaryField]: next,
[counterpart]: convertedStr,
});
};
}
const yearTransform = (next: string | null) => {
if (next === null) return null;
const n = Number.parseInt(next, 10);
return Number.isNaN(n) ? null : n;
};
return (
<FieldHistoryProvider scope={{ type: 'yacht', id: yachtId }}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* CM-9: per-vessel point-of-contact (overrides interest + client). */}
<div className="md:col-span-2">
<ProxyCard entityType="yacht" entityId={yachtId} />
</div>
{/* 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>
{/* 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>
{/* 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>
<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>
</div>
</FieldHistoryProvider>
);
}
function YachtInterestsTab({ yachtId }: { yachtId: string }) {
const { data, isLoading } = useQuery<{
data: Array<{
id: string;
pipelineStage: string;
clientName: string | null;
berthMooringNumber: string | null;
berthMoorings?: string[];
updatedAt: string;
}>;
}>({
queryKey: ['interests', 'by-yacht', yachtId],
queryFn: () => apiFetch(`/api/v1/interests?yachtId=${yachtId}&limit=50&order=desc`),
});
const interests = data?.data ?? [];
if (isLoading) return <p className="text-sm text-muted-foreground">Loading</p>;
if (interests.length === 0) {
return (
<div className="rounded-md border border-dashed bg-muted/30 p-6 text-center">
<p className="text-sm font-medium">No interests linked to this yacht</p>
<p className="text-xs text-muted-foreground mt-1">
Interests for this yacht will appear here once a sales rep links them. Add an interest
from the Interests tab on the owner&rsquo;s client page.
</p>
</div>
);
}
return (
<ul className="space-y-2">
{interests.map((i) => (
<li
key={i.id}
className="flex items-center gap-3 rounded-md border bg-muted/30 p-3 text-sm"
>
<span className="w-36 shrink-0 text-xs font-medium uppercase text-muted-foreground">
{stageLabel(i.pipelineStage)}
</span>
<span className="flex-1 truncate">{i.clientName ?? '-'}</span>
{(() => {
const label = deriveInterestBerthLabel(i.berthMoorings) ?? i.berthMooringNumber;
return label ? (
<span className="shrink-0 text-xs text-muted-foreground">Berth {label}</span>
) : null;
})()}
</li>
))}
</ul>
);
}
function YachtTenanciesTab({ yachtId }: { yachtId: string }) {
const routeParams = useParams<{ portSlug: string }>();
const portSlug = routeParams?.portSlug ?? '';
const [createOpen, setCreateOpen] = useState(false);
const { data, isLoading } = useQuery<{ data: TenancyRow[] }>({
queryKey: ['tenancies', 'by-yacht', yachtId],
queryFn: () => apiFetch(`/api/v1/tenancies?yachtId=${yachtId}&limit=50&order=desc`),
});
if (isLoading) return <p className="text-sm text-muted-foreground">Loading</p>;
return (
<div className="space-y-4">
<div className="flex items-center justify-end">
<PermissionGate resource="tenancies" action="manage">
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1.5 h-4 w-4" aria-hidden />
Create tenancy
</Button>
</PermissionGate>
</div>
<TenancyList
tenancies={data?.data ?? []}
showBerth
portSlug={portSlug}
emptyMessage="No tenancies for this yacht."
/>
<TenancyCreateDialog open={createOpen} onOpenChange={setCreateOpen} yachtId={yachtId} />
</div>
);
}
export function getYachtTabs({
yachtId,
currentUserId,
yacht,
tenanciesModuleEnabled = false,
}: YachtTabsOptions & { tenanciesModuleEnabled?: boolean }): DetailTab[] {
const tabs: DetailTab[] = [
{
id: 'overview',
label: 'Overview',
content: <OverviewTab yachtId={yachtId} yacht={yacht} currentUserId={currentUserId} />,
},
{
id: 'ownership-history',
label: 'Ownership History',
content: <YachtOwnershipHistory yachtId={yachtId} />,
},
{
id: 'interests',
label: 'Interests',
content: <YachtInterestsTab yachtId={yachtId} />,
},
{
id: 'notes',
label: 'Notes',
badge: yacht.noteCount,
content: (
<NotesList
entityType="yachts"
entityId={yachtId}
currentUserId={currentUserId}
aggregate
parentInvalidateKey={['yachts', yachtId]}
/>
),
},
{
id: 'activity',
label: 'Activity',
content: (
<EntityActivityFeed
endpoint={`/api/v1/yachts/${yachtId}/activity`}
emptyText="No activity recorded for this yacht yet."
/>
),
},
];
if (tenanciesModuleEnabled) {
// Insert after Interests (index 3) so the ordering reads:
// Overview → Ownership History → Interests → Tenancies.
tabs.splice(3, 0, {
id: 'tenancies',
label: 'Tenancies',
content: <YachtTenanciesTab yachtId={yachtId} />,
});
}
return tabs;
}