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

@@ -1,10 +1,11 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Loader2, Plus, X, ChevronsUpDown, Check } from 'lucide-react';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
@@ -19,13 +20,39 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { TagPicker } from '@/components/shared/tag-picker';
import { CountryCombobox } from '@/components/shared/country-combobox';
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
import { ClientForm } from '@/components/clients/client-form';
import { YachtForm } from '@/components/yachts/yacht-form';
import { InterestForm } from '@/components/interests/interest-form';
import { apiFetch } from '@/lib/api/client';
import { useEntityOptions } from '@/hooks/use-entity-options';
import { toastError } from '@/lib/api/toast-error';
import { createCompanySchema, type CreateCompanyInput } from '@/lib/validators/companies';
import type { CountryCode } from '@/lib/i18n/countries';
import { cn } from '@/lib/utils';
type CompanyStatus = 'active' | 'dissolved';
@@ -52,8 +79,29 @@ interface CompanyFormProps {
export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
const queryClient = useQueryClient();
const router = useRouter();
const isEdit = !!company;
const [formError, setFormError] = useState<string | null>(null);
// Connection state — only used in create mode. Editing companies is done
// from the detail page where members + yachts have their own tabs that
// know how to handle removal / reassignment cleanly.
const [attachedClientIds, setAttachedClientIds] = useState<string[]>([]);
const [attachedYachtIds, setAttachedYachtIds] = useState<string[]>([]);
const [clientFormOpen, setClientFormOpen] = useState(false);
const [yachtFormOpen, setYachtFormOpen] = useState(false);
// After successful save the dialog flow can branch: ask the rep whether to
// also attach the picked clients' yachts (when any of them own yachts), and
// optionally chain to a New Interest form pre-filled with one of the
// attached clients.
const [createdCompanyId, setCreatedCompanyId] = useState<string | null>(null);
const [pendingYachtPullIn, setPendingYachtPullIn] = useState<
{ yachtId: string; yachtName: string }[] | null
>(null);
// Reserved for the inverse pull-in (attached yacht → owner client). Wired
// through but the inferring query is deferred — owner history isn't yet
// surfaced cheaply via the yacht endpoint.
// const [pendingOwnerPullIn, setPendingOwnerPullIn] = useState<...>(null);
const [createInterestFor, setCreateInterestFor] = useState<string | null>(null);
const {
register,
@@ -108,13 +156,80 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
method: 'PATCH',
body: rest,
});
} else {
await apiFetch('/api/v1/companies', { method: 'POST', body: data });
return null;
}
const res = await apiFetch<{ data: { id: string } }>('/api/v1/companies', {
method: 'POST',
body: data,
});
const newCompanyId = res.data.id;
// Connect each attached client as a company member. Failures collected
// here surface as a toast but don't roll back the company create — the
// rep can fix individual mismatches from the company detail page.
for (const clientId of attachedClientIds) {
try {
await apiFetch(`/api/v1/companies/${newCompanyId}/members`, {
method: 'POST',
body: { clientId, role: 'member' },
});
} catch (err) {
toastError(err);
}
}
// Transfer ownership of each attached yacht to the company. This uses
// the existing yacht-transfer endpoint so the audit log + ownership
// history records the change just like a manual transfer would.
for (const yachtId of attachedYachtIds) {
try {
await apiFetch(`/api/v1/yachts/${yachtId}/transfer`, {
method: 'POST',
body: {
newOwner: { type: 'company', id: newCompanyId },
reason: 'Attached during company creation',
},
});
} catch (err) {
toastError(err);
}
}
return newCompanyId;
},
onSuccess: () => {
onSuccess: async (newCompanyId) => {
queryClient.invalidateQueries({ queryKey: ['companies'] });
onOpenChange(false);
if (isEdit || !newCompanyId) {
onOpenChange(false);
return;
}
setCreatedCompanyId(newCompanyId);
// Step 2a: If any attached client owns yachts the rep didn't already
// attach, prompt to pull them in. Resolved here so the rep can opt out
// per-yacht rather than getting a blanket "everything attached" flow.
try {
const yachtsToOffer: { yachtId: string; yachtName: string }[] = [];
for (const clientId of attachedClientIds) {
const res = await apiFetch<{
data: Array<{ id: string; name: string; currentOwnerType: string }>;
}>(`/api/v1/yachts?ownerType=client&ownerId=${clientId}`);
for (const y of res.data) {
if (!attachedYachtIds.includes(y.id)) {
yachtsToOffer.push({ yachtId: y.id, yachtName: y.name });
}
}
}
if (yachtsToOffer.length > 0) {
setPendingYachtPullIn(yachtsToOffer);
return;
}
} catch {
// Yacht lookup failure is non-fatal — fall through to interest prompt.
}
// (Step 2b — yacht-owner pull-in — deferred. Adding it cleanly needs
// the yachts API to surface prior owners post-transfer, which currently
// only lives in the activity log. Tracked for follow-up.)
finishWithInterestPrompt(newCompanyId);
},
onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : 'Failed to save company';
@@ -122,6 +237,15 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
},
});
function finishWithInterestPrompt(newCompanyId: string) {
void newCompanyId;
if (attachedClientIds.length > 0) {
setCreateInterestFor(attachedClientIds[0] ?? null);
} else {
onOpenChange(false);
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
@@ -242,6 +366,68 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
<Separator />
{/* Connections — only on create. Editing membership / yacht ownership
from this form would race with the same actions on the detail
tabs (and the audit trail of a "create + attach 5 clients in one
flow" is much more readable than 6 separate create rows). */}
{!isEdit && (
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Connections
</h3>
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-xs">Member clients</Label>
<EntityMultiPicker
endpoint="/api/v1/clients/options"
labelKey="fullName"
placeholder="Add a client…"
selectedIds={attachedClientIds}
onChange={setAttachedClientIds}
/>
<p className="text-[11px] text-muted-foreground">
Each pick becomes a company member with role=member. You can refine roles
afterwards on the Members tab.
</p>
</div>
<div className="space-y-1">
<Label className="text-xs">Yachts owned by the company</Label>
<EntityMultiPicker
endpoint="/api/v1/yachts/options"
labelKey="name"
placeholder="Add a yacht…"
selectedIds={attachedYachtIds}
onChange={setAttachedYachtIds}
/>
<p className="text-[11px] text-muted-foreground">
Adding a yacht transfers its ownership to this company (logged in the yacht&apos;s
audit trail). Skip if you only want to associate without changing ownership.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setClientFormOpen(true)}
>
<Plus className="mr-1 h-3.5 w-3.5" /> New client
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setYachtFormOpen(true)}
>
<Plus className="mr-1 h-3.5 w-3.5" /> New yacht
</Button>
</div>
</div>
</div>
)}
<Separator />
{/* Notes */}
<div className="space-y-2">
<Label>Notes</Label>
@@ -279,6 +465,238 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
</SheetFooter>
</form>
</SheetContent>
{/* Stacked "+ New client" / "+ New yacht" forms. On successful create
the picker we open them from doesn't know the new id yet — the
ClientList / YachtList query refetches via react-query invalidation
and the rep can pick the new entity from the dropdown immediately. */}
<ClientForm open={clientFormOpen} onOpenChange={setClientFormOpen} />
{yachtFormOpen && (
<YachtForm
open={yachtFormOpen}
onOpenChange={setYachtFormOpen}
// No initialOwner — the new yacht starts unowned-by-rules-engine; the
// company-form will optionally transfer it on save.
/>
)}
<AlertDialog
open={!!pendingYachtPullIn}
onOpenChange={(o) => {
if (!o && createdCompanyId) {
setPendingYachtPullIn(null);
finishWithInterestPrompt(createdCompanyId);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Attach these yachts too?</AlertDialogTitle>
<AlertDialogDescription>
The clients you added own {pendingYachtPullIn?.length ?? 0}{' '}
{pendingYachtPullIn?.length === 1 ? 'yacht' : 'yachts'} not yet linked to this
company. Attaching transfers their ownership.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-1 rounded-md border bg-muted/30 p-2 text-sm">
{pendingYachtPullIn?.map((y) => (
<div key={y.yachtId} className="flex items-center justify-between">
<span className="truncate">{y.yachtName}</span>
</div>
))}
</div>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setPendingYachtPullIn(null);
if (createdCompanyId) finishWithInterestPrompt(createdCompanyId);
}}
>
Skip
</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
if (!createdCompanyId || !pendingYachtPullIn) return;
for (const y of pendingYachtPullIn) {
try {
await apiFetch(`/api/v1/yachts/${y.yachtId}/transfer`, {
method: 'POST',
body: {
newOwner: { type: 'company', id: createdCompanyId },
reason: 'Attached during company creation (yacht pull-in)',
},
});
} catch (err) {
toastError(err);
}
}
setPendingYachtPullIn(null);
finishWithInterestPrompt(createdCompanyId);
}}
>
Attach all
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={!!createInterestFor && !pendingYachtPullIn}
onOpenChange={(o) => {
if (!o) {
setCreateInterestFor(null);
onOpenChange(false);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Create an interest now?</AlertDialogTitle>
<AlertDialogDescription>
The new company is connected to {attachedClientIds.length}{' '}
{attachedClientIds.length === 1 ? 'client' : 'clients'}. Want to open a new interest
dialog pre-filled with one of them?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setCreateInterestFor(null);
onOpenChange(false);
}}
>
Not now
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
// Close the company form, then open the interest form. The
// interest form is rendered below via createInterestFor.
onOpenChange(false);
}}
>
Create interest
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Detached follow-up: interest form pre-filled with the first attached
client. Stays mounted after this form closes so the rep can finish
the new-interest flow uninterrupted. */}
{createInterestFor && !open && (
<InterestForm
open={true}
onOpenChange={(o) => {
if (!o) {
setCreateInterestFor(null);
router.refresh();
}
}}
defaultClientId={createInterestFor}
/>
)}
</Sheet>
);
}
/**
* Lightweight multi-pick combobox. Used by the company-form Connections
* section for both clients and yachts since they share the same shape
* (`{ value, label }` via useEntityOptions). Selected items render as
* removable chips above the picker so the rep can see at a glance what
* they're about to attach.
*/
function EntityMultiPicker({
endpoint,
labelKey,
placeholder,
selectedIds,
onChange,
}: {
endpoint: string;
labelKey: string;
placeholder: string;
selectedIds: string[];
onChange: (ids: string[]) => void;
}) {
const [open, setOpen] = useState(false);
const { options, setSearch } = useEntityOptions({ endpoint, labelKey });
const labelById = useMemo(() => {
const m = new Map<string, string>();
for (const o of options) m.set(o.value, o.label);
return m;
}, [options]);
function toggle(id: string) {
if (selectedIds.includes(id)) {
onChange(selectedIds.filter((x) => x !== id));
} else {
onChange([...selectedIds, id]);
}
}
return (
<div className="space-y-2">
{selectedIds.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{selectedIds.map((id) => (
<Badge key={id} variant="secondary" className="gap-1 pr-1">
<span className="max-w-[14rem] truncate">{labelById.get(id) ?? id.slice(0, 8)}</span>
<button
type="button"
className="rounded-full p-0.5 hover:bg-muted-foreground/20"
onClick={() => toggle(id)}
aria-label="Remove"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
) : null}
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
role="combobox"
aria-expanded={open}
className={cn('w-full justify-between font-normal', !selectedIds.length && 'text-muted-foreground')}
>
{placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popper-anchor-width)] min-w-[280px] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput placeholder="Search…" onValueChange={setSearch} />
<CommandList>
<CommandEmpty>No results.</CommandEmpty>
<CommandGroup>
{options.map((opt) => {
const isSelected = selectedIds.includes(opt.value);
return (
<CommandItem
key={opt.value}
value={opt.value}
onSelect={() => toggle(opt.value)}
>
<Check
className={cn(
'mr-2 h-4 w-4',
isSelected ? 'opacity-100' : 'opacity-0',
)}
/>
{opt.label}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}