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,4 +1,3 @@
import Link from 'next/link';
import {
Bell,
BookOpen,
@@ -23,21 +22,8 @@ import {
Globe,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { PageHeader } from '@/components/shared/page-header';
interface AdminSection {
href: string;
label: string;
description: string;
icon: typeof Settings;
}
interface AdminGroup {
title: string;
description: string;
sections: AdminSection[];
}
import { AdminSectionsBrowser, type AdminGroup } from '@/components/admin/admin-sections-browser';
const GROUPS: AdminGroup[] = [
{
@@ -76,8 +62,9 @@ const GROUPS: AdminGroup[] = [
},
{
href: 'documenso',
label: 'Documenso & EOI',
description: 'API credentials, EOI template, and default in-app vs Documenso pathway.',
label: 'EOI signing service',
description:
'API credentials, EOI template, and default in-app vs external signing pathway.',
icon: FileText,
},
{
@@ -279,43 +266,9 @@ export default async function AdminLandingPage({
<div className="space-y-8">
<PageHeader
title="Administration"
description="Per-port configuration and system administration. Each card below opens a dedicated settings page."
description="Per-port configuration and system administration. Use the search to jump to a setting, or browse the grouped index below."
/>
{GROUPS.map((group) => (
<section key={group.title} className="space-y-3">
<div>
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{group.title}
</h2>
<p className="text-xs text-muted-foreground/80">{group.description}</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{group.sections.map((s) => {
const Icon = s.icon;
return (
<Link
key={s.href}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/admin/${s.href}` as any}
className="block group"
>
<Card className="h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30">
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
<Icon className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary" />
<div className="flex-1">
<CardTitle className="text-base">{s.label}</CardTitle>
</div>
</CardHeader>
<CardContent>
<CardDescription>{s.description}</CardDescription>
</CardContent>
</Card>
</Link>
);
})}
</div>
</section>
))}
<AdminSectionsBrowser portSlug={portSlug} groups={GROUPS} />
</div>
);
}

View File

@@ -0,0 +1,159 @@
'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { Search, X } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export interface AdminSection {
href: string;
label: string;
description: string;
icon: LucideIcon;
}
export interface AdminGroup {
title: string;
description: string;
sections: AdminSection[];
}
interface AdminSectionsBrowserProps {
portSlug: string;
groups: AdminGroup[];
}
/**
* Searchable index of admin settings cards. The unfiltered view renders the
* grouped grid (Access / Configuration / Content / …); typing in the search
* input collapses every section into a flat result list of matching cards.
*
* Match is substring against label + description + group title so a search
* for "tax" finds Document Templates (description mentions tax-id mergefield)
* as well as ID fields, without needing perfect spelling of the label.
*/
export function AdminSectionsBrowser({ portSlug, groups }: AdminSectionsBrowserProps) {
const [query, setQuery] = useState('');
const q = query.trim().toLowerCase();
// Flatten + filter when there's an active query; otherwise let the grouped
// view render. The grouped view is also memoised because the section count
// is large (30+) and the JSX otherwise rebuilds on every keystroke.
const filteredMatches = useMemo(() => {
if (!q) return null;
const matches: Array<AdminSection & { groupTitle: string }> = [];
for (const g of groups) {
for (const s of g.sections) {
const hay = `${s.label} ${s.description} ${g.title}`.toLowerCase();
if (hay.includes(q)) matches.push({ ...s, groupTitle: g.title });
}
}
return matches;
}, [q, groups]);
return (
<div className="space-y-6">
<div className="relative max-w-md">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
inputMode="search"
placeholder="Search settings…"
aria-label="Search admin settings"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-9 pl-9 pr-9"
/>
{query ? (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setQuery('')}
className="absolute right-1 top-1 h-7 w-7"
aria-label="Clear search"
>
<X className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
{filteredMatches ? (
filteredMatches.length === 0 ? (
<p className="rounded-md border border-dashed py-8 text-center text-sm text-muted-foreground">
No settings match &quot;{query}&quot;.
</p>
) : (
<div className="space-y-3">
<p className="text-xs uppercase tracking-wider text-muted-foreground">
{filteredMatches.length} match{filteredMatches.length === 1 ? '' : 'es'}
</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filteredMatches.map((s) => (
<SectionCard key={`${s.href}-${s.groupTitle}`} portSlug={portSlug} section={s} groupTitle={s.groupTitle} />
))}
</div>
</div>
)
) : (
groups.map((group) => (
<section key={group.title} className="space-y-3">
<div>
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{group.title}
</h2>
<p className="text-xs text-muted-foreground/80">{group.description}</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{group.sections.map((s) => (
<SectionCard key={s.href} portSlug={portSlug} section={s} />
))}
</div>
</section>
))
)}
</div>
);
}
function SectionCard({
portSlug,
section,
groupTitle,
}: {
portSlug: string;
section: AdminSection;
/** Optional "from group X" tag for search-result mode. */
groupTitle?: string;
}) {
const Icon = section.icon;
return (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/admin/${section.href}` as any}
className="block group"
>
<Card className={cn('h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30')}>
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
<Icon className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary" />
<div className="flex-1">
<CardTitle className="text-base">{section.label}</CardTitle>
{groupTitle ? (
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
{groupTitle}
</p>
) : null}
</div>
</CardHeader>
<CardContent>
<CardDescription>{section.description}</CardDescription>
</CardContent>
</Card>
</Link>
);
}

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>
);
}

View File

@@ -164,9 +164,8 @@ export function BulkArchiveWizard({ open, onOpenChange, clientIds, onSuccess }:
<div className="rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground">
Low-stakes defaults: release available/under-offer berths, keep sold ones, cancel
reservations, leave invoices/signing envelopes alone. Yachts stay on the
archived client. To customise per-client, archive that client individually
instead.
reservations, leave invoices/signing envelopes alone. Yachts stay on the archived
client. To customise per-client, archive that client individually instead.
</div>
</div>
)}

View File

@@ -234,10 +234,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
<div className="space-y-3">
{fields.map((field, index) => (
<div
key={field.id}
className="space-y-3 p-3 rounded-lg border bg-muted/30"
>
<div key={field.id} className="space-y-3 p-3 rounded-lg border bg-muted/30">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12 sm:items-end sm:gap-2">
<div className="space-y-1 sm:col-span-3">
<Label className="text-xs">Channel</Label>

View File

@@ -139,11 +139,7 @@ export function ClientList() {
return (
<div className="space-y-4">
<PageHeader
title="Clients"
description="Manage your client records"
variant="gradient"
/>
<PageHeader title="Clients" description="Manage your client records" variant="gradient" />
<div className="flex flex-wrap items-center gap-2">
<FilterBar

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>
);
}

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">

View File

@@ -230,8 +230,12 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="documenso-template">Generated EOI rendered + signed externally</SelectItem>
<SelectItem value="inapp">Manual EOI rendered in CRM, sent for e-signature</SelectItem>
<SelectItem value="documenso-template">
Generated EOI rendered + signed externally
</SelectItem>
<SelectItem value="inapp">
Manual EOI rendered in CRM, sent for e-signature
</SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -157,7 +157,8 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
};
const handleCancel = async () => {
if (!confirm('Cancel this document? This voids the signing envelope and cannot be undone.')) return;
if (!confirm('Cancel this document? This voids the signing envelope and cannot be undone.'))
return;
setIsCancelling(true);
try {
await apiFetch(`/api/v1/documents/${documentId}/cancel`, { method: 'POST' });

View File

@@ -26,6 +26,9 @@ import { Skeleton } from '@/components/ui/skeleton';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/ui-store';
import { Input } from '@/components/ui/input';
import { CountryCombobox } from '@/components/shared/country-combobox';
import { toastError } from '@/lib/api/toast-error';
const DOCUMENSO_TEMPLATE_VALUE = 'documenso-template';
@@ -39,6 +42,7 @@ interface InAppTemplate {
interface EoiContextResponse {
data: {
client: {
id: string;
fullName: string;
nationality: string | null;
primaryEmail: string | null;
@@ -46,6 +50,7 @@ interface EoiContextResponse {
address: { street: string; city: string; country: string } | null;
};
yacht: {
id: string;
name: string;
lengthFt: string | null;
widthFt: string | null;
@@ -119,6 +124,17 @@ export function EoiGenerateDialog({
const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]);
async function patchClient(body: Record<string, unknown>) {
if (!ctx) return;
await apiFetch(`/api/v1/clients/${ctx.client.id}`, { method: 'PATCH', body });
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'eoi-context'] });
}
async function patchYacht(body: Record<string, unknown>) {
if (!ctx?.yacht) return;
await apiFetch(`/api/v1/yachts/${ctx.yacht.id}`, { method: 'PATCH', body });
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'eoi-context'] });
}
// Required for the EOI's top paragraph (Section 2). Without these
// the document is unsignable, so generation is blocked.
const required = ctx
@@ -128,6 +144,27 @@ export function EoiGenerateDialog({
label: 'Full name',
value: ctx.client.fullName,
present: !!ctx.client.fullName,
edit: {
onSave: async (next: string | null) =>
await patchClient({ fullName: next ?? '' }),
placeholder: 'Full legal name',
},
},
{
key: 'nationality',
label: 'Nationality',
value: ctx.client.nationality,
present: !!ctx.client.nationality,
edit: {
variant: 'country' as const,
onSave: async (next: string | null) => {
// Country combobox emits the ISO code; the read-only string is the
// localised country name (resolved server-side). Coerce here so we
// store the canonical ISO.
const iso = next ? (next as string).toUpperCase() : null;
await patchClient({ nationalityIso: iso });
},
},
},
{
key: 'email',
@@ -155,6 +192,13 @@ export function EoiGenerateDialog({
key: 'yacht',
label: 'Yacht name',
value: ctx.yacht?.name ?? null,
edit: ctx.yacht
? {
onSave: async (next: string | null) =>
await patchYacht({ name: next ?? '' }),
placeholder: 'Yacht name',
}
: undefined,
},
{
key: 'dimensions',
@@ -263,8 +307,12 @@ export function EoiGenerateDialog({
<PreviewRow
key={row.key}
label={row.label}
// Nationality stores the localised country name in the preview
// but commits the ISO. Pass the underlying ISO via a closure
// so the CountryCombobox can highlight it correctly.
value={row.value}
missing={!row.present}
edit={row.edit}
/>
))}
</dl>
@@ -275,22 +323,44 @@ export function EoiGenerateDialog({
</p>
<dl className="space-y-1.5">
{optional.map((row) => (
<PreviewRow key={row.key} label={row.label} value={row.value} />
<PreviewRow
key={row.key}
label={row.label}
value={row.value}
edit={row.edit}
/>
))}
</dl>
</div>
{portSlug && clientId && (
<div className="border-t pt-2">
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/clients/${clientId}` as any}
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
onClick={() => onOpenChange(false)}
>
<Pencil className="size-3" />
Wrong details? Edit on the client&apos;s page
<ExternalLink className="size-3" />
</Link>
<div className="border-t pt-2 space-y-1">
<p className="text-[11px] text-muted-foreground">
Editing name / nationality / yacht name above patches the underlying records
directly. For phone, address, or to manage linked berths, jump to the canonical
page:
</p>
<div className="flex flex-wrap gap-3">
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/clients/${clientId}` as any}
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
onClick={() => onOpenChange(false)}
>
<Pencil className="size-3" />
Edit client details
<ExternalLink className="size-3" />
</Link>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/interests/${interestId}` as any}
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
onClick={() => onOpenChange(false)}
>
<Pencil className="size-3" />
Manage linked berths
<ExternalLink className="size-3" />
</Link>
</div>
</div>
)}
</div>
@@ -328,17 +398,48 @@ function PreviewRow({
label,
value,
missing = false,
edit,
}: {
label: string;
value: string | null;
missing?: boolean;
/** When provided, renders a pencil affordance that opens an inline editor.
* The save handler is owned by the row config so each field can hit the
* right API (clients PATCH, yachts PATCH, …). */
edit?: {
onSave: (next: string | null) => Promise<void>;
variant?: 'text' | 'country';
placeholder?: string;
};
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value ?? '');
const [saving, setSaving] = useState(false);
async function commit(next: string) {
const trimmed = next.trim();
if (!edit) return;
if (trimmed === (value ?? '')) {
setEditing(false);
return;
}
setSaving(true);
try {
await edit.onSave(trimmed === '' ? null : trimmed);
setEditing(false);
} catch (err) {
toastError(err);
} finally {
setSaving(false);
}
}
return (
<div className="flex items-baseline gap-2 text-sm">
<dt className="w-32 shrink-0 text-xs text-muted-foreground">{label}</dt>
<dd
className={cn(
'flex-1 break-words',
'flex-1 break-words inline-flex items-center gap-2',
missing
? 'text-rose-700 font-medium'
: value
@@ -346,7 +447,54 @@ function PreviewRow({
: 'text-muted-foreground italic',
)}
>
{value ?? (missing ? 'Missing — required' : 'Not set')}
{edit && editing ? (
edit.variant === 'country' ? (
<CountryCombobox
value={value}
onChange={(iso) => void commit(iso ?? '')}
defaultOpen
onOpenChange={(o) => !o && setEditing(false)}
compact={false}
className="h-7 w-full"
/>
) : (
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void commit(draft);
if (e.key === 'Escape') {
setDraft(value ?? '');
setEditing(false);
}
}}
onBlur={() => !saving && void commit(draft)}
placeholder={edit.placeholder}
autoFocus
disabled={saving}
className="h-7 text-sm"
/>
)
) : (
<>
<span className="flex-1">
{value ?? (missing ? 'Missing — required' : 'Not set')}
</span>
{edit ? (
<button
type="button"
onClick={() => {
setDraft(value ?? '');
setEditing(true);
}}
className="ml-1 inline-flex items-center rounded p-0.5 text-muted-foreground hover:bg-muted/60 hover:text-foreground"
aria-label={`Edit ${label}`}
>
<Pencil className="h-3 w-3" />
</button>
) : null}
</>
)}
</dd>
</div>
);

View File

@@ -69,11 +69,7 @@ export function FolderTreeSidebar({ selectedFolderId, onSelect, footer }: Folder
<div className="mb-2 px-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Folders
</div>
<TreeBody
selectedFolderId={selectedFolderId}
onSelect={onSelect}
footer={footer}
/>
<TreeBody selectedFolderId={selectedFolderId} onSelect={onSelect} footer={footer} />
</aside>
</>
);

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';

View File

@@ -21,9 +21,7 @@ const TABS_LEFT: TabSpec[] = [
{ label: 'Clients', icon: Users, segment: 'clients' },
];
const TABS_RIGHT: TabSpec[] = [
{ label: 'Berths', icon: Anchor, segment: 'berths' },
];
const TABS_RIGHT: TabSpec[] = [{ label: 'Berths', icon: Anchor, segment: 'berths' }];
interface MobileBottomTabsProps {
onMoreClick: () => void;
@@ -49,12 +47,7 @@ export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTab
)}
>
{TABS_LEFT.map((tab) => (
<NavTab
key={tab.segment}
tab={tab}
portSlug={portSlug}
active={isActive(tab.segment)}
/>
<NavTab key={tab.segment} tab={tab} portSlug={portSlug} active={isActive(tab.segment)} />
))}
{/* Search button — styled identically to the other navbar tabs. */}
@@ -68,12 +61,7 @@ export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTab
</button>
{TABS_RIGHT.map((tab) => (
<NavTab
key={tab.segment}
tab={tab}
portSlug={portSlug}
active={isActive(tab.segment)}
/>
<NavTab key={tab.segment} tab={tab} portSlug={portSlug} active={isActive(tab.segment)} />
))}
<button
@@ -88,15 +76,7 @@ export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTab
);
}
function NavTab({
tab,
portSlug,
active,
}: {
tab: TabSpec;
portSlug: string;
active: boolean;
}) {
function NavTab({ tab, portSlug, active }: { tab: TabSpec; portSlug: string; active: boolean }) {
const Icon = tab.icon;
return (
<Link

View File

@@ -2,6 +2,7 @@
import { ChevronLeft, Plus } from 'lucide-react';
import { useRouter, usePathname } from 'next/navigation';
import type { Route } from 'next';
import { useUIStore } from '@/stores/ui-store';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
@@ -127,8 +128,11 @@ export function Topbar({ ports, user }: TopbarProps) {
the server redirect strips the query string, so point
straight at the new path. The Reminders section's
useCreateFromUrl handler still picks up ?create=1. */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<DropdownMenuItem onClick={() => router.push(`${base}/inbox?create=1#reminders` as any)}>
<DropdownMenuItem
onClick={() =>
router.push(`${base}/inbox?create=1#reminders` as unknown as Route)
}
>
New Reminder
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -230,12 +230,10 @@ export function ReminderForm({
)}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Link to entity (optional)
</Label>
<Label className="text-xs text-muted-foreground">Link to entity (optional)</Label>
<p className="text-[11px] text-muted-foreground">
Pick a client first to scope the interest and berth dropdowns to that
client&apos;s deals.
Pick a client first to scope the interest and berth dropdowns to that client&apos;s
deals.
</p>
<div className="grid grid-cols-1 gap-2">
<ClientPicker

View File

@@ -11,12 +11,7 @@ import { PageHeader } from '@/components/shared/page-header';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
Select,
SelectContent,

View File

@@ -366,8 +366,8 @@ function EmptyHint() {
<Search className="size-7" aria-hidden />
</div>
<p className="text-sm text-muted-foreground">
Search clients, yachts, interests, berths, invoices, documents paste a UUID or
invoice number to jump directly.
Search clients, yachts, interests, berths, invoices, documents paste a UUID or invoice
number to jump directly.
</p>
</div>
);
@@ -450,7 +450,9 @@ function RowList({
</Section>
) : null}
{variant === 'results' && results.length > 0 ? renderResultRows(results, query, onSelect) : null}
{variant === 'results' && results.length > 0
? renderResultRows(results, query, onSelect)
: null}
</div>
);
}

View File

@@ -51,11 +51,14 @@ function parseTyped(raw: string): { display: string; numeric: number | null } {
const fracPart = dot === -1 ? null : cleaned.slice(dot + 1);
const intDigitsOnly = intPart.replace('-', '');
const intNumeric = intDigitsOnly === '' ? 0 : Number(intDigitsOnly);
const numeric = (negative ? -1 : 1) * (intNumeric + (fracPart ? Number(`0.${fracPart}`) || 0 : 0));
const numeric =
(negative ? -1 : 1) * (intNumeric + (fracPart ? Number(`0.${fracPart}`) || 0 : 0));
const intDisplay =
intDigitsOnly === ''
? (negative ? '-' : '')
? negative
? '-'
: ''
: (negative ? '-' : '') + groupFormatter.format(intNumeric);
const display = fracPart === null ? intDisplay : `${intDisplay}.${fracPart}`;

View File

@@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query';
import { formatDistanceToNow } from 'date-fns';
import { apiFetch } from '@/lib/api/client';
import { STAGE_LABELS, formatSource, type PipelineStage } from '@/lib/constants';
interface AuditRow {
id: string;
@@ -35,7 +36,33 @@ function formatAction(action: string): string {
function formatField(field: string | null): string | null {
if (!field) return null;
return field.replace(/_/g, ' ');
return field
.replace(/_/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.toLowerCase();
}
/** Resolve enum-typed values to their human-readable label so the row reads
* "10% Deposit" instead of "deposit_10pct". Returns the raw value for any
* unrecognised field/value. */
function formatValueForField(field: string | null, value: unknown): string {
if (value === null || value === undefined) return '';
if (field) {
const f = field.replace(/_/g, '').toLowerCase();
if (typeof value === 'string') {
if (f === 'pipelinestage' || f === 'stage') {
return STAGE_LABELS[value as PipelineStage] ?? value.replace(/_/g, ' ');
}
if (f === 'source') return formatSource(value) ?? value;
if (f === 'leadcategory' || f === 'category' || f === 'outcome') {
return value.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
}
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return JSON.stringify(value);
}
function summarize(row: AuditRow): string {
@@ -104,12 +131,12 @@ export function EntityActivityFeed({ endpoint, emptyText = 'No activity yet.' }:
<div className="mt-1 text-xs space-x-2">
{row.oldValue !== null && row.oldValue !== undefined ? (
<span className="line-through text-muted-foreground">
{String(JSON.stringify(row.oldValue)).slice(0, 80)}
{formatValueForField(row.fieldChanged, row.oldValue).slice(0, 80)}
</span>
) : null}
{row.newValue !== null && row.newValue !== undefined ? (
<span className="text-foreground">
{String(JSON.stringify(row.newValue)).slice(0, 80)}
{formatValueForField(row.fieldChanged, row.newValue).slice(0, 80)}
</span>
) : null}
</div>

View File

@@ -66,15 +66,7 @@ export type InlineEditableFieldProps = TextProps | SelectFieldProps | TextareaPr
* Enter/blur and cancels on Escape.
*/
export function InlineEditableField(props: InlineEditableFieldProps) {
const {
value,
displayValue,
onSave,
placeholder,
emptyText = '-',
className,
disabled,
} = props;
const { value, displayValue, onSave, placeholder, emptyText = '-', className, disabled } = props;
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value ?? '');
const [saving, setSaving] = useState(false);

View File

@@ -89,8 +89,7 @@ export function OwnerPicker({
const selectedLabel = (() => {
if (!value) return placeholder;
if (valueDetail?.data) {
const name =
value.type === 'client' ? valueDetail.data.fullName : valueDetail.data.name;
const name = value.type === 'client' ? valueDetail.data.fullName : valueDetail.data.name;
if (name) return name;
}
const match = options.find((o) => o.id === value.id);

View File

@@ -42,10 +42,7 @@ export function useDashboardWidgets() {
// list so flipping on a widget whose service isn't wired up does
// nothing silently — the toggle simply isn't shown.
const availableWidgets: DashboardWidget[] = useMemo(
() =>
DASHBOARD_WIDGETS.filter(
(w) => !w.requires || integrations.available[w.requires],
),
() => DASHBOARD_WIDGETS.filter((w) => !w.requires || integrations.available[w.requires]),
[integrations],
);

View File

@@ -192,9 +192,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
function humanizeEnum(raw: string): string {
const override = LABEL_OVERRIDES[raw.toLowerCase()];
if (override) return override;
return raw
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
return raw.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
export function toSelectOptions<T extends readonly string[]>(

View File

@@ -0,0 +1,64 @@
-- 0053 — Measurement units (entry-unit tracking + interest dual-store)
--
-- Problem: dimensions on interests/yachts/berths are stored in ft OR m, but
-- the CRM blindly converts between them on display. When a rep edits the
-- converted side, the original entered value drifts by floating-point error
-- (e.g. 18.29m → 60.039ft → back to 18.297m).
--
-- Fix: every dimension gets two pieces of info — a value column per unit
-- (so we can render the user's literal entry verbatim) AND a small
-- discriminator column (`*_unit`) saying which side the user originally
-- typed in. The form prefers the entered unit when displaying; the other
-- unit is computed only for export pathways (EOI PDF, recommender).
--
-- Interests previously only stored ft; this migration adds *_m columns
-- alongside. Yachts + berths already store both; only the discriminator
-- needs to be added.
--
-- Backfill: existing rows are flagged as `ft`-entered since that was the
-- only way to enter them before this change. m-side values get computed
-- (only for interests where they were null) via `* 0.3048`.
-- ── interests: dual-store + discriminator ─────────────────────────────────
ALTER TABLE interests
ADD COLUMN IF NOT EXISTS desired_length_m numeric,
ADD COLUMN IF NOT EXISTS desired_width_m numeric,
ADD COLUMN IF NOT EXISTS desired_draft_m numeric,
ADD COLUMN IF NOT EXISTS desired_length_unit text NOT NULL DEFAULT 'ft',
ADD COLUMN IF NOT EXISTS desired_width_unit text NOT NULL DEFAULT 'ft',
ADD COLUMN IF NOT EXISTS desired_draft_unit text NOT NULL DEFAULT 'ft';
UPDATE interests SET desired_length_m = ROUND(desired_length_ft * 0.3048::numeric, 2) WHERE desired_length_m IS NULL AND desired_length_ft IS NOT NULL;
UPDATE interests SET desired_width_m = ROUND(desired_width_ft * 0.3048::numeric, 2) WHERE desired_width_m IS NULL AND desired_width_ft IS NOT NULL;
UPDATE interests SET desired_draft_m = ROUND(desired_draft_ft * 0.3048::numeric, 2) WHERE desired_draft_m IS NULL AND desired_draft_ft IS NOT NULL;
-- ── yachts: discriminator only ────────────────────────────────────────────
ALTER TABLE yachts
ADD COLUMN IF NOT EXISTS length_unit text NOT NULL DEFAULT 'ft',
ADD COLUMN IF NOT EXISTS width_unit text NOT NULL DEFAULT 'ft',
ADD COLUMN IF NOT EXISTS draft_unit text NOT NULL DEFAULT 'ft';
-- ── berths: discriminator only (multi-axis) ───────────────────────────────
ALTER TABLE berths
ADD COLUMN IF NOT EXISTS length_unit text NOT NULL DEFAULT 'ft',
ADD COLUMN IF NOT EXISTS width_unit text NOT NULL DEFAULT 'ft',
ADD COLUMN IF NOT EXISTS draft_unit text NOT NULL DEFAULT 'ft',
ADD COLUMN IF NOT EXISTS nominal_boat_size_unit text NOT NULL DEFAULT 'ft',
ADD COLUMN IF NOT EXISTS water_depth_unit text NOT NULL DEFAULT 'ft';
-- Constrain to known values. (Cheaper than a separate enum type for two
-- string values, and easier to drop if we ever add a third unit.)
ALTER TABLE interests
ADD CONSTRAINT chk_interest_desired_length_unit CHECK (desired_length_unit IN ('ft','m')),
ADD CONSTRAINT chk_interest_desired_width_unit CHECK (desired_width_unit IN ('ft','m')),
ADD CONSTRAINT chk_interest_desired_draft_unit CHECK (desired_draft_unit IN ('ft','m'));
ALTER TABLE yachts
ADD CONSTRAINT chk_yacht_length_unit CHECK (length_unit IN ('ft','m')),
ADD CONSTRAINT chk_yacht_width_unit CHECK (width_unit IN ('ft','m')),
ADD CONSTRAINT chk_yacht_draft_unit CHECK (draft_unit IN ('ft','m'));
ALTER TABLE berths
ADD CONSTRAINT chk_berth_length_unit CHECK (length_unit IN ('ft','m')),
ADD CONSTRAINT chk_berth_width_unit CHECK (width_unit IN ('ft','m')),
ADD CONSTRAINT chk_berth_draft_unit CHECK (draft_unit IN ('ft','m')),
ADD CONSTRAINT chk_berth_nominal_boat_size_unit CHECK (nominal_boat_size_unit IN ('ft','m')),
ADD CONSTRAINT chk_berth_water_depth_unit CHECK (water_depth_unit IN ('ft','m'));

View File

@@ -40,6 +40,12 @@ export const berths = pgTable(
nominalBoatSizeM: numeric('nominal_boat_size_m'),
waterDepth: numeric('water_depth'),
waterDepthM: numeric('water_depth_m'),
/** Entry-unit discriminators — see interests.desiredLengthUnit comment. */
lengthUnit: text('length_unit').notNull().default('ft'),
widthUnit: text('width_unit').notNull().default('ft'),
draftUnit: text('draft_unit').notNull().default('ft'),
nominalBoatSizeUnit: text('nominal_boat_size_unit').notNull().default('ft'),
waterDepthUnit: text('water_depth_unit').notNull().default('ft'),
waterDepthIsMinimum: boolean('water_depth_is_minimum').default(false),
sidePontoon: text('side_pontoon'),
powerCapacity: numeric('power_capacity'), // kW

View File

@@ -58,11 +58,21 @@ export const interests = pgTable(
outcomeReason: text('outcome_reason'),
/** When the outcome was decided. Lets us age 'how long ago did we lose'. */
outcomeAt: timestamp('outcome_at', { withTimezone: true }),
/** Recommender inputs - imperial; resolver treats nulls as "no constraint"
* on that axis, with a banner prompting the rep to add the missing dim. */
/** Recommender inputs - dual-stored. ft is the canonical unit the
* recommender SQL queries on; m is the human-friendly entry the rep
* may have actually typed. The matching `*_unit` column says which
* side is source-of-truth — display prefers that side and recomputes
* the other so the rep's literal entry doesn't drift through repeated
* conversions. Resolver treats nulls as "no constraint" on that axis. */
desiredLengthFt: numeric('desired_length_ft'),
desiredWidthFt: numeric('desired_width_ft'),
desiredDraftFt: numeric('desired_draft_ft'),
desiredLengthM: numeric('desired_length_m'),
desiredWidthM: numeric('desired_width_m'),
desiredDraftM: numeric('desired_draft_m'),
desiredLengthUnit: text('desired_length_unit').notNull().default('ft'),
desiredWidthUnit: text('desired_width_unit').notNull().default('ft'),
desiredDraftUnit: text('desired_draft_unit').notNull().default('ft'),
archivedAt: timestamp('archived_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),

View File

@@ -35,6 +35,12 @@ export const yachts = pgTable(
lengthM: numeric('length_m'),
widthM: numeric('width_m'),
draftM: numeric('draft_m'),
/** Discriminator: which side ('ft' | 'm') the rep originally typed in.
* Used by the form to render that side verbatim (avoiding round-trip
* conversion drift on subsequent edits). */
lengthUnit: text('length_unit').notNull().default('ft'),
widthUnit: text('width_unit').notNull().default('ft'),
draftUnit: text('draft_unit').notNull().default('ft'),
currentOwnerType: text('current_owner_type').notNull(), // 'client' | 'company'
currentOwnerId: text('current_owner_id').notNull(),
status: text('status').notNull().default('active'), // 'active' | 'retired' | 'sold_away'

View File

@@ -1,9 +1,11 @@
import { and, eq, gte, lte, inArray, sql } from 'drizzle-orm';
import { and, eq, gte, lte, inArray, isNull, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
import { clients } from '@/lib/db/schema/clients';
import { interestBerths, interests } from '@/lib/db/schema/interests';
import { tags } from '@/lib/db/schema/system';
import { PIPELINE_STAGES } from '@/lib/constants';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { diffEntity } from '@/lib/entity-diff';
import { NotFoundError, ValidationError } from '@/lib/errors';
@@ -133,14 +135,63 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
}
}
const latestStageByBerthId = await getLatestInterestStageByBerth(berthIds, portId);
const data = (result.data as Array<Record<string, unknown>>).map((b) => ({
...b,
tags: tagsByBerthId[b.id as string] ?? [],
latestInterestStage: latestStageByBerthId[b.id as string] ?? null,
}));
return { data, total: result.total };
}
/**
* For each berth id, returns the most-advanced pipeline stage among its
* linked active interests (outcome IS NULL, not archived). Used by the
* berth list + detail to surface the deal furthest along on a berth so
* reps can see at a glance whether a berth is "Reservation Sent" via
* its connected interest, even though berth.status only tracks
* available/under_offer/sold.
*/
async function getLatestInterestStageByBerth(
berthIds: string[],
portId: string,
): Promise<Record<string, string>> {
if (berthIds.length === 0) return {};
const rows = await db
.select({
berthId: interestBerths.berthId,
pipelineStage: interests.pipelineStage,
})
.from(interestBerths)
.innerJoin(interests, eq(interestBerths.interestId, interests.id))
.where(
and(
eq(interests.portId, portId),
inArray(interestBerths.berthId, berthIds),
isNull(interests.outcome),
isNull(interests.archivedAt),
),
);
// Pipeline stages are an ordered enum — rank by position in PIPELINE_STAGES
// so "contract_signed" beats "eoi_sent". Falls back to 0 for any unknown
// legacy values so they're treated as least-advanced.
const rankOf = (stage: string) => {
const idx = (PIPELINE_STAGES as readonly string[]).indexOf(stage);
return idx === -1 ? -1 : idx;
};
const top: Record<string, string> = {};
for (const row of rows) {
const current = top[row.berthId];
if (!current || rankOf(row.pipelineStage) > rankOf(current)) {
top[row.berthId] = row.pipelineStage;
}
}
return top;
}
// ─── Get By ID ────────────────────────────────────────────────────────────────
export async function getBerthById(id: string, portId: string) {
@@ -160,7 +211,13 @@ export async function getBerthById(id: string, portId: string) {
.innerJoin(tags, eq(berthTags.tagId, tags.id))
.where(eq(berthTags.berthId, id));
return { ...berth, tags: tagRows };
const latestStageMap = await getLatestInterestStageByBerth([id], portId);
return {
...berth,
tags: tagRows,
latestInterestStage: latestStageMap[id] ?? null,
};
}
// ─── Update ───────────────────────────────────────────────────────────────────

View File

@@ -16,6 +16,7 @@ import { formatBerthRange } from '@/lib/templates/berth-range';
export type EoiContext = {
client: {
id: string;
fullName: string;
nationality: string | null;
primaryEmail: string | null;
@@ -24,6 +25,7 @@ export type EoiContext = {
};
/** Optional. The EOI's Section 3 yacht block is left blank when null. */
yacht: {
id: string;
name: string;
lengthFt: string | null;
widthFt: string | null;
@@ -275,6 +277,7 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
return {
client: {
id: client.id,
fullName: client.fullName,
nationality: client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null,
primaryEmail: firstEmail?.value ?? null,
@@ -283,6 +286,7 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
},
yacht: yacht
? {
id: yacht.id,
name: yacht.name,
lengthFt: yacht.lengthFt,
widthFt: yacht.widthFt,

View File

@@ -1377,7 +1377,10 @@ async function expandGraph(
JOIN interests i ON ib.interest_id = i.id
JOIN clients c ON i.client_id = c.id
JOIN berths b ON ib.berth_id = b.id
WHERE ib.berth_id IN (${sql.join(direct.berthIds.map((id) => sql`${id}`), sql`, `)})
WHERE ib.berth_id IN (${sql.join(
direct.berthIds.map((id) => sql`${id}`),
sql`, `,
)})
AND i.port_id = ${portId}
AND i.archived_at IS NULL
ORDER BY ib.is_primary DESC, i.created_at DESC
@@ -1420,7 +1423,10 @@ async function expandGraph(
ORDER BY ib2.is_primary DESC
LIMIT 1
) b ON TRUE
WHERE i.id IN (${sql.join(direct.interestIds.map((id) => sql`${id}`), sql`, `)})
WHERE i.id IN (${sql.join(
direct.interestIds.map((id) => sql`${id}`),
sql`, `,
)})
AND i.port_id = ${portId}
`)
: [];
@@ -1447,7 +1453,10 @@ async function expandGraph(
WHERE ib.interest_id = i.id
ORDER BY ib.is_primary DESC LIMIT 1
) b ON TRUE
WHERE i.client_id IN (${sql.join(direct.clientIds.map((id) => sql`${id}`), sql`, `)})
WHERE i.client_id IN (${sql.join(
direct.clientIds.map((id) => sql`${id}`),
sql`, `,
)})
AND i.port_id = ${portId}
AND i.archived_at IS NULL
ORDER BY i.created_at DESC
@@ -1468,7 +1477,10 @@ async function expandGraph(
FROM yachts y
JOIN clients c ON y.current_owner_id = c.id
WHERE y.current_owner_type = 'client'
AND y.current_owner_id IN (${sql.join(direct.clientIds.map((id) => sql`${id}`), sql`, `)})
AND y.current_owner_id IN (${sql.join(
direct.clientIds.map((id) => sql`${id}`),
sql`, `,
)})
AND y.port_id = ${portId}
ORDER BY y.name
LIMIT ${perBucketCap * direct.clientIds.length}
@@ -1488,7 +1500,10 @@ async function expandGraph(
FROM company_memberships cm
JOIN companies co ON cm.company_id = co.id
JOIN clients c ON cm.client_id = c.id
WHERE cm.client_id IN (${sql.join(direct.clientIds.map((id) => sql`${id}`), sql`, `)})
WHERE cm.client_id IN (${sql.join(
direct.clientIds.map((id) => sql`${id}`),
sql`, `,
)})
AND cm.end_date IS NULL
AND co.port_id = ${portId}
ORDER BY co.name
@@ -1522,7 +1537,10 @@ async function expandGraph(
WHERE ib.interest_id = i.id
ORDER BY ib.is_primary DESC LIMIT 1
) b ON TRUE
WHERE i.yacht_id IN (${sql.join(direct.yachtIds.map((id) => sql`${id}`), sql`, `)})
WHERE i.yacht_id IN (${sql.join(
direct.yachtIds.map((id) => sql`${id}`),
sql`, `,
)})
AND i.port_id = ${portId}
AND i.archived_at IS NULL
ORDER BY i.created_at DESC
@@ -1545,7 +1563,10 @@ async function expandGraph(
ON y.current_owner_type = 'client' AND y.current_owner_id = c.id
LEFT JOIN companies co
ON y.current_owner_type = 'company' AND y.current_owner_id = co.id
WHERE y.id IN (${sql.join(direct.yachtIds.map((id) => sql`${id}`), sql`, `)})
WHERE y.id IN (${sql.join(
direct.yachtIds.map((id) => sql`${id}`),
sql`, `,
)})
AND y.port_id = ${portId}
AND y.current_owner_id IS NOT NULL
`),
@@ -1567,7 +1588,10 @@ async function expandGraph(
FROM company_memberships cm
JOIN clients c ON cm.client_id = c.id
JOIN companies co ON cm.company_id = co.id
WHERE cm.company_id IN (${sql.join(direct.companyIds.map((id) => sql`${id}`), sql`, `)})
WHERE cm.company_id IN (${sql.join(
direct.companyIds.map((id) => sql`${id}`),
sql`, `,
)})
AND cm.end_date IS NULL
AND c.port_id = ${portId}
ORDER BY c.full_name
@@ -1727,9 +1751,11 @@ async function expandGraph(
* in both, the direct version wins. Direct matches sort before
* related matches.
*/
function mergeWithExpansion<
T extends { id: string; relatedVia?: RelatedVia | null },
>(direct: T[], expansion: T[], cap: number): T[] {
function mergeWithExpansion<T extends { id: string; relatedVia?: RelatedVia | null }>(
direct: T[],
expansion: T[],
cap: number,
): T[] {
const seen = new Set(direct.map((r) => r.id));
const merged = [
...direct.map((r) => ({ ...r, relatedVia: null as RelatedVia | null })),

View File

@@ -26,6 +26,8 @@ const optionalDesiredDimSchema = z
return String(Math.round(n * 100) / 100);
});
const desiredUnitSchema = z.enum(['ft', 'm']).optional();
export const createInterestSchema = z.object({
clientId: z.string().min(1),
yachtId: z.string().optional(),
@@ -42,6 +44,12 @@ export const createInterestSchema = z.object({
desiredLengthFt: optionalDesiredDimSchema,
desiredWidthFt: optionalDesiredDimSchema,
desiredDraftFt: optionalDesiredDimSchema,
desiredLengthM: optionalDesiredDimSchema,
desiredWidthM: optionalDesiredDimSchema,
desiredDraftM: optionalDesiredDimSchema,
desiredLengthUnit: desiredUnitSchema,
desiredWidthUnit: desiredUnitSchema,
desiredDraftUnit: desiredUnitSchema,
});
// ─── Update ──────────────────────────────────────────────────────────────────

View File

@@ -41,6 +41,7 @@ async function buildSyntheticEoiPdf(): Promise<Uint8Array> {
function makeContext(overrides: Partial<EoiContext> = {}): EoiContext {
return {
client: {
id: 'client-test-1',
fullName: 'Alice Smith',
nationality: 'US',
primaryEmail: 'alice@example.com',
@@ -48,6 +49,7 @@ function makeContext(overrides: Partial<EoiContext> = {}): EoiContext {
address: { street: '123 Main St', city: 'Austin', country: 'USA' },
},
yacht: {
id: 'yacht-test-1',
name: 'Sea Breeze',
lengthFt: '45',
widthFt: '14',
@@ -106,6 +108,7 @@ describe('fillEoiFormFields', () => {
sourcePdf,
makeContext({
client: {
id: 'client-test-2',
fullName: 'Bob',
nationality: null,
primaryEmail: null,

View File

@@ -32,10 +32,18 @@ describe('companies.service — createCompany', () => {
it('rejects duplicate name case-insensitively (ConflictError)', async () => {
const port = await makePort();
await createCompany(port.id, { name: 'Aegean Holdings', status: 'active', tagIds: [] }, makeAuditMeta({ portId: port.id }));
await createCompany(
port.id,
{ name: 'Aegean Holdings', status: 'active', tagIds: [] },
makeAuditMeta({ portId: port.id }),
);
await expect(
createCompany(port.id, { name: 'AEGEAN HOLDINGS', status: 'active', tagIds: [] }, makeAuditMeta({ portId: port.id })),
createCompany(
port.id,
{ name: 'AEGEAN HOLDINGS', status: 'active', tagIds: [] },
makeAuditMeta({ portId: port.id }),
),
).rejects.toBeInstanceOf(ConflictError);
});

View File

@@ -6,6 +6,7 @@ import type { EoiContext } from '@/lib/services/eoi-context';
function makeContext(overrides?: Partial<EoiContext>): EoiContext {
return {
client: {
id: 'client-fixture-1',
fullName: 'Alice Smith',
nationality: 'US',
primaryEmail: 'alice@example.com',
@@ -13,6 +14,7 @@ function makeContext(overrides?: Partial<EoiContext>): EoiContext {
address: { street: '123 Main St', city: 'Austin', country: 'USA' },
},
yacht: {
id: 'yacht-fixture-1',
name: 'Sea Breeze',
lengthFt: '45',
widthFt: '14',

View File

@@ -50,6 +50,11 @@ function makeBerth(overrides: Partial<Berth> = {}): Berth {
statusOverrideMode: null,
lastImportedAt: null,
currentPdfVersionId: null,
lengthUnit: 'ft',
widthUnit: 'ft',
draftUnit: 'ft',
nominalBoatSizeUnit: 'ft',
waterDepthUnit: 'ft',
createdAt: new Date(),
updatedAt: new Date(),
...overrides,