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:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
<SelectContent>
|
||||
{DOCUMENT_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t.replace(/_/g, ' ')}
|
||||
{DOCUMENT_TYPE_LABELS[t]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{documentType === 'other' ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use the Title below to describe the document - that's how it'll appear
|
||||
everywhere it's referenced.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs">Title</Label>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3 text-sm sm:grid sm:grid-cols-[auto_1fr_auto_auto_auto_auto] sm:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${expanded ? 'Collapse' : 'Expand'} signers for ${doc.title}`}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpandedDocId(expanded ? null : doc.id)}
|
||||
className="flex min-h-[44px] min-w-[44px] items-center justify-center text-muted-foreground transition-transform"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4" aria-hidden />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
{totalSigners > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${expanded ? 'Collapse' : 'Expand'} signers for ${doc.title}`}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpandedDocId(expanded ? null : doc.id)}
|
||||
className="flex min-h-[44px] min-w-[44px] items-center justify-center text-muted-foreground transition-transform"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4" aria-hidden />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
// Keep the column reserved so the grid layout stays aligned
|
||||
// across rows; this row has no signers to expand into.
|
||||
<span className="hidden h-[44px] w-[44px] sm:block" aria-hidden />
|
||||
)}
|
||||
<Link
|
||||
href={`/${portSlug}/documents/${doc.id}`}
|
||||
className="min-w-0 truncate font-medium text-foreground hover:text-brand"
|
||||
@@ -413,8 +488,44 @@ function FlatFolderListing({
|
||||
);
|
||||
};
|
||||
|
||||
// Uploaded-file row — simpler than a signature doc since there's no
|
||||
// signer/status concept. Links to the underlying file via download URL
|
||||
// and surfaces an "Uploaded" type pill so the rep distinguishes it
|
||||
// from signature workflows at a glance.
|
||||
const renderFileRow = (file: HubFile) => {
|
||||
return (
|
||||
<li
|
||||
key={`file:${file.id}`}
|
||||
className="border-b last:border-b-0 transition-colors hover:bg-gradient-brand-soft/40"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3 text-sm sm:grid sm:grid-cols-[auto_1fr_auto_auto_auto_auto] sm:gap-3">
|
||||
{/* Empty action column to align with doc-row layout */}
|
||||
<span className="hidden h-[44px] w-[44px] sm:block" aria-hidden />
|
||||
<a
|
||||
href={`/api/v1/files/${file.id}/download`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="min-w-0 truncate font-medium text-foreground hover:text-brand"
|
||||
>
|
||||
{file.originalName ?? file.filename}
|
||||
</a>
|
||||
<span className="text-xs text-muted-foreground">Uploaded file</span>
|
||||
<StatusPill status="completed" withDot>
|
||||
Stored
|
||||
</StatusPill>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{(file.sizeBytes / 1024).toFixed(0)} KB
|
||||
</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{new Date(file.createdAt).toLocaleDateString(undefined)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder="Search by title..."
|
||||
@@ -483,13 +594,13 @@ function FlatFolderListing({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
{isLoading || filesLoading ? (
|
||||
<ul className="rounded-md border bg-white">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<li key={i} className="h-12 animate-pulse border-b last:border-b-0 bg-muted/40" />
|
||||
))}
|
||||
</ul>
|
||||
) : documents.length === 0 && childFolders.length === 0 ? (
|
||||
) : mergedRows.length === 0 && childFolders.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<FileText className="h-7 w-7" aria-hidden />}
|
||||
title="No documents in this folder"
|
||||
@@ -510,7 +621,11 @@ function FlatFolderListing({
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="rounded-md border bg-white shadow-xs">{documents.map(renderRow)}</ul>
|
||||
<ul className="rounded-md border bg-white shadow-xs">
|
||||
{mergedRows.map((row) =>
|
||||
row.kind === 'doc' ? renderRow(row.doc) : renderFileRow(row.file),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<Dialog open={uploadOpen} onOpenChange={setUploadOpen}>
|
||||
@@ -535,7 +650,7 @@ function FlatFolderListing({
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ export function FilePreviewDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl w-full h-[80vh] flex flex-col">
|
||||
<DialogContent className="w-[min(95vw,1400px)] sm:max-w-none lg:max-w-none h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 truncate">
|
||||
<span className="truncate">{fileName ?? 'Preview'}</span>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
@@ -153,11 +153,19 @@ export function TenancyList({
|
||||
}: TenancyListProps) {
|
||||
const routeParams = useParams<{ portSlug: string }>();
|
||||
const portSlug = portSlugProp ?? routeParams?.portSlug ?? '';
|
||||
const router = useRouter();
|
||||
|
||||
if (tenancies.length === 0) {
|
||||
return <EmptyState title="No tenancies" description={emptyMessage ?? 'No tenancies yet.'} />;
|
||||
}
|
||||
|
||||
// Row-level navigation: clicking anywhere on the row (except inside
|
||||
// an inner `<a>` or `<button>`) opens the tenancy detail page. Inner
|
||||
// links (BerthLink / ClientLink / YachtLink) and the "View contract"
|
||||
// button keep their own behaviour because the click handler bails
|
||||
// when the target is inside an interactive element.
|
||||
const openTenancy = (id: string) => router.push(`/${portSlug}/tenancies/${id}` as never);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
@@ -174,7 +182,23 @@ export function TenancyList({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tenancies.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableRow
|
||||
key={r.id}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
className="cursor-pointer hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest('a, button')) return;
|
||||
openTenancy(r.id);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
if ((e.target as HTMLElement).closest('a, button')) return;
|
||||
e.preventDefault();
|
||||
openTenancy(r.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showBerth && (
|
||||
<TableCell>
|
||||
<BerthLink berthId={r.berthId} portSlug={portSlug} />
|
||||
|
||||
@@ -50,7 +50,13 @@ const DialogContent = React.forwardRef<
|
||||
'fixed top-0 right-0 bottom-0 left-0 z-50 grid w-full gap-4 border-0 bg-background p-4 shadow-lg duration-200 sm:p-6',
|
||||
'max-h-dvh overflow-y-auto sm:max-h-[calc(100dvh-2rem)]',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'sm:top-[50%] sm:right-auto sm:bottom-auto sm:left-[50%] sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:border sm:rounded-lg',
|
||||
// Default width: bumped 2026-05-26 from `sm:max-w-lg` (32rem) to
|
||||
// `sm:max-w-xl lg:max-w-3xl` so every Dialog has a generous
|
||||
// desktop default. Confirm dialogs override DOWN with
|
||||
// `sm:max-w-md`; content-heavy dialogs (file preview, signing
|
||||
// details, EOI generate) override UP with `lg:max-w-5xl` or
|
||||
// `lg:max-w-[min(95vw,1400px)]`.
|
||||
'sm:top-[50%] sm:right-auto sm:bottom-auto sm:left-[50%] sm:max-w-xl lg:max-w-3xl sm:translate-x-[-50%] sm:translate-y-[-50%] sm:border sm:rounded-lg',
|
||||
// Desktop animation: subtle centered fade + zoom (no slide-from-
|
||||
// corner so the dialog appears in place rather than flying in
|
||||
// from top-right). The base fade-in/out classes above provide
|
||||
|
||||
@@ -132,8 +132,8 @@ export function YachtTransferDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Transfer ownership</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will close the current ownership record and open a new one. The change is auditable
|
||||
and atomic.
|
||||
This will close the current ownership record and open a new one. The change is logged in
|
||||
the audit history.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
@@ -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