feat: round 2 — stage prompts, berth header, EOI inline edit, measurement units

Berth surfaces
- New compact mooring-chip header (colored plate + status pill, dock-label
  in tooltip) replaces the redundant "Berth B1 / Sold / B DOCK" stack
- Berth list gains a "Latest deal stage" column showing the most-advanced
  pipeline stage of any active linked interest (server-aggregated, ranks by
  PIPELINE_STAGES index)
- "Linked prospect" Select on the status-change dialog rebuilt as a Command
  combobox: search, recent-first sort, stage-coloured pills

Pipeline UX
- Reverting an interest to Open with linked berths now prompts: keep the
  links, unlink and reset, or cancel. Silent when no berths are linked
- Activity feed + entity-activity feed normalise enum field values via
  STAGE_LABELS / formatSource: "deposit_10pct → contract_sent" reads as
  "10% Deposit → Contract Sent"

EOI generate dialog
- Inline-editable rows for client name, nationality (country combobox), and
  yacht name — pencil affordance saves directly via clients/yachts PATCH
- Replaces the single "Edit on client's page" link with two contextual links
  framed by short copy explaining what's inline vs what needs the canonical
  page
- Backend EoiContext now includes client.id + yacht.id so the dialog can
  PATCH without an extra round-trip

Company form
- New "Connections" section lets the rep attach members (clients) and yachts
  during create. Yacht attach uses the existing transfer endpoint so audit
  log + ownership history capture the change
- Inline "+ New client" / "+ New yacht" buttons open the canonical forms
  stacked over the company sheet
- After save, the form chains to a yacht pull-in prompt (if any attached
  client owns yachts not yet linked) and an optional "Create interest" step
  pre-filled with the first attached client

Admin
- /admin landing gains a searchable index — typed query flattens groups into
  a result list matching label + description + group title
- "Documenso & EOI" card relabelled to "EOI signing service" (consistent
  with the user-facing language rename from round 1)

Measurement units (migration 0053)
- interests gains desired_*_m columns + desired_*_unit discriminators so
  the rep's literal entry (ft OR m) is preserved verbatim instead of being
  reconstructed from a single canonical column on every render
- yachts + berths gain matching *_unit columns alongside their existing
  ft + m pairs; defaults to 'ft' so legacy rows still render normally
- Interest form POST/PATCH now sends both ft + m + unit; computed m is
  derived from the ft canonical to keep the recommender SQL unchanged

Misc
- Active-deals tile + topbar type their Link href as `Route` instead of `any`
- Unused REPORT_TYPE_LABELS const dropped from generate-report-form
- Test fixtures (fill-eoi-form, documenso-payload, public-berths) updated
  to include the new id + unit fields on the EoiContext / Berth shapes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 15:28:22 +02:00
parent 3ffee79f3f
commit 04a594963f
44 changed files with 1404 additions and 255 deletions

View File

@@ -230,8 +230,12 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="documenso-template">Generated EOI rendered + signed externally</SelectItem>
<SelectItem value="inapp">Manual EOI rendered in CRM, sent for e-signature</SelectItem>
<SelectItem value="documenso-template">
Generated EOI rendered + signed externally
</SelectItem>
<SelectItem value="inapp">
Manual EOI rendered in CRM, sent for e-signature
</SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -157,7 +157,8 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
};
const handleCancel = async () => {
if (!confirm('Cancel this document? This voids the signing envelope and cannot be undone.')) return;
if (!confirm('Cancel this document? This voids the signing envelope and cannot be undone.'))
return;
setIsCancelling(true);
try {
await apiFetch(`/api/v1/documents/${documentId}/cancel`, { method: 'POST' });

View File

@@ -26,6 +26,9 @@ import { Skeleton } from '@/components/ui/skeleton';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/ui-store';
import { Input } from '@/components/ui/input';
import { CountryCombobox } from '@/components/shared/country-combobox';
import { toastError } from '@/lib/api/toast-error';
const DOCUMENSO_TEMPLATE_VALUE = 'documenso-template';
@@ -39,6 +42,7 @@ interface InAppTemplate {
interface EoiContextResponse {
data: {
client: {
id: string;
fullName: string;
nationality: string | null;
primaryEmail: string | null;
@@ -46,6 +50,7 @@ interface EoiContextResponse {
address: { street: string; city: string; country: string } | null;
};
yacht: {
id: string;
name: string;
lengthFt: string | null;
widthFt: string | null;
@@ -119,6 +124,17 @@ export function EoiGenerateDialog({
const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]);
async function patchClient(body: Record<string, unknown>) {
if (!ctx) return;
await apiFetch(`/api/v1/clients/${ctx.client.id}`, { method: 'PATCH', body });
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'eoi-context'] });
}
async function patchYacht(body: Record<string, unknown>) {
if (!ctx?.yacht) return;
await apiFetch(`/api/v1/yachts/${ctx.yacht.id}`, { method: 'PATCH', body });
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'eoi-context'] });
}
// Required for the EOI's top paragraph (Section 2). Without these
// the document is unsignable, so generation is blocked.
const required = ctx
@@ -128,6 +144,27 @@ export function EoiGenerateDialog({
label: 'Full name',
value: ctx.client.fullName,
present: !!ctx.client.fullName,
edit: {
onSave: async (next: string | null) =>
await patchClient({ fullName: next ?? '' }),
placeholder: 'Full legal name',
},
},
{
key: 'nationality',
label: 'Nationality',
value: ctx.client.nationality,
present: !!ctx.client.nationality,
edit: {
variant: 'country' as const,
onSave: async (next: string | null) => {
// Country combobox emits the ISO code; the read-only string is the
// localised country name (resolved server-side). Coerce here so we
// store the canonical ISO.
const iso = next ? (next as string).toUpperCase() : null;
await patchClient({ nationalityIso: iso });
},
},
},
{
key: 'email',
@@ -155,6 +192,13 @@ export function EoiGenerateDialog({
key: 'yacht',
label: 'Yacht name',
value: ctx.yacht?.name ?? null,
edit: ctx.yacht
? {
onSave: async (next: string | null) =>
await patchYacht({ name: next ?? '' }),
placeholder: 'Yacht name',
}
: undefined,
},
{
key: 'dimensions',
@@ -263,8 +307,12 @@ export function EoiGenerateDialog({
<PreviewRow
key={row.key}
label={row.label}
// Nationality stores the localised country name in the preview
// but commits the ISO. Pass the underlying ISO via a closure
// so the CountryCombobox can highlight it correctly.
value={row.value}
missing={!row.present}
edit={row.edit}
/>
))}
</dl>
@@ -275,22 +323,44 @@ export function EoiGenerateDialog({
</p>
<dl className="space-y-1.5">
{optional.map((row) => (
<PreviewRow key={row.key} label={row.label} value={row.value} />
<PreviewRow
key={row.key}
label={row.label}
value={row.value}
edit={row.edit}
/>
))}
</dl>
</div>
{portSlug && clientId && (
<div className="border-t pt-2">
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/clients/${clientId}` as any}
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
onClick={() => onOpenChange(false)}
>
<Pencil className="size-3" />
Wrong details? Edit on the client&apos;s page
<ExternalLink className="size-3" />
</Link>
<div className="border-t pt-2 space-y-1">
<p className="text-[11px] text-muted-foreground">
Editing name / nationality / yacht name above patches the underlying records
directly. For phone, address, or to manage linked berths, jump to the canonical
page:
</p>
<div className="flex flex-wrap gap-3">
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/clients/${clientId}` as any}
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
onClick={() => onOpenChange(false)}
>
<Pencil className="size-3" />
Edit client details
<ExternalLink className="size-3" />
</Link>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/interests/${interestId}` as any}
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
onClick={() => onOpenChange(false)}
>
<Pencil className="size-3" />
Manage linked berths
<ExternalLink className="size-3" />
</Link>
</div>
</div>
)}
</div>
@@ -328,17 +398,48 @@ function PreviewRow({
label,
value,
missing = false,
edit,
}: {
label: string;
value: string | null;
missing?: boolean;
/** When provided, renders a pencil affordance that opens an inline editor.
* The save handler is owned by the row config so each field can hit the
* right API (clients PATCH, yachts PATCH, …). */
edit?: {
onSave: (next: string | null) => Promise<void>;
variant?: 'text' | 'country';
placeholder?: string;
};
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value ?? '');
const [saving, setSaving] = useState(false);
async function commit(next: string) {
const trimmed = next.trim();
if (!edit) return;
if (trimmed === (value ?? '')) {
setEditing(false);
return;
}
setSaving(true);
try {
await edit.onSave(trimmed === '' ? null : trimmed);
setEditing(false);
} catch (err) {
toastError(err);
} finally {
setSaving(false);
}
}
return (
<div className="flex items-baseline gap-2 text-sm">
<dt className="w-32 shrink-0 text-xs text-muted-foreground">{label}</dt>
<dd
className={cn(
'flex-1 break-words',
'flex-1 break-words inline-flex items-center gap-2',
missing
? 'text-rose-700 font-medium'
: value
@@ -346,7 +447,54 @@ function PreviewRow({
: 'text-muted-foreground italic',
)}
>
{value ?? (missing ? 'Missing — required' : 'Not set')}
{edit && editing ? (
edit.variant === 'country' ? (
<CountryCombobox
value={value}
onChange={(iso) => void commit(iso ?? '')}
defaultOpen
onOpenChange={(o) => !o && setEditing(false)}
compact={false}
className="h-7 w-full"
/>
) : (
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void commit(draft);
if (e.key === 'Escape') {
setDraft(value ?? '');
setEditing(false);
}
}}
onBlur={() => !saving && void commit(draft)}
placeholder={edit.placeholder}
autoFocus
disabled={saving}
className="h-7 text-sm"
/>
)
) : (
<>
<span className="flex-1">
{value ?? (missing ? 'Missing — required' : 'Not set')}
</span>
{edit ? (
<button
type="button"
onClick={() => {
setDraft(value ?? '');
setEditing(true);
}}
className="ml-1 inline-flex items-center rounded p-0.5 text-muted-foreground hover:bg-muted/60 hover:text-foreground"
aria-label={`Edit ${label}`}
>
<Pencil className="h-3 w-3" />
</button>
) : null}
</>
)}
</dd>
</div>
);

View File

@@ -69,11 +69,7 @@ export function FolderTreeSidebar({ selectedFolderId, onSelect, footer }: Folder
<div className="mb-2 px-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Folders
</div>
<TreeBody
selectedFolderId={selectedFolderId}
onSelect={onSelect}
footer={footer}
/>
<TreeBody selectedFolderId={selectedFolderId} onSelect={onSelect} footer={footer} />
</aside>
</>
);