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) => {
try {
const q = req.nextUrl.searchParams.get('q');
if (!q) {
return NextResponse.json({ data: [] });
}
// Empty `q` returns the most recent companies (capped to the same
// 10-row limit the search path uses). The companion CompanyPicker
// 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);
return NextResponse.json({ data: companies });
} catch (error) {

View File

@@ -44,6 +44,11 @@ interface ClientFormProps {
* or opening the create-interest dialog pre-filled with that
* clientId. Skipped in edit mode. */
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
* inquiry-inbox "Convert to client" triage step (P-4.5) so the rep
* doesn't retype values they just read in the inbox. The
@@ -84,6 +89,7 @@ export function ClientForm({
onOpenChange,
client,
onUseExistingClient,
onCreated,
prefill,
}: ClientFormProps) {
const queryClient = useQueryClient();
@@ -253,6 +259,7 @@ export function ClientForm({
body: { tagIds: tIds },
});
}
return null;
} else {
const res = await apiFetch<{ data: { id: string } }>('/api/v1/clients', {
method: 'POST',
@@ -287,13 +294,19 @@ export function ClientForm({
);
}
}
return { id: res.data.id };
}
},
onSuccess: () => {
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['clients'] });
// M-U10: confirm the write landed. Without this the rep closes
// the sheet not sure whether the create/edit actually saved.
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);
},
});

View File

@@ -25,7 +25,7 @@ import { DocumentTemplatePicker } from '@/components/documents/document-template
import { FileUploadZone } from '@/components/files/file-upload-zone';
import { apiFetch } from '@/lib/api/client';
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
// capitalized. Falls back to capitalize-first-letter for any value not in the
@@ -311,11 +311,17 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
<SelectContent>
{DOCUMENT_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t.replace(/_/g, ' ')}
{DOCUMENT_TYPE_LABELS[t]}
</SelectItem>
))}
</SelectContent>
</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 className="flex flex-col gap-2">
<Label className="text-xs">Title</Label>

View File

@@ -3,7 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link';
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 { Button } from '@/components/ui/button';
@@ -43,6 +43,24 @@ interface HubDoc {
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> = {
eoi: 'EOI',
contract: 'Contract',
@@ -335,6 +353,57 @@ function FlatFolderListing({
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
// 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"
>
<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
type="button"
aria-label={`${expanded ? 'Collapse' : 'Expand'} signers for ${doc.title}`}
aria-expanded={expanded}
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 />
) : (
<ChevronRight className="h-4 w-4" aria-hidden />
)}
</button>
{totalSigners > 0 ? (
<button
type="button"
aria-label={`${expanded ? 'Collapse' : 'Expand'} signers for ${doc.title}`}
aria-expanded={expanded}
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 />
) : (
<ChevronRight className="h-4 w-4" aria-hidden />
)}
</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
href={`/${portSlug}/documents/${doc.id}`}
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 (
<>
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by title..."
@@ -483,13 +594,13 @@ function FlatFolderListing({
</div>
) : null}
{isLoading ? (
{isLoading || filesLoading ? (
<ul className="rounded-md border bg-white">
{[0, 1, 2, 3, 4].map((i) => (
<li key={i} className="h-12 animate-pulse border-b last:border-b-0 bg-muted/40" />
))}
</ul>
) : documents.length === 0 && childFolders.length === 0 ? (
) : mergedRows.length === 0 && childFolders.length === 0 ? (
<EmptyState
icon={<FileText className="h-7 w-7" aria-hidden />}
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}>
@@ -535,7 +650,7 @@ function FlatFolderListing({
/>
</DialogContent>
</Dialog>
</>
</div>
);
}

View File

@@ -106,7 +106,7 @@ export function FilePreviewDialog({
return (
<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>
<DialogTitle className="flex items-center gap-2 truncate">
<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="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>
{rec.area ? <span className="text-xs text-muted-foreground">{rec.area}</span> : null}
<StatusPill status={statusToPill(rec.status)}>{formatStatus(rec.status)}</StatusPill>
<Popover>
<PopoverTrigger asChild>
@@ -219,10 +221,51 @@ function RecommendationCard({ rec, portSlug, onAdd }: RecommendationCardProps) {
</PopoverContent>
</Popover>
{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">
<Flame className="size-3" aria-hidden />
Heat {Math.round(rec.heat!.total)}
</span>
<Popover>
<PopoverTrigger asChild>
<button
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}
</div>
<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 { TagPicker } from '@/components/shared/tag-picker';
import { ReminderDaysInput } from '@/components/shared/reminder-days-input';
import { ClientForm } from '@/components/clients/client-form';
import { YachtForm } from '@/components/yachts/yacht-form';
import { YachtPicker } from '@/components/yachts/yacht-picker';
import { apiFetch } from '@/lib/api/client';
@@ -127,6 +128,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
const selectedBerthId = watch('berthId');
const selectedYachtId = watch('yachtId');
const [createYachtOpen, setCreateYachtOpen] = useState(false);
const [createClientOpen, setCreateClientOpen] = useState(false);
const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false);
// Auto-fill pipelineStage + leadCategory based on whether a berth was
@@ -258,6 +260,10 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
clientId: defaultClientId ?? '',
yachtId: undefined,
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,
tagIds: [],
});
@@ -369,7 +375,21 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
</h3>
<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>
<PopoverTrigger asChild>
<Button
@@ -813,6 +833,13 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
onCreated={(y) => setValue('yachtId', y.id, { shouldDirty: true })}
/>
)}
{createClientOpen && (
<ClientForm
open={createClientOpen}
onOpenChange={setCreateClientOpen}
onCreated={(id) => setValue('clientId', id, { shouldDirty: true })}
/>
)}
</Sheet>
);
}

View File

@@ -709,6 +709,10 @@ function OverviewTab({
}) {
const params = useParams<{ portSlug: string }>();
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 +
// 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
@@ -1464,17 +1468,20 @@ function OverviewTab({
<LinkedBerthsList interestId={interestId} />
{/* Berth recommender (plan §5.3) - always-mounted card driven by the
interest's desired dimensions. Renders an inline guidance message
when dimensions aren't set yet. */}
<BerthRecommenderPanel
interestId={interestId}
desiredLengthFt={toNum(interest.desiredLengthFt)}
desiredWidthFt={toNum(interest.desiredWidthFt)}
desiredDraftFt={toNum(interest.desiredDraftFt)}
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
linkedBerthCount={interest.linkedBerthCount ?? 0}
/>
{/* Berth recommender (plan §5.3) — surfaces only when the rep has
captured at least desired length. Without dimensions the panel's
guidance card is noise on Overview; the dedicated tab is also
hidden in the same condition below. */}
{hasDesiredDims ? (
<BerthRecommenderPanel
interestId={interestId}
desiredLengthFt={toNum(interest.desiredLengthFt)}
desiredWidthFt={toNum(interest.desiredWidthFt)}
desiredDraftFt={toNum(interest.desiredDraftFt)}
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
linkedBerthCount={interest.linkedBerthCount ?? 0}
/>
) : null}
{confirmDialog}
{/* Mounted at the Overview level so the EOI milestone's "Generate EOI"
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
// becomes the next active document).
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[] = [
{
@@ -1573,19 +1588,23 @@ export function getInterestTabs({
label: 'Documents',
content: <InterestDocumentsTab interestId={interestId} />,
},
{
id: 'recommendations',
label: 'Berth Recommendations',
content: (
<BerthRecommenderPanel
interestId={interestId}
desiredLengthFt={toNum(interest.desiredLengthFt)}
desiredWidthFt={toNum(interest.desiredWidthFt)}
desiredDraftFt={toNum(interest.desiredDraftFt)}
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
/>
),
},
...(hasDesiredDims
? [
{
id: 'recommendations',
label: 'Berth Recommendations',
content: (
<BerthRecommenderPanel
interestId={interestId}
desiredLengthFt={toNum(interest.desiredLengthFt)}
desiredWidthFt={toNum(interest.desiredWidthFt)}
desiredDraftFt={toNum(interest.desiredDraftFt)}
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
/>
),
} satisfies DetailTab,
]
: []),
{
id: 'activity',
label: 'Activity',

View File

@@ -138,8 +138,12 @@ export function BerthPicker({
return rows.filter((b) => b.mooringNumber.toLowerCase().includes(q));
}, [clientId, clientInterests, searchData, debounced]);
const labelFor = (o: BerthOption) =>
o.area ? `Berth ${o.mooringNumber} · ${o.area}` : `Berth ${o.mooringNumber}`;
// Mooring numbers already carry the area letter as their prefix
// (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
// 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' },
merge: { past: 'merged' },
revert: { past: 'reverted' },
transfer: { past: 'transferred' },
};
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') {
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);
}

View File

@@ -1,6 +1,8 @@
'use client';
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 { formatDistanceToNow } from 'date-fns';
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({
entityType,
entityId,
@@ -142,6 +166,8 @@ export function NotesList({
parentInvalidateKey,
}: NotesListProps) {
const queryClient = useQueryClient();
const routeParams = useParams<{ portSlug: string }>();
const portSlug = routeParams?.portSlug ?? '';
const invalidateAll = () => {
queryClient.invalidateQueries({ queryKey });
if (parentInvalidateKey) {
@@ -296,14 +322,34 @@ export function NotesList({
{aggregateOn &&
note.source &&
note.source !== SELF_SOURCE[entityType] &&
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]}`}
title={`From ${note.source}`}
>
{SOURCE_LABEL[note.source]} · {note.sourceLabel}
</span>
)}
note.sourceLabel &&
(() => {
// Source badge links to the originating entity so reps
// can pivot from "this note about a linked yacht" to
// the yacht detail page directly. Falls back to a
// plain span when no sourceId is present (rare; aggregator
// 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
interest was at when the note was authored. Lets a
rep trace how the deal's notes evolved (concerns

View File

@@ -1,6 +1,6 @@
'use client';
import { useParams } from 'next/navigation';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
@@ -153,11 +153,19 @@ export function TenancyList({
}: TenancyListProps) {
const routeParams = useParams<{ portSlug: string }>();
const portSlug = portSlugProp ?? routeParams?.portSlug ?? '';
const router = useRouter();
if (tenancies.length === 0) {
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 (
<div className="rounded-md border">
<Table>
@@ -174,7 +182,23 @@ export function TenancyList({
</TableHeader>
<TableBody>
{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 && (
<TableCell>
<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',
'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',
'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-
// corner so the dialog appears in place rather than flying in
// from top-right). The base fade-in/out classes above provide

View File

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

View File

@@ -384,6 +384,21 @@ export const DOCUMENT_TYPES = ['eoi', 'contract', 'nda', 'reservation_agreement'
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 ───────────────────────────────────────────────────────
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({
where: and(
eq(berthTenancies.clientId, id),
eq(berthTenancies.portId, portId),
eq(berthTenancies.status, 'active'),
inArray(berthTenancies.status, ['pending', 'active']),
),
columns: {
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 {
companies,
@@ -301,10 +301,19 @@ export async function listCompanies(portId: string, query: ListCompaniesInput) {
// ─── Autocomplete ────────────────────────────────────────────────────────────
export async function autocomplete(portId: string, q: string) {
const pattern = `%${q}%`;
return await db
.select()
.from(companies)
// Empty query → return the 10 most-recently-updated companies for the
// port so the picker has something to scan on first open. Non-empty
// query → ilike-match against name + legalName as before.
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(
and(
eq(companies.portId, portId),

View File

@@ -270,8 +270,19 @@ export async function deleteFile(id: string, portId: string, meta: AuditMeta) {
// ─── List ─────────────────────────────────────────────────────────────────────
export async function listFiles(portId: string, query: ListFilesInput) {
const { page, limit, sort, order, search, clientId, yachtId, companyId, interestId, category } =
query;
const {
page,
limit,
sort,
order,
search,
clientId,
yachtId,
companyId,
interestId,
category,
folderId,
} = query;
const filters = [];
@@ -290,6 +301,12 @@ export async function listFiles(portId: string, query: ListFilesInput) {
if (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 =
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);
// 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
await tx
.update(yachtOwnershipHistory)
@@ -267,13 +298,30 @@ export async function transferOwnership(
.where(eq(yachts.id, yachtId))
.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({
userId: meta.userId,
portId,
action: 'update',
action: 'transfer',
entityType: 'yacht',
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,
userAgent: meta.userAgent,
});