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:
@@ -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',
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user