diff --git a/src/app/(dashboard)/[portSlug]/admin/page.tsx b/src/app/(dashboard)/[portSlug]/admin/page.tsx index a8a17f88..ae1db070 100644 --- a/src/app/(dashboard)/[portSlug]/admin/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/page.tsx @@ -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({
- {GROUPS.map((group) => ( -
-
-

- {group.title} -

-

{group.description}

-
-
- {group.sections.map((s) => { - const Icon = s.icon; - return ( - - - - -
- {s.label} -
-
- - {s.description} - -
- - ); - })} -
-
- ))} +
); } diff --git a/src/components/admin/admin-sections-browser.tsx b/src/components/admin/admin-sections-browser.tsx new file mode 100644 index 00000000..3a52f45c --- /dev/null +++ b/src/components/admin/admin-sections-browser.tsx @@ -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 = []; + 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 ( +
+
+ + setQuery(e.target.value)} + className="h-9 pl-9 pr-9" + /> + {query ? ( + + ) : null} +
+ + {filteredMatches ? ( + filteredMatches.length === 0 ? ( +

+ No settings match "{query}". +

+ ) : ( +
+

+ {filteredMatches.length} match{filteredMatches.length === 1 ? '' : 'es'} +

+
+ {filteredMatches.map((s) => ( + + ))} +
+
+ ) + ) : ( + groups.map((group) => ( +
+
+

+ {group.title} +

+

{group.description}

+
+
+ {group.sections.map((s) => ( + + ))} +
+
+ )) + )} +
+ ); +} + +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 ( + + + + +
+ {section.label} + {groupTitle ? ( +

+ {groupTitle} +

+ ) : null} +
+
+ + {section.description} + +
+ + ); +} diff --git a/src/components/berths/berth-columns.tsx b/src/components/berths/berth-columns.tsx index f13c611a..9df3bcda 100644 --- a/src/components/berths/berth-columns.tsx +++ b/src/components/berths/berth-columns.tsx @@ -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[] = [ header: 'Status', cell: ({ row }) => , }, + { + id: 'latestInterestStage', + header: 'Latest deal stage', + enableSorting: false, + cell: ({ row }) => { + const s = row.original.latestInterestStage; + if (!s) return -; + return ( + + {stageLabel(s)} + + ); + }, + }, { id: 'sidePontoon', header: 'Side / Pontoon', diff --git a/src/components/berths/berth-detail-header.tsx b/src/components/berths/berth-detail-header.tsx index bc8a50c1..3cdd3f77 100644 --- a/src/components/berths/berth-detail-header.tsx +++ b/src/components/berths/berth-detail-header.tsx @@ -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 && (
- + setValue('interestId', id ?? undefined)} + />

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. */} -

-
-
-

- Berth {berth.mooringNumber} -

- - {STATUS_LABELS[berth.status] ?? berth.status} - +
+
+ {/* 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. */} +
+ {berth.mooringNumber}
- {berth.area && ( -
- - {berth.area} Dock - -
- )} + + {STATUS_LABELS[berth.status] ?? berth.status} +
@@ -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 ( + + + + + + + + + No prospects found. + + { + onChange(null); + setOpen(false); + }} + className="text-muted-foreground" + > + — No interest — + + + + {sorted.map((opt) => ( + { + onChange(opt.id); + setOpen(false); + }} + className="flex items-center gap-2" + > + + {opt.clientName || '(unnamed)'} + + {stageLabel(opt.pipelineStage)} + + {value === opt.id ? : null} + + ))} + + + + + + ); +} diff --git a/src/components/clients/bulk-archive-wizard.tsx b/src/components/clients/bulk-archive-wizard.tsx index d3bacd67..4384ac9d 100644 --- a/src/components/clients/bulk-archive-wizard.tsx +++ b/src/components/clients/bulk-archive-wizard.tsx @@ -164,9 +164,8 @@ export function BulkArchiveWizard({ open, onOpenChange, clientIds, onSuccess }:
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.
)} diff --git a/src/components/clients/client-form.tsx b/src/components/clients/client-form.tsx index 466005df..09269f57 100644 --- a/src/components/clients/client-form.tsx +++ b/src/components/clients/client-form.tsx @@ -234,10 +234,7 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
{fields.map((field, index) => ( -
+
diff --git a/src/components/clients/client-list.tsx b/src/components/clients/client-list.tsx index 8139a25c..28b22e1a 100644 --- a/src/components/clients/client-list.tsx +++ b/src/components/clients/client-list.tsx @@ -139,11 +139,7 @@ export function ClientList() { return (
- +
(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([]); + const [attachedYachtIds, setAttachedYachtIds] = useState([]); + 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(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(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 ( @@ -242,6 +366,68 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) { + {/* 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 && ( +
+

+ Connections +

+
+
+ + +

+ Each pick becomes a company member with role=member. You can refine roles + afterwards on the Members tab. +

+
+
+ + +

+ 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. +

+
+
+ + +
+
+
+ )} + + + {/* Notes */}
@@ -279,6 +465,238 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) { + + {/* 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. */} + + {yachtFormOpen && ( + + )} + + { + if (!o && createdCompanyId) { + setPendingYachtPullIn(null); + finishWithInterestPrompt(createdCompanyId); + } + }} + > + + + Attach these yachts too? + + The clients you added own {pendingYachtPullIn?.length ?? 0}{' '} + {pendingYachtPullIn?.length === 1 ? 'yacht' : 'yachts'} not yet linked to this + company. Attaching transfers their ownership. + + +
+ {pendingYachtPullIn?.map((y) => ( +
+ {y.yachtName} +
+ ))} +
+ + { + setPendingYachtPullIn(null); + if (createdCompanyId) finishWithInterestPrompt(createdCompanyId); + }} + > + Skip + + { + 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 + + +
+
+ + { + if (!o) { + setCreateInterestFor(null); + onOpenChange(false); + } + }} + > + + + Create an interest now? + + 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? + + + + { + setCreateInterestFor(null); + onOpenChange(false); + }} + > + Not now + + { + // Close the company form, then open the interest form. The + // interest form is rendered below via createInterestFor. + onOpenChange(false); + }} + > + Create interest + + + + + + {/* 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 && ( + { + if (!o) { + setCreateInterestFor(null); + router.refresh(); + } + }} + defaultClientId={createInterestFor} + /> + )} ); } + +/** + * 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(); + 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 ( +
+ {selectedIds.length > 0 ? ( +
+ {selectedIds.map((id) => ( + + {labelById.get(id) ?? id.slice(0, 8)} + + + ))} +
+ ) : null} + + + + + + + + + No results. + + {options.map((opt) => { + const isSelected = selectedIds.includes(opt.value); + return ( + toggle(opt.value)} + > + + {opt.label} + + ); + })} + + + + + +
+ ); +} diff --git a/src/components/dashboard/activity-feed.tsx b/src/components/dashboard/activity-feed.tsx index c82b7ec1..97c372cf 100644 --- a/src/components/dashboard/activity-feed.tsx +++ b/src/components/dashboard/activity-feed.tsx @@ -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() { )}

{diffLine ? ( -

+

{diffLine}

) : null} diff --git a/src/components/dashboard/berth-status-chart.tsx b/src/components/dashboard/berth-status-chart.tsx index 1479056f..973978da 100644 --- a/src/components/dashboard/berth-status-chart.tsx +++ b/src/components/dashboard/berth-status-chart.tsx @@ -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 ?? '']; }} /> diff --git a/src/components/dashboard/customize-widgets-menu.tsx b/src/components/dashboard/customize-widgets-menu.tsx index e05ec422..bada2462 100644 --- a/src/components/dashboard/customize-widgets-menu.tsx +++ b/src/components/dashboard/customize-widgets-menu.tsx @@ -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 ( @@ -51,8 +49,8 @@ export function CustomizeWidgetsMenu() { Customize dashboard - 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. @@ -114,11 +112,7 @@ export function CustomizeWidgetsMenu() { > Show all -
diff --git a/src/components/dashboard/source-conversion-chart.tsx b/src/components/dashboard/source-conversion-chart.tsx index f9af5b91..7cee3370 100644 --- a/src/components/dashboard/source-conversion-chart.tsx +++ b/src/components/dashboard/source-conversion-chart.tsx @@ -57,9 +57,7 @@ export function SourceConversionChart() {
    {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 (
  • diff --git a/src/components/documents/create-document-wizard.tsx b/src/components/documents/create-document-wizard.tsx index 32ca9913..9fa8860b 100644 --- a/src/components/documents/create-document-wizard.tsx +++ b/src/components/documents/create-document-wizard.tsx @@ -230,8 +230,12 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) { - Generated EOI — rendered + signed externally - Manual EOI — rendered in CRM, sent for e-signature + + Generated EOI — rendered + signed externally + + + Manual EOI — rendered in CRM, sent for e-signature +
    diff --git a/src/components/documents/document-detail.tsx b/src/components/documents/document-detail.tsx index b4100585..dd8116de 100644 --- a/src/components/documents/document-detail.tsx +++ b/src/components/documents/document-detail.tsx @@ -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' }); diff --git a/src/components/documents/eoi-generate-dialog.tsx b/src/components/documents/eoi-generate-dialog.tsx index 47488269..0f6b7bcd 100644 --- a/src/components/documents/eoi-generate-dialog.tsx +++ b/src/components/documents/eoi-generate-dialog.tsx @@ -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) { + 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) { + 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({ ))} @@ -275,22 +323,44 @@ export function EoiGenerateDialog({

    {optional.map((row) => ( - + ))}
{portSlug && clientId && ( -
- onOpenChange(false)} - > - - Wrong details? Edit on the client's page - - +
+

+ Editing name / nationality / yacht name above patches the underlying records + directly. For phone, address, or to manage linked berths, jump to the canonical + page: +

+
+ onOpenChange(false)} + > + + Edit client details + + + onOpenChange(false)} + > + + Manage linked berths + + +
)}
@@ -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; + 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 (
{label}
- {value ?? (missing ? 'Missing — required' : 'Not set')} + {edit && editing ? ( + edit.variant === 'country' ? ( + void commit(iso ?? '')} + defaultOpen + onOpenChange={(o) => !o && setEditing(false)} + compact={false} + className="h-7 w-full" + /> + ) : ( + 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" + /> + ) + ) : ( + <> + + {value ?? (missing ? 'Missing — required' : 'Not set')} + + {edit ? ( + + ) : null} + + )}
); diff --git a/src/components/documents/folder-tree-sidebar.tsx b/src/components/documents/folder-tree-sidebar.tsx index cc395ddd..a23d9382 100644 --- a/src/components/documents/folder-tree-sidebar.tsx +++ b/src/components/documents/folder-tree-sidebar.tsx @@ -69,11 +69,7 @@ export function FolderTreeSidebar({ selectedFolderId, onSelect, footer }: Folder
Folders
- + ); diff --git a/src/components/interests/external-eoi-upload-dialog.tsx b/src/components/interests/external-eoi-upload-dialog.tsx index 0184b07f..4870d780 100644 --- a/src/components/interests/external-eoi-upload-dialog.tsx +++ b/src/components/interests/external-eoi-upload-dialog.tsx @@ -76,9 +76,9 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc Upload externally-signed EOI - 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. diff --git a/src/components/interests/inline-stage-picker.tsx b/src/components/interests/inline-stage-picker.tsx index c410c6de..77f2ba7e 100644 --- a/src/components/interests/inline-stage-picker.tsx +++ b/src/components/interests/inline-stage-picker.tsx @@ -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(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(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 ( + <> { @@ -272,5 +343,45 @@ export function InlineStagePicker({ )} + { + if (!o && !unlinking) setOpenConfirmTarget(null); + }} + > + + + Reset this deal to Open? + + This interest has {linkedBerthCount} linked{' '} + {linkedBerthCount === 1 ? 'berth' : 'berths'}. Going back to Open{' '} + 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. + + + + Cancel + + { + e.preventDefault(); + if (openConfirmTarget) void unlinkAllAndOpen(openConfirmTarget); + }} + > + {unlinking && } + Unlink {linkedBerthCount} {linkedBerthCount === 1 ? 'berth' : 'berths'} & reset + + + + + ); } diff --git a/src/components/interests/interest-eoi-tab.tsx b/src/components/interests/interest-eoi-tab.tsx index 764d0475..5700e2e1 100644 --- a/src/components/interests/interest-eoi-tab.tsx +++ b/src/components/interests/interest-eoi-tab.tsx @@ -329,8 +329,8 @@ function EmptyEoiState({ No EOI in flight for this interest

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

{TABS_RIGHT.map((tab) => ( - + ))}
); } diff --git a/src/components/shared/currency-input.tsx b/src/components/shared/currency-input.tsx index 6c38f69d..c7c31a9e 100644 --- a/src/components/shared/currency-input.tsx +++ b/src/components/shared/currency-input.tsx @@ -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}`; diff --git a/src/components/shared/entity-activity-feed.tsx b/src/components/shared/entity-activity-feed.tsx index 661c0c04..cf9ec2ab 100644 --- a/src/components/shared/entity-activity-feed.tsx +++ b/src/components/shared/entity-activity-feed.tsx @@ -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.' }:
{row.oldValue !== null && row.oldValue !== undefined ? ( - {String(JSON.stringify(row.oldValue)).slice(0, 80)} + {formatValueForField(row.fieldChanged, row.oldValue).slice(0, 80)} ) : null} {row.newValue !== null && row.newValue !== undefined ? ( - → {String(JSON.stringify(row.newValue)).slice(0, 80)} + → {formatValueForField(row.fieldChanged, row.newValue).slice(0, 80)} ) : null}
diff --git a/src/components/shared/inline-editable-field.tsx b/src/components/shared/inline-editable-field.tsx index 2f650a67..b8888f94 100644 --- a/src/components/shared/inline-editable-field.tsx +++ b/src/components/shared/inline-editable-field.tsx @@ -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); diff --git a/src/components/shared/owner-picker.tsx b/src/components/shared/owner-picker.tsx index 5ead8b23..7f2812c6 100644 --- a/src/components/shared/owner-picker.tsx +++ b/src/components/shared/owner-picker.tsx @@ -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); diff --git a/src/hooks/use-dashboard-widgets.ts b/src/hooks/use-dashboard-widgets.ts index 953a9191..c216e1d7 100644 --- a/src/hooks/use-dashboard-widgets.ts +++ b/src/hooks/use-dashboard-widgets.ts @@ -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], ); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index fe12fcba..57f51ef4 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -192,9 +192,7 @@ const LABEL_OVERRIDES: Record = { 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( diff --git a/src/lib/db/migrations/0053_measurement_units.sql b/src/lib/db/migrations/0053_measurement_units.sql new file mode 100644 index 00000000..9538b0ed --- /dev/null +++ b/src/lib/db/migrations/0053_measurement_units.sql @@ -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')); diff --git a/src/lib/db/schema/berths.ts b/src/lib/db/schema/berths.ts index 86ed9999..6810e57e 100644 --- a/src/lib/db/schema/berths.ts +++ b/src/lib/db/schema/berths.ts @@ -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 diff --git a/src/lib/db/schema/interests.ts b/src/lib/db/schema/interests.ts index 56d23868..e3f64657 100644 --- a/src/lib/db/schema/interests.ts +++ b/src/lib/db/schema/interests.ts @@ -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(), diff --git a/src/lib/db/schema/yachts.ts b/src/lib/db/schema/yachts.ts index 5ade72a3..5f219f97 100644 --- a/src/lib/db/schema/yachts.ts +++ b/src/lib/db/schema/yachts.ts @@ -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' diff --git a/src/lib/services/berths.service.ts b/src/lib/services/berths.service.ts index a1f274da..9412548f 100644 --- a/src/lib/services/berths.service.ts +++ b/src/lib/services/berths.service.ts @@ -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>).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> { + 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 = {}; + 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 ─────────────────────────────────────────────────────────────────── diff --git a/src/lib/services/eoi-context.ts b/src/lib/services/eoi-context.ts index cbcc476f..d7382e6f 100644 --- a/src/lib/services/eoi-context.ts +++ b/src/lib/services/eoi-context.ts @@ -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, diff --git a/src/lib/services/search.service.ts b/src/lib/services/search.service.ts index cbed3266..59ea9a97 100644 --- a/src/lib/services/search.service.ts +++ b/src/lib/services/search.service.ts @@ -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( + 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 })), diff --git a/src/lib/validators/interests.ts b/src/lib/validators/interests.ts index 7451cebb..19a32ad2 100644 --- a/src/lib/validators/interests.ts +++ b/src/lib/validators/interests.ts @@ -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 ────────────────────────────────────────────────────────────────── diff --git a/tests/unit/pdf/fill-eoi-form.test.ts b/tests/unit/pdf/fill-eoi-form.test.ts index 6f38b1ff..27d9d9e6 100644 --- a/tests/unit/pdf/fill-eoi-form.test.ts +++ b/tests/unit/pdf/fill-eoi-form.test.ts @@ -41,6 +41,7 @@ async function buildSyntheticEoiPdf(): Promise { function makeContext(overrides: Partial = {}): 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 { 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, diff --git a/tests/unit/services/companies.test.ts b/tests/unit/services/companies.test.ts index 63d50ae8..13fed424 100644 --- a/tests/unit/services/companies.test.ts +++ b/tests/unit/services/companies.test.ts @@ -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); }); diff --git a/tests/unit/services/documenso-payload.test.ts b/tests/unit/services/documenso-payload.test.ts index 0365b06a..f39e79ce 100644 --- a/tests/unit/services/documenso-payload.test.ts +++ b/tests/unit/services/documenso-payload.test.ts @@ -6,6 +6,7 @@ import type { EoiContext } from '@/lib/services/eoi-context'; function makeContext(overrides?: Partial): 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 { address: { street: '123 Main St', city: 'Austin', country: 'USA' }, }, yacht: { + id: 'yacht-fixture-1', name: 'Sea Breeze', lengthFt: '45', widthFt: '14', diff --git a/tests/unit/services/public-berths.test.ts b/tests/unit/services/public-berths.test.ts index a4ff9bd6..ce2d7243 100644 --- a/tests/unit/services/public-berths.test.ts +++ b/tests/unit/services/public-berths.test.ts @@ -50,6 +50,11 @@ function makeBerth(overrides: Partial = {}): Berth { statusOverrideMode: null, lastImportedAt: null, currentPdfVersionId: null, + lengthUnit: 'ft', + widthUnit: 'ft', + draftUnit: 'ft', + nominalBoatSizeUnit: 'ft', + waterDepthUnit: 'ft', createdAt: new Date(), updatedAt: new Date(), ...overrides,