feat(uat-polish): live-UAT round — dialog widths, recommender polish, inline create, tenancy + notes plumbing

Compendium of polish + small-fix work captured during the 2026-05-26
live UAT session. Every change has a corresponding entry in
docs/superpowers/audits/active-uat.md with file:line evidence + root
cause + alternatives considered.

Dialog primitive width
- DialogContent default bumped from sm:max-w-lg (512px) to
  sm:max-w-xl + lg:max-w-3xl so every consumer gets a sane desktop
  default. Confirm dialogs override DOWN, content-heavy dialogs
  override UP.
- FilePreviewDialog full-viewport via w-[min(95vw,1400px)] +
  h-[85vh] so PDFs render at usable width on real desktops.

Recommender card
- Heat badge now a Popover with the score (X/100), the formula in
  plain English, the four component breakdowns (recency / furthest
  stage / interest count / EOI count), and a pointer to the admin
  weight tuning page.
- Area letter span dropped from the card header - mooring number
  already prefixes it.
- BerthRecommenderPanel + the dedicated "Berth Recommendations" tab
  both hidden when interest.desiredLengthFt is null. The empty
  guidance card was reading as noise. interest-tabs.tsx computes
  hasDesiredDims once and gates the inline mount + tab strip
  spread off it.

BerthPicker
- Drop area suffix from row labels. Mooring number already carries
  the area letter prefix; group heading conveys the same context.
  Same fix flows to every BerthPicker consumer (tenancy
  create/renew/transfer, interest form, linked-berths picker).

CreateDocumentWizard
- DOCUMENT_TYPE_LABELS constant added to constants.ts. Wizard reads
  from the map instead of naive replace(/_/g, ' '): "EOI",
  "Contract", "NDA", "Reservation Agreement", "Other".
- "Other" option surfaces a hint pointing the rep at the Title
  field so they describe what the doc actually is.

InterestForm inline client + yacht create
- ClientForm gains an onCreated(clientId) callback. Mutation
  returns { id } in create mode so onSuccess can forward.
- InterestForm renders an "Add new" Button next to the Client label
  (create mode only - hidden on edit), opens ClientForm, auto-
  selects the new client into the draft. Mirrors the existing
  inline yacht-create pattern.
- Reset path includes source: 'manual' alongside the other create-
  mode defaults; the manual flow was dropping back to a blank
  source dropdown on reopen.

Tenancy list
- ClientTenanciesTab activeTenancies query now includes status
  IN ('pending', 'active'). Was filtering to active-only; pending
  rows from manual create + webhook auto-create were invisible on
  the client detail's Tenancies tab.
- TenancyList rows are now keyboard- and click-navigable to the
  tenancy detail page (Enter/Space included). Inner links + buttons
  stop propagation so per-cell navigation works.

NotesList source badge
- Aggregated-mode source badge ("Yacht / Test Yacht") is now a Link
  to the source entity's detail page. New sourceLinkFor helper
  centralises the URL mapping across clients/companies/yachts/
  interests + residential variants.

Yacht transfer audit log
- transferOwnership emits a distinct 'transfer' AuditAction (added
  to AuditAction union in src/lib/audit.ts) with old/new owner
  names resolved at write time. EntityActivityFeed renders
  "Matt transferred owner to Jane Smith" instead of "Matt updated
  this record." formatValueForField unwraps the { name } shape so
  the audit_logs Record<string, unknown> typing stays clean.
- yacht-transfer-dialog copy: dropped "atomic" jargon. Reads "The
  change is logged in the audit history" instead.

Companies autocomplete
- /api/v1/companies/autocomplete now returns the 10 most-recently-
  updated companies when the query string is empty. Was returning
  []. CompanyPicker popover opens with results to scan instead of a
  blank dropdown.

DocumentsHub FlatFolderListing
- Uploaded files (the files table) now merge into the documents
  table view via a parallel /api/v1/files?folderId=X query +
  client-side merge into a unified row list. listFiles service
  honours the folderId filter that was already accepted by the
  validator. New renderFileRow renders file rows with an "Uploaded
  file" type pill + "Stored" status pill, links the filename to
  the download URL. Existing FolderDropZone invalidation covers
  the new query, so drag-drop and New-document-menu uploads
  refresh the list without a page reload.
- FlatFolderListing wrapped in a vertically-spaced container so
  subfolders / search row / list have consistent gap.
- Per-row chevron only renders when totalSigners > 0; empty
  placeholder column kept so grid alignment doesn't jump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 20:07:45 +02:00
parent cae5d39607
commit 8e81670b11
19 changed files with 497 additions and 82 deletions

View File

@@ -6,10 +6,13 @@ import { autocomplete } from '@/lib/services/companies.service';
export const autocompleteHandler: RouteHandler = async (req, ctx) => { export const autocompleteHandler: RouteHandler = async (req, ctx) => {
try { try {
const q = req.nextUrl.searchParams.get('q'); // Empty `q` returns the most recent companies (capped to the same
if (!q) { // 10-row limit the search path uses). The companion CompanyPicker
return NextResponse.json({ data: [] }); // initially renders with an empty query because the popover opens
} // before the rep has typed anything — returning [] there hid every
// company even when the system had rows. Returning a recent list
// gives the rep something to scan or select immediately.
const q = req.nextUrl.searchParams.get('q') ?? '';
const companies = await autocomplete(ctx.portId, q); const companies = await autocomplete(ctx.portId, q);
return NextResponse.json({ data: companies }); return NextResponse.json({ data: companies });
} catch (error) { } catch (error) {

View File

@@ -44,6 +44,11 @@ interface ClientFormProps {
* or opening the create-interest dialog pre-filled with that * or opening the create-interest dialog pre-filled with that
* clientId. Skipped in edit mode. */ * clientId. Skipped in edit mode. */
onUseExistingClient?: (clientId: string) => void; onUseExistingClient?: (clientId: string) => void;
/** Optional callback fired with the newly-created client's id after a
* successful create. Lets a parent flow (e.g. the new-interest drawer)
* chain a "use the client I just created" follow-up without making the
* rep re-select. Not called in edit mode. */
onCreated?: (clientId: string) => void;
/** Optional initial values for the create flow - used by the /** Optional initial values for the create flow - used by the
* inquiry-inbox "Convert to client" triage step (P-4.5) so the rep * inquiry-inbox "Convert to client" triage step (P-4.5) so the rep
* doesn't retype values they just read in the inbox. The * doesn't retype values they just read in the inbox. The
@@ -84,6 +89,7 @@ export function ClientForm({
onOpenChange, onOpenChange,
client, client,
onUseExistingClient, onUseExistingClient,
onCreated,
prefill, prefill,
}: ClientFormProps) { }: ClientFormProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -253,6 +259,7 @@ export function ClientForm({
body: { tagIds: tIds }, body: { tagIds: tIds },
}); });
} }
return null;
} else { } else {
const res = await apiFetch<{ data: { id: string } }>('/api/v1/clients', { const res = await apiFetch<{ data: { id: string } }>('/api/v1/clients', {
method: 'POST', method: 'POST',
@@ -287,13 +294,19 @@ export function ClientForm({
); );
} }
} }
return { id: res.data.id };
} }
}, },
onSuccess: () => { onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['clients'] }); queryClient.invalidateQueries({ queryKey: ['clients'] });
// M-U10: confirm the write landed. Without this the rep closes // M-U10: confirm the write landed. Without this the rep closes
// the sheet not sure whether the create/edit actually saved. // the sheet not sure whether the create/edit actually saved.
toast.success(isEdit ? 'Client updated' : 'Client created'); toast.success(isEdit ? 'Client updated' : 'Client created');
// Parent flow gets a chance to chain off the new id (e.g. the
// new-interest drawer auto-selects the freshly-created client).
if (result && 'id' in result && result.id && onCreated) {
onCreated(result.id);
}
onOpenChange(false); onOpenChange(false);
}, },
}); });

View File

@@ -25,7 +25,7 @@ import { DocumentTemplatePicker } from '@/components/documents/document-template
import { FileUploadZone } from '@/components/files/file-upload-zone'; import { FileUploadZone } from '@/components/files/file-upload-zone';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error'; import { toastError } from '@/lib/api/toast-error';
import { DOCUMENT_TYPES } from '@/lib/constants'; import { DOCUMENT_TYPES, DOCUMENT_TYPE_LABELS } from '@/lib/constants';
// Display labels for SIGNER_ROLES - internal values stay lowercase, UI shows // Display labels for SIGNER_ROLES - internal values stay lowercase, UI shows
// capitalized. Falls back to capitalize-first-letter for any value not in the // capitalized. Falls back to capitalize-first-letter for any value not in the
@@ -311,11 +311,17 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
<SelectContent> <SelectContent>
{DOCUMENT_TYPES.map((t) => ( {DOCUMENT_TYPES.map((t) => (
<SelectItem key={t} value={t}> <SelectItem key={t} value={t}>
{t.replace(/_/g, ' ')} {DOCUMENT_TYPE_LABELS[t]}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{documentType === 'other' ? (
<p className="text-xs text-muted-foreground">
Use the Title below to describe the document - that&apos;s how it&apos;ll appear
everywhere it&apos;s referenced.
</p>
) : null}
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-xs">Title</Label> <Label className="text-xs">Title</Label>

View File

@@ -3,7 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ChevronDown, ChevronRight, FileText, Folder, Lock, Plus, Upload } from 'lucide-react'; import { ChevronDown, ChevronRight, FileText, Folder, Lock, Plus, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -43,6 +43,24 @@ interface HubDoc {
signers?: Array<{ id: string; signerEmail: string; signerName: string; status: string }>; signers?: Array<{ id: string; signerEmail: string; signerName: string; status: string }>;
} }
/**
* Lightweight files-table row used by FlatFolderListing's parallel files
* query. Folder views show both signature documents (the `documents`
* table) AND plain uploaded files (the `files` table) so reps see
* everything that physically lives in the folder in one list. The two
* sources merge into a unified `HubRow` for rendering.
*/
interface HubFile {
id: string;
filename: string;
originalName: string | null;
mimeType: string | null;
sizeBytes: number;
createdAt: string;
}
type HubRow = { kind: 'doc'; doc: HubDoc } | { kind: 'file'; file: HubFile };
const TYPE_LABELS: Record<string, string> = { const TYPE_LABELS: Record<string, string> = {
eoi: 'EOI', eoi: 'EOI',
contract: 'Contract', contract: 'Contract',
@@ -335,6 +353,57 @@ function FlatFolderListing({
filterDefinitions: [], filterDefinitions: [],
}); });
// Plain uploaded files in this folder. Lives in a separate table from
// signature documents, so we query it in parallel and merge into the
// unified row list. Skip the search/typeFilter for files (those filters
// are doc-specific); applying them client-side would create a confusing
// partial view where the search bar hides files unrelated to the query.
// folderId === null → root; UUID → that folder; the endpoint resolves
// `folderId=` as null to keep the URL idempotent across folder switches.
const filesQueryString = useMemo(() => {
const p = new URLSearchParams();
p.set('folderId', folderId ?? '');
p.set('limit', '50');
p.set('sort', 'createdAt');
p.set('order', 'desc');
return p.toString();
}, [folderId]);
const { data: filesResp, isLoading: filesLoading } = useQuery<{ data: HubFile[] }>({
queryKey: ['files', 'hub', 'folder', filesQueryString],
queryFn: async (): Promise<{ data: HubFile[] }> => {
const res = await fetch(`/api/v1/files?${filesQueryString}`, { credentials: 'include' });
if (!res.ok) return { data: [] };
return (await res.json()) as { data: HubFile[] };
},
});
const folderFiles: HubFile[] = useMemo(() => filesResp?.data ?? [], [filesResp]);
// Merge docs + files, sorted by createdAt desc so the most-recent item
// (regardless of source) is at the top. Filter file rows out when the
// type-chip is active (chips filter docs only — files have no
// documentType column, treating them as "uploads" type means they'd
// disappear under any chip selection, which is the right UX).
const mergedRows: HubRow[] = useMemo(() => {
const docRows = documents.map((doc) => ({ kind: 'doc' as const, doc }));
const fileRows = typeFilter
? []
: folderFiles
// Client-side search across filenames so the search box covers
// both sources uniformly.
.filter((f) =>
search
? (f.filename ?? '').toLowerCase().includes(search.toLowerCase()) ||
(f.originalName ?? '').toLowerCase().includes(search.toLowerCase())
: true,
)
.map((file) => ({ kind: 'file' as const, file }));
return [...docRows, ...fileRows].sort((a, b) => {
const aTime = a.kind === 'doc' ? a.doc.createdAt : a.file.createdAt;
const bTime = b.kind === 'doc' ? b.doc.createdAt : b.file.createdAt;
return bTime.localeCompare(aTime);
});
}, [documents, folderFiles, typeFilter, search]);
// Realtime invalidation is lifted to DocumentsHub so it survives mode // Realtime invalidation is lifted to DocumentsHub so it survives mode
// switches (root / entity-folder / flat-folder). Don't re-subscribe here. // switches (root / entity-folder / flat-folder). Don't re-subscribe here.
@@ -357,19 +426,25 @@ function FlatFolderListing({
className="border-b last:border-b-0 transition-colors hover:bg-gradient-brand-soft/40" className="border-b last:border-b-0 transition-colors hover:bg-gradient-brand-soft/40"
> >
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3 text-sm sm:grid sm:grid-cols-[auto_1fr_auto_auto_auto_auto] sm:gap-3"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3 text-sm sm:grid sm:grid-cols-[auto_1fr_auto_auto_auto_auto] sm:gap-3">
<button {totalSigners > 0 ? (
type="button" <button
aria-label={`${expanded ? 'Collapse' : 'Expand'} signers for ${doc.title}`} type="button"
aria-expanded={expanded} aria-label={`${expanded ? 'Collapse' : 'Expand'} signers for ${doc.title}`}
onClick={() => setExpandedDocId(expanded ? null : doc.id)} aria-expanded={expanded}
className="flex min-h-[44px] min-w-[44px] items-center justify-center text-muted-foreground transition-transform" onClick={() => setExpandedDocId(expanded ? null : doc.id)}
> className="flex min-h-[44px] min-w-[44px] items-center justify-center text-muted-foreground transition-transform"
{expanded ? ( >
<ChevronDown className="h-4 w-4" aria-hidden /> {expanded ? (
) : ( <ChevronDown className="h-4 w-4" aria-hidden />
<ChevronRight className="h-4 w-4" aria-hidden /> ) : (
)} <ChevronRight className="h-4 w-4" aria-hidden />
</button> )}
</button>
) : (
// Keep the column reserved so the grid layout stays aligned
// across rows; this row has no signers to expand into.
<span className="hidden h-[44px] w-[44px] sm:block" aria-hidden />
)}
<Link <Link
href={`/${portSlug}/documents/${doc.id}`} href={`/${portSlug}/documents/${doc.id}`}
className="min-w-0 truncate font-medium text-foreground hover:text-brand" className="min-w-0 truncate font-medium text-foreground hover:text-brand"
@@ -413,8 +488,44 @@ function FlatFolderListing({
); );
}; };
// Uploaded-file row — simpler than a signature doc since there's no
// signer/status concept. Links to the underlying file via download URL
// and surfaces an "Uploaded" type pill so the rep distinguishes it
// from signature workflows at a glance.
const renderFileRow = (file: HubFile) => {
return (
<li
key={`file:${file.id}`}
className="border-b last:border-b-0 transition-colors hover:bg-gradient-brand-soft/40"
>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3 text-sm sm:grid sm:grid-cols-[auto_1fr_auto_auto_auto_auto] sm:gap-3">
{/* Empty action column to align with doc-row layout */}
<span className="hidden h-[44px] w-[44px] sm:block" aria-hidden />
<a
href={`/api/v1/files/${file.id}/download`}
target="_blank"
rel="noreferrer"
className="min-w-0 truncate font-medium text-foreground hover:text-brand"
>
{file.originalName ?? file.filename}
</a>
<span className="text-xs text-muted-foreground">Uploaded file</span>
<StatusPill status="completed" withDot>
Stored
</StatusPill>
<span className="text-xs tabular-nums text-muted-foreground">
{(file.sizeBytes / 1024).toFixed(0)} KB
</span>
<span className="text-xs tabular-nums text-muted-foreground">
{new Date(file.createdAt).toLocaleDateString(undefined)}
</span>
</div>
</li>
);
};
return ( return (
<> <div className="space-y-4">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Input <Input
placeholder="Search by title..." placeholder="Search by title..."
@@ -483,13 +594,13 @@ function FlatFolderListing({
</div> </div>
) : null} ) : null}
{isLoading ? ( {isLoading || filesLoading ? (
<ul className="rounded-md border bg-white"> <ul className="rounded-md border bg-white">
{[0, 1, 2, 3, 4].map((i) => ( {[0, 1, 2, 3, 4].map((i) => (
<li key={i} className="h-12 animate-pulse border-b last:border-b-0 bg-muted/40" /> <li key={i} className="h-12 animate-pulse border-b last:border-b-0 bg-muted/40" />
))} ))}
</ul> </ul>
) : documents.length === 0 && childFolders.length === 0 ? ( ) : mergedRows.length === 0 && childFolders.length === 0 ? (
<EmptyState <EmptyState
icon={<FileText className="h-7 w-7" aria-hidden />} icon={<FileText className="h-7 w-7" aria-hidden />}
title="No documents in this folder" title="No documents in this folder"
@@ -510,7 +621,11 @@ function FlatFolderListing({
} }
/> />
) : ( ) : (
<ul className="rounded-md border bg-white shadow-xs">{documents.map(renderRow)}</ul> <ul className="rounded-md border bg-white shadow-xs">
{mergedRows.map((row) =>
row.kind === 'doc' ? renderRow(row.doc) : renderFileRow(row.file),
)}
</ul>
)} )}
<Dialog open={uploadOpen} onOpenChange={setUploadOpen}> <Dialog open={uploadOpen} onOpenChange={setUploadOpen}>
@@ -535,7 +650,7 @@ function FlatFolderListing({
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</> </div>
); );
} }

View File

@@ -106,7 +106,7 @@ export function FilePreviewDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl w-full h-[80vh] flex flex-col"> <DialogContent className="w-[min(95vw,1400px)] sm:max-w-none lg:max-w-none h-[85vh] flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 truncate"> <DialogTitle className="flex items-center gap-2 truncate">
<span className="truncate">{fileName ?? 'Preview'}</span> <span className="truncate">{fileName ?? 'Preview'}</span>

View File

@@ -179,8 +179,10 @@ function RecommendationCard({ rec, portSlug, onAdd }: RecommendationCardProps) {
> >
<div className="min-w-0 flex-1 space-y-1"> <div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{/* Mooring number already carries the area letter prefix
(canonical `^[A-Z]+\d+$`), so the trailing `· A` was pure
visual noise — same call as the BerthPicker grouping. */}
<span className="font-semibold">{rec.mooringNumber}</span> <span className="font-semibold">{rec.mooringNumber}</span>
{rec.area ? <span className="text-xs text-muted-foreground">{rec.area}</span> : null}
<StatusPill status={statusToPill(rec.status)}>{formatStatus(rec.status)}</StatusPill> <StatusPill status={statusToPill(rec.status)}>{formatStatus(rec.status)}</StatusPill>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@@ -219,10 +221,51 @@ function RecommendationCard({ rec, portSlug, onAdd }: RecommendationCardProps) {
</PopoverContent> </PopoverContent>
</Popover> </Popover>
{showHeat ? ( {showHeat ? (
<span className="inline-flex items-center gap-1 rounded-md border border-rose-200 bg-rose-50 px-2 py-0.5 text-xs font-medium text-rose-800"> <Popover>
<Flame className="size-3" aria-hidden /> <PopoverTrigger asChild>
Heat {Math.round(rec.heat!.total)} <button
</span> type="button"
className="inline-flex items-center gap-1 rounded-md border border-rose-200 bg-rose-50 px-2 py-0.5 text-xs font-medium text-rose-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Heat score: ${Math.round(rec.heat!.total)} out of 100`}
>
<Flame className="size-3" aria-hidden />
Heat {Math.round(rec.heat!.total)}
<HelpCircle className="size-3 opacity-60" aria-hidden />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-80 text-xs leading-relaxed">
<p className="font-medium text-foreground">
Heat score · {Math.round(rec.heat!.total)} / 100
</p>
<p className="mt-2 text-muted-foreground">
How warm this berth is for a re-pitch. Calculated from past interest history:
how recent the last fall-through was, how far along it got, how many distinct
interests touched it, and how many of those reached EOI. Higher = better target.
</p>
<ul className="mt-3 space-y-1 text-muted-foreground">
<li>
<span className="font-medium text-foreground">Recency</span>:{' '}
{Math.round(rec.heat!.recency)}
</li>
<li>
<span className="font-medium text-foreground">Furthest stage</span>:{' '}
{Math.round(rec.heat!.furthestStage)}
</li>
<li>
<span className="font-medium text-foreground">Interest count</span>:{' '}
{Math.round(rec.heat!.interestCount)}
</li>
<li>
<span className="font-medium text-foreground">EOI count</span>:{' '}
{Math.round(rec.heat!.eoiCount)}
</li>
</ul>
<p className="mt-3 text-muted-foreground">
Admins tune the weights in{' '}
<span className="font-medium">Admin Recommender</span>.
</p>
</PopoverContent>
</Popover>
) : null} ) : null}
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">

View File

@@ -43,6 +43,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { TagPicker } from '@/components/shared/tag-picker'; import { TagPicker } from '@/components/shared/tag-picker';
import { ReminderDaysInput } from '@/components/shared/reminder-days-input'; import { ReminderDaysInput } from '@/components/shared/reminder-days-input';
import { ClientForm } from '@/components/clients/client-form';
import { YachtForm } from '@/components/yachts/yacht-form'; import { YachtForm } from '@/components/yachts/yacht-form';
import { YachtPicker } from '@/components/yachts/yacht-picker'; import { YachtPicker } from '@/components/yachts/yacht-picker';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
@@ -127,6 +128,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
const selectedBerthId = watch('berthId'); const selectedBerthId = watch('berthId');
const selectedYachtId = watch('yachtId'); const selectedYachtId = watch('yachtId');
const [createYachtOpen, setCreateYachtOpen] = useState(false); const [createYachtOpen, setCreateYachtOpen] = useState(false);
const [createClientOpen, setCreateClientOpen] = useState(false);
const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false); const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false);
// Auto-fill pipelineStage + leadCategory based on whether a berth was // Auto-fill pipelineStage + leadCategory based on whether a berth was
@@ -258,6 +260,10 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
clientId: defaultClientId ?? '', clientId: defaultClientId ?? '',
yachtId: undefined, yachtId: undefined,
pipelineStage: 'enquiry', pipelineStage: 'enquiry',
// Mirror the defaultValues block — manual-create flow always
// defaults source to 'manual'. The reset path was dropping it,
// leaving a freshly-opened drawer with a blank source selector.
source: 'manual',
reminderEnabled: false, reminderEnabled: false,
tagIds: [], tagIds: [],
}); });
@@ -369,7 +375,21 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
</h3> </h3>
<div className="space-y-1"> <div className="space-y-1">
<Label>Client *</Label> <div className="flex items-center justify-between gap-2">
<Label>Client *</Label>
{!isEdit && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => setCreateClientOpen(true)}
>
<Plus className="mr-1 h-3 w-3" aria-hidden />
Add new
</Button>
)}
</div>
<Popover open={clientOpen} onOpenChange={setClientOpen} modal> <Popover open={clientOpen} onOpenChange={setClientOpen} modal>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
@@ -813,6 +833,13 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
onCreated={(y) => setValue('yachtId', y.id, { shouldDirty: true })} onCreated={(y) => setValue('yachtId', y.id, { shouldDirty: true })}
/> />
)} )}
{createClientOpen && (
<ClientForm
open={createClientOpen}
onOpenChange={setCreateClientOpen}
onCreated={(id) => setValue('clientId', id, { shouldDirty: true })}
/>
)}
</Sheet> </Sheet>
); );
} }

View File

@@ -709,6 +709,10 @@ function OverviewTab({
}) { }) {
const params = useParams<{ portSlug: string }>(); const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? ''; const portSlug = params?.portSlug ?? '';
// Same gate the parent uses to skip the dedicated Berth Recommendations
// tab — without desired length the recommender has no ranking signal,
// so the empty-state guidance card was reading as Overview noise.
const hasDesiredDims = toNum(interest.desiredLengthFt) !== null;
// QueryClient lifted to the top of the tab so the inline-edit email + // QueryClient lifted to the top of the tab so the inline-edit email +
// Lift the EOI generate dialog into the Overview so the milestone card // Lift the EOI generate dialog into the Overview so the milestone card
// can launch it inline - same dialog the dedicated EOI tab uses, so the // can launch it inline - same dialog the dedicated EOI tab uses, so the
@@ -1464,17 +1468,20 @@ function OverviewTab({
<LinkedBerthsList interestId={interestId} /> <LinkedBerthsList interestId={interestId} />
{/* Berth recommender (plan §5.3) - always-mounted card driven by the {/* Berth recommender (plan §5.3) — surfaces only when the rep has
interest's desired dimensions. Renders an inline guidance message captured at least desired length. Without dimensions the panel's
when dimensions aren't set yet. */} guidance card is noise on Overview; the dedicated tab is also
<BerthRecommenderPanel hidden in the same condition below. */}
interestId={interestId} {hasDesiredDims ? (
desiredLengthFt={toNum(interest.desiredLengthFt)} <BerthRecommenderPanel
desiredWidthFt={toNum(interest.desiredWidthFt)} interestId={interestId}
desiredDraftFt={toNum(interest.desiredDraftFt)} desiredLengthFt={toNum(interest.desiredLengthFt)}
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'} desiredWidthFt={toNum(interest.desiredWidthFt)}
linkedBerthCount={interest.linkedBerthCount ?? 0} desiredDraftFt={toNum(interest.desiredDraftFt)}
/> desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
linkedBerthCount={interest.linkedBerthCount ?? 0}
/>
) : null}
{confirmDialog} {confirmDialog}
{/* Mounted at the Overview level so the EOI milestone's "Generate EOI" {/* Mounted at the Overview level so the EOI milestone's "Generate EOI"
footer button can launch the dialog without leaving the tab. Same footer button can launch the dialog without leaving the tab. Same
@@ -1517,6 +1524,14 @@ export function getInterestTabs({
// Contract: from deposit_paid onward (deal is committed and the contract // Contract: from deposit_paid onward (deal is committed and the contract
// becomes the next active document). // becomes the next active document).
const showContractTab = stageIdx >= depositIdx && stageIdx <= contractIdx; const showContractTab = stageIdx >= depositIdx && stageIdx <= contractIdx;
// Berth recommendations: surface ONLY when the rep has captured at
// least one desired dimension on the interest. Without dimensions the
// recommender has no signal to rank against — it would render the
// empty "set desired dimensions" guidance card, which the user flagged
// as noise on the Overview tab AND as a wasted tab in the strip.
// Hide both surfaces when length is missing (length is the primary
// ranking input; width / draft fall back to length when null).
const hasDesiredDims = toNum(interest.desiredLengthFt) !== null;
const tabs: DetailTab[] = [ const tabs: DetailTab[] = [
{ {
@@ -1573,19 +1588,23 @@ export function getInterestTabs({
label: 'Documents', label: 'Documents',
content: <InterestDocumentsTab interestId={interestId} />, content: <InterestDocumentsTab interestId={interestId} />,
}, },
{ ...(hasDesiredDims
id: 'recommendations', ? [
label: 'Berth Recommendations', {
content: ( id: 'recommendations',
<BerthRecommenderPanel label: 'Berth Recommendations',
interestId={interestId} content: (
desiredLengthFt={toNum(interest.desiredLengthFt)} <BerthRecommenderPanel
desiredWidthFt={toNum(interest.desiredWidthFt)} interestId={interestId}
desiredDraftFt={toNum(interest.desiredDraftFt)} desiredLengthFt={toNum(interest.desiredLengthFt)}
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'} desiredWidthFt={toNum(interest.desiredWidthFt)}
/> desiredDraftFt={toNum(interest.desiredDraftFt)}
), desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
}, />
),
} satisfies DetailTab,
]
: []),
{ {
id: 'activity', id: 'activity',
label: 'Activity', label: 'Activity',

View File

@@ -138,8 +138,12 @@ export function BerthPicker({
return rows.filter((b) => b.mooringNumber.toLowerCase().includes(q)); return rows.filter((b) => b.mooringNumber.toLowerCase().includes(q));
}, [clientId, clientInterests, searchData, debounced]); }, [clientId, clientInterests, searchData, debounced]);
const labelFor = (o: BerthOption) => // Mooring numbers already carry the area letter as their prefix
o.area ? `Berth ${o.mooringNumber} · ${o.area}` : `Berth ${o.mooringNumber}`; // (canonical form `^[A-Z]+\d+$` per CLAUDE.md), and the option list
// is grouped under the area letter heading. Repeating " · A" after
// "Berth A1" reads as noise — drop the area suffix from the row
// label. The grouping heading still conveys the same info.
const labelFor = (o: BerthOption) => `Berth ${o.mooringNumber}`;
// Group helper outside render so memoization works; takes/returns plain // Group helper outside render so memoization works; takes/returns plain
// values so the same logic plugs into linked-berths and recommender pickers later. // values so the same logic plugs into linked-berths and recommender pickers later.

View File

@@ -31,6 +31,7 @@ const ACTION_VERBS: Record<string, { past: string }> = {
restore: { past: 'restored' }, restore: { past: 'restored' },
merge: { past: 'merged' }, merge: { past: 'merged' },
revert: { past: 'reverted' }, revert: { past: 'reverted' },
transfer: { past: 'transferred' },
}; };
function actionVerb(action: string): string { function actionVerb(action: string): string {
@@ -62,6 +63,18 @@ function formatValueForField(field: string | null, value: unknown): string {
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value); return String(value);
} }
// Owner / ownership transfers stash the human label on `value.name`
// (the audit-log table requires Record<string, unknown> for the
// value columns). Surface it as the cell's printed label so the feed
// reads "set owner to Jane Smith" instead of `{"name":"Jane Smith"}`.
if (
value &&
typeof value === 'object' &&
'name' in value &&
typeof (value as { name: unknown }).name === 'string'
) {
return (value as { name: string }).name;
}
return JSON.stringify(value); return JSON.stringify(value);
} }

View File

@@ -1,6 +1,8 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery, useMutation, useQueryClient, type QueryKey } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient, type QueryKey } from '@tanstack/react-query';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { Lock, Pencil, Trash2, Send, Loader2 } from 'lucide-react'; import { Lock, Pencil, Trash2, Send, Loader2 } from 'lucide-react';
@@ -134,6 +136,28 @@ function sortByGroup(notes: Note[]): Note[] {
}); });
} }
/** Resolve the detail-page URL for a note's source entity so the
* aggregated-mode source badge can navigate the rep to that record. */
function sourceLinkFor(portSlug: string, source: NoteSource, sourceId: string): string | null {
if (!portSlug) return null;
switch (source) {
case 'client':
return `/${portSlug}/clients/${sourceId}`;
case 'company':
return `/${portSlug}/companies/${sourceId}`;
case 'yacht':
return `/${portSlug}/yachts/${sourceId}`;
case 'interest':
return `/${portSlug}/interests/${sourceId}`;
case 'residential_client':
return `/${portSlug}/residential/clients/${sourceId}`;
case 'residential_interest':
return `/${portSlug}/residential/interests/${sourceId}`;
default:
return null;
}
}
export function NotesList({ export function NotesList({
entityType, entityType,
entityId, entityId,
@@ -142,6 +166,8 @@ export function NotesList({
parentInvalidateKey, parentInvalidateKey,
}: NotesListProps) { }: NotesListProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const routeParams = useParams<{ portSlug: string }>();
const portSlug = routeParams?.portSlug ?? '';
const invalidateAll = () => { const invalidateAll = () => {
queryClient.invalidateQueries({ queryKey }); queryClient.invalidateQueries({ queryKey });
if (parentInvalidateKey) { if (parentInvalidateKey) {
@@ -296,14 +322,34 @@ export function NotesList({
{aggregateOn && {aggregateOn &&
note.source && note.source &&
note.source !== SELF_SOURCE[entityType] && note.source !== SELF_SOURCE[entityType] &&
note.sourceLabel && ( note.sourceLabel &&
<span (() => {
className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium ${SOURCE_BADGE_CLASS[note.source]}`} // Source badge links to the originating entity so reps
title={`From ${note.source}`} // can pivot from "this note about a linked yacht" to
> // the yacht detail page directly. Falls back to a
{SOURCE_LABEL[note.source]} · {note.sourceLabel} // plain span when no sourceId is present (rare; aggregator
</span> // returns it for every materialised note).
)} const sourceHref = note.sourceId
? sourceLinkFor(portSlug, note.source, note.sourceId)
: null;
const className = `inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium ${SOURCE_BADGE_CLASS[note.source]} ${sourceHref ? 'hover:opacity-80 transition-opacity' : ''}`;
const body = `${SOURCE_LABEL[note.source]} · ${note.sourceLabel}`;
const title = `Open this ${note.source}`;
return sourceHref ? (
<Link
href={sourceHref as never}
className={className}
title={title}
onClick={(e) => e.stopPropagation()}
>
{body}
</Link>
) : (
<span className={className} title={`From ${note.source}`}>
{body}
</span>
);
})()}
{/* Pipeline-stage stamp: shows what stage the linked {/* Pipeline-stage stamp: shows what stage the linked
interest was at when the note was authored. Lets a interest was at when the note was authored. Lets a
rep trace how the deal's notes evolved (concerns rep trace how the deal's notes evolved (concerns

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useParams } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@@ -153,11 +153,19 @@ export function TenancyList({
}: TenancyListProps) { }: TenancyListProps) {
const routeParams = useParams<{ portSlug: string }>(); const routeParams = useParams<{ portSlug: string }>();
const portSlug = portSlugProp ?? routeParams?.portSlug ?? ''; const portSlug = portSlugProp ?? routeParams?.portSlug ?? '';
const router = useRouter();
if (tenancies.length === 0) { if (tenancies.length === 0) {
return <EmptyState title="No tenancies" description={emptyMessage ?? 'No tenancies yet.'} />; return <EmptyState title="No tenancies" description={emptyMessage ?? 'No tenancies yet.'} />;
} }
// Row-level navigation: clicking anywhere on the row (except inside
// an inner `<a>` or `<button>`) opens the tenancy detail page. Inner
// links (BerthLink / ClientLink / YachtLink) and the "View contract"
// button keep their own behaviour because the click handler bails
// when the target is inside an interactive element.
const openTenancy = (id: string) => router.push(`/${portSlug}/tenancies/${id}` as never);
return ( return (
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <Table>
@@ -174,7 +182,23 @@ export function TenancyList({
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{tenancies.map((r) => ( {tenancies.map((r) => (
<TableRow key={r.id}> <TableRow
key={r.id}
role="link"
tabIndex={0}
className="cursor-pointer hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={(e) => {
if ((e.target as HTMLElement).closest('a, button')) return;
openTenancy(r.id);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if ((e.target as HTMLElement).closest('a, button')) return;
e.preventDefault();
openTenancy(r.id);
}
}}
>
{showBerth && ( {showBerth && (
<TableCell> <TableCell>
<BerthLink berthId={r.berthId} portSlug={portSlug} /> <BerthLink berthId={r.berthId} portSlug={portSlug} />

View File

@@ -50,7 +50,13 @@ const DialogContent = React.forwardRef<
'fixed top-0 right-0 bottom-0 left-0 z-50 grid w-full gap-4 border-0 bg-background p-4 shadow-lg duration-200 sm:p-6', 'fixed top-0 right-0 bottom-0 left-0 z-50 grid w-full gap-4 border-0 bg-background p-4 shadow-lg duration-200 sm:p-6',
'max-h-dvh overflow-y-auto sm:max-h-[calc(100dvh-2rem)]', 'max-h-dvh overflow-y-auto sm:max-h-[calc(100dvh-2rem)]',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'sm:top-[50%] sm:right-auto sm:bottom-auto sm:left-[50%] sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:border sm:rounded-lg', // Default width: bumped 2026-05-26 from `sm:max-w-lg` (32rem) to
// `sm:max-w-xl lg:max-w-3xl` so every Dialog has a generous
// desktop default. Confirm dialogs override DOWN with
// `sm:max-w-md`; content-heavy dialogs (file preview, signing
// details, EOI generate) override UP with `lg:max-w-5xl` or
// `lg:max-w-[min(95vw,1400px)]`.
'sm:top-[50%] sm:right-auto sm:bottom-auto sm:left-[50%] sm:max-w-xl lg:max-w-3xl sm:translate-x-[-50%] sm:translate-y-[-50%] sm:border sm:rounded-lg',
// Desktop animation: subtle centered fade + zoom (no slide-from- // Desktop animation: subtle centered fade + zoom (no slide-from-
// corner so the dialog appears in place rather than flying in // corner so the dialog appears in place rather than flying in
// from top-right). The base fade-in/out classes above provide // from top-right). The base fade-in/out classes above provide

View File

@@ -132,8 +132,8 @@ export function YachtTransferDialog({
<DialogHeader> <DialogHeader>
<DialogTitle>Transfer ownership</DialogTitle> <DialogTitle>Transfer ownership</DialogTitle>
<DialogDescription> <DialogDescription>
This will close the current ownership record and open a new one. The change is auditable This will close the current ownership record and open a new one. The change is logged in
and atomic. the audit history.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>

View File

@@ -384,6 +384,21 @@ export const DOCUMENT_TYPES = ['eoi', 'contract', 'nda', 'reservation_agreement'
export type DocumentType = (typeof DOCUMENT_TYPES)[number]; export type DocumentType = (typeof DOCUMENT_TYPES)[number];
/**
* Display labels for `DOCUMENT_TYPES`. Use these everywhere a doc type
* is rendered in user-facing copy (selectors, badges, exports). The
* raw enum values are kebab-case-ish and not safe to title-case via
* a naive `replace(/_/g, ' ')` — "Eoi"/"Nda" read wrong; the proper
* labels surface acronyms and friendly multi-word forms.
*/
export const DOCUMENT_TYPE_LABELS: Record<DocumentType, string> = {
eoi: 'EOI',
contract: 'Contract',
nda: 'NDA',
reservation_agreement: 'Reservation Agreement',
other: 'Other',
};
// ─── Document Statuses ─────────────────────────────────────────────────────── // ─── Document Statuses ───────────────────────────────────────────────────────
export const DOCUMENT_STATUSES = [ export const DOCUMENT_STATUSES = [

View File

@@ -412,11 +412,18 @@ export async function getClientById(id: string, portId: string) {
), ),
); );
// Include pending tenancies alongside active ones — a tenancy starts
// in `pending` (auto-created from a signed Reservation Agreement, or
// manually created via the "Create tenancy" button) and stays pending
// until the rep confirms start date + tenure type via the
// pending→active activation flow. Reps need to SEE pending rows on
// the client tab to act on them; only filtering to `active` hid the
// freshly-created tenancy entirely (UAT 2026-05-26).
const activeTenancies = await db.query.berthTenancies.findMany({ const activeTenancies = await db.query.berthTenancies.findMany({
where: and( where: and(
eq(berthTenancies.clientId, id), eq(berthTenancies.clientId, id),
eq(berthTenancies.portId, portId), eq(berthTenancies.portId, portId),
eq(berthTenancies.status, 'active'), inArray(berthTenancies.status, ['pending', 'active']),
), ),
columns: { columns: {
id: true, id: true,

View File

@@ -1,4 +1,4 @@
import { and, count, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm'; import { and, count, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { import {
companies, companies,
@@ -301,10 +301,19 @@ export async function listCompanies(portId: string, query: ListCompaniesInput) {
// ─── Autocomplete ──────────────────────────────────────────────────────────── // ─── Autocomplete ────────────────────────────────────────────────────────────
export async function autocomplete(portId: string, q: string) { export async function autocomplete(portId: string, q: string) {
const pattern = `%${q}%`; // Empty query → return the 10 most-recently-updated companies for the
return await db // port so the picker has something to scan on first open. Non-empty
.select() // query → ilike-match against name + legalName as before.
.from(companies) const trimmed = q.trim();
const baseQuery = db.select().from(companies);
if (!trimmed) {
return await baseQuery
.where(eq(companies.portId, portId))
.orderBy(desc(companies.updatedAt))
.limit(10);
}
const pattern = `%${trimmed}%`;
return await baseQuery
.where( .where(
and( and(
eq(companies.portId, portId), eq(companies.portId, portId),

View File

@@ -270,8 +270,19 @@ export async function deleteFile(id: string, portId: string, meta: AuditMeta) {
// ─── List ───────────────────────────────────────────────────────────────────── // ─── List ─────────────────────────────────────────────────────────────────────
export async function listFiles(portId: string, query: ListFilesInput) { export async function listFiles(portId: string, query: ListFilesInput) {
const { page, limit, sort, order, search, clientId, yachtId, companyId, interestId, category } = const {
query; page,
limit,
sort,
order,
search,
clientId,
yachtId,
companyId,
interestId,
category,
folderId,
} = query;
const filters = []; const filters = [];
@@ -290,6 +301,12 @@ export async function listFiles(portId: string, query: ListFilesInput) {
if (category) { if (category) {
filters.push(eq(files.category, category)); filters.push(eq(files.category, category));
} }
// folderId === null sentinel is the root folder (no parent); a UUID
// narrows to that specific folder. `undefined` returns files across
// every folder for the port (existing legacy behaviour).
if (folderId !== undefined) {
filters.push(folderId === null ? isNull(files.folderId) : eq(files.folderId, folderId));
}
const sortColumn = const sortColumn =
sort === 'filename' ? files.filename : sort === 'sizeBytes' ? files.sizeBytes : files.createdAt; sort === 'filename' ? files.filename : sort === 'sizeBytes' ? files.sizeBytes : files.createdAt;

View File

@@ -233,6 +233,37 @@ export async function transferOwnership(
await assertOwnerExists(portId, data.newOwner, tx); await assertOwnerExists(portId, data.newOwner, tx);
// Resolve old + new owner names so the audit log row reads as a
// sentence ("Matt transferred owner from Smith to Jones") rather
// than a generic "updated this record." Resolution mirrors the
// assertOwnerExists pattern — same client/company tables, scoped
// to the same port.
const resolveOwnerName = async (
ownerType: string | null,
ownerId: string | null,
): Promise<string | null> => {
if (!ownerType || !ownerId) return null;
if (ownerType === 'client') {
const row = await tx.query.clients.findFirst({
where: and(eq(clients.id, ownerId), eq(clients.portId, portId)),
columns: { fullName: true },
});
return row?.fullName ?? null;
}
if (ownerType === 'company') {
const row = await tx.query.companies.findFirst({
where: and(eq(companies.id, ownerId), eq(companies.portId, portId)),
columns: { name: true },
});
return row?.name ?? null;
}
return null;
};
const [oldOwnerName, newOwnerName] = await Promise.all([
resolveOwnerName(yacht.currentOwnerType, yacht.currentOwnerId),
resolveOwnerName(data.newOwner.type, data.newOwner.id),
]);
// Close the currently-active history row // Close the currently-active history row
await tx await tx
.update(yachtOwnershipHistory) .update(yachtOwnershipHistory)
@@ -267,13 +298,30 @@ export async function transferOwnership(
.where(eq(yachts.id, yachtId)) .where(eq(yachts.id, yachtId))
.returning(); .returning();
// Audit log shape designed for the EntityActivityFeed sentence
// formatter: a discrete `transfer` action + human-readable owner
// names render as "Matt transferred owner from X to Y" instead of
// the generic "updated this record." Reason + new-owner-type
// ride along in metadata for downstream consumers that need the
// structured form.
void createAuditLog({ void createAuditLog({
userId: meta.userId, userId: meta.userId,
portId, portId,
action: 'update', action: 'transfer',
entityType: 'yacht', entityType: 'yacht',
entityId: yachtId, entityId: yachtId,
newValue: { ownerTransferTo: data.newOwner, reason: data.transferReason }, fieldChanged: 'owner',
// oldValue/newValue are Record<string, unknown> in the audit schema;
// wrap the owner-name strings in a `name` field so the type matches
// and the feed's `formatValueForField` can pluck the readable label.
oldValue: oldOwnerName ? { name: oldOwnerName } : undefined,
newValue: newOwnerName ? { name: newOwnerName } : undefined,
metadata: {
newOwnerType: data.newOwner.type,
newOwnerId: data.newOwner.id,
reason: data.transferReason ?? null,
notes: data.transferNotes ?? null,
},
ipAddress: meta.ipAddress, ipAddress: meta.ipAddress,
userAgent: meta.userAgent, userAgent: meta.userAgent,
}); });