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

@@ -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,
});