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>
427 lines
16 KiB
TypeScript
427 lines
16 KiB
TypeScript
'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';
|
|
import { useAutoAnimate } from '@formkit/auto-animate/react';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
|
import { apiFetch } from '@/lib/api/client';
|
|
import { stageLabel } from '@/lib/constants';
|
|
|
|
type NoteSource =
|
|
| 'client'
|
|
| 'interest'
|
|
| 'yacht'
|
|
| 'company'
|
|
| 'residential_client'
|
|
| 'residential_interest';
|
|
|
|
interface Note {
|
|
id: string;
|
|
content: string;
|
|
authorId: string;
|
|
authorName?: string;
|
|
isLocked: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
/** Aggregated-mode only: which child entity this note came from. */
|
|
source?: NoteSource;
|
|
sourceId?: string;
|
|
sourceLabel?: string;
|
|
/** Pipeline stage the linked interest was at when the note was authored.
|
|
* Only populated for interest notes - drives the small stage chip. */
|
|
pipelineStageAtCreation?: string | null;
|
|
}
|
|
|
|
type NotesEntityType =
|
|
| 'clients'
|
|
| 'interests'
|
|
| 'yachts'
|
|
| 'companies'
|
|
| 'residential_clients'
|
|
| 'residential_interests';
|
|
|
|
/** Maps the entity-type the list is rendered for to the `source` value
|
|
* the aggregator uses when a note came from THAT entity itself
|
|
* (vs. a related entity). Used to decide whether a note is editable
|
|
* in-place or read-only with an "Open source" affordance. */
|
|
const SELF_SOURCE: Record<NotesEntityType, NoteSource | null> = {
|
|
clients: 'client',
|
|
yachts: 'yacht',
|
|
companies: 'company',
|
|
residential_clients: 'residential_client',
|
|
// Aggregate-mode is only meaningful for the entities above. Interests
|
|
// and residential_interests are leaf nodes - there's nothing to roll
|
|
// up to them.
|
|
interests: null,
|
|
residential_interests: null,
|
|
};
|
|
|
|
const AGGREGATABLE: ReadonlySet<NotesEntityType> = new Set<NotesEntityType>([
|
|
'clients',
|
|
'yachts',
|
|
'companies',
|
|
'residential_clients',
|
|
]);
|
|
|
|
const SOURCE_BADGE_CLASS: Record<NoteSource, string> = {
|
|
client: 'bg-violet-100 text-violet-900',
|
|
interest: 'bg-blue-100 text-blue-900',
|
|
yacht: 'bg-emerald-100 text-emerald-900',
|
|
company: 'bg-amber-100 text-amber-900',
|
|
residential_client: 'bg-violet-100 text-violet-900',
|
|
residential_interest: 'bg-blue-100 text-blue-900',
|
|
};
|
|
|
|
const SOURCE_LABEL: Record<NoteSource, string> = {
|
|
client: 'Client',
|
|
interest: 'Interest',
|
|
yacht: 'Yacht',
|
|
company: 'Company',
|
|
residential_client: 'Resident',
|
|
residential_interest: 'Inquiry',
|
|
};
|
|
|
|
interface NotesListProps {
|
|
entityType: NotesEntityType;
|
|
entityId: string;
|
|
currentUserId?: string;
|
|
/**
|
|
* Aggregate-on-read: union the entity's own notes with notes from
|
|
* related entities (interests, owned yachts / company yachts, owner
|
|
* client). Cross-source notes render with a source chip and are
|
|
* read-only here - open the source entity's page to edit.
|
|
*
|
|
* Supported for entityType in {clients, yachts, companies,
|
|
* residential_clients}. Ignored for interests / residential_interests.
|
|
*/
|
|
aggregate?: boolean;
|
|
/**
|
|
* Optional parent-entity query key to invalidate alongside the notes
|
|
* query on create/update/delete. The parent entity detail typically
|
|
* hydrates a `recentNote` / `notesCount` teaser that goes stale after
|
|
* a note mutation; passing the detail's query key here keeps it in
|
|
* sync without a hard refresh.
|
|
*/
|
|
parentInvalidateKey?: QueryKey;
|
|
}
|
|
|
|
const NOTE_EDIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
|
|
|
/** Sort by source then chronologically inside each source.
|
|
* Used by the aggregated view's "Group by source" toggle. */
|
|
function sortByGroup(notes: Note[]): Note[] {
|
|
const sourceOrder: Record<string, number> = {
|
|
client: 0,
|
|
company: 1,
|
|
yacht: 2,
|
|
interest: 3,
|
|
residential_client: 0,
|
|
residential_interest: 1,
|
|
};
|
|
return [...notes].sort((a, b) => {
|
|
const aRank = sourceOrder[a.source ?? ''] ?? 99;
|
|
const bRank = sourceOrder[b.source ?? ''] ?? 99;
|
|
if (aRank !== bRank) return aRank - bRank;
|
|
const aLabel = a.sourceLabel ?? '';
|
|
const bLabel = b.sourceLabel ?? '';
|
|
if (aLabel !== bLabel) return aLabel.localeCompare(bLabel);
|
|
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
|
});
|
|
}
|
|
|
|
/** 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,
|
|
currentUserId,
|
|
aggregate,
|
|
parentInvalidateKey,
|
|
}: NotesListProps) {
|
|
const queryClient = useQueryClient();
|
|
const routeParams = useParams<{ portSlug: string }>();
|
|
const portSlug = routeParams?.portSlug ?? '';
|
|
const invalidateAll = () => {
|
|
queryClient.invalidateQueries({ queryKey });
|
|
if (parentInvalidateKey) {
|
|
queryClient.invalidateQueries({ queryKey: parentInvalidateKey });
|
|
}
|
|
};
|
|
const [newNote, setNewNote] = useState('');
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [editContent, setEditContent] = useState('');
|
|
const [groupBySource, setGroupBySource] = useState(false);
|
|
// Wall-clock 'now' ticked every 30s so the per-note "Xm left to edit"
|
|
// countdown decrements on screen. Reading `Date.now()` directly inside
|
|
// render is impure (different value every call); pinning to a state
|
|
// value means React Compiler can memoize cleanly.
|
|
const [now, setNow] = useState(() => Date.now());
|
|
useEffect(() => {
|
|
const id = setInterval(() => setNow(Date.now()), 30_000);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
|
|
const aggregateOn = !!aggregate && AGGREGATABLE.has(entityType);
|
|
const baseEndpoint = `/api/v1/${entityType}/${entityId}/notes`;
|
|
const listEndpoint = aggregateOn ? `${baseEndpoint}?aggregate=true` : baseEndpoint;
|
|
const queryKey = [entityType, entityId, 'notes', aggregateOn ? 'aggregated' : 'own'];
|
|
|
|
// Smooth animation when notes are added / edited / deleted - replaces
|
|
// the abrupt re-render with a per-row fade/slide.
|
|
const [animateRef] = useAutoAnimate<HTMLDivElement>();
|
|
|
|
const { data: notes = [], isLoading } = useQuery<Note[]>({
|
|
queryKey,
|
|
queryFn: () => apiFetch<{ data: Note[] }>(listEndpoint).then((r) => r.data),
|
|
});
|
|
|
|
// Mutations always target the parent entity (client). Aggregated
|
|
// notes from interests/yachts are read-only here - the rep edits
|
|
// them on the source entity's page (we surface a "Open source" link
|
|
// below). Keeping mutations against `baseEndpoint` keeps the POST
|
|
// route handler clean.
|
|
const createMutation = useMutation({
|
|
mutationFn: (content: string) => apiFetch(baseEndpoint, { method: 'POST', body: { content } }),
|
|
onSuccess: () => {
|
|
invalidateAll();
|
|
setNewNote('');
|
|
},
|
|
});
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: ({ noteId, content }: { noteId: string; content: string }) =>
|
|
apiFetch(`${baseEndpoint}/${noteId}`, { method: 'PATCH', body: { content } }),
|
|
onSuccess: () => {
|
|
invalidateAll();
|
|
setEditingId(null);
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (noteId: string) => apiFetch(`${baseEndpoint}/${noteId}`, { method: 'DELETE' }),
|
|
onSuccess: () => invalidateAll(),
|
|
});
|
|
|
|
function canEdit(note: Note): boolean {
|
|
if (note.authorId !== currentUserId) return false;
|
|
if (note.isLocked) return false;
|
|
// Aggregated view: only notes from THIS entity itself are editable
|
|
// in-place. Notes pulled in from related entities (e.g. interests
|
|
// surfaced under a client) must be edited on the source page so the
|
|
// owning entity's timeline records the change.
|
|
const selfSource = SELF_SOURCE[entityType];
|
|
if (aggregateOn && note.source && note.source !== selfSource) return false;
|
|
const elapsed = now - new Date(note.createdAt).getTime();
|
|
return elapsed < NOTE_EDIT_WINDOW_MS;
|
|
}
|
|
|
|
function getTimeRemaining(note: Note): string | null {
|
|
const elapsed = now - new Date(note.createdAt).getTime();
|
|
const remaining = NOTE_EDIT_WINDOW_MS - elapsed;
|
|
if (remaining <= 0) return null;
|
|
const mins = Math.ceil(remaining / 60000);
|
|
return `${mins}m left to edit`;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Create note form */}
|
|
<div className="space-y-2">
|
|
<Textarea
|
|
placeholder="Add a note..."
|
|
value={newNote}
|
|
onChange={(e) => setNewNote(e.target.value)}
|
|
rows={3}
|
|
/>
|
|
<div className="flex justify-end">
|
|
<Button
|
|
size="sm"
|
|
disabled={!newNote.trim() || createMutation.isPending}
|
|
onClick={() => createMutation.mutate(newNote.trim())}
|
|
>
|
|
{createMutation.isPending ? (
|
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
|
|
) : (
|
|
<Send className="mr-1.5 h-4 w-4" aria-hidden />
|
|
)}
|
|
Add Note
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Aggregated-mode controls - sort toggle. Only renders when
|
|
* aggregation is on and there's actually content to group. */}
|
|
{aggregateOn && notes.length > 0 && (
|
|
<div className="flex items-center justify-end gap-2 text-xs text-muted-foreground">
|
|
<span>View:</span>
|
|
<button
|
|
type="button"
|
|
className={!groupBySource ? 'font-medium text-foreground' : 'hover:text-foreground'}
|
|
onClick={() => setGroupBySource(false)}
|
|
>
|
|
Chronological
|
|
</button>
|
|
<span>·</span>
|
|
<button
|
|
type="button"
|
|
className={groupBySource ? 'font-medium text-foreground' : 'hover:text-foreground'}
|
|
onClick={() => setGroupBySource(true)}
|
|
>
|
|
Group by source
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Notes list */}
|
|
{isLoading ? (
|
|
<div className="text-center py-8 text-muted-foreground">Loading notes...</div>
|
|
) : notes.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground">No notes yet</div>
|
|
) : (
|
|
<div ref={animateRef} className="space-y-3">
|
|
{(groupBySource ? sortByGroup(notes) : notes).map((note) => (
|
|
<div key={note.id} className="flex gap-3 p-3 rounded-lg border bg-card">
|
|
<Avatar className="h-8 w-8 shrink-0">
|
|
<AvatarFallback className="text-xs">
|
|
{(note.authorName ?? 'U').charAt(0).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1 min-w-0 space-y-1">
|
|
<div className="flex items-center gap-2 text-sm flex-wrap">
|
|
<span className="font-medium">{note.authorName ?? 'User'}</span>
|
|
<span className="text-muted-foreground">
|
|
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true })}
|
|
</span>
|
|
{aggregateOn &&
|
|
note.source &&
|
|
note.source !== SELF_SOURCE[entityType] &&
|
|
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
|
|
raised at qualified vs after reservation). Only
|
|
populated for interest notes from 2026-05-15+. */}
|
|
{note.pipelineStageAtCreation && (
|
|
<span
|
|
className="inline-flex items-center rounded-full bg-indigo-50 px-1.5 py-0.5 text-xs font-medium text-indigo-900"
|
|
title="Pipeline stage when note was authored"
|
|
>
|
|
@ {stageLabel(note.pipelineStageAtCreation)}
|
|
</span>
|
|
)}
|
|
{note.isLocked && <Lock className="h-3 w-3 text-muted-foreground" aria-hidden />}
|
|
{canEdit(note) && (
|
|
<span className="text-xs text-muted-foreground">{getTimeRemaining(note)}</span>
|
|
)}
|
|
</div>
|
|
{editingId === note.id ? (
|
|
<div className="space-y-2">
|
|
<Textarea
|
|
value={editContent}
|
|
onChange={(e) => setEditContent(e.target.value)}
|
|
rows={3}
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
disabled={!editContent.trim() || updateMutation.isPending}
|
|
onClick={() =>
|
|
updateMutation.mutate({ noteId: note.id, content: editContent.trim() })
|
|
}
|
|
>
|
|
Save
|
|
</Button>
|
|
<Button size="sm" variant="ghost" onClick={() => setEditingId(null)}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm whitespace-pre-wrap">{note.content}</p>
|
|
)}
|
|
</div>
|
|
{canEdit(note) && editingId !== note.id && (
|
|
<div className="flex gap-1 shrink-0">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={() => {
|
|
setEditingId(note.id);
|
|
setEditContent(note.content);
|
|
}}
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" aria-hidden />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-destructive"
|
|
onClick={() => deleteMutation.mutate(note.id)}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|