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

@@ -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',