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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user