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

@@ -14,6 +14,7 @@ import {
import { TagBadge } from '@/components/shared/tag-badge';
import { formatCurrency } from '@/lib/utils/currency';
import { mooringLetterDot } from './mooring-letter-tone';
import { stageBadgeClass, stageLabel } from '@/lib/constants';
export type BerthRow = {
id: string;
@@ -61,6 +62,9 @@ export type BerthRow = {
tenureStartDate: string | null;
tenureEndDate: string | null;
tags: Array<{ id: string; name: string; color: string }>;
/** Most-advanced pipeline stage among the berth's active interests. Null
* when no active interest is linked. Read-only; computed server-side. */
latestInterestStage?: string | null;
};
/**
@@ -72,6 +76,7 @@ export type BerthRow = {
export const BERTH_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
{ id: 'area', label: 'Area' },
{ id: 'status', label: 'Status' },
{ id: 'latestInterestStage', label: 'Latest deal stage' },
{ id: 'sidePontoon', label: 'Side / Pontoon' },
{ id: 'dimensions', label: 'Dimensions' },
{ id: 'nominalBoatSize', label: 'Nominal boat size' },
@@ -206,6 +211,22 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
header: 'Status',
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: 'latestInterestStage',
header: 'Latest deal stage',
enableSorting: false,
cell: ({ row }) => {
const s = row.original.latestInterestStage;
if (!s) return <span className="text-muted-foreground">-</span>;
return (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${stageBadgeClass(s)}`}
>
{stageLabel(s)}
</span>
);
},
},
{
id: 'sidePontoon',
header: 'Side / Pontoon',

View File

@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { Pencil, RefreshCw } from 'lucide-react';
import { Check, ChevronsUpDown, Pencil, RefreshCw } from 'lucide-react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useForm } from 'react-hook-form';
@@ -28,11 +28,21 @@ import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { PermissionGate } from '@/components/shared/permission-gate';
import { BerthForm } from './berth-form';
import { mooringLetterDot } from './mooring-letter-tone';
import { cn } from '@/lib/utils';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { useVocabulary } from '@/hooks/use-vocabulary';
import { updateBerthStatusSchema, type UpdateBerthStatusInput } from '@/lib/validators/berths';
import { BERTH_STATUSES } from '@/lib/constants';
import { BERTH_STATUSES, stageBadgeClass, stageDotClass, stageLabel } from '@/lib/constants';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
type BerthDetailData = {
id: string;
@@ -92,6 +102,8 @@ interface InterestOption {
id: string;
clientName: string;
pipelineStage: string;
/** Used to sort the picker — most recently interacted with floats to the top. */
updatedAt?: string;
}
function StatusChangeDialog({
@@ -128,10 +140,15 @@ function StatusChangeDialog({
// the picker is actually visible to avoid an unnecessary round-trip
// for available-status changes.
const interestsQuery = useQuery<{
data: Array<{ id: string; clientName: string; pipelineStage: string }>;
data: Array<{
id: string;
clientName: string;
pipelineStage: string;
updatedAt?: string;
}>;
}>({
queryKey: ['interests', 'status-link-picker'],
queryFn: () => apiFetch('/api/v1/interests?pageSize=200'),
queryFn: () => apiFetch('/api/v1/interests?pageSize=200&sort=updatedAt&order=desc'),
enabled: open && showInterestPicker,
staleTime: 60_000,
});
@@ -205,22 +222,11 @@ function StatusChangeDialog({
{showInterestPicker && (
<div className="space-y-2">
<Label>Linked prospect (optional)</Label>
<Select
value={interestId ?? '__none__'}
onValueChange={(v) => setValue('interestId', v === '__none__' ? undefined : v)}
>
<SelectTrigger>
<SelectValue placeholder="Select an interest…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> No interest </SelectItem>
{interestOptions.map((opt) => (
<SelectItem key={opt.id} value={opt.id}>
{opt.clientName} · {opt.pipelineStage}
</SelectItem>
))}
</SelectContent>
</Select>
<InterestLinkPicker
value={interestId ?? null}
options={interestOptions}
onChange={(id) => setValue('interestId', id ?? undefined)}
/>
<p className="text-xs text-muted-foreground">
Link this status change to the prospect (interest) it relates to. The change will
appear on that interest&apos;s timeline, and the berth gets attached to the prospect
@@ -252,27 +258,29 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
{/* Stacks vertically on phone widths so the action buttons don't
squeeze the area subtitle into a two-line wrap. From sm up the
title/area block sits side-by-side with the action buttons. */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-xl sm:text-2xl font-bold text-foreground">
Berth {berth.mooringNumber}
</h1>
<span
className={`inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium ${STATUS_COLORS[berth.status] ?? 'bg-muted text-muted-foreground border-muted'}`}
>
{STATUS_LABELS[berth.status] ?? berth.status}
</span>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
<div className="flex-1 min-w-0 flex items-center gap-3 flex-wrap">
{/* Compact mooring chip — the mooring number sits inside a
rounded plate tinted by the mooring-letter palette (same
colour used for the row-accent in the berth list). The
redundant "B Dock" tag from the previous design is replaced
with a title attribute so the area only surfaces on hover,
keeping the header lean. */}
<div
className={cn(
'inline-flex h-12 min-w-[3.25rem] items-center justify-center rounded-2xl px-3 text-lg font-bold tracking-tight text-white shadow-sm',
mooringLetterDot(berth.mooringNumber) ?? 'bg-slate-400',
)}
title={berth.area ? `${berth.area} Dock` : undefined}
aria-label={`Berth ${berth.mooringNumber}${berth.area ? `, ${berth.area} Dock` : ''}`}
>
{berth.mooringNumber}
</div>
{berth.area && (
<div className="mt-2">
<span
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-0.5 text-xs font-semibold uppercase tracking-wide text-white ${mooringLetterDot(berth.mooringNumber) ?? 'bg-slate-400'}`}
>
{berth.area} Dock
</span>
</div>
)}
<span
className={`inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium ${STATUS_COLORS[berth.status] ?? 'bg-muted text-muted-foreground border-muted'}`}
>
{STATUS_LABELS[berth.status] ?? berth.status}
</span>
</div>
<div className="flex flex-wrap items-center gap-2 sm:shrink-0">
@@ -301,3 +309,119 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
</>
);
}
/**
* Searchable combobox for picking a linked prospect when changing berth
* status. Replaces the bare Select which had no filter, no stage colours,
* and no recency sort — for ports with 200+ active interests that became
* a scroll-fest. Stage labels render with the same coloured pill the rest
* of the CRM uses for stage badges so the rep can scan the list visually.
*/
function InterestLinkPicker({
value,
options,
onChange,
}: {
value: string | null;
options: InterestOption[];
onChange: (id: string | null) => void;
}) {
const [open, setOpen] = useState(false);
// Sort with the most recently updated interest first so reps see the
// active deals at the top of the list — older / dormant ones drop
// beneath. `updatedAt` is set on every patch + every stage advance.
const sorted = [...options].sort((a, b) => {
if (!a.updatedAt && !b.updatedAt) return 0;
if (!a.updatedAt) return 1;
if (!b.updatedAt) return -1;
return b.updatedAt.localeCompare(a.updatedAt);
});
const selected = value ? sorted.find((o) => o.id === value) : null;
return (
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className={cn('w-full justify-between font-normal', !value && 'text-muted-foreground')}
>
{selected ? (
<span className="flex min-w-0 items-center gap-2">
<span
className={cn(
'inline-block h-2 w-2 shrink-0 rounded-full',
stageDotClass(selected.pipelineStage),
)}
aria-hidden
/>
<span className="truncate">{selected.clientName}</span>
<span className="ml-1 shrink-0 text-xs text-muted-foreground">
· {stageLabel(selected.pipelineStage)}
</span>
</span>
) : (
'— No interest —'
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popper-anchor-width)] min-w-[320px] p-0" align="start">
<Command>
<CommandInput placeholder="Search prospects…" />
<CommandList>
<CommandEmpty>No prospects found.</CommandEmpty>
<CommandGroup>
<CommandItem
value="__none__"
onSelect={() => {
onChange(null);
setOpen(false);
}}
className="text-muted-foreground"
>
No interest
</CommandItem>
</CommandGroup>
<CommandGroup heading="Most recent first">
{sorted.map((opt) => (
<CommandItem
// cmdk filters by `value`; include client name + stage so the
// search input matches both. Falling back to id keeps the
// option selectable even if a name is blank.
key={opt.id}
value={`${opt.clientName} ${stageLabel(opt.pipelineStage)} ${opt.id}`}
onSelect={() => {
onChange(opt.id);
setOpen(false);
}}
className="flex items-center gap-2"
>
<span
className={cn(
'inline-block h-2 w-2 shrink-0 rounded-full',
stageDotClass(opt.pipelineStage),
)}
aria-hidden
/>
<span className="flex-1 truncate">{opt.clientName || '(unnamed)'}</span>
<span
className={cn(
'inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[10px] font-medium',
stageBadgeClass(opt.pipelineStage),
)}
>
{stageLabel(opt.pipelineStage)}
</span>
{value === opt.id ? <Check className="h-3.5 w-3.5 text-muted-foreground" /> : null}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}