From 8e81670b114edbf43703cf8014b005c9a88dd8da Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 26 May 2026 20:07:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(uat-polish):=20live-UAT=20round=20?= =?UTF-8?q?=E2=80=94=20dialog=20widths,=20recommender=20polish,=20inline?= =?UTF-8?q?=20create,=20tenancy=20+=20notes=20plumbing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- .../api/v1/companies/autocomplete/handlers.ts | 11 +- src/components/clients/client-form.tsx | 15 +- .../documents/create-document-wizard.tsx | 10 +- src/components/documents/documents-hub.tsx | 153 +++++++++++++++--- src/components/files/file-preview-dialog.tsx | 2 +- .../interests/berth-recommender-panel.tsx | 53 +++++- src/components/interests/interest-form.tsx | 29 +++- src/components/interests/interest-tabs.tsx | 67 +++++--- src/components/shared/berth-picker.tsx | 8 +- .../shared/entity-activity-feed.tsx | 13 ++ src/components/shared/notes-list.tsx | 62 ++++++- src/components/tenancies/tenancy-list.tsx | 28 +++- src/components/ui/dialog.tsx | 8 +- .../yachts/yacht-transfer-dialog.tsx | 4 +- src/lib/constants.ts | 15 ++ src/lib/services/clients.service.ts | 9 +- src/lib/services/companies.service.ts | 19 ++- src/lib/services/files.ts | 21 ++- src/lib/services/yachts.service.ts | 52 +++++- 19 files changed, 497 insertions(+), 82 deletions(-) diff --git a/src/app/api/v1/companies/autocomplete/handlers.ts b/src/app/api/v1/companies/autocomplete/handlers.ts index f08b3e70..25dabd24 100644 --- a/src/app/api/v1/companies/autocomplete/handlers.ts +++ b/src/app/api/v1/companies/autocomplete/handlers.ts @@ -6,10 +6,13 @@ import { autocomplete } from '@/lib/services/companies.service'; export const autocompleteHandler: RouteHandler = async (req, ctx) => { try { - const q = req.nextUrl.searchParams.get('q'); - if (!q) { - return NextResponse.json({ data: [] }); - } + // Empty `q` returns the most recent companies (capped to the same + // 10-row limit the search path uses). The companion CompanyPicker + // initially renders with an empty query because the popover opens + // before the rep has typed anything — returning [] there hid every + // company even when the system had rows. Returning a recent list + // gives the rep something to scan or select immediately. + const q = req.nextUrl.searchParams.get('q') ?? ''; const companies = await autocomplete(ctx.portId, q); return NextResponse.json({ data: companies }); } catch (error) { diff --git a/src/components/clients/client-form.tsx b/src/components/clients/client-form.tsx index a4663e24..0cb1976e 100644 --- a/src/components/clients/client-form.tsx +++ b/src/components/clients/client-form.tsx @@ -44,6 +44,11 @@ interface ClientFormProps { * or opening the create-interest dialog pre-filled with that * clientId. Skipped in edit mode. */ onUseExistingClient?: (clientId: string) => void; + /** Optional callback fired with the newly-created client's id after a + * successful create. Lets a parent flow (e.g. the new-interest drawer) + * chain a "use the client I just created" follow-up without making the + * rep re-select. Not called in edit mode. */ + onCreated?: (clientId: string) => void; /** Optional initial values for the create flow - used by the * inquiry-inbox "Convert to client" triage step (P-4.5) so the rep * doesn't retype values they just read in the inbox. The @@ -84,6 +89,7 @@ export function ClientForm({ onOpenChange, client, onUseExistingClient, + onCreated, prefill, }: ClientFormProps) { const queryClient = useQueryClient(); @@ -253,6 +259,7 @@ export function ClientForm({ body: { tagIds: tIds }, }); } + return null; } else { const res = await apiFetch<{ data: { id: string } }>('/api/v1/clients', { method: 'POST', @@ -287,13 +294,19 @@ export function ClientForm({ ); } } + return { id: res.data.id }; } }, - onSuccess: () => { + onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['clients'] }); // M-U10: confirm the write landed. Without this the rep closes // the sheet not sure whether the create/edit actually saved. toast.success(isEdit ? 'Client updated' : 'Client created'); + // Parent flow gets a chance to chain off the new id (e.g. the + // new-interest drawer auto-selects the freshly-created client). + if (result && 'id' in result && result.id && onCreated) { + onCreated(result.id); + } onOpenChange(false); }, }); diff --git a/src/components/documents/create-document-wizard.tsx b/src/components/documents/create-document-wizard.tsx index 07c72778..00e0b641 100644 --- a/src/components/documents/create-document-wizard.tsx +++ b/src/components/documents/create-document-wizard.tsx @@ -25,7 +25,7 @@ import { DocumentTemplatePicker } from '@/components/documents/document-template import { FileUploadZone } from '@/components/files/file-upload-zone'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; -import { DOCUMENT_TYPES } from '@/lib/constants'; +import { DOCUMENT_TYPES, DOCUMENT_TYPE_LABELS } from '@/lib/constants'; // Display labels for SIGNER_ROLES - internal values stay lowercase, UI shows // capitalized. Falls back to capitalize-first-letter for any value not in the @@ -311,11 +311,17 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) { {DOCUMENT_TYPES.map((t) => ( - {t.replace(/_/g, ' ')} + {DOCUMENT_TYPE_LABELS[t]} ))} + {documentType === 'other' ? ( +

+ Use the Title below to describe the document - that's how it'll appear + everywhere it's referenced. +

+ ) : null}
diff --git a/src/components/documents/documents-hub.tsx b/src/components/documents/documents-hub.tsx index d8ce66e8..34ddf6d5 100644 --- a/src/components/documents/documents-hub.tsx +++ b/src/components/documents/documents-hub.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Link from 'next/link'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; -import { useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { ChevronDown, ChevronRight, FileText, Folder, Lock, Plus, Upload } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -43,6 +43,24 @@ interface HubDoc { signers?: Array<{ id: string; signerEmail: string; signerName: string; status: string }>; } +/** + * Lightweight files-table row used by FlatFolderListing's parallel files + * query. Folder views show both signature documents (the `documents` + * table) AND plain uploaded files (the `files` table) so reps see + * everything that physically lives in the folder in one list. The two + * sources merge into a unified `HubRow` for rendering. + */ +interface HubFile { + id: string; + filename: string; + originalName: string | null; + mimeType: string | null; + sizeBytes: number; + createdAt: string; +} + +type HubRow = { kind: 'doc'; doc: HubDoc } | { kind: 'file'; file: HubFile }; + const TYPE_LABELS: Record = { eoi: 'EOI', contract: 'Contract', @@ -335,6 +353,57 @@ function FlatFolderListing({ filterDefinitions: [], }); + // Plain uploaded files in this folder. Lives in a separate table from + // signature documents, so we query it in parallel and merge into the + // unified row list. Skip the search/typeFilter for files (those filters + // are doc-specific); applying them client-side would create a confusing + // partial view where the search bar hides files unrelated to the query. + // folderId === null → root; UUID → that folder; the endpoint resolves + // `folderId=` as null to keep the URL idempotent across folder switches. + const filesQueryString = useMemo(() => { + const p = new URLSearchParams(); + p.set('folderId', folderId ?? ''); + p.set('limit', '50'); + p.set('sort', 'createdAt'); + p.set('order', 'desc'); + return p.toString(); + }, [folderId]); + const { data: filesResp, isLoading: filesLoading } = useQuery<{ data: HubFile[] }>({ + queryKey: ['files', 'hub', 'folder', filesQueryString], + queryFn: async (): Promise<{ data: HubFile[] }> => { + const res = await fetch(`/api/v1/files?${filesQueryString}`, { credentials: 'include' }); + if (!res.ok) return { data: [] }; + return (await res.json()) as { data: HubFile[] }; + }, + }); + const folderFiles: HubFile[] = useMemo(() => filesResp?.data ?? [], [filesResp]); + + // Merge docs + files, sorted by createdAt desc so the most-recent item + // (regardless of source) is at the top. Filter file rows out when the + // type-chip is active (chips filter docs only — files have no + // documentType column, treating them as "uploads" type means they'd + // disappear under any chip selection, which is the right UX). + const mergedRows: HubRow[] = useMemo(() => { + const docRows = documents.map((doc) => ({ kind: 'doc' as const, doc })); + const fileRows = typeFilter + ? [] + : folderFiles + // Client-side search across filenames so the search box covers + // both sources uniformly. + .filter((f) => + search + ? (f.filename ?? '').toLowerCase().includes(search.toLowerCase()) || + (f.originalName ?? '').toLowerCase().includes(search.toLowerCase()) + : true, + ) + .map((file) => ({ kind: 'file' as const, file })); + return [...docRows, ...fileRows].sort((a, b) => { + const aTime = a.kind === 'doc' ? a.doc.createdAt : a.file.createdAt; + const bTime = b.kind === 'doc' ? b.doc.createdAt : b.file.createdAt; + return bTime.localeCompare(aTime); + }); + }, [documents, folderFiles, typeFilter, search]); + // Realtime invalidation is lifted to DocumentsHub so it survives mode // switches (root / entity-folder / flat-folder). Don't re-subscribe here. @@ -357,19 +426,25 @@ function FlatFolderListing({ className="border-b last:border-b-0 transition-colors hover:bg-gradient-brand-soft/40" >
- + {totalSigners > 0 ? ( + + ) : ( + // Keep the column reserved so the grid layout stays aligned + // across rows; this row has no signers to expand into. + + )} { + return ( +
  • +
    + {/* Empty action column to align with doc-row layout */} + + + {file.originalName ?? file.filename} + + Uploaded file + + Stored + + + {(file.sizeBytes / 1024).toFixed(0)} KB + + + {new Date(file.createdAt).toLocaleDateString(undefined)} + +
    +
  • + ); + }; + return ( - <> +
    ) : null} - {isLoading ? ( + {isLoading || filesLoading ? (
      {[0, 1, 2, 3, 4].map((i) => (
    • ))}
    - ) : documents.length === 0 && childFolders.length === 0 ? ( + ) : mergedRows.length === 0 && childFolders.length === 0 ? ( } title="No documents in this folder" @@ -510,7 +621,11 @@ function FlatFolderListing({ } /> ) : ( -
      {documents.map(renderRow)}
    +
      + {mergedRows.map((row) => + row.kind === 'doc' ? renderRow(row.doc) : renderFileRow(row.file), + )} +
    )} @@ -535,7 +650,7 @@ function FlatFolderListing({ /> - +
    ); } diff --git a/src/components/files/file-preview-dialog.tsx b/src/components/files/file-preview-dialog.tsx index eb6a0307..b9f64816 100644 --- a/src/components/files/file-preview-dialog.tsx +++ b/src/components/files/file-preview-dialog.tsx @@ -106,7 +106,7 @@ export function FilePreviewDialog({ return ( - + {fileName ?? 'Preview'} diff --git a/src/components/interests/berth-recommender-panel.tsx b/src/components/interests/berth-recommender-panel.tsx index 273b0e60..f270b280 100644 --- a/src/components/interests/berth-recommender-panel.tsx +++ b/src/components/interests/berth-recommender-panel.tsx @@ -179,8 +179,10 @@ function RecommendationCard({ rec, portSlug, onAdd }: RecommendationCardProps) { >
    + {/* 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. */} {rec.mooringNumber} - {rec.area ? {rec.area} : null} {formatStatus(rec.status)} @@ -219,10 +221,51 @@ function RecommendationCard({ rec, portSlug, onAdd }: RecommendationCardProps) { {showHeat ? ( - - - Heat {Math.round(rec.heat!.total)} - + + + + + +

    + Heat score · {Math.round(rec.heat!.total)} / 100 +

    +

    + 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. +

    +
      +
    • + Recency:{' '} + {Math.round(rec.heat!.recency)} +
    • +
    • + Furthest stage:{' '} + {Math.round(rec.heat!.furthestStage)} +
    • +
    • + Interest count:{' '} + {Math.round(rec.heat!.interestCount)} +
    • +
    • + EOI count:{' '} + {Math.round(rec.heat!.eoiCount)} +
    • +
    +

    + Admins tune the weights in{' '} + Admin → Recommender. +

    +
    +
    ) : null}
    diff --git a/src/components/interests/interest-form.tsx b/src/components/interests/interest-form.tsx index 5231001a..b194bd98 100644 --- a/src/components/interests/interest-form.tsx +++ b/src/components/interests/interest-form.tsx @@ -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 }:
    - +
    + + {!isEdit && ( + + )} +