- 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>
439 lines
15 KiB
TypeScript
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’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;
|
|
}
|