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

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

View File

@@ -14,6 +14,7 @@ import {
import { TagBadge } from '@/components/shared/tag-badge';
import { formatCurrency } from '@/lib/utils/currency';
import { mooringLetterDot } from './mooring-letter-tone';
import { stageBadgeClass, stageLabel } from '@/lib/constants';
export type BerthRow = {
id: string;
@@ -61,6 +62,9 @@ export type BerthRow = {
tenureStartDate: string | null;
tenureEndDate: string | null;
tags: Array<{ id: string; name: string; color: string }>;
/** Most-advanced pipeline stage among the berth's active interests. Null
* when no active interest is linked. Read-only; computed server-side. */
latestInterestStage?: string | null;
};
/**
@@ -72,6 +76,7 @@ export type BerthRow = {
export const BERTH_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
{ id: 'area', label: 'Area' },
{ id: 'status', label: 'Status' },
{ id: 'latestInterestStage', label: 'Latest deal stage' },
{ id: 'sidePontoon', label: 'Side / Pontoon' },
{ id: 'dimensions', label: 'Dimensions' },
{ id: 'nominalBoatSize', label: 'Nominal boat size' },
@@ -206,6 +211,22 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
header: 'Status',
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: 'latestInterestStage',
header: 'Latest deal stage',
enableSorting: false,
cell: ({ row }) => {
const s = row.original.latestInterestStage;
if (!s) return <span className="text-muted-foreground">-</span>;
return (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${stageBadgeClass(s)}`}
>
{stageLabel(s)}
</span>
);
},
},
{
id: 'sidePontoon',
header: 'Side / Pontoon',

View File

@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { Pencil, RefreshCw } from 'lucide-react';
import { Check, ChevronsUpDown, Pencil, RefreshCw } from 'lucide-react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useForm } from 'react-hook-form';
@@ -28,11 +28,21 @@ import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { PermissionGate } from '@/components/shared/permission-gate';
import { BerthForm } from './berth-form';
import { mooringLetterDot } from './mooring-letter-tone';
import { cn } from '@/lib/utils';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { useVocabulary } from '@/hooks/use-vocabulary';
import { updateBerthStatusSchema, type UpdateBerthStatusInput } from '@/lib/validators/berths';
import { BERTH_STATUSES } from '@/lib/constants';
import { BERTH_STATUSES, stageBadgeClass, stageDotClass, stageLabel } from '@/lib/constants';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
type BerthDetailData = {
id: string;
@@ -92,6 +102,8 @@ interface InterestOption {
id: string;
clientName: string;
pipelineStage: string;
/** Used to sort the picker — most recently interacted with floats to the top. */
updatedAt?: string;
}
function StatusChangeDialog({
@@ -128,10 +140,15 @@ function StatusChangeDialog({
// the picker is actually visible to avoid an unnecessary round-trip
// for available-status changes.
const interestsQuery = useQuery<{
data: Array<{ id: string; clientName: string; pipelineStage: string }>;
data: Array<{
id: string;
clientName: string;
pipelineStage: string;
updatedAt?: string;
}>;
}>({
queryKey: ['interests', 'status-link-picker'],
queryFn: () => apiFetch('/api/v1/interests?pageSize=200'),
queryFn: () => apiFetch('/api/v1/interests?pageSize=200&sort=updatedAt&order=desc'),
enabled: open && showInterestPicker,
staleTime: 60_000,
});
@@ -205,22 +222,11 @@ function StatusChangeDialog({
{showInterestPicker && (
<div className="space-y-2">
<Label>Linked prospect (optional)</Label>
<Select
value={interestId ?? '__none__'}
onValueChange={(v) => setValue('interestId', v === '__none__' ? undefined : v)}
>
<SelectTrigger>
<SelectValue placeholder="Select an interest…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> No interest </SelectItem>
{interestOptions.map((opt) => (
<SelectItem key={opt.id} value={opt.id}>
{opt.clientName} · {opt.pipelineStage}
</SelectItem>
))}
</SelectContent>
</Select>
<InterestLinkPicker
value={interestId ?? null}
options={interestOptions}
onChange={(id) => setValue('interestId', id ?? undefined)}
/>
<p className="text-xs text-muted-foreground">
Link this status change to the prospect (interest) it relates to. The change will
appear on that interest&apos;s timeline, and the berth gets attached to the prospect
@@ -252,27 +258,29 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
{/* Stacks vertically on phone widths so the action buttons don't
squeeze the area subtitle into a two-line wrap. From sm up the
title/area block sits side-by-side with the action buttons. */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-xl sm:text-2xl font-bold text-foreground">
Berth {berth.mooringNumber}
</h1>
<span
className={`inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium ${STATUS_COLORS[berth.status] ?? 'bg-muted text-muted-foreground border-muted'}`}
>
{STATUS_LABELS[berth.status] ?? berth.status}
</span>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
<div className="flex-1 min-w-0 flex items-center gap-3 flex-wrap">
{/* Compact mooring chip — the mooring number sits inside a
rounded plate tinted by the mooring-letter palette (same
colour used for the row-accent in the berth list). The
redundant "B Dock" tag from the previous design is replaced
with a title attribute so the area only surfaces on hover,
keeping the header lean. */}
<div
className={cn(
'inline-flex h-12 min-w-[3.25rem] items-center justify-center rounded-2xl px-3 text-lg font-bold tracking-tight text-white shadow-sm',
mooringLetterDot(berth.mooringNumber) ?? 'bg-slate-400',
)}
title={berth.area ? `${berth.area} Dock` : undefined}
aria-label={`Berth ${berth.mooringNumber}${berth.area ? `, ${berth.area} Dock` : ''}`}
>
{berth.mooringNumber}
</div>
{berth.area && (
<div className="mt-2">
<span
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-0.5 text-xs font-semibold uppercase tracking-wide text-white ${mooringLetterDot(berth.mooringNumber) ?? 'bg-slate-400'}`}
>
{berth.area} Dock
</span>
</div>
)}
<span
className={`inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium ${STATUS_COLORS[berth.status] ?? 'bg-muted text-muted-foreground border-muted'}`}
>
{STATUS_LABELS[berth.status] ?? berth.status}
</span>
</div>
<div className="flex flex-wrap items-center gap-2 sm:shrink-0">
@@ -301,3 +309,119 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
</>
);
}
/**
* Searchable combobox for picking a linked prospect when changing berth
* status. Replaces the bare Select which had no filter, no stage colours,
* and no recency sort — for ports with 200+ active interests that became
* a scroll-fest. Stage labels render with the same coloured pill the rest
* of the CRM uses for stage badges so the rep can scan the list visually.
*/
function InterestLinkPicker({
value,
options,
onChange,
}: {
value: string | null;
options: InterestOption[];
onChange: (id: string | null) => void;
}) {
const [open, setOpen] = useState(false);
// Sort with the most recently updated interest first so reps see the
// active deals at the top of the list — older / dormant ones drop
// beneath. `updatedAt` is set on every patch + every stage advance.
const sorted = [...options].sort((a, b) => {
if (!a.updatedAt && !b.updatedAt) return 0;
if (!a.updatedAt) return 1;
if (!b.updatedAt) return -1;
return b.updatedAt.localeCompare(a.updatedAt);
});
const selected = value ? sorted.find((o) => o.id === value) : null;
return (
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className={cn('w-full justify-between font-normal', !value && 'text-muted-foreground')}
>
{selected ? (
<span className="flex min-w-0 items-center gap-2">
<span
className={cn(
'inline-block h-2 w-2 shrink-0 rounded-full',
stageDotClass(selected.pipelineStage),
)}
aria-hidden
/>
<span className="truncate">{selected.clientName}</span>
<span className="ml-1 shrink-0 text-xs text-muted-foreground">
· {stageLabel(selected.pipelineStage)}
</span>
</span>
) : (
'— No interest —'
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popper-anchor-width)] min-w-[320px] p-0" align="start">
<Command>
<CommandInput placeholder="Search prospects…" />
<CommandList>
<CommandEmpty>No prospects found.</CommandEmpty>
<CommandGroup>
<CommandItem
value="__none__"
onSelect={() => {
onChange(null);
setOpen(false);
}}
className="text-muted-foreground"
>
No interest
</CommandItem>
</CommandGroup>
<CommandGroup heading="Most recent first">
{sorted.map((opt) => (
<CommandItem
// cmdk filters by `value`; include client name + stage so the
// search input matches both. Falling back to id keeps the
// option selectable even if a name is blank.
key={opt.id}
value={`${opt.clientName} ${stageLabel(opt.pipelineStage)} ${opt.id}`}
onSelect={() => {
onChange(opt.id);
setOpen(false);
}}
className="flex items-center gap-2"
>
<span
className={cn(
'inline-block h-2 w-2 shrink-0 rounded-full',
stageDotClass(opt.pipelineStage),
)}
aria-hidden
/>
<span className="flex-1 truncate">{opt.clientName || '(unnamed)'}</span>
<span
className={cn(
'inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[10px] font-medium',
stageBadgeClass(opt.pipelineStage),
)}
>
{stageLabel(opt.pipelineStage)}
</span>
{value === opt.id ? <Check className="h-3.5 w-3.5 text-muted-foreground" /> : null}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,11 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Loader2, Plus, X, ChevronsUpDown, Check } from 'lucide-react';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
@@ -19,13 +20,39 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { TagPicker } from '@/components/shared/tag-picker';
import { CountryCombobox } from '@/components/shared/country-combobox';
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
import { ClientForm } from '@/components/clients/client-form';
import { YachtForm } from '@/components/yachts/yacht-form';
import { InterestForm } from '@/components/interests/interest-form';
import { apiFetch } from '@/lib/api/client';
import { useEntityOptions } from '@/hooks/use-entity-options';
import { toastError } from '@/lib/api/toast-error';
import { createCompanySchema, type CreateCompanyInput } from '@/lib/validators/companies';
import type { CountryCode } from '@/lib/i18n/countries';
import { cn } from '@/lib/utils';
type CompanyStatus = 'active' | 'dissolved';
@@ -52,8 +79,29 @@ interface CompanyFormProps {
export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
const queryClient = useQueryClient();
const router = useRouter();
const isEdit = !!company;
const [formError, setFormError] = useState<string | null>(null);
// Connection state — only used in create mode. Editing companies is done
// from the detail page where members + yachts have their own tabs that
// know how to handle removal / reassignment cleanly.
const [attachedClientIds, setAttachedClientIds] = useState<string[]>([]);
const [attachedYachtIds, setAttachedYachtIds] = useState<string[]>([]);
const [clientFormOpen, setClientFormOpen] = useState(false);
const [yachtFormOpen, setYachtFormOpen] = useState(false);
// After successful save the dialog flow can branch: ask the rep whether to
// also attach the picked clients' yachts (when any of them own yachts), and
// optionally chain to a New Interest form pre-filled with one of the
// attached clients.
const [createdCompanyId, setCreatedCompanyId] = useState<string | null>(null);
const [pendingYachtPullIn, setPendingYachtPullIn] = useState<
{ yachtId: string; yachtName: string }[] | null
>(null);
// Reserved for the inverse pull-in (attached yacht → owner client). Wired
// through but the inferring query is deferred — owner history isn't yet
// surfaced cheaply via the yacht endpoint.
// const [pendingOwnerPullIn, setPendingOwnerPullIn] = useState<...>(null);
const [createInterestFor, setCreateInterestFor] = useState<string | null>(null);
const {
register,
@@ -108,13 +156,80 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
method: 'PATCH',
body: rest,
});
} else {
await apiFetch('/api/v1/companies', { method: 'POST', body: data });
return null;
}
const res = await apiFetch<{ data: { id: string } }>('/api/v1/companies', {
method: 'POST',
body: data,
});
const newCompanyId = res.data.id;
// Connect each attached client as a company member. Failures collected
// here surface as a toast but don't roll back the company create — the
// rep can fix individual mismatches from the company detail page.
for (const clientId of attachedClientIds) {
try {
await apiFetch(`/api/v1/companies/${newCompanyId}/members`, {
method: 'POST',
body: { clientId, role: 'member' },
});
} catch (err) {
toastError(err);
}
}
// Transfer ownership of each attached yacht to the company. This uses
// the existing yacht-transfer endpoint so the audit log + ownership
// history records the change just like a manual transfer would.
for (const yachtId of attachedYachtIds) {
try {
await apiFetch(`/api/v1/yachts/${yachtId}/transfer`, {
method: 'POST',
body: {
newOwner: { type: 'company', id: newCompanyId },
reason: 'Attached during company creation',
},
});
} catch (err) {
toastError(err);
}
}
return newCompanyId;
},
onSuccess: () => {
onSuccess: async (newCompanyId) => {
queryClient.invalidateQueries({ queryKey: ['companies'] });
onOpenChange(false);
if (isEdit || !newCompanyId) {
onOpenChange(false);
return;
}
setCreatedCompanyId(newCompanyId);
// Step 2a: If any attached client owns yachts the rep didn't already
// attach, prompt to pull them in. Resolved here so the rep can opt out
// per-yacht rather than getting a blanket "everything attached" flow.
try {
const yachtsToOffer: { yachtId: string; yachtName: string }[] = [];
for (const clientId of attachedClientIds) {
const res = await apiFetch<{
data: Array<{ id: string; name: string; currentOwnerType: string }>;
}>(`/api/v1/yachts?ownerType=client&ownerId=${clientId}`);
for (const y of res.data) {
if (!attachedYachtIds.includes(y.id)) {
yachtsToOffer.push({ yachtId: y.id, yachtName: y.name });
}
}
}
if (yachtsToOffer.length > 0) {
setPendingYachtPullIn(yachtsToOffer);
return;
}
} catch {
// Yacht lookup failure is non-fatal — fall through to interest prompt.
}
// (Step 2b — yacht-owner pull-in — deferred. Adding it cleanly needs
// the yachts API to surface prior owners post-transfer, which currently
// only lives in the activity log. Tracked for follow-up.)
finishWithInterestPrompt(newCompanyId);
},
onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : 'Failed to save company';
@@ -122,6 +237,15 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
},
});
function finishWithInterestPrompt(newCompanyId: string) {
void newCompanyId;
if (attachedClientIds.length > 0) {
setCreateInterestFor(attachedClientIds[0] ?? null);
} else {
onOpenChange(false);
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
@@ -242,6 +366,68 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
<Separator />
{/* Connections — only on create. Editing membership / yacht ownership
from this form would race with the same actions on the detail
tabs (and the audit trail of a "create + attach 5 clients in one
flow" is much more readable than 6 separate create rows). */}
{!isEdit && (
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Connections
</h3>
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-xs">Member clients</Label>
<EntityMultiPicker
endpoint="/api/v1/clients/options"
labelKey="fullName"
placeholder="Add a client…"
selectedIds={attachedClientIds}
onChange={setAttachedClientIds}
/>
<p className="text-[11px] text-muted-foreground">
Each pick becomes a company member with role=member. You can refine roles
afterwards on the Members tab.
</p>
</div>
<div className="space-y-1">
<Label className="text-xs">Yachts owned by the company</Label>
<EntityMultiPicker
endpoint="/api/v1/yachts/options"
labelKey="name"
placeholder="Add a yacht…"
selectedIds={attachedYachtIds}
onChange={setAttachedYachtIds}
/>
<p className="text-[11px] text-muted-foreground">
Adding a yacht transfers its ownership to this company (logged in the yacht&apos;s
audit trail). Skip if you only want to associate without changing ownership.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setClientFormOpen(true)}
>
<Plus className="mr-1 h-3.5 w-3.5" /> New client
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setYachtFormOpen(true)}
>
<Plus className="mr-1 h-3.5 w-3.5" /> New yacht
</Button>
</div>
</div>
</div>
)}
<Separator />
{/* Notes */}
<div className="space-y-2">
<Label>Notes</Label>
@@ -279,6 +465,238 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
</SheetFooter>
</form>
</SheetContent>
{/* Stacked "+ New client" / "+ New yacht" forms. On successful create
the picker we open them from doesn't know the new id yet — the
ClientList / YachtList query refetches via react-query invalidation
and the rep can pick the new entity from the dropdown immediately. */}
<ClientForm open={clientFormOpen} onOpenChange={setClientFormOpen} />
{yachtFormOpen && (
<YachtForm
open={yachtFormOpen}
onOpenChange={setYachtFormOpen}
// No initialOwner — the new yacht starts unowned-by-rules-engine; the
// company-form will optionally transfer it on save.
/>
)}
<AlertDialog
open={!!pendingYachtPullIn}
onOpenChange={(o) => {
if (!o && createdCompanyId) {
setPendingYachtPullIn(null);
finishWithInterestPrompt(createdCompanyId);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Attach these yachts too?</AlertDialogTitle>
<AlertDialogDescription>
The clients you added own {pendingYachtPullIn?.length ?? 0}{' '}
{pendingYachtPullIn?.length === 1 ? 'yacht' : 'yachts'} not yet linked to this
company. Attaching transfers their ownership.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-1 rounded-md border bg-muted/30 p-2 text-sm">
{pendingYachtPullIn?.map((y) => (
<div key={y.yachtId} className="flex items-center justify-between">
<span className="truncate">{y.yachtName}</span>
</div>
))}
</div>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setPendingYachtPullIn(null);
if (createdCompanyId) finishWithInterestPrompt(createdCompanyId);
}}
>
Skip
</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
if (!createdCompanyId || !pendingYachtPullIn) return;
for (const y of pendingYachtPullIn) {
try {
await apiFetch(`/api/v1/yachts/${y.yachtId}/transfer`, {
method: 'POST',
body: {
newOwner: { type: 'company', id: createdCompanyId },
reason: 'Attached during company creation (yacht pull-in)',
},
});
} catch (err) {
toastError(err);
}
}
setPendingYachtPullIn(null);
finishWithInterestPrompt(createdCompanyId);
}}
>
Attach all
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={!!createInterestFor && !pendingYachtPullIn}
onOpenChange={(o) => {
if (!o) {
setCreateInterestFor(null);
onOpenChange(false);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Create an interest now?</AlertDialogTitle>
<AlertDialogDescription>
The new company is connected to {attachedClientIds.length}{' '}
{attachedClientIds.length === 1 ? 'client' : 'clients'}. Want to open a new interest
dialog pre-filled with one of them?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setCreateInterestFor(null);
onOpenChange(false);
}}
>
Not now
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
// Close the company form, then open the interest form. The
// interest form is rendered below via createInterestFor.
onOpenChange(false);
}}
>
Create interest
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Detached follow-up: interest form pre-filled with the first attached
client. Stays mounted after this form closes so the rep can finish
the new-interest flow uninterrupted. */}
{createInterestFor && !open && (
<InterestForm
open={true}
onOpenChange={(o) => {
if (!o) {
setCreateInterestFor(null);
router.refresh();
}
}}
defaultClientId={createInterestFor}
/>
)}
</Sheet>
);
}
/**
* Lightweight multi-pick combobox. Used by the company-form Connections
* section for both clients and yachts since they share the same shape
* (`{ value, label }` via useEntityOptions). Selected items render as
* removable chips above the picker so the rep can see at a glance what
* they're about to attach.
*/
function EntityMultiPicker({
endpoint,
labelKey,
placeholder,
selectedIds,
onChange,
}: {
endpoint: string;
labelKey: string;
placeholder: string;
selectedIds: string[];
onChange: (ids: string[]) => void;
}) {
const [open, setOpen] = useState(false);
const { options, setSearch } = useEntityOptions({ endpoint, labelKey });
const labelById = useMemo(() => {
const m = new Map<string, string>();
for (const o of options) m.set(o.value, o.label);
return m;
}, [options]);
function toggle(id: string) {
if (selectedIds.includes(id)) {
onChange(selectedIds.filter((x) => x !== id));
} else {
onChange([...selectedIds, id]);
}
}
return (
<div className="space-y-2">
{selectedIds.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{selectedIds.map((id) => (
<Badge key={id} variant="secondary" className="gap-1 pr-1">
<span className="max-w-[14rem] truncate">{labelById.get(id) ?? id.slice(0, 8)}</span>
<button
type="button"
className="rounded-full p-0.5 hover:bg-muted-foreground/20"
onClick={() => toggle(id)}
aria-label="Remove"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
) : null}
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
role="combobox"
aria-expanded={open}
className={cn('w-full justify-between font-normal', !selectedIds.length && 'text-muted-foreground')}
>
{placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popper-anchor-width)] min-w-[280px] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput placeholder="Search…" onValueChange={setSearch} />
<CommandList>
<CommandEmpty>No results.</CommandEmpty>
<CommandGroup>
{options.map((opt) => {
const isSelected = selectedIds.includes(opt.value);
return (
<CommandItem
key={opt.value}
value={opt.value}
onSelect={() => toggle(opt.value)}
>
<Check
className={cn(
'mr-2 h-4 w-4',
isSelected ? 'opacity-100' : 'opacity-0',
)}
/>
{opt.label}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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