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

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