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:
@@ -1,4 +1,3 @@
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Bell,
|
||||
BookOpen,
|
||||
@@ -23,21 +22,8 @@ import {
|
||||
Globe,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
|
||||
interface AdminSection {
|
||||
href: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: typeof Settings;
|
||||
}
|
||||
|
||||
interface AdminGroup {
|
||||
title: string;
|
||||
description: string;
|
||||
sections: AdminSection[];
|
||||
}
|
||||
import { AdminSectionsBrowser, type AdminGroup } from '@/components/admin/admin-sections-browser';
|
||||
|
||||
const GROUPS: AdminGroup[] = [
|
||||
{
|
||||
@@ -76,8 +62,9 @@ const GROUPS: AdminGroup[] = [
|
||||
},
|
||||
{
|
||||
href: 'documenso',
|
||||
label: 'Documenso & EOI',
|
||||
description: 'API credentials, EOI template, and default in-app vs Documenso pathway.',
|
||||
label: 'EOI signing service',
|
||||
description:
|
||||
'API credentials, EOI template, and default in-app vs external signing pathway.',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
@@ -279,43 +266,9 @@ export default async function AdminLandingPage({
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Administration"
|
||||
description="Per-port configuration and system administration. Each card below opens a dedicated settings page."
|
||||
description="Per-port configuration and system administration. Use the search to jump to a setting, or browse the grouped index below."
|
||||
/>
|
||||
{GROUPS.map((group) => (
|
||||
<section key={group.title} className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group.title}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground/80">{group.description}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{group.sections.map((s) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<Link
|
||||
key={s.href}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/admin/${s.href}` as any}
|
||||
className="block group"
|
||||
>
|
||||
<Card className="h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30">
|
||||
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
|
||||
<Icon className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary" />
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-base">{s.label}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription>{s.description}</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
<AdminSectionsBrowser portSlug={portSlug} groups={GROUPS} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
159
src/components/admin/admin-sections-browser.tsx
Normal file
159
src/components/admin/admin-sections-browser.tsx
Normal 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 "{query}".
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { TagBadge } from '@/components/shared/tag-badge';
|
||||
import { formatCurrency } from '@/lib/utils/currency';
|
||||
import { mooringLetterDot } from './mooring-letter-tone';
|
||||
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
||||
|
||||
export type BerthRow = {
|
||||
id: string;
|
||||
@@ -61,6 +62,9 @@ export type BerthRow = {
|
||||
tenureStartDate: string | null;
|
||||
tenureEndDate: string | null;
|
||||
tags: Array<{ id: string; name: string; color: string }>;
|
||||
/** Most-advanced pipeline stage among the berth's active interests. Null
|
||||
* when no active interest is linked. Read-only; computed server-side. */
|
||||
latestInterestStage?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -72,6 +76,7 @@ export type BerthRow = {
|
||||
export const BERTH_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
|
||||
{ id: 'area', label: 'Area' },
|
||||
{ id: 'status', label: 'Status' },
|
||||
{ id: 'latestInterestStage', label: 'Latest deal stage' },
|
||||
{ id: 'sidePontoon', label: 'Side / Pontoon' },
|
||||
{ id: 'dimensions', label: 'Dimensions' },
|
||||
{ id: 'nominalBoatSize', label: 'Nominal boat size' },
|
||||
@@ -206,6 +211,22 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
|
||||
header: 'Status',
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'latestInterestStage',
|
||||
header: 'Latest deal stage',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const s = row.original.latestInterestStage;
|
||||
if (!s) return <span className="text-muted-foreground">-</span>;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${stageBadgeClass(s)}`}
|
||||
>
|
||||
{stageLabel(s)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sidePontoon',
|
||||
header: 'Side / Pontoon',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Pencil, RefreshCw } from 'lucide-react';
|
||||
import { Check, ChevronsUpDown, Pencil, RefreshCw } from 'lucide-react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@@ -28,11 +28,21 @@ import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { BerthForm } from './berth-form';
|
||||
import { mooringLetterDot } from './mooring-letter-tone';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
import { useVocabulary } from '@/hooks/use-vocabulary';
|
||||
import { updateBerthStatusSchema, type UpdateBerthStatusInput } from '@/lib/validators/berths';
|
||||
import { BERTH_STATUSES } from '@/lib/constants';
|
||||
import { BERTH_STATUSES, stageBadgeClass, stageDotClass, stageLabel } from '@/lib/constants';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
|
||||
type BerthDetailData = {
|
||||
id: string;
|
||||
@@ -92,6 +102,8 @@ interface InterestOption {
|
||||
id: string;
|
||||
clientName: string;
|
||||
pipelineStage: string;
|
||||
/** Used to sort the picker — most recently interacted with floats to the top. */
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
function StatusChangeDialog({
|
||||
@@ -128,10 +140,15 @@ function StatusChangeDialog({
|
||||
// the picker is actually visible to avoid an unnecessary round-trip
|
||||
// for available-status changes.
|
||||
const interestsQuery = useQuery<{
|
||||
data: Array<{ id: string; clientName: string; pipelineStage: string }>;
|
||||
data: Array<{
|
||||
id: string;
|
||||
clientName: string;
|
||||
pipelineStage: string;
|
||||
updatedAt?: string;
|
||||
}>;
|
||||
}>({
|
||||
queryKey: ['interests', 'status-link-picker'],
|
||||
queryFn: () => apiFetch('/api/v1/interests?pageSize=200'),
|
||||
queryFn: () => apiFetch('/api/v1/interests?pageSize=200&sort=updatedAt&order=desc'),
|
||||
enabled: open && showInterestPicker,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
@@ -205,22 +222,11 @@ function StatusChangeDialog({
|
||||
{showInterestPicker && (
|
||||
<div className="space-y-2">
|
||||
<Label>Linked prospect (optional)</Label>
|
||||
<Select
|
||||
value={interestId ?? '__none__'}
|
||||
onValueChange={(v) => setValue('interestId', v === '__none__' ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an interest…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">— No interest —</SelectItem>
|
||||
{interestOptions.map((opt) => (
|
||||
<SelectItem key={opt.id} value={opt.id}>
|
||||
{opt.clientName} · {opt.pipelineStage}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InterestLinkPicker
|
||||
value={interestId ?? null}
|
||||
options={interestOptions}
|
||||
onChange={(id) => setValue('interestId', id ?? undefined)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Link this status change to the prospect (interest) it relates to. The change will
|
||||
appear on that interest's timeline, and the berth gets attached to the prospect
|
||||
@@ -252,27 +258,29 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
|
||||
{/* Stacks vertically on phone widths so the action buttons don't
|
||||
squeeze the area subtitle into a two-line wrap. From sm up the
|
||||
title/area block sits side-by-side with the action buttons. */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-foreground">
|
||||
Berth {berth.mooringNumber}
|
||||
</h1>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium ${STATUS_COLORS[berth.status] ?? 'bg-muted text-muted-foreground border-muted'}`}
|
||||
>
|
||||
{STATUS_LABELS[berth.status] ?? berth.status}
|
||||
</span>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
|
||||
<div className="flex-1 min-w-0 flex items-center gap-3 flex-wrap">
|
||||
{/* Compact mooring chip — the mooring number sits inside a
|
||||
rounded plate tinted by the mooring-letter palette (same
|
||||
colour used for the row-accent in the berth list). The
|
||||
redundant "B Dock" tag from the previous design is replaced
|
||||
with a title attribute so the area only surfaces on hover,
|
||||
keeping the header lean. */}
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex h-12 min-w-[3.25rem] items-center justify-center rounded-2xl px-3 text-lg font-bold tracking-tight text-white shadow-sm',
|
||||
mooringLetterDot(berth.mooringNumber) ?? 'bg-slate-400',
|
||||
)}
|
||||
title={berth.area ? `${berth.area} Dock` : undefined}
|
||||
aria-label={`Berth ${berth.mooringNumber}${berth.area ? `, ${berth.area} Dock` : ''}`}
|
||||
>
|
||||
{berth.mooringNumber}
|
||||
</div>
|
||||
{berth.area && (
|
||||
<div className="mt-2">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-0.5 text-xs font-semibold uppercase tracking-wide text-white ${mooringLetterDot(berth.mooringNumber) ?? 'bg-slate-400'}`}
|
||||
>
|
||||
{berth.area} Dock
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium ${STATUS_COLORS[berth.status] ?? 'bg-muted text-muted-foreground border-muted'}`}
|
||||
>
|
||||
{STATUS_LABELS[berth.status] ?? berth.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 sm:shrink-0">
|
||||
@@ -301,3 +309,119 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable combobox for picking a linked prospect when changing berth
|
||||
* status. Replaces the bare Select which had no filter, no stage colours,
|
||||
* and no recency sort — for ports with 200+ active interests that became
|
||||
* a scroll-fest. Stage labels render with the same coloured pill the rest
|
||||
* of the CRM uses for stage badges so the rep can scan the list visually.
|
||||
*/
|
||||
function InterestLinkPicker({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
value: string | null;
|
||||
options: InterestOption[];
|
||||
onChange: (id: string | null) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
// Sort with the most recently updated interest first so reps see the
|
||||
// active deals at the top of the list — older / dormant ones drop
|
||||
// beneath. `updatedAt` is set on every patch + every stage advance.
|
||||
const sorted = [...options].sort((a, b) => {
|
||||
if (!a.updatedAt && !b.updatedAt) return 0;
|
||||
if (!a.updatedAt) return 1;
|
||||
if (!b.updatedAt) return -1;
|
||||
return b.updatedAt.localeCompare(a.updatedAt);
|
||||
});
|
||||
const selected = value ? sorted.find((o) => o.id === value) : null;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn('w-full justify-between font-normal', !value && 'text-muted-foreground')}
|
||||
>
|
||||
{selected ? (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block h-2 w-2 shrink-0 rounded-full',
|
||||
stageDotClass(selected.pipelineStage),
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate">{selected.clientName}</span>
|
||||
<span className="ml-1 shrink-0 text-xs text-muted-foreground">
|
||||
· {stageLabel(selected.pipelineStage)}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
'— No interest —'
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popper-anchor-width)] min-w-[320px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search prospects…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No prospects found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
onSelect={() => {
|
||||
onChange(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
— No interest —
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Most recent first">
|
||||
{sorted.map((opt) => (
|
||||
<CommandItem
|
||||
// cmdk filters by `value`; include client name + stage so the
|
||||
// search input matches both. Falling back to id keeps the
|
||||
// option selectable even if a name is blank.
|
||||
key={opt.id}
|
||||
value={`${opt.clientName} ${stageLabel(opt.pipelineStage)} ${opt.id}`}
|
||||
onSelect={() => {
|
||||
onChange(opt.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block h-2 w-2 shrink-0 rounded-full',
|
||||
stageDotClass(opt.pipelineStage),
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="flex-1 truncate">{opt.clientName || '(unnamed)'}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[10px] font-medium',
|
||||
stageBadgeClass(opt.pipelineStage),
|
||||
)}
|
||||
>
|
||||
{stageLabel(opt.pipelineStage)}
|
||||
</span>
|
||||
{value === opt.id ? <Check className="h-3.5 w-3.5 text-muted-foreground" /> : null}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ?? ''];
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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's deals.
|
||||
Pick a client first to scope the interest and berth dropdowns to that client's
|
||||
deals.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<ClientPicker
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -42,10 +42,7 @@ export function useDashboardWidgets() {
|
||||
// list so flipping on a widget whose service isn't wired up does
|
||||
// nothing silently — the toggle simply isn't shown.
|
||||
const availableWidgets: DashboardWidget[] = useMemo(
|
||||
() =>
|
||||
DASHBOARD_WIDGETS.filter(
|
||||
(w) => !w.requires || integrations.available[w.requires],
|
||||
),
|
||||
() => DASHBOARD_WIDGETS.filter((w) => !w.requires || integrations.available[w.requires]),
|
||||
[integrations],
|
||||
);
|
||||
|
||||
|
||||
@@ -192,9 +192,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
||||
function humanizeEnum(raw: string): string {
|
||||
const override = LABEL_OVERRIDES[raw.toLowerCase()];
|
||||
if (override) return override;
|
||||
return raw
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
return raw.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
export function toSelectOptions<T extends readonly string[]>(
|
||||
|
||||
64
src/lib/db/migrations/0053_measurement_units.sql
Normal file
64
src/lib/db/migrations/0053_measurement_units.sql
Normal 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'));
|
||||
@@ -40,6 +40,12 @@ export const berths = pgTable(
|
||||
nominalBoatSizeM: numeric('nominal_boat_size_m'),
|
||||
waterDepth: numeric('water_depth'),
|
||||
waterDepthM: numeric('water_depth_m'),
|
||||
/** Entry-unit discriminators — see interests.desiredLengthUnit comment. */
|
||||
lengthUnit: text('length_unit').notNull().default('ft'),
|
||||
widthUnit: text('width_unit').notNull().default('ft'),
|
||||
draftUnit: text('draft_unit').notNull().default('ft'),
|
||||
nominalBoatSizeUnit: text('nominal_boat_size_unit').notNull().default('ft'),
|
||||
waterDepthUnit: text('water_depth_unit').notNull().default('ft'),
|
||||
waterDepthIsMinimum: boolean('water_depth_is_minimum').default(false),
|
||||
sidePontoon: text('side_pontoon'),
|
||||
powerCapacity: numeric('power_capacity'), // kW
|
||||
|
||||
@@ -58,11 +58,21 @@ export const interests = pgTable(
|
||||
outcomeReason: text('outcome_reason'),
|
||||
/** When the outcome was decided. Lets us age 'how long ago did we lose'. */
|
||||
outcomeAt: timestamp('outcome_at', { withTimezone: true }),
|
||||
/** Recommender inputs - imperial; resolver treats nulls as "no constraint"
|
||||
* on that axis, with a banner prompting the rep to add the missing dim. */
|
||||
/** Recommender inputs - dual-stored. ft is the canonical unit the
|
||||
* recommender SQL queries on; m is the human-friendly entry the rep
|
||||
* may have actually typed. The matching `*_unit` column says which
|
||||
* side is source-of-truth — display prefers that side and recomputes
|
||||
* the other so the rep's literal entry doesn't drift through repeated
|
||||
* conversions. Resolver treats nulls as "no constraint" on that axis. */
|
||||
desiredLengthFt: numeric('desired_length_ft'),
|
||||
desiredWidthFt: numeric('desired_width_ft'),
|
||||
desiredDraftFt: numeric('desired_draft_ft'),
|
||||
desiredLengthM: numeric('desired_length_m'),
|
||||
desiredWidthM: numeric('desired_width_m'),
|
||||
desiredDraftM: numeric('desired_draft_m'),
|
||||
desiredLengthUnit: text('desired_length_unit').notNull().default('ft'),
|
||||
desiredWidthUnit: text('desired_width_unit').notNull().default('ft'),
|
||||
desiredDraftUnit: text('desired_draft_unit').notNull().default('ft'),
|
||||
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
@@ -35,6 +35,12 @@ export const yachts = pgTable(
|
||||
lengthM: numeric('length_m'),
|
||||
widthM: numeric('width_m'),
|
||||
draftM: numeric('draft_m'),
|
||||
/** Discriminator: which side ('ft' | 'm') the rep originally typed in.
|
||||
* Used by the form to render that side verbatim (avoiding round-trip
|
||||
* conversion drift on subsequent edits). */
|
||||
lengthUnit: text('length_unit').notNull().default('ft'),
|
||||
widthUnit: text('width_unit').notNull().default('ft'),
|
||||
draftUnit: text('draft_unit').notNull().default('ft'),
|
||||
currentOwnerType: text('current_owner_type').notNull(), // 'client' | 'company'
|
||||
currentOwnerId: text('current_owner_id').notNull(),
|
||||
status: text('status').notNull().default('active'), // 'active' | 'retired' | 'sold_away'
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { and, eq, gte, lte, inArray, sql } from 'drizzle-orm';
|
||||
import { and, eq, gte, lte, inArray, isNull, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
|
||||
import { clients } from '@/lib/db/schema/clients';
|
||||
import { interestBerths, interests } from '@/lib/db/schema/interests';
|
||||
import { tags } from '@/lib/db/schema/system';
|
||||
import { PIPELINE_STAGES } from '@/lib/constants';
|
||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||
import { diffEntity } from '@/lib/entity-diff';
|
||||
import { NotFoundError, ValidationError } from '@/lib/errors';
|
||||
@@ -133,14 +135,63 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
|
||||
}
|
||||
}
|
||||
|
||||
const latestStageByBerthId = await getLatestInterestStageByBerth(berthIds, portId);
|
||||
|
||||
const data = (result.data as Array<Record<string, unknown>>).map((b) => ({
|
||||
...b,
|
||||
tags: tagsByBerthId[b.id as string] ?? [],
|
||||
latestInterestStage: latestStageByBerthId[b.id as string] ?? null,
|
||||
}));
|
||||
|
||||
return { data, total: result.total };
|
||||
}
|
||||
|
||||
/**
|
||||
* For each berth id, returns the most-advanced pipeline stage among its
|
||||
* linked active interests (outcome IS NULL, not archived). Used by the
|
||||
* berth list + detail to surface the deal furthest along on a berth so
|
||||
* reps can see at a glance whether a berth is "Reservation Sent" via
|
||||
* its connected interest, even though berth.status only tracks
|
||||
* available/under_offer/sold.
|
||||
*/
|
||||
async function getLatestInterestStageByBerth(
|
||||
berthIds: string[],
|
||||
portId: string,
|
||||
): Promise<Record<string, string>> {
|
||||
if (berthIds.length === 0) return {};
|
||||
const rows = await db
|
||||
.select({
|
||||
berthId: interestBerths.berthId,
|
||||
pipelineStage: interests.pipelineStage,
|
||||
})
|
||||
.from(interestBerths)
|
||||
.innerJoin(interests, eq(interestBerths.interestId, interests.id))
|
||||
.where(
|
||||
and(
|
||||
eq(interests.portId, portId),
|
||||
inArray(interestBerths.berthId, berthIds),
|
||||
isNull(interests.outcome),
|
||||
isNull(interests.archivedAt),
|
||||
),
|
||||
);
|
||||
|
||||
// Pipeline stages are an ordered enum — rank by position in PIPELINE_STAGES
|
||||
// so "contract_signed" beats "eoi_sent". Falls back to 0 for any unknown
|
||||
// legacy values so they're treated as least-advanced.
|
||||
const rankOf = (stage: string) => {
|
||||
const idx = (PIPELINE_STAGES as readonly string[]).indexOf(stage);
|
||||
return idx === -1 ? -1 : idx;
|
||||
};
|
||||
const top: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
const current = top[row.berthId];
|
||||
if (!current || rankOf(row.pipelineStage) > rankOf(current)) {
|
||||
top[row.berthId] = row.pipelineStage;
|
||||
}
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
// ─── Get By ID ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getBerthById(id: string, portId: string) {
|
||||
@@ -160,7 +211,13 @@ export async function getBerthById(id: string, portId: string) {
|
||||
.innerJoin(tags, eq(berthTags.tagId, tags.id))
|
||||
.where(eq(berthTags.berthId, id));
|
||||
|
||||
return { ...berth, tags: tagRows };
|
||||
const latestStageMap = await getLatestInterestStageByBerth([id], portId);
|
||||
|
||||
return {
|
||||
...berth,
|
||||
tags: tagRows,
|
||||
latestInterestStage: latestStageMap[id] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Update ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -16,6 +16,7 @@ import { formatBerthRange } from '@/lib/templates/berth-range';
|
||||
|
||||
export type EoiContext = {
|
||||
client: {
|
||||
id: string;
|
||||
fullName: string;
|
||||
nationality: string | null;
|
||||
primaryEmail: string | null;
|
||||
@@ -24,6 +25,7 @@ export type EoiContext = {
|
||||
};
|
||||
/** Optional. The EOI's Section 3 yacht block is left blank when null. */
|
||||
yacht: {
|
||||
id: string;
|
||||
name: string;
|
||||
lengthFt: string | null;
|
||||
widthFt: string | null;
|
||||
@@ -275,6 +277,7 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
|
||||
|
||||
return {
|
||||
client: {
|
||||
id: client.id,
|
||||
fullName: client.fullName,
|
||||
nationality: client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null,
|
||||
primaryEmail: firstEmail?.value ?? null,
|
||||
@@ -283,6 +286,7 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
|
||||
},
|
||||
yacht: yacht
|
||||
? {
|
||||
id: yacht.id,
|
||||
name: yacht.name,
|
||||
lengthFt: yacht.lengthFt,
|
||||
widthFt: yacht.widthFt,
|
||||
|
||||
@@ -1377,7 +1377,10 @@ async function expandGraph(
|
||||
JOIN interests i ON ib.interest_id = i.id
|
||||
JOIN clients c ON i.client_id = c.id
|
||||
JOIN berths b ON ib.berth_id = b.id
|
||||
WHERE ib.berth_id IN (${sql.join(direct.berthIds.map((id) => sql`${id}`), sql`, `)})
|
||||
WHERE ib.berth_id IN (${sql.join(
|
||||
direct.berthIds.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})
|
||||
AND i.port_id = ${portId}
|
||||
AND i.archived_at IS NULL
|
||||
ORDER BY ib.is_primary DESC, i.created_at DESC
|
||||
@@ -1420,7 +1423,10 @@ async function expandGraph(
|
||||
ORDER BY ib2.is_primary DESC
|
||||
LIMIT 1
|
||||
) b ON TRUE
|
||||
WHERE i.id IN (${sql.join(direct.interestIds.map((id) => sql`${id}`), sql`, `)})
|
||||
WHERE i.id IN (${sql.join(
|
||||
direct.interestIds.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})
|
||||
AND i.port_id = ${portId}
|
||||
`)
|
||||
: [];
|
||||
@@ -1447,7 +1453,10 @@ async function expandGraph(
|
||||
WHERE ib.interest_id = i.id
|
||||
ORDER BY ib.is_primary DESC LIMIT 1
|
||||
) b ON TRUE
|
||||
WHERE i.client_id IN (${sql.join(direct.clientIds.map((id) => sql`${id}`), sql`, `)})
|
||||
WHERE i.client_id IN (${sql.join(
|
||||
direct.clientIds.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})
|
||||
AND i.port_id = ${portId}
|
||||
AND i.archived_at IS NULL
|
||||
ORDER BY i.created_at DESC
|
||||
@@ -1468,7 +1477,10 @@ async function expandGraph(
|
||||
FROM yachts y
|
||||
JOIN clients c ON y.current_owner_id = c.id
|
||||
WHERE y.current_owner_type = 'client'
|
||||
AND y.current_owner_id IN (${sql.join(direct.clientIds.map((id) => sql`${id}`), sql`, `)})
|
||||
AND y.current_owner_id IN (${sql.join(
|
||||
direct.clientIds.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})
|
||||
AND y.port_id = ${portId}
|
||||
ORDER BY y.name
|
||||
LIMIT ${perBucketCap * direct.clientIds.length}
|
||||
@@ -1488,7 +1500,10 @@ async function expandGraph(
|
||||
FROM company_memberships cm
|
||||
JOIN companies co ON cm.company_id = co.id
|
||||
JOIN clients c ON cm.client_id = c.id
|
||||
WHERE cm.client_id IN (${sql.join(direct.clientIds.map((id) => sql`${id}`), sql`, `)})
|
||||
WHERE cm.client_id IN (${sql.join(
|
||||
direct.clientIds.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})
|
||||
AND cm.end_date IS NULL
|
||||
AND co.port_id = ${portId}
|
||||
ORDER BY co.name
|
||||
@@ -1522,7 +1537,10 @@ async function expandGraph(
|
||||
WHERE ib.interest_id = i.id
|
||||
ORDER BY ib.is_primary DESC LIMIT 1
|
||||
) b ON TRUE
|
||||
WHERE i.yacht_id IN (${sql.join(direct.yachtIds.map((id) => sql`${id}`), sql`, `)})
|
||||
WHERE i.yacht_id IN (${sql.join(
|
||||
direct.yachtIds.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})
|
||||
AND i.port_id = ${portId}
|
||||
AND i.archived_at IS NULL
|
||||
ORDER BY i.created_at DESC
|
||||
@@ -1545,7 +1563,10 @@ async function expandGraph(
|
||||
ON y.current_owner_type = 'client' AND y.current_owner_id = c.id
|
||||
LEFT JOIN companies co
|
||||
ON y.current_owner_type = 'company' AND y.current_owner_id = co.id
|
||||
WHERE y.id IN (${sql.join(direct.yachtIds.map((id) => sql`${id}`), sql`, `)})
|
||||
WHERE y.id IN (${sql.join(
|
||||
direct.yachtIds.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})
|
||||
AND y.port_id = ${portId}
|
||||
AND y.current_owner_id IS NOT NULL
|
||||
`),
|
||||
@@ -1567,7 +1588,10 @@ async function expandGraph(
|
||||
FROM company_memberships cm
|
||||
JOIN clients c ON cm.client_id = c.id
|
||||
JOIN companies co ON cm.company_id = co.id
|
||||
WHERE cm.company_id IN (${sql.join(direct.companyIds.map((id) => sql`${id}`), sql`, `)})
|
||||
WHERE cm.company_id IN (${sql.join(
|
||||
direct.companyIds.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})
|
||||
AND cm.end_date IS NULL
|
||||
AND c.port_id = ${portId}
|
||||
ORDER BY c.full_name
|
||||
@@ -1727,9 +1751,11 @@ async function expandGraph(
|
||||
* in both, the direct version wins. Direct matches sort before
|
||||
* related matches.
|
||||
*/
|
||||
function mergeWithExpansion<
|
||||
T extends { id: string; relatedVia?: RelatedVia | null },
|
||||
>(direct: T[], expansion: T[], cap: number): T[] {
|
||||
function mergeWithExpansion<T extends { id: string; relatedVia?: RelatedVia | null }>(
|
||||
direct: T[],
|
||||
expansion: T[],
|
||||
cap: number,
|
||||
): T[] {
|
||||
const seen = new Set(direct.map((r) => r.id));
|
||||
const merged = [
|
||||
...direct.map((r) => ({ ...r, relatedVia: null as RelatedVia | null })),
|
||||
|
||||
@@ -26,6 +26,8 @@ const optionalDesiredDimSchema = z
|
||||
return String(Math.round(n * 100) / 100);
|
||||
});
|
||||
|
||||
const desiredUnitSchema = z.enum(['ft', 'm']).optional();
|
||||
|
||||
export const createInterestSchema = z.object({
|
||||
clientId: z.string().min(1),
|
||||
yachtId: z.string().optional(),
|
||||
@@ -42,6 +44,12 @@ export const createInterestSchema = z.object({
|
||||
desiredLengthFt: optionalDesiredDimSchema,
|
||||
desiredWidthFt: optionalDesiredDimSchema,
|
||||
desiredDraftFt: optionalDesiredDimSchema,
|
||||
desiredLengthM: optionalDesiredDimSchema,
|
||||
desiredWidthM: optionalDesiredDimSchema,
|
||||
desiredDraftM: optionalDesiredDimSchema,
|
||||
desiredLengthUnit: desiredUnitSchema,
|
||||
desiredWidthUnit: desiredUnitSchema,
|
||||
desiredDraftUnit: desiredUnitSchema,
|
||||
});
|
||||
|
||||
// ─── Update ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -41,6 +41,7 @@ async function buildSyntheticEoiPdf(): Promise<Uint8Array> {
|
||||
function makeContext(overrides: Partial<EoiContext> = {}): EoiContext {
|
||||
return {
|
||||
client: {
|
||||
id: 'client-test-1',
|
||||
fullName: 'Alice Smith',
|
||||
nationality: 'US',
|
||||
primaryEmail: 'alice@example.com',
|
||||
@@ -48,6 +49,7 @@ function makeContext(overrides: Partial<EoiContext> = {}): EoiContext {
|
||||
address: { street: '123 Main St', city: 'Austin', country: 'USA' },
|
||||
},
|
||||
yacht: {
|
||||
id: 'yacht-test-1',
|
||||
name: 'Sea Breeze',
|
||||
lengthFt: '45',
|
||||
widthFt: '14',
|
||||
@@ -106,6 +108,7 @@ describe('fillEoiFormFields', () => {
|
||||
sourcePdf,
|
||||
makeContext({
|
||||
client: {
|
||||
id: 'client-test-2',
|
||||
fullName: 'Bob',
|
||||
nationality: null,
|
||||
primaryEmail: null,
|
||||
|
||||
@@ -32,10 +32,18 @@ describe('companies.service — createCompany', () => {
|
||||
|
||||
it('rejects duplicate name case-insensitively (ConflictError)', async () => {
|
||||
const port = await makePort();
|
||||
await createCompany(port.id, { name: 'Aegean Holdings', status: 'active', tagIds: [] }, makeAuditMeta({ portId: port.id }));
|
||||
await createCompany(
|
||||
port.id,
|
||||
{ name: 'Aegean Holdings', status: 'active', tagIds: [] },
|
||||
makeAuditMeta({ portId: port.id }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
createCompany(port.id, { name: 'AEGEAN HOLDINGS', status: 'active', tagIds: [] }, makeAuditMeta({ portId: port.id })),
|
||||
createCompany(
|
||||
port.id,
|
||||
{ name: 'AEGEAN HOLDINGS', status: 'active', tagIds: [] },
|
||||
makeAuditMeta({ portId: port.id }),
|
||||
),
|
||||
).rejects.toBeInstanceOf(ConflictError);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { EoiContext } from '@/lib/services/eoi-context';
|
||||
function makeContext(overrides?: Partial<EoiContext>): EoiContext {
|
||||
return {
|
||||
client: {
|
||||
id: 'client-fixture-1',
|
||||
fullName: 'Alice Smith',
|
||||
nationality: 'US',
|
||||
primaryEmail: 'alice@example.com',
|
||||
@@ -13,6 +14,7 @@ function makeContext(overrides?: Partial<EoiContext>): EoiContext {
|
||||
address: { street: '123 Main St', city: 'Austin', country: 'USA' },
|
||||
},
|
||||
yacht: {
|
||||
id: 'yacht-fixture-1',
|
||||
name: 'Sea Breeze',
|
||||
lengthFt: '45',
|
||||
widthFt: '14',
|
||||
|
||||
@@ -50,6 +50,11 @@ function makeBerth(overrides: Partial<Berth> = {}): Berth {
|
||||
statusOverrideMode: null,
|
||||
lastImportedAt: null,
|
||||
currentPdfVersionId: null,
|
||||
lengthUnit: 'ft',
|
||||
widthUnit: 'ft',
|
||||
draftUnit: 'ft',
|
||||
nominalBoatSizeUnit: 'ft',
|
||||
waterDepthUnit: 'ft',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
|
||||
Reference in New Issue
Block a user