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 { import {
Bell, Bell,
BookOpen, BookOpen,
@@ -23,21 +22,8 @@ import {
Globe, Globe,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
import { AdminSectionsBrowser, type AdminGroup } from '@/components/admin/admin-sections-browser';
interface AdminSection {
href: string;
label: string;
description: string;
icon: typeof Settings;
}
interface AdminGroup {
title: string;
description: string;
sections: AdminSection[];
}
const GROUPS: AdminGroup[] = [ const GROUPS: AdminGroup[] = [
{ {
@@ -76,8 +62,9 @@ const GROUPS: AdminGroup[] = [
}, },
{ {
href: 'documenso', href: 'documenso',
label: 'Documenso & EOI', label: 'EOI signing service',
description: 'API credentials, EOI template, and default in-app vs Documenso pathway.', description:
'API credentials, EOI template, and default in-app vs external signing pathway.',
icon: FileText, icon: FileText,
}, },
{ {
@@ -279,43 +266,9 @@ export default async function AdminLandingPage({
<div className="space-y-8"> <div className="space-y-8">
<PageHeader <PageHeader
title="Administration" 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) => ( <AdminSectionsBrowser portSlug={portSlug} groups={GROUPS} />
<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>
))}
</div> </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 { TagBadge } from '@/components/shared/tag-badge';
import { formatCurrency } from '@/lib/utils/currency'; import { formatCurrency } from '@/lib/utils/currency';
import { mooringLetterDot } from './mooring-letter-tone'; import { mooringLetterDot } from './mooring-letter-tone';
import { stageBadgeClass, stageLabel } from '@/lib/constants';
export type BerthRow = { export type BerthRow = {
id: string; id: string;
@@ -61,6 +62,9 @@ export type BerthRow = {
tenureStartDate: string | null; tenureStartDate: string | null;
tenureEndDate: string | null; tenureEndDate: string | null;
tags: Array<{ id: string; name: string; color: string }>; 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 }> = [ export const BERTH_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
{ id: 'area', label: 'Area' }, { id: 'area', label: 'Area' },
{ id: 'status', label: 'Status' }, { id: 'status', label: 'Status' },
{ id: 'latestInterestStage', label: 'Latest deal stage' },
{ id: 'sidePontoon', label: 'Side / Pontoon' }, { id: 'sidePontoon', label: 'Side / Pontoon' },
{ id: 'dimensions', label: 'Dimensions' }, { id: 'dimensions', label: 'Dimensions' },
{ id: 'nominalBoatSize', label: 'Nominal boat size' }, { id: 'nominalBoatSize', label: 'Nominal boat size' },
@@ -206,6 +211,22 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
header: 'Status', header: 'Status',
cell: ({ row }) => <StatusBadge status={row.original.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', id: 'sidePontoon',
header: 'Side / Pontoon', header: 'Side / Pontoon',

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; 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 { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useForm } from 'react-hook-form'; 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 { PermissionGate } from '@/components/shared/permission-gate';
import { BerthForm } from './berth-form'; import { BerthForm } from './berth-form';
import { mooringLetterDot } from './mooring-letter-tone'; import { mooringLetterDot } from './mooring-letter-tone';
import { cn } from '@/lib/utils';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error'; import { toastError } from '@/lib/api/toast-error';
import { useVocabulary } from '@/hooks/use-vocabulary'; import { useVocabulary } from '@/hooks/use-vocabulary';
import { updateBerthStatusSchema, type UpdateBerthStatusInput } from '@/lib/validators/berths'; 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 = { type BerthDetailData = {
id: string; id: string;
@@ -92,6 +102,8 @@ interface InterestOption {
id: string; id: string;
clientName: string; clientName: string;
pipelineStage: string; pipelineStage: string;
/** Used to sort the picker — most recently interacted with floats to the top. */
updatedAt?: string;
} }
function StatusChangeDialog({ function StatusChangeDialog({
@@ -128,10 +140,15 @@ function StatusChangeDialog({
// the picker is actually visible to avoid an unnecessary round-trip // the picker is actually visible to avoid an unnecessary round-trip
// for available-status changes. // for available-status changes.
const interestsQuery = useQuery<{ 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'], 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, enabled: open && showInterestPicker,
staleTime: 60_000, staleTime: 60_000,
}); });
@@ -205,22 +222,11 @@ function StatusChangeDialog({
{showInterestPicker && ( {showInterestPicker && (
<div className="space-y-2"> <div className="space-y-2">
<Label>Linked prospect (optional)</Label> <Label>Linked prospect (optional)</Label>
<Select <InterestLinkPicker
value={interestId ?? '__none__'} value={interestId ?? null}
onValueChange={(v) => setValue('interestId', v === '__none__' ? undefined : v)} options={interestOptions}
> onChange={(id) => setValue('interestId', id ?? undefined)}
<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>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Link this status change to the prospect (interest) it relates to. The change will 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 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 {/* Stacks vertically on phone widths so the action buttons don't
squeeze the area subtitle into a two-line wrap. From sm up the squeeze the area subtitle into a two-line wrap. From sm up the
title/area block sits side-by-side with the action buttons. */} 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 flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0 flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-3 flex-wrap"> {/* Compact mooring chip — the mooring number sits inside a
<h1 className="text-xl sm:text-2xl font-bold text-foreground"> rounded plate tinted by the mooring-letter palette (same
Berth {berth.mooringNumber} colour used for the row-accent in the berth list). The
</h1> redundant "B Dock" tag from the previous design is replaced
<span with a title attribute so the area only surfaces on hover,
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'}`} keeping the header lean. */}
> <div
{STATUS_LABELS[berth.status] ?? berth.status} className={cn(
</span> '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> </div>
{berth.area && ( <span
<div className="mt-2"> 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'}`}
<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'}`} {STATUS_LABELS[berth.status] ?? berth.status}
> </span>
{berth.area} Dock
</span>
</div>
)}
</div> </div>
<div className="flex flex-wrap items-center gap-2 sm:shrink-0"> <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"> <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 Low-stakes defaults: release available/under-offer berths, keep sold ones, cancel
reservations, leave invoices/signing envelopes alone. Yachts stay on the reservations, leave invoices/signing envelopes alone. Yachts stay on the archived
archived client. To customise per-client, archive that client individually client. To customise per-client, archive that client individually instead.
instead.
</div> </div>
</div> </div>
)} )}

View File

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

View File

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

View File

@@ -1,10 +1,11 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query'; 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 { z } from 'zod';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -19,13 +20,39 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'; 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 { Separator } from '@/components/ui/separator';
import { TagPicker } from '@/components/shared/tag-picker'; import { TagPicker } from '@/components/shared/tag-picker';
import { CountryCombobox } from '@/components/shared/country-combobox'; import { CountryCombobox } from '@/components/shared/country-combobox';
import { SubdivisionCombobox } from '@/components/shared/subdivision-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 { 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 { createCompanySchema, type CreateCompanyInput } from '@/lib/validators/companies';
import type { CountryCode } from '@/lib/i18n/countries'; import type { CountryCode } from '@/lib/i18n/countries';
import { cn } from '@/lib/utils';
type CompanyStatus = 'active' | 'dissolved'; type CompanyStatus = 'active' | 'dissolved';
@@ -52,8 +79,29 @@ interface CompanyFormProps {
export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) { export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const router = useRouter();
const isEdit = !!company; const isEdit = !!company;
const [formError, setFormError] = useState<string | null>(null); 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 { const {
register, register,
@@ -108,13 +156,80 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
method: 'PATCH', method: 'PATCH',
body: rest, body: rest,
}); });
} else { return null;
await apiFetch('/api/v1/companies', { method: 'POST', body: data });
} }
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'] }); 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) => { onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : 'Failed to save company'; 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 ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto"> <SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
@@ -242,6 +366,68 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
<Separator /> <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 */} {/* Notes */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Notes</Label> <Label>Notes</Label>
@@ -279,6 +465,238 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
</SheetFooter> </SheetFooter>
</form> </form>
</SheetContent> </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> </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 { Badge } from '@/components/ui/badge';
import { CardSkeleton } from '@/components/shared/loading-skeleton'; import { CardSkeleton } from '@/components/shared/loading-skeleton';
import { WidgetErrorBoundary } from './widget-error-boundary'; import { WidgetErrorBoundary } from './widget-error-boundary';
import { STAGE_LABELS, formatSource, type PipelineStage } from '@/lib/constants';
interface ActivityItem { interface ActivityItem {
id: string; id: string;
@@ -35,10 +36,32 @@ function humanizeFieldName(name: string): string {
.replace(/\b\w/g, (c) => c.toUpperCase()); .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 /** 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 * through as-is; objects flatten to "k: v, k: v"; arrays compress to a
* count; nulls / empty render as em-dash. */ * 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 (value === null || value === undefined || value === '') return '—';
if (typeof value === 'string') return value; if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(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 '—'; if (entries.length === 0) return '—';
return entries return entries
.slice(0, 3) .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(', '); .join(', ');
} }
return String(value); return String(value);
@@ -79,7 +105,7 @@ function buildDiffLine(item: ActivityItem): string | null {
.slice(0, 2) .slice(0, 2)
.map(([field, v]) => { .map(([field, v]) => {
const { old, new: nextValue } = v as { old: unknown; new: unknown }; 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(' · '); .join(' · ');
} }
@@ -87,7 +113,8 @@ function buildDiffLine(item: ActivityItem): string | null {
// Shape B: single-field change with explicit columns. // Shape B: single-field change with explicit columns.
if (item.fieldChanged) { 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. // Shape C: flat oldValue vs flat newValue.
@@ -104,7 +131,7 @@ function buildDiffLine(item: ActivityItem): string | null {
if (keys.length === 0) return null; if (keys.length === 0) return null;
return keys return keys
.slice(0, 2) .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(' · '); .join(' · ');
} }
@@ -184,10 +211,7 @@ function ActivityFeedInner() {
)} )}
</p> </p>
{diffLine ? ( {diffLine ? (
<p <p className="truncate text-xs text-muted-foreground mt-0.5" title={diffLine}>
className="truncate text-xs text-muted-foreground mt-0.5"
title={diffLine}
>
{diffLine} {diffLine}
</p> </p>
) : null} ) : null}

View File

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

View File

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

View File

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

View File

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

View File

@@ -157,7 +157,8 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
}; };
const handleCancel = async () => { 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); setIsCancelling(true);
try { try {
await apiFetch(`/api/v1/documents/${documentId}/cancel`, { method: 'POST' }); 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 { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/ui-store'; 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'; const DOCUMENSO_TEMPLATE_VALUE = 'documenso-template';
@@ -39,6 +42,7 @@ interface InAppTemplate {
interface EoiContextResponse { interface EoiContextResponse {
data: { data: {
client: { client: {
id: string;
fullName: string; fullName: string;
nationality: string | null; nationality: string | null;
primaryEmail: string | null; primaryEmail: string | null;
@@ -46,6 +50,7 @@ interface EoiContextResponse {
address: { street: string; city: string; country: string } | null; address: { street: string; city: string; country: string } | null;
}; };
yacht: { yacht: {
id: string;
name: string; name: string;
lengthFt: string | null; lengthFt: string | null;
widthFt: string | null; widthFt: string | null;
@@ -119,6 +124,17 @@ export function EoiGenerateDialog({
const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]); 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 // Required for the EOI's top paragraph (Section 2). Without these
// the document is unsignable, so generation is blocked. // the document is unsignable, so generation is blocked.
const required = ctx const required = ctx
@@ -128,6 +144,27 @@ export function EoiGenerateDialog({
label: 'Full name', label: 'Full name',
value: ctx.client.fullName, value: ctx.client.fullName,
present: !!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', key: 'email',
@@ -155,6 +192,13 @@ export function EoiGenerateDialog({
key: 'yacht', key: 'yacht',
label: 'Yacht name', label: 'Yacht name',
value: ctx.yacht?.name ?? null, value: ctx.yacht?.name ?? null,
edit: ctx.yacht
? {
onSave: async (next: string | null) =>
await patchYacht({ name: next ?? '' }),
placeholder: 'Yacht name',
}
: undefined,
}, },
{ {
key: 'dimensions', key: 'dimensions',
@@ -263,8 +307,12 @@ export function EoiGenerateDialog({
<PreviewRow <PreviewRow
key={row.key} key={row.key}
label={row.label} 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} value={row.value}
missing={!row.present} missing={!row.present}
edit={row.edit}
/> />
))} ))}
</dl> </dl>
@@ -275,22 +323,44 @@ export function EoiGenerateDialog({
</p> </p>
<dl className="space-y-1.5"> <dl className="space-y-1.5">
{optional.map((row) => ( {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> </dl>
</div> </div>
{portSlug && clientId && ( {portSlug && clientId && (
<div className="border-t pt-2"> <div className="border-t pt-2 space-y-1">
<Link <p className="text-[11px] text-muted-foreground">
// eslint-disable-next-line @typescript-eslint/no-explicit-any Editing name / nationality / yacht name above patches the underlying records
href={`/${portSlug}/clients/${clientId}` as any} directly. For phone, address, or to manage linked berths, jump to the canonical
className="inline-flex items-center gap-1 text-xs text-primary hover:underline" page:
onClick={() => onOpenChange(false)} </p>
> <div className="flex flex-wrap gap-3">
<Pencil className="size-3" /> <Link
Wrong details? Edit on the client&apos;s page // eslint-disable-next-line @typescript-eslint/no-explicit-any
<ExternalLink className="size-3" /> href={`/${portSlug}/clients/${clientId}` as any}
</Link> 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>
)} )}
</div> </div>
@@ -328,17 +398,48 @@ function PreviewRow({
label, label,
value, value,
missing = false, missing = false,
edit,
}: { }: {
label: string; label: string;
value: string | null; value: string | null;
missing?: boolean; 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 ( return (
<div className="flex items-baseline gap-2 text-sm"> <div className="flex items-baseline gap-2 text-sm">
<dt className="w-32 shrink-0 text-xs text-muted-foreground">{label}</dt> <dt className="w-32 shrink-0 text-xs text-muted-foreground">{label}</dt>
<dd <dd
className={cn( className={cn(
'flex-1 break-words', 'flex-1 break-words inline-flex items-center gap-2',
missing missing
? 'text-rose-700 font-medium' ? 'text-rose-700 font-medium'
: value : value
@@ -346,7 +447,54 @@ function PreviewRow({
: 'text-muted-foreground italic', : '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> </dd>
</div> </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"> <div className="mb-2 px-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Folders Folders
</div> </div>
<TreeBody <TreeBody selectedFolderId={selectedFolderId} onSelect={onSelect} footer={footer} />
selectedFolderId={selectedFolderId}
onSelect={onSelect}
footer={footer}
/>
</aside> </aside>
</> </>
); );

View File

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

View File

@@ -1,13 +1,23 @@
'use client'; 'use client';
import { useState } from 'react'; 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 { AlertTriangle, Check, ChevronDown, ChevronLeft, Loader2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Textarea } from '@/components/ui/textarea'; 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 { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error'; import { toastError } from '@/lib/api/toast-error';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -56,11 +66,28 @@ export function InlineStagePicker({
// interest's history, accessible via the activity timeline. // interest's history, accessible via the activity timeline.
const [overrideTarget, setOverrideTarget] = useState<PipelineStage | null>(null); const [overrideTarget, setOverrideTarget] = useState<PipelineStage | null>(null);
const [overrideReason, setOverrideReason] = useState(''); 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 { can } = usePermissions();
const canOverride = can('interests', 'override_stage'); const canOverride = can('interests', 'override_stage');
const stage = safeStage(currentStage); 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({ const mutation = useMutation({
mutationFn: async ({ next, reason }: { next: PipelineStage; reason: string | null }) => { mutationFn: async ({ next, reason }: { next: PipelineStage; reason: string | null }) => {
const needsOverride = !canTransitionStage(stage, next); const needsOverride = !canTransitionStage(stage, next);
@@ -94,6 +121,15 @@ export function InlineStagePicker({
setOpen(false); setOpen(false);
return; 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); const isOverride = !canTransitionStage(stage, next);
if (isOverride && canOverride) { if (isOverride && canOverride) {
// Switch into the confirm view rather than firing the mutation // Switch into the confirm view rather than firing the mutation
@@ -107,6 +143,40 @@ export function InlineStagePicker({
mutation.mutate({ next, reason: null }); 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() { function commitOverride() {
if (!overrideTarget) return; if (!overrideTarget) return;
setPendingStage(overrideTarget); setPendingStage(overrideTarget);
@@ -122,6 +192,7 @@ export function InlineStagePicker({
} }
return ( return (
<>
<Popover <Popover
open={open} open={open}
onOpenChange={(o) => { onOpenChange={(o) => {
@@ -272,5 +343,45 @@ export function InlineStagePicker({
)} )}
</PopoverContent> </PopoverContent>
</Popover> </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 No EOI in flight for this interest
</h2> </h2>
<p className="mt-1 text-sm text-muted-foreground"> <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 Generate the EOI to send it for signing the signing service handles the signing chain. You
upload a paper-signed copy if it was signed outside the system. can also upload a paper-signed copy if it was signed outside the system.
</p> </p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2"> <div className="mt-5 flex flex-wrap items-center justify-center gap-2">
<Button onClick={onGenerate} size="sm" className="gap-1.5"> <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({ const mutation = useMutation({
mutationFn: async (data: CreateInterestInput) => { 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) { if (isEdit) {
const { tagIds: tIds, ...rest } = data; const { tagIds: tIds, ...rest } = enriched;
await apiFetch(`/api/v1/interests/${interest!.id}`, { method: 'PATCH', body: rest }); await apiFetch(`/api/v1/interests/${interest!.id}`, { method: 'PATCH', body: rest });
if (tIds) { if (tIds) {
await apiFetch(`/api/v1/interests/${interest!.id}/tags`, { await apiFetch(`/api/v1/interests/${interest!.id}/tags`, {
@@ -207,7 +220,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
}); });
} }
} else { } else {
await apiFetch('/api/v1/interests', { method: 'POST', body: data }); await apiFetch('/api/v1/interests', { method: 'POST', body: enriched });
} }
}, },
onSuccess: () => { 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 selectedClient = clientOptions.find((c) => c.value === selectedClientId);
const selectedBerth = berthOptions.find((b) => b.value === selectedBerthId); 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)); return String(round2(v));
} }
function computeAltDisplay( function computeAltDisplay(ftValue: string | number | undefined, unit: 'ft' | 'm'): string | null {
ftValue: string | number | undefined,
unit: 'ft' | 'm',
): string | null {
if (ftValue === undefined || ftValue === null || ftValue === '') return null; if (ftValue === undefined || ftValue === null || ftValue === '') return null;
const ft = typeof ftValue === 'number' ? ftValue : Number(ftValue); const ft = typeof ftValue === 'number' ? ftValue : Number(ftValue);
if (!Number.isFinite(ft) || ft <= 0) return null; 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 { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { HelpCircle } from 'lucide-react'; import { HelpCircle } from 'lucide-react';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';

View File

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

View File

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

View File

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

View File

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

View File

@@ -366,8 +366,8 @@ function EmptyHint() {
<Search className="size-7" aria-hidden /> <Search className="size-7" aria-hidden />
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Search clients, yachts, interests, berths, invoices, documents paste a UUID or Search clients, yachts, interests, berths, invoices, documents paste a UUID or invoice
invoice number to jump directly. number to jump directly.
</p> </p>
</div> </div>
); );
@@ -450,7 +450,9 @@ function RowList({
</Section> </Section>
) : null} ) : null}
{variant === 'results' && results.length > 0 ? renderResultRows(results, query, onSelect) : null} {variant === 'results' && results.length > 0
? renderResultRows(results, query, onSelect)
: null}
</div> </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 fracPart = dot === -1 ? null : cleaned.slice(dot + 1);
const intDigitsOnly = intPart.replace('-', ''); const intDigitsOnly = intPart.replace('-', '');
const intNumeric = intDigitsOnly === '' ? 0 : Number(intDigitsOnly); 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 = const intDisplay =
intDigitsOnly === '' intDigitsOnly === ''
? (negative ? '-' : '') ? negative
? '-'
: ''
: (negative ? '-' : '') + groupFormatter.format(intNumeric); : (negative ? '-' : '') + groupFormatter.format(intNumeric);
const display = fracPart === null ? intDisplay : `${intDisplay}.${fracPart}`; 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 { formatDistanceToNow } from 'date-fns';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { STAGE_LABELS, formatSource, type PipelineStage } from '@/lib/constants';
interface AuditRow { interface AuditRow {
id: string; id: string;
@@ -35,7 +36,33 @@ function formatAction(action: string): string {
function formatField(field: string | null): string | null { function formatField(field: string | null): string | null {
if (!field) return 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 { 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"> <div className="mt-1 text-xs space-x-2">
{row.oldValue !== null && row.oldValue !== undefined ? ( {row.oldValue !== null && row.oldValue !== undefined ? (
<span className="line-through text-muted-foreground"> <span className="line-through text-muted-foreground">
{String(JSON.stringify(row.oldValue)).slice(0, 80)} {formatValueForField(row.fieldChanged, row.oldValue).slice(0, 80)}
</span> </span>
) : null} ) : null}
{row.newValue !== null && row.newValue !== undefined ? ( {row.newValue !== null && row.newValue !== undefined ? (
<span className="text-foreground"> <span className="text-foreground">
{String(JSON.stringify(row.newValue)).slice(0, 80)} {formatValueForField(row.fieldChanged, row.newValue).slice(0, 80)}
</span> </span>
) : null} ) : null}
</div> </div>

View File

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

View File

@@ -89,8 +89,7 @@ export function OwnerPicker({
const selectedLabel = (() => { const selectedLabel = (() => {
if (!value) return placeholder; if (!value) return placeholder;
if (valueDetail?.data) { if (valueDetail?.data) {
const name = const name = value.type === 'client' ? valueDetail.data.fullName : valueDetail.data.name;
value.type === 'client' ? valueDetail.data.fullName : valueDetail.data.name;
if (name) return name; if (name) return name;
} }
const match = options.find((o) => o.id === value.id); 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 // list so flipping on a widget whose service isn't wired up does
// nothing silently — the toggle simply isn't shown. // nothing silently — the toggle simply isn't shown.
const availableWidgets: DashboardWidget[] = useMemo( 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], [integrations],
); );

View File

@@ -192,9 +192,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
function humanizeEnum(raw: string): string { function humanizeEnum(raw: string): string {
const override = LABEL_OVERRIDES[raw.toLowerCase()]; const override = LABEL_OVERRIDES[raw.toLowerCase()];
if (override) return override; if (override) return override;
return raw return raw.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
} }
export function toSelectOptions<T extends readonly string[]>( 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'), nominalBoatSizeM: numeric('nominal_boat_size_m'),
waterDepth: numeric('water_depth'), waterDepth: numeric('water_depth'),
waterDepthM: numeric('water_depth_m'), 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), waterDepthIsMinimum: boolean('water_depth_is_minimum').default(false),
sidePontoon: text('side_pontoon'), sidePontoon: text('side_pontoon'),
powerCapacity: numeric('power_capacity'), // kW powerCapacity: numeric('power_capacity'), // kW

View File

@@ -58,11 +58,21 @@ export const interests = pgTable(
outcomeReason: text('outcome_reason'), outcomeReason: text('outcome_reason'),
/** When the outcome was decided. Lets us age 'how long ago did we lose'. */ /** When the outcome was decided. Lets us age 'how long ago did we lose'. */
outcomeAt: timestamp('outcome_at', { withTimezone: true }), outcomeAt: timestamp('outcome_at', { withTimezone: true }),
/** Recommender inputs - imperial; resolver treats nulls as "no constraint" /** Recommender inputs - dual-stored. ft is the canonical unit the
* on that axis, with a banner prompting the rep to add the missing dim. */ * 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'), desiredLengthFt: numeric('desired_length_ft'),
desiredWidthFt: numeric('desired_width_ft'), desiredWidthFt: numeric('desired_width_ft'),
desiredDraftFt: numeric('desired_draft_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 }), archivedAt: timestamp('archived_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_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'), lengthM: numeric('length_m'),
widthM: numeric('width_m'), widthM: numeric('width_m'),
draftM: numeric('draft_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' currentOwnerType: text('current_owner_type').notNull(), // 'client' | 'company'
currentOwnerId: text('current_owner_id').notNull(), currentOwnerId: text('current_owner_id').notNull(),
status: text('status').notNull().default('active'), // 'active' | 'retired' | 'sold_away' 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 { db } from '@/lib/db';
import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths'; import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
import { clients } from '@/lib/db/schema/clients'; import { clients } from '@/lib/db/schema/clients';
import { interestBerths, interests } from '@/lib/db/schema/interests';
import { tags } from '@/lib/db/schema/system'; import { tags } from '@/lib/db/schema/system';
import { PIPELINE_STAGES } from '@/lib/constants';
import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { diffEntity } from '@/lib/entity-diff'; import { diffEntity } from '@/lib/entity-diff';
import { NotFoundError, ValidationError } from '@/lib/errors'; 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) => ({ const data = (result.data as Array<Record<string, unknown>>).map((b) => ({
...b, ...b,
tags: tagsByBerthId[b.id as string] ?? [], tags: tagsByBerthId[b.id as string] ?? [],
latestInterestStage: latestStageByBerthId[b.id as string] ?? null,
})); }));
return { data, total: result.total }; 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 ──────────────────────────────────────────────────────────────── // ─── Get By ID ────────────────────────────────────────────────────────────────
export async function getBerthById(id: string, portId: string) { 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)) .innerJoin(tags, eq(berthTags.tagId, tags.id))
.where(eq(berthTags.berthId, 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 ─────────────────────────────────────────────────────────────────── // ─── Update ───────────────────────────────────────────────────────────────────

View File

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

View File

@@ -1377,7 +1377,10 @@ async function expandGraph(
JOIN interests i ON ib.interest_id = i.id JOIN interests i ON ib.interest_id = i.id
JOIN clients c ON i.client_id = c.id JOIN clients c ON i.client_id = c.id
JOIN berths b ON ib.berth_id = b.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.port_id = ${portId}
AND i.archived_at IS NULL AND i.archived_at IS NULL
ORDER BY ib.is_primary DESC, i.created_at DESC ORDER BY ib.is_primary DESC, i.created_at DESC
@@ -1420,7 +1423,10 @@ async function expandGraph(
ORDER BY ib2.is_primary DESC ORDER BY ib2.is_primary DESC
LIMIT 1 LIMIT 1
) b ON TRUE ) 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} AND i.port_id = ${portId}
`) `)
: []; : [];
@@ -1447,7 +1453,10 @@ async function expandGraph(
WHERE ib.interest_id = i.id WHERE ib.interest_id = i.id
ORDER BY ib.is_primary DESC LIMIT 1 ORDER BY ib.is_primary DESC LIMIT 1
) b ON TRUE ) 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.port_id = ${portId}
AND i.archived_at IS NULL AND i.archived_at IS NULL
ORDER BY i.created_at DESC ORDER BY i.created_at DESC
@@ -1468,7 +1477,10 @@ async function expandGraph(
FROM yachts y FROM yachts y
JOIN clients c ON y.current_owner_id = c.id JOIN clients c ON y.current_owner_id = c.id
WHERE y.current_owner_type = 'client' 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} AND y.port_id = ${portId}
ORDER BY y.name ORDER BY y.name
LIMIT ${perBucketCap * direct.clientIds.length} LIMIT ${perBucketCap * direct.clientIds.length}
@@ -1488,7 +1500,10 @@ async function expandGraph(
FROM company_memberships cm FROM company_memberships cm
JOIN companies co ON cm.company_id = co.id JOIN companies co ON cm.company_id = co.id
JOIN clients c ON cm.client_id = c.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 cm.end_date IS NULL
AND co.port_id = ${portId} AND co.port_id = ${portId}
ORDER BY co.name ORDER BY co.name
@@ -1522,7 +1537,10 @@ async function expandGraph(
WHERE ib.interest_id = i.id WHERE ib.interest_id = i.id
ORDER BY ib.is_primary DESC LIMIT 1 ORDER BY ib.is_primary DESC LIMIT 1
) b ON TRUE ) 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.port_id = ${portId}
AND i.archived_at IS NULL AND i.archived_at IS NULL
ORDER BY i.created_at DESC 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 ON y.current_owner_type = 'client' AND y.current_owner_id = c.id
LEFT JOIN companies co LEFT JOIN companies co
ON y.current_owner_type = 'company' AND y.current_owner_id = co.id 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.port_id = ${portId}
AND y.current_owner_id IS NOT NULL AND y.current_owner_id IS NOT NULL
`), `),
@@ -1567,7 +1588,10 @@ async function expandGraph(
FROM company_memberships cm FROM company_memberships cm
JOIN clients c ON cm.client_id = c.id JOIN clients c ON cm.client_id = c.id
JOIN companies co ON cm.company_id = co.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 cm.end_date IS NULL
AND c.port_id = ${portId} AND c.port_id = ${portId}
ORDER BY c.full_name ORDER BY c.full_name
@@ -1727,9 +1751,11 @@ async function expandGraph(
* in both, the direct version wins. Direct matches sort before * in both, the direct version wins. Direct matches sort before
* related matches. * related matches.
*/ */
function mergeWithExpansion< function mergeWithExpansion<T extends { id: string; relatedVia?: RelatedVia | null }>(
T extends { id: string; relatedVia?: RelatedVia | null }, direct: T[],
>(direct: T[], expansion: T[], cap: number): T[] { expansion: T[],
cap: number,
): T[] {
const seen = new Set(direct.map((r) => r.id)); const seen = new Set(direct.map((r) => r.id));
const merged = [ const merged = [
...direct.map((r) => ({ ...r, relatedVia: null as RelatedVia | null })), ...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); return String(Math.round(n * 100) / 100);
}); });
const desiredUnitSchema = z.enum(['ft', 'm']).optional();
export const createInterestSchema = z.object({ export const createInterestSchema = z.object({
clientId: z.string().min(1), clientId: z.string().min(1),
yachtId: z.string().optional(), yachtId: z.string().optional(),
@@ -42,6 +44,12 @@ export const createInterestSchema = z.object({
desiredLengthFt: optionalDesiredDimSchema, desiredLengthFt: optionalDesiredDimSchema,
desiredWidthFt: optionalDesiredDimSchema, desiredWidthFt: optionalDesiredDimSchema,
desiredDraftFt: optionalDesiredDimSchema, desiredDraftFt: optionalDesiredDimSchema,
desiredLengthM: optionalDesiredDimSchema,
desiredWidthM: optionalDesiredDimSchema,
desiredDraftM: optionalDesiredDimSchema,
desiredLengthUnit: desiredUnitSchema,
desiredWidthUnit: desiredUnitSchema,
desiredDraftUnit: desiredUnitSchema,
}); });
// ─── Update ────────────────────────────────────────────────────────────────── // ─── Update ──────────────────────────────────────────────────────────────────

View File

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

View File

@@ -32,10 +32,18 @@ describe('companies.service — createCompany', () => {
it('rejects duplicate name case-insensitively (ConflictError)', async () => { it('rejects duplicate name case-insensitively (ConflictError)', async () => {
const port = await makePort(); 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( 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); ).rejects.toBeInstanceOf(ConflictError);
}); });

View File

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

View File

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