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