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) => {
|
export const autocompleteHandler: RouteHandler = async (req, ctx) => {
|
||||||
try {
|
try {
|
||||||
const q = req.nextUrl.searchParams.get('q');
|
// Empty `q` returns the most recent companies (capped to the same
|
||||||
if (!q) {
|
// 10-row limit the search path uses). The companion CompanyPicker
|
||||||
return NextResponse.json({ data: [] });
|
// 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);
|
const companies = await autocomplete(ctx.portId, q);
|
||||||
return NextResponse.json({ data: companies });
|
return NextResponse.json({ data: companies });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ interface ClientFormProps {
|
|||||||
* or opening the create-interest dialog pre-filled with that
|
* or opening the create-interest dialog pre-filled with that
|
||||||
* clientId. Skipped in edit mode. */
|
* clientId. Skipped in edit mode. */
|
||||||
onUseExistingClient?: (clientId: string) => void;
|
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
|
/** Optional initial values for the create flow - used by the
|
||||||
* inquiry-inbox "Convert to client" triage step (P-4.5) so the rep
|
* inquiry-inbox "Convert to client" triage step (P-4.5) so the rep
|
||||||
* doesn't retype values they just read in the inbox. The
|
* doesn't retype values they just read in the inbox. The
|
||||||
@@ -84,6 +89,7 @@ export function ClientForm({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
client,
|
client,
|
||||||
onUseExistingClient,
|
onUseExistingClient,
|
||||||
|
onCreated,
|
||||||
prefill,
|
prefill,
|
||||||
}: ClientFormProps) {
|
}: ClientFormProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -253,6 +259,7 @@ export function ClientForm({
|
|||||||
body: { tagIds: tIds },
|
body: { tagIds: tIds },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
} else {
|
} else {
|
||||||
const res = await apiFetch<{ data: { id: string } }>('/api/v1/clients', {
|
const res = await apiFetch<{ data: { id: string } }>('/api/v1/clients', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -287,13 +294,19 @@ export function ClientForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return { id: res.data.id };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: (result) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||||||
// M-U10: confirm the write landed. Without this the rep closes
|
// M-U10: confirm the write landed. Without this the rep closes
|
||||||
// the sheet not sure whether the create/edit actually saved.
|
// the sheet not sure whether the create/edit actually saved.
|
||||||
toast.success(isEdit ? 'Client updated' : 'Client created');
|
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);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { DocumentTemplatePicker } from '@/components/documents/document-template
|
|||||||
import { FileUploadZone } from '@/components/files/file-upload-zone';
|
import { FileUploadZone } from '@/components/files/file-upload-zone';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { toastError } from '@/lib/api/toast-error';
|
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
|
// Display labels for SIGNER_ROLES - internal values stay lowercase, UI shows
|
||||||
// capitalized. Falls back to capitalize-first-letter for any value not in the
|
// capitalized. Falls back to capitalize-first-letter for any value not in the
|
||||||
@@ -311,11 +311,17 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{DOCUMENT_TYPES.map((t) => (
|
{DOCUMENT_TYPES.map((t) => (
|
||||||
<SelectItem key={t} value={t}>
|
<SelectItem key={t} value={t}>
|
||||||
{t.replace(/_/g, ' ')}
|
{DOCUMENT_TYPE_LABELS[t]}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-xs">Title</Label>
|
<Label className="text-xs">Title</Label>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
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 { ChevronDown, ChevronRight, FileText, Folder, Lock, Plus, Upload } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -43,6 +43,24 @@ interface HubDoc {
|
|||||||
signers?: Array<{ id: string; signerEmail: string; signerName: string; status: string }>;
|
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> = {
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
eoi: 'EOI',
|
eoi: 'EOI',
|
||||||
contract: 'Contract',
|
contract: 'Contract',
|
||||||
@@ -335,6 +353,57 @@ function FlatFolderListing({
|
|||||||
filterDefinitions: [],
|
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
|
// Realtime invalidation is lifted to DocumentsHub so it survives mode
|
||||||
// switches (root / entity-folder / flat-folder). Don't re-subscribe here.
|
// 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"
|
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">
|
<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
|
{totalSigners > 0 ? (
|
||||||
type="button"
|
<button
|
||||||
aria-label={`${expanded ? 'Collapse' : 'Expand'} signers for ${doc.title}`}
|
type="button"
|
||||||
aria-expanded={expanded}
|
aria-label={`${expanded ? 'Collapse' : 'Expand'} signers for ${doc.title}`}
|
||||||
onClick={() => setExpandedDocId(expanded ? null : doc.id)}
|
aria-expanded={expanded}
|
||||||
className="flex min-h-[44px] min-w-[44px] items-center justify-center text-muted-foreground transition-transform"
|
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 />
|
{expanded ? (
|
||||||
) : (
|
<ChevronDown className="h-4 w-4" aria-hidden />
|
||||||
<ChevronRight className="h-4 w-4" aria-hidden />
|
) : (
|
||||||
)}
|
<ChevronRight className="h-4 w-4" aria-hidden />
|
||||||
</button>
|
)}
|
||||||
|
</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
|
<Link
|
||||||
href={`/${portSlug}/documents/${doc.id}`}
|
href={`/${portSlug}/documents/${doc.id}`}
|
||||||
className="min-w-0 truncate font-medium text-foreground hover:text-brand"
|
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 (
|
return (
|
||||||
<>
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search by title..."
|
placeholder="Search by title..."
|
||||||
@@ -483,13 +594,13 @@ function FlatFolderListing({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading || filesLoading ? (
|
||||||
<ul className="rounded-md border bg-white">
|
<ul className="rounded-md border bg-white">
|
||||||
{[0, 1, 2, 3, 4].map((i) => (
|
{[0, 1, 2, 3, 4].map((i) => (
|
||||||
<li key={i} className="h-12 animate-pulse border-b last:border-b-0 bg-muted/40" />
|
<li key={i} className="h-12 animate-pulse border-b last:border-b-0 bg-muted/40" />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : documents.length === 0 && childFolders.length === 0 ? (
|
) : mergedRows.length === 0 && childFolders.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<FileText className="h-7 w-7" aria-hidden />}
|
icon={<FileText className="h-7 w-7" aria-hidden />}
|
||||||
title="No documents in this folder"
|
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}>
|
<Dialog open={uploadOpen} onOpenChange={setUploadOpen}>
|
||||||
@@ -535,7 +650,7 @@ function FlatFolderListing({
|
|||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export function FilePreviewDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<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>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2 truncate">
|
<DialogTitle className="flex items-center gap-2 truncate">
|
||||||
<span className="truncate">{fileName ?? 'Preview'}</span>
|
<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="min-w-0 flex-1 space-y-1">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<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>
|
<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>
|
<StatusPill status={statusToPill(rec.status)}>{formatStatus(rec.status)}</StatusPill>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@@ -219,10 +221,51 @@ function RecommendationCard({ rec, portSlug, onAdd }: RecommendationCardProps) {
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
{showHeat ? (
|
{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">
|
<Popover>
|
||||||
<Flame className="size-3" aria-hidden />
|
<PopoverTrigger asChild>
|
||||||
Heat {Math.round(rec.heat!.total)}
|
<button
|
||||||
</span>
|
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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { TagPicker } from '@/components/shared/tag-picker';
|
import { TagPicker } from '@/components/shared/tag-picker';
|
||||||
import { ReminderDaysInput } from '@/components/shared/reminder-days-input';
|
import { ReminderDaysInput } from '@/components/shared/reminder-days-input';
|
||||||
|
import { ClientForm } from '@/components/clients/client-form';
|
||||||
import { YachtForm } from '@/components/yachts/yacht-form';
|
import { YachtForm } from '@/components/yachts/yacht-form';
|
||||||
import { YachtPicker } from '@/components/yachts/yacht-picker';
|
import { YachtPicker } from '@/components/yachts/yacht-picker';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
@@ -127,6 +128,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
|||||||
const selectedBerthId = watch('berthId');
|
const selectedBerthId = watch('berthId');
|
||||||
const selectedYachtId = watch('yachtId');
|
const selectedYachtId = watch('yachtId');
|
||||||
const [createYachtOpen, setCreateYachtOpen] = useState(false);
|
const [createYachtOpen, setCreateYachtOpen] = useState(false);
|
||||||
|
const [createClientOpen, setCreateClientOpen] = useState(false);
|
||||||
const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false);
|
const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false);
|
||||||
|
|
||||||
// Auto-fill pipelineStage + leadCategory based on whether a berth was
|
// Auto-fill pipelineStage + leadCategory based on whether a berth was
|
||||||
@@ -258,6 +260,10 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
|||||||
clientId: defaultClientId ?? '',
|
clientId: defaultClientId ?? '',
|
||||||
yachtId: undefined,
|
yachtId: undefined,
|
||||||
pipelineStage: 'enquiry',
|
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,
|
reminderEnabled: false,
|
||||||
tagIds: [],
|
tagIds: [],
|
||||||
});
|
});
|
||||||
@@ -369,7 +375,21 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<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>
|
<Popover open={clientOpen} onOpenChange={setClientOpen} modal>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -813,6 +833,13 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
|||||||
onCreated={(y) => setValue('yachtId', y.id, { shouldDirty: true })}
|
onCreated={(y) => setValue('yachtId', y.id, { shouldDirty: true })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{createClientOpen && (
|
||||||
|
<ClientForm
|
||||||
|
open={createClientOpen}
|
||||||
|
onOpenChange={setCreateClientOpen}
|
||||||
|
onCreated={(id) => setValue('clientId', id, { shouldDirty: true })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -709,6 +709,10 @@ function OverviewTab({
|
|||||||
}) {
|
}) {
|
||||||
const params = useParams<{ portSlug: string }>();
|
const params = useParams<{ portSlug: string }>();
|
||||||
const portSlug = params?.portSlug ?? '';
|
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 +
|
// 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
|
// 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
|
// can launch it inline - same dialog the dedicated EOI tab uses, so the
|
||||||
@@ -1464,17 +1468,20 @@ function OverviewTab({
|
|||||||
|
|
||||||
<LinkedBerthsList interestId={interestId} />
|
<LinkedBerthsList interestId={interestId} />
|
||||||
|
|
||||||
{/* Berth recommender (plan §5.3) - always-mounted card driven by the
|
{/* Berth recommender (plan §5.3) — surfaces only when the rep has
|
||||||
interest's desired dimensions. Renders an inline guidance message
|
captured at least desired length. Without dimensions the panel's
|
||||||
when dimensions aren't set yet. */}
|
guidance card is noise on Overview; the dedicated tab is also
|
||||||
<BerthRecommenderPanel
|
hidden in the same condition below. */}
|
||||||
interestId={interestId}
|
{hasDesiredDims ? (
|
||||||
desiredLengthFt={toNum(interest.desiredLengthFt)}
|
<BerthRecommenderPanel
|
||||||
desiredWidthFt={toNum(interest.desiredWidthFt)}
|
interestId={interestId}
|
||||||
desiredDraftFt={toNum(interest.desiredDraftFt)}
|
desiredLengthFt={toNum(interest.desiredLengthFt)}
|
||||||
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
|
desiredWidthFt={toNum(interest.desiredWidthFt)}
|
||||||
linkedBerthCount={interest.linkedBerthCount ?? 0}
|
desiredDraftFt={toNum(interest.desiredDraftFt)}
|
||||||
/>
|
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
|
||||||
|
linkedBerthCount={interest.linkedBerthCount ?? 0}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{confirmDialog}
|
{confirmDialog}
|
||||||
{/* Mounted at the Overview level so the EOI milestone's "Generate EOI"
|
{/* Mounted at the Overview level so the EOI milestone's "Generate EOI"
|
||||||
footer button can launch the dialog without leaving the tab. Same
|
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
|
// Contract: from deposit_paid onward (deal is committed and the contract
|
||||||
// becomes the next active document).
|
// becomes the next active document).
|
||||||
const showContractTab = stageIdx >= depositIdx && stageIdx <= contractIdx;
|
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[] = [
|
const tabs: DetailTab[] = [
|
||||||
{
|
{
|
||||||
@@ -1573,19 +1588,23 @@ export function getInterestTabs({
|
|||||||
label: 'Documents',
|
label: 'Documents',
|
||||||
content: <InterestDocumentsTab interestId={interestId} />,
|
content: <InterestDocumentsTab interestId={interestId} />,
|
||||||
},
|
},
|
||||||
{
|
...(hasDesiredDims
|
||||||
id: 'recommendations',
|
? [
|
||||||
label: 'Berth Recommendations',
|
{
|
||||||
content: (
|
id: 'recommendations',
|
||||||
<BerthRecommenderPanel
|
label: 'Berth Recommendations',
|
||||||
interestId={interestId}
|
content: (
|
||||||
desiredLengthFt={toNum(interest.desiredLengthFt)}
|
<BerthRecommenderPanel
|
||||||
desiredWidthFt={toNum(interest.desiredWidthFt)}
|
interestId={interestId}
|
||||||
desiredDraftFt={toNum(interest.desiredDraftFt)}
|
desiredLengthFt={toNum(interest.desiredLengthFt)}
|
||||||
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
|
desiredWidthFt={toNum(interest.desiredWidthFt)}
|
||||||
/>
|
desiredDraftFt={toNum(interest.desiredDraftFt)}
|
||||||
),
|
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
|
||||||
},
|
/>
|
||||||
|
),
|
||||||
|
} satisfies DetailTab,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
id: 'activity',
|
id: 'activity',
|
||||||
label: 'Activity',
|
label: 'Activity',
|
||||||
|
|||||||
@@ -138,8 +138,12 @@ export function BerthPicker({
|
|||||||
return rows.filter((b) => b.mooringNumber.toLowerCase().includes(q));
|
return rows.filter((b) => b.mooringNumber.toLowerCase().includes(q));
|
||||||
}, [clientId, clientInterests, searchData, debounced]);
|
}, [clientId, clientInterests, searchData, debounced]);
|
||||||
|
|
||||||
const labelFor = (o: BerthOption) =>
|
// Mooring numbers already carry the area letter as their prefix
|
||||||
o.area ? `Berth ${o.mooringNumber} · ${o.area}` : `Berth ${o.mooringNumber}`;
|
// (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
|
// Group helper outside render so memoization works; takes/returns plain
|
||||||
// values so the same logic plugs into linked-berths and recommender pickers later.
|
// 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' },
|
restore: { past: 'restored' },
|
||||||
merge: { past: 'merged' },
|
merge: { past: 'merged' },
|
||||||
revert: { past: 'reverted' },
|
revert: { past: 'reverted' },
|
||||||
|
transfer: { past: 'transferred' },
|
||||||
};
|
};
|
||||||
|
|
||||||
function actionVerb(action: string): string {
|
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') {
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||||
return String(value);
|
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);
|
return JSON.stringify(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
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 { useQuery, useMutation, useQueryClient, type QueryKey } from '@tanstack/react-query';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { Lock, Pencil, Trash2, Send, Loader2 } from 'lucide-react';
|
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({
|
export function NotesList({
|
||||||
entityType,
|
entityType,
|
||||||
entityId,
|
entityId,
|
||||||
@@ -142,6 +166,8 @@ export function NotesList({
|
|||||||
parentInvalidateKey,
|
parentInvalidateKey,
|
||||||
}: NotesListProps) {
|
}: NotesListProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const routeParams = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = routeParams?.portSlug ?? '';
|
||||||
const invalidateAll = () => {
|
const invalidateAll = () => {
|
||||||
queryClient.invalidateQueries({ queryKey });
|
queryClient.invalidateQueries({ queryKey });
|
||||||
if (parentInvalidateKey) {
|
if (parentInvalidateKey) {
|
||||||
@@ -296,14 +322,34 @@ export function NotesList({
|
|||||||
{aggregateOn &&
|
{aggregateOn &&
|
||||||
note.source &&
|
note.source &&
|
||||||
note.source !== SELF_SOURCE[entityType] &&
|
note.source !== SELF_SOURCE[entityType] &&
|
||||||
note.sourceLabel && (
|
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]}`}
|
// Source badge links to the originating entity so reps
|
||||||
title={`From ${note.source}`}
|
// can pivot from "this note about a linked yacht" to
|
||||||
>
|
// the yacht detail page directly. Falls back to a
|
||||||
{SOURCE_LABEL[note.source]} · {note.sourceLabel}
|
// plain span when no sourceId is present (rare; aggregator
|
||||||
</span>
|
// 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
|
{/* Pipeline-stage stamp: shows what stage the linked
|
||||||
interest was at when the note was authored. Lets a
|
interest was at when the note was authored. Lets a
|
||||||
rep trace how the deal's notes evolved (concerns
|
rep trace how the deal's notes evolved (concerns
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
@@ -153,11 +153,19 @@ export function TenancyList({
|
|||||||
}: TenancyListProps) {
|
}: TenancyListProps) {
|
||||||
const routeParams = useParams<{ portSlug: string }>();
|
const routeParams = useParams<{ portSlug: string }>();
|
||||||
const portSlug = portSlugProp ?? routeParams?.portSlug ?? '';
|
const portSlug = portSlugProp ?? routeParams?.portSlug ?? '';
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
if (tenancies.length === 0) {
|
if (tenancies.length === 0) {
|
||||||
return <EmptyState title="No tenancies" description={emptyMessage ?? 'No tenancies yet.'} />;
|
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 (
|
return (
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
@@ -174,7 +182,23 @@ export function TenancyList({
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{tenancies.map((r) => (
|
{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 && (
|
{showBerth && (
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<BerthLink berthId={r.berthId} portSlug={portSlug} />
|
<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',
|
'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)]',
|
'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',
|
'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-
|
// Desktop animation: subtle centered fade + zoom (no slide-from-
|
||||||
// corner so the dialog appears in place rather than flying in
|
// corner so the dialog appears in place rather than flying in
|
||||||
// from top-right). The base fade-in/out classes above provide
|
// from top-right). The base fade-in/out classes above provide
|
||||||
|
|||||||
@@ -132,8 +132,8 @@ export function YachtTransferDialog({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Transfer ownership</DialogTitle>
|
<DialogTitle>Transfer ownership</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This will close the current ownership record and open a new one. The change is auditable
|
This will close the current ownership record and open a new one. The change is logged in
|
||||||
and atomic.
|
the audit history.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -384,6 +384,21 @@ export const DOCUMENT_TYPES = ['eoi', 'contract', 'nda', 'reservation_agreement'
|
|||||||
|
|
||||||
export type DocumentType = (typeof DOCUMENT_TYPES)[number];
|
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 ───────────────────────────────────────────────────────
|
// ─── Document Statuses ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const 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({
|
const activeTenancies = await db.query.berthTenancies.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(berthTenancies.clientId, id),
|
eq(berthTenancies.clientId, id),
|
||||||
eq(berthTenancies.portId, portId),
|
eq(berthTenancies.portId, portId),
|
||||||
eq(berthTenancies.status, 'active'),
|
inArray(berthTenancies.status, ['pending', 'active']),
|
||||||
),
|
),
|
||||||
columns: {
|
columns: {
|
||||||
id: true,
|
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 { db } from '@/lib/db';
|
||||||
import {
|
import {
|
||||||
companies,
|
companies,
|
||||||
@@ -301,10 +301,19 @@ export async function listCompanies(portId: string, query: ListCompaniesInput) {
|
|||||||
// ─── Autocomplete ────────────────────────────────────────────────────────────
|
// ─── Autocomplete ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function autocomplete(portId: string, q: string) {
|
export async function autocomplete(portId: string, q: string) {
|
||||||
const pattern = `%${q}%`;
|
// Empty query → return the 10 most-recently-updated companies for the
|
||||||
return await db
|
// port so the picker has something to scan on first open. Non-empty
|
||||||
.select()
|
// query → ilike-match against name + legalName as before.
|
||||||
.from(companies)
|
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(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(companies.portId, portId),
|
eq(companies.portId, portId),
|
||||||
|
|||||||
@@ -270,8 +270,19 @@ export async function deleteFile(id: string, portId: string, meta: AuditMeta) {
|
|||||||
// ─── List ─────────────────────────────────────────────────────────────────────
|
// ─── List ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function listFiles(portId: string, query: ListFilesInput) {
|
export async function listFiles(portId: string, query: ListFilesInput) {
|
||||||
const { page, limit, sort, order, search, clientId, yachtId, companyId, interestId, category } =
|
const {
|
||||||
query;
|
page,
|
||||||
|
limit,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
search,
|
||||||
|
clientId,
|
||||||
|
yachtId,
|
||||||
|
companyId,
|
||||||
|
interestId,
|
||||||
|
category,
|
||||||
|
folderId,
|
||||||
|
} = query;
|
||||||
|
|
||||||
const filters = [];
|
const filters = [];
|
||||||
|
|
||||||
@@ -290,6 +301,12 @@ export async function listFiles(portId: string, query: ListFilesInput) {
|
|||||||
if (category) {
|
if (category) {
|
||||||
filters.push(eq(files.category, 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 =
|
const sortColumn =
|
||||||
sort === 'filename' ? files.filename : sort === 'sizeBytes' ? files.sizeBytes : files.createdAt;
|
sort === 'filename' ? files.filename : sort === 'sizeBytes' ? files.sizeBytes : files.createdAt;
|
||||||
|
|||||||
@@ -233,6 +233,37 @@ export async function transferOwnership(
|
|||||||
|
|
||||||
await assertOwnerExists(portId, data.newOwner, tx);
|
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
|
// Close the currently-active history row
|
||||||
await tx
|
await tx
|
||||||
.update(yachtOwnershipHistory)
|
.update(yachtOwnershipHistory)
|
||||||
@@ -267,13 +298,30 @@ export async function transferOwnership(
|
|||||||
.where(eq(yachts.id, yachtId))
|
.where(eq(yachts.id, yachtId))
|
||||||
.returning();
|
.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({
|
void createAuditLog({
|
||||||
userId: meta.userId,
|
userId: meta.userId,
|
||||||
portId,
|
portId,
|
||||||
action: 'update',
|
action: 'transfer',
|
||||||
entityType: 'yacht',
|
entityType: 'yacht',
|
||||||
entityId: yachtId,
|
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,
|
ipAddress: meta.ipAddress,
|
||||||
userAgent: meta.userAgent,
|
userAgent: meta.userAgent,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user