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

@@ -76,9 +76,9 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
<DialogHeader>
<DialogTitle>Upload externally-signed EOI</DialogTitle>
<DialogDescription>
For EOIs signed outside our signing service (paper, in person, alternate e-sign vendor). The
uploaded PDF is filed against this interest and the pipeline stage is advanced to EOI
Signed.
For EOIs signed outside our signing service (paper, in person, alternate e-sign vendor).
The uploaded PDF is filed against this interest and the pipeline stage is advanced to
EOI Signed.
</DialogDescription>
</DialogHeader>

View File

@@ -1,13 +1,23 @@
'use client';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, Check, ChevronDown, ChevronLeft, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Textarea } from '@/components/ui/textarea';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { cn } from '@/lib/utils';
@@ -56,11 +66,28 @@ export function InlineStagePicker({
// interest's history, accessible via the activity timeline.
const [overrideTarget, setOverrideTarget] = useState<PipelineStage | null>(null);
const [overrideReason, setOverrideReason] = useState('');
// When dropping the stage back to 'open' on an interest with linked
// berths, prompt the rep whether to keep or unlink them. Going back to
// open usually means restarting the lead, so the berth association is
// often stale; offering a one-tap unlink prevents the public-map +
// recommender from showing the berths as "under offer" for a dead deal.
const [openConfirmTarget, setOpenConfirmTarget] = useState<PipelineStage | null>(null);
const [unlinking, setUnlinking] = useState(false);
const { can } = usePermissions();
const canOverride = can('interests', 'override_stage');
const stage = safeStage(currentStage);
// Fetch the linked-berth list lazily so we know whether to surface the
// unlink-prompt when the rep drops the stage back to 'open'.
const { data: linkedBerths } = useQuery<{ data: Array<{ berthId: string }> }>({
queryKey: ['interest-berths', interestId, 'count-only'],
queryFn: () => apiFetch(`/api/v1/interests/${interestId}/berths`),
enabled: open,
staleTime: 30_000,
});
const linkedBerthCount = linkedBerths?.data.length ?? 0;
const mutation = useMutation({
mutationFn: async ({ next, reason }: { next: PipelineStage; reason: string | null }) => {
const needsOverride = !canTransitionStage(stage, next);
@@ -94,6 +121,15 @@ export function InlineStagePicker({
setOpen(false);
return;
}
// Rewind-to-open guard: if the rep is dropping the stage back to
// 'open' AND the interest still has linked berths, intercept to ask
// whether to unlink them. Skipped when there are no linked berths
// (the prompt would be noise) or when the rep already came from open.
if (next === 'open' && stage !== 'open' && linkedBerthCount > 0) {
setOpenConfirmTarget(next);
setOpen(false);
return;
}
const isOverride = !canTransitionStage(stage, next);
if (isOverride && canOverride) {
// Switch into the confirm view rather than firing the mutation
@@ -107,6 +143,40 @@ export function InlineStagePicker({
mutation.mutate({ next, reason: null });
}
async function unlinkAllAndOpen(target: PipelineStage) {
setUnlinking(true);
try {
const ids = (linkedBerths?.data ?? []).map((b) => b.berthId);
await Promise.all(
ids.map((berthId) =>
apiFetch(`/api/v1/interests/${interestId}/berths/${berthId}`, { method: 'DELETE' }),
),
);
// After unlinking, the canTransition table might no longer flag this
// as an override — re-evaluate just in case.
const isOverride = !canTransitionStage(stage, target);
mutation.mutate({
next: target,
reason: isOverride ? 'Reverted to Open and unlinked all berths' : null,
});
setOpenConfirmTarget(null);
} catch (err) {
toastError(err);
} finally {
setUnlinking(false);
}
}
function keepBerthsAndOpen(target: PipelineStage) {
const isOverride = !canTransitionStage(stage, target);
setPendingStage(target);
mutation.mutate({
next: target,
reason: isOverride ? 'Reverted to Open (kept linked berths)' : null,
});
setOpenConfirmTarget(null);
}
function commitOverride() {
if (!overrideTarget) return;
setPendingStage(overrideTarget);
@@ -122,6 +192,7 @@ export function InlineStagePicker({
}
return (
<>
<Popover
open={open}
onOpenChange={(o) => {
@@ -272,5 +343,45 @@ export function InlineStagePicker({
)}
</PopoverContent>
</Popover>
<AlertDialog
open={!!openConfirmTarget}
onOpenChange={(o) => {
if (!o && !unlinking) setOpenConfirmTarget(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset this deal to Open?</AlertDialogTitle>
<AlertDialogDescription>
This interest has {linkedBerthCount} linked{' '}
{linkedBerthCount === 1 ? 'berth' : 'berths'}. Going back to <strong>Open</strong>{' '}
usually means restarting the lead keeping the berth links would leave them showing as
under offer on the public map for a deal that&apos;s no longer in progress.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<AlertDialogCancel disabled={unlinking}>Cancel</AlertDialogCancel>
<Button
type="button"
variant="outline"
disabled={unlinking}
onClick={() => openConfirmTarget && keepBerthsAndOpen(openConfirmTarget)}
>
Keep berth links
</Button>
<AlertDialogAction
disabled={unlinking}
onClick={(e) => {
e.preventDefault();
if (openConfirmTarget) void unlinkAllAndOpen(openConfirmTarget);
}}
>
{unlinking && <Loader2 className="mr-1.5 size-3.5 animate-spin" />}
Unlink {linkedBerthCount} {linkedBerthCount === 1 ? 'berth' : 'berths'} & reset
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -329,8 +329,8 @@ function EmptyEoiState({
No EOI in flight for this interest
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Generate the EOI to send it for signing the signing service handles the signing chain. You can also
upload a paper-signed copy if it was signed outside the system.
Generate the EOI to send it for signing the signing service handles the signing chain. You
can also upload a paper-signed copy if it was signed outside the system.
</p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
<Button onClick={onGenerate} size="sm" className="gap-1.5">

View File

@@ -197,8 +197,21 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
const mutation = useMutation({
mutationFn: async (data: CreateInterestInput) => {
// Enrich with the dual-store ft+m values + the entry-unit. The form
// tracks the canonical ft via DimensionInput; we compute the matching
// m value for the API and stamp the unit so a future edit can render
// the rep's literal entry without conversion drift.
const enriched: CreateInterestInput = {
...data,
desiredLengthM: ftToMStr(data.desiredLengthFt),
desiredWidthM: ftToMStr(data.desiredWidthFt),
desiredDraftM: ftToMStr(data.desiredDraftFt),
desiredLengthUnit: desiredUnit,
desiredWidthUnit: desiredUnit,
desiredDraftUnit: desiredUnit,
};
if (isEdit) {
const { tagIds: tIds, ...rest } = data;
const { tagIds: tIds, ...rest } = enriched;
await apiFetch(`/api/v1/interests/${interest!.id}`, { method: 'PATCH', body: rest });
if (tIds) {
await apiFetch(`/api/v1/interests/${interest!.id}/tags`, {
@@ -207,7 +220,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
});
}
} else {
await apiFetch('/api/v1/interests', { method: 'POST', body: data });
await apiFetch('/api/v1/interests', { method: 'POST', body: enriched });
}
},
onSuccess: () => {
@@ -216,6 +229,13 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
},
});
function ftToMStr(ft: string | number | undefined | null): string | undefined {
if (ft === undefined || ft === null || ft === '') return undefined;
const n = typeof ft === 'number' ? ft : Number(ft);
if (!Number.isFinite(n) || n <= 0) return undefined;
return String(Math.round(n * 0.3048 * 100) / 100);
}
const selectedClient = clientOptions.find((c) => c.value === selectedClientId);
const selectedBerth = berthOptions.find((b) => b.value === selectedBerthId);
@@ -728,10 +748,7 @@ function computeDisplay(ftValue: string | number | undefined, unit: 'ft' | 'm'):
return String(round2(v));
}
function computeAltDisplay(
ftValue: string | number | undefined,
unit: 'ft' | 'm',
): string | null {
function computeAltDisplay(ftValue: string | number | undefined, unit: 'ft' | 'm'): string | null {
if (ftValue === undefined || ftValue === null || ftValue === '') return null;
const ft = typeof ftValue === 'number' ? ftValue : Number(ftValue);
if (!Number.isFinite(ft) || ft <= 0) return null;

View File

@@ -36,12 +36,7 @@ import { Label } from '@/components/ui/label';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { HelpCircle } from 'lucide-react';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';