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

@@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { CardSkeleton } from '@/components/shared/loading-skeleton';
import { WidgetErrorBoundary } from './widget-error-boundary';
import { STAGE_LABELS, formatSource, type PipelineStage } from '@/lib/constants';
interface ActivityItem {
id: string;
@@ -35,10 +36,32 @@ function humanizeFieldName(name: string): string {
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Map enum-typed field values to their canonical human labels. The audit
* log stores raw enum strings (`deposit_10pct`, `lost_other_marina`); the
* feed should read like `10% Deposit`, not the wire value. */
function normalizeEnumValue(field: string, value: unknown): unknown {
if (typeof value !== 'string') return value;
const f = field.replace(/_/g, '').toLowerCase();
if (f === 'pipelinestage' || f === 'stage') {
return STAGE_LABELS[value as PipelineStage] ?? humanizeFieldName(value);
}
if (f === 'source') {
return formatSource(value) ?? value;
}
if (f === 'leadcategory' || f === 'category') {
return humanizeFieldName(value);
}
if (f === 'outcome') {
return humanizeFieldName(value);
}
return value;
}
/** Render a JSON-ish value as a short, single-line preview. Strings come
* through as-is; objects flatten to "k: v, k: v"; arrays compress to a
* count; nulls / empty render as em-dash. */
function shortValue(value: unknown): string {
function shortValue(value: unknown, fieldContext?: string): string {
if (fieldContext) value = normalizeEnumValue(fieldContext, value);
if (value === null || value === undefined || value === '') return '—';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
@@ -48,7 +71,10 @@ function shortValue(value: unknown): string {
if (entries.length === 0) return '—';
return entries
.slice(0, 3)
.map(([k, v]) => `${humanizeFieldName(k)}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
.map(
([k, v]) =>
`${humanizeFieldName(k)}: ${typeof v === 'string' ? normalizeEnumValue(k, v) : JSON.stringify(v)}`,
)
.join(', ');
}
return String(value);
@@ -79,7 +105,7 @@ function buildDiffLine(item: ActivityItem): string | null {
.slice(0, 2)
.map(([field, v]) => {
const { old, new: nextValue } = v as { old: unknown; new: unknown };
return `${humanizeFieldName(field)}: ${shortValue(old)}${shortValue(nextValue)}`;
return `${humanizeFieldName(field)}: ${shortValue(old, field)}${shortValue(nextValue, field)}`;
})
.join(' · ');
}
@@ -87,7 +113,8 @@ function buildDiffLine(item: ActivityItem): string | null {
// Shape B: single-field change with explicit columns.
if (item.fieldChanged) {
return `${humanizeFieldName(item.fieldChanged)}: ${shortValue(item.oldValue)}${shortValue(item.newValue)}`;
const field = item.fieldChanged;
return `${humanizeFieldName(field)}: ${shortValue(item.oldValue, field)}${shortValue(item.newValue, field)}`;
}
// Shape C: flat oldValue vs flat newValue.
@@ -104,7 +131,7 @@ function buildDiffLine(item: ActivityItem): string | null {
if (keys.length === 0) return null;
return keys
.slice(0, 2)
.map((k) => `${humanizeFieldName(k)}: ${shortValue(oldObj[k])}${shortValue(newObj[k])}`)
.map((k) => `${humanizeFieldName(k)}: ${shortValue(oldObj[k], k)}${shortValue(newObj[k], k)}`)
.join(' · ');
}
@@ -184,10 +211,7 @@ function ActivityFeedInner() {
)}
</p>
{diffLine ? (
<p
className="truncate text-xs text-muted-foreground mt-0.5"
title={diffLine}
>
<p className="truncate text-xs text-muted-foreground mt-0.5" title={diffLine}>
{diffLine}
</p>
) : null}

View File

@@ -81,8 +81,8 @@ export function BerthStatusChart() {
const numeric = typeof value === 'number' ? value : Number(value ?? 0);
const total = stats?.total ?? 0;
const pct = total > 0 ? Math.round((numeric / total) * 100) : 0;
const label = (payload as { payload?: { label?: string } } | undefined)
?.payload?.label;
const label = (payload as { payload?: { label?: string } } | undefined)?.payload
?.label;
return [`${numeric} (${pct}%)`, label ?? ''];
}}
/>

View File

@@ -35,9 +35,7 @@ export function CustomizeWidgetsMenu() {
const allHidden = visibleCount === 0;
// Reset is a no-op when state already matches the registry defaults —
// disable in that case to avoid pointless API round-trips.
const matchesDefaults = allWidgets.every(
(w) => (visibility[w.id] ?? false) === w.defaultVisible,
);
const matchesDefaults = allWidgets.every((w) => (visibility[w.id] ?? false) === w.defaultVisible);
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -51,8 +49,8 @@ export function CustomizeWidgetsMenu() {
<DialogHeader>
<DialogTitle>Customize dashboard</DialogTitle>
<DialogDescription>
Pick which analytics cards appear on your dashboard. Hidden cards leave no empty
space the layout reflows to fill the available width.
Pick which analytics cards appear on your dashboard. Hidden cards leave no empty space
the layout reflows to fill the available width.
</DialogDescription>
</DialogHeader>
@@ -114,11 +112,7 @@ export function CustomizeWidgetsMenu() {
>
Show all
</Button>
<Button
size="sm"
onClick={() => setOpen(false)}
className="w-full sm:w-auto"
>
<Button size="sm" onClick={() => setOpen(false)} className="w-full sm:w-auto">
Done
</Button>
</div>

View File

@@ -57,9 +57,7 @@ export function SourceConversionChart() {
<ul className="space-y-3">
{rows.map((r) => {
const pct = Math.round(r.conversionRate * 100);
const label = r.source
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
const label = r.source.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
return (
<li key={r.source} className="space-y-1">
<div className="flex items-center justify-between text-xs">