- InterestEoiTab history link renamed "Open" → "Open in Documents" so the cross-section nav target is unambiguous. - DocumentDetail Interest link sub-text now shows the derived `berthLabel` (formatBerthRange of the in-EOI-bundle subset, falling back to primary, then all linked berths). The link no longer duplicates the Client name; falls back to clientName or "No berths linked" when no berths exist. - New /<port>/residential/page.tsx redirects to /residential/clients so the breadcrumb's Residential link works. - Residential interests list — whole row is now a Link target (was hidden behind a trailing "View" link); hover + border accent on the full row. - Expenses PageHeader description "Track and manage port expenses" → "Track and manage business expenses" (drop the redundant "port", same audit pattern flagged in the queue). - DropdownMenu base content capped at `max-h-96` (was the Radix available-height variable, which stretched menus edge-to-edge); the existing internal scroll handles overflow. - Yacht Overview Notes block: replaced the legacy single-field textarea with the threaded `<NotesList entityType="yachts">` for parity with clients/interests/companies. Legacy `yacht.notes` column stays in schema for EOI/contract merge-field path. tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
383 lines
12 KiB
TypeScript
383 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useParams } from 'next/navigation';
|
|
|
|
import type { DetailTab } from '@/components/shared/detail-layout';
|
|
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
|
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
|
|
import { NotesList } from '@/components/shared/notes-list';
|
|
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
|
|
import { ReservationList, type ReservationRow } from '@/components/reservations/reservation-list';
|
|
import { RemindersInline } from '@/components/reminders/reminders-inline';
|
|
import { YachtOwnershipHistory } from '@/components/yachts/yacht-ownership-history';
|
|
import { apiFetch } from '@/lib/api/client';
|
|
import { stageLabel } from '@/lib/constants';
|
|
|
|
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 }>;
|
|
}
|
|
|
|
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 }: { label: string; children: React.ReactNode }) {
|
|
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">{children}</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;
|
|
}
|
|
const FT_PER_M = 3.28084;
|
|
const converted = isFt ? n / FT_PER_M : n * FT_PER_M;
|
|
const convertedStr = converted
|
|
.toFixed(2)
|
|
.replace(/\.0+$/, '')
|
|
.replace(/(\.\d)0$/, '$1');
|
|
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 (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{/* Identity */}
|
|
<div className="space-y-1">
|
|
<h3 className="text-sm font-medium mb-2">Identity</h3>
|
|
<dl>
|
|
<EditableRow label="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)">
|
|
<InlineEditableField value={yacht.lengthFt} onSave={saveDimension('lengthFt')} />
|
|
</EditableRow>
|
|
<EditableRow label="Width (ft)">
|
|
<InlineEditableField value={yacht.widthFt} onSave={saveDimension('widthFt')} />
|
|
</EditableRow>
|
|
<EditableRow label="Draft (ft)">
|
|
<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>
|
|
);
|
|
}
|
|
|
|
function YachtInterestsTab({ yachtId }: { yachtId: string }) {
|
|
const { data, isLoading } = useQuery<{
|
|
data: Array<{
|
|
id: string;
|
|
pipelineStage: string;
|
|
clientName: string | null;
|
|
berthMooringNumber: string | null;
|
|
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>
|
|
{i.berthMooringNumber && (
|
|
<span className="shrink-0 text-xs text-muted-foreground">
|
|
Berth {i.berthMooringNumber}
|
|
</span>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|
|
|
|
function YachtReservationsTab({ yachtId }: { yachtId: string }) {
|
|
const routeParams = useParams<{ portSlug: string }>();
|
|
const portSlug = routeParams?.portSlug ?? '';
|
|
|
|
const { data, isLoading } = useQuery<{ data: ReservationRow[] }>({
|
|
queryKey: ['berth-reservations', 'by-yacht', yachtId],
|
|
queryFn: () => apiFetch(`/api/v1/berth-reservations?yachtId=${yachtId}&limit=50&order=desc`),
|
|
});
|
|
|
|
if (isLoading) return <p className="text-sm text-muted-foreground">Loading…</p>;
|
|
|
|
return (
|
|
<ReservationList
|
|
reservations={data?.data ?? []}
|
|
showBerth
|
|
portSlug={portSlug}
|
|
emptyMessage="No reservations for this yacht."
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions): DetailTab[] {
|
|
return [
|
|
{
|
|
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: 'reservations',
|
|
label: 'Reservations',
|
|
content: <YachtReservationsTab yachtId={yachtId} />,
|
|
},
|
|
{
|
|
id: 'notes',
|
|
label: 'Notes',
|
|
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."
|
|
/>
|
|
),
|
|
},
|
|
];
|
|
}
|