feat(audit-session): legacy-stage canonicalization + multi-berth label sweep + PDF/UI polish

Critical data-correctness fixes
- external-eoi.service: stage-advance list rewritten against canonical
  7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to
  legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so
  EOI uploads from 'qualified' silently skipped the stage flip. Now also
  writes eoiDocStatus='signed' alongside eoiStatus='signed'.
- public-interest.service + api/public/interests/route: pipelineStage
  'open' → 'enquiry' for new public interests.
- interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker
  comments updated.
- Display fallbacks canonicalized: dashboard.service, dashboard-report-data,
  pdf/templates/{interest,client}-summary, interest-picker, timeline route
  all route through canonicalizeStage / stageLabelFor.

Multi-berth interest label sweep
- New helper src/lib/templates/interest-berth-label.ts with 9 unit tests
  (deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments,
  falls back to 'first + N more').
- New batched aggregator getAllBerthMooringsForInterests on the
  interest-berths service.
- BoardInterestRow + listInterests + getInterest extended with
  berthMoorings: string[].
- Swept render sites: interest-detail-header, pipeline-card +
  pipeline-column (kanban), interest-columns (list), interest-card,
  interest-detail (breadcrumb), client-pipeline-summary +
  client-interests-tab, yacht-tabs, shared interest-picker.
- PDF report "New interests (in period)" Source column → Berth column.

Dashboard PDF report fixes
- Hardcoded EUR → reads ports.default_currency once at the top of
  resolveDashboardReportData. Falls back to USD.
- 'maintenance' berth-status bucket removed everywhere (wasn't in
  canonical BERTH_STATUSES); cleaned from dashboard.service,
  dashboard-report-data, occupancy-report, berth-status-chart, fixture.
- Berth demand ranking: dropped placeholder Tier column (resolver
  hardcoded 'A' — heat-tier never plumbed through).
- Deal pulse distribution: tier values capitalized (hot → Hot etc.).
- Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing
  "Validation failed" when all sections checked).
- Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no
  more 2-line wraps on "needs date range"); accepts initialRange?:
  DateRange so the dashboard's active range pre-fills dateFrom/dateTo via
  rangeToBounds.

Interest banner overcounts fix
- interest-berth-status-banner: filters out self-caused under-offer
  berths (where the only active deal touching the berth IS this same
  interest). Waits for all competing-queries before committing the
  count. Was showing "3 berths unavailable" when only 1 actually had a
  competitor.

Sessions list ordering
- sessions-list: client-side sort by lastAt desc + displays lastAt
  instead of firstAt so visible timestamp matches the sort key.

Audit log polish
- Details button: side Sheet → Popover anchored to the button (in-place
  inline dropdown). Works with the virtualized table.
- From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3.

EntityFolderView (Documents Hub entity view)
- Per-row Download button (hover-reveal icon).
- File-type icon prefix + tighter row layout.
- Per-row interest-berth badge: files.ts attaches interestBerthLabel via
  one batched getAllBerthMooringsForInterests call across all groups.
  AggregatedFile type + EntityFolderView render the badge linking back
  to the parent interest.

External EOI upload dialog
- Title input pre-fills from the derived default via controlled
  displayTitle = title || defaultTitle (no setState-in-effect).

EOI Generate dialog
- Success toast on mutation success.
- Primary berth's "Include in EOI" checkbox is now forced-on + disabled
  with tooltip: the primary IS the canonical "berth for this deal",
  excluding it is semantically nonsense.

Primary berth must always be in EOI bundle (service + backfill)
- interest-berths.service: insert path forces is_in_eoi_bundle=true
  whenever is_primary=true; update path coerces back to true when the
  caller tries to set false on a primary. Backfilled 7 existing rows.

Documenso redirect URL fallback
- port-config getPortDocumensoConfig: resolution chain extended to
  documenso_redirect_url → public_site_url → null. Operators with
  public_site_url configured (most ports) now get sensible signer
  landing without setting two settings.

World-map click → navigate
- website-analytics-shell: country click navigates to the nationality-
  filtered Clients page via router.push instead of copying a URL to
  clipboard.

Documents Hub: subfolder grid in main panel
- Subfolder cards rendered above the documents list when the current
  folder has children. Lets reps drill into subfolders from the main
  content area, not only via the sidebar tree.

Interest list initial sort
- usePaginatedQuery gains initialSort option (used when URL has no sort
  param). Interest list passes updatedAt desc so the table header
  surfaces the active sort visibly + most-recently-added/edited bubble
  to the top.

Interest auto-assign on create
- interests.service createInterest: three-tier owner resolution chain
  — explicit input → port's default_new_interest_owner setting →
  creator (when not super-admin). Super-admins skipped since they often
  create on behalf of other reps.

Backfills
- 12 interests with eoi_status='signed' + missing eoi_doc_status='signed'
  aligned.
- 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false
  flipped to true.

Verified
- pnpm tsc --noEmit: clean
- pnpm exec vitest run: 1463 / 1463 passed

Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md
across all 4 buckets, including two OPEN QUESTIONS (Reservations module
re-imagine, Reports dedicated page promotion).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 20:41:27 +02:00
parent 70d1e7e9b2
commit 41737fa950
47 changed files with 905 additions and 269 deletions

View File

@@ -57,7 +57,7 @@ export async function POST(req: NextRequest) {
yachtId: result.yachtId,
companyId: result.companyId,
source: 'website',
pipelineStage: 'open',
pipelineStage: 'enquiry',
berthId: result.berthId,
},
metadata: { type: 'public_registration', ip },

View File

@@ -11,7 +11,7 @@ import { user, userProfiles } from '@/lib/db/schema/users';
import { berths } from '@/lib/db/schema/berths';
import { yachts } from '@/lib/db/schema/yachts';
import { clients } from '@/lib/db/schema/clients';
import { stageLabel } from '@/lib/constants';
import { canonicalizeStage, stageLabel } from '@/lib/constants';
const OUTCOME_LABELS: Record<string, string> = {
won: 'Won',
@@ -219,17 +219,17 @@ export const GET = withAuth(
// Fallback: when no audit-log entries exist for this interest (typical
// for seed/imported data inserted directly into the table without going
// through the service), synthesize a "Created at <stage>" event so the
// tab isn't empty when the interest is clearly past `open`.
// tab isn't empty when the interest is clearly past the initial stage.
const hasCreateAudit = allEvents.some((e) => e.action === 'create');
if (!hasCreateAudit) {
const stage = stageLabel(interest.pipelineStage);
const created = interest.createdAt ?? new Date();
const isInitialStage = canonicalizeStage(interest.pipelineStage) === 'enquiry';
allEvents.push({
id: `synth-${interest.id}-create`,
type: 'audit',
action: 'create',
description:
interest.pipelineStage === 'open' ? 'Interest created' : `Interest created at ${stage}`,
description: isInitialStage ? 'Interest created' : `Interest created at ${stage}`,
userId: null,
userName: null,
createdAt: created,

View File

@@ -16,7 +16,7 @@ import { createAuditLog } from '@/lib/audit';
const dashboardConfigSchema = z.object({
kind: z.literal('dashboard'),
widgetIds: z.array(z.string()).min(1).max(20),
widgetIds: z.array(z.string()).min(1).max(40),
dateFrom: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)

View File

@@ -22,13 +22,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { AuditLogCard } from './audit-log-card';
@@ -149,10 +143,10 @@ export function AuditLogList() {
const [userId, setUserId] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
/** Currently-open audit detail row. Drives the side Sheet that
* exposes the full oldValue / newValue / metadata / IP / UA payload
* so reps can inspect a row without leaving the search list. */
const [detailEntry, setDetailEntry] = useState<AuditEntry | null>(null);
// Per-row detail is surfaced inline via a Popover anchored to the
// Details button (see column cell below). Lets the rep inspect the
// full oldValue / newValue / metadata / IP / UA payload without
// leaving the table or opening a Sheet.
const debouncedSearch = useDebounced(search);
const debouncedUserId = useDebounced(userId);
@@ -368,14 +362,76 @@ export function AuditLogList() {
Boolean(e.oldValue) || Boolean(e.newValue) || Boolean(e.metadata) || Boolean(e.userAgent);
if (!hasDetail) return null;
return (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setDetailEntry(e)}
>
Details
</Button>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs">
Details
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
side="bottom"
className="w-[420px] max-h-[60vh] overflow-y-auto p-3"
>
<div className="space-y-3 text-sm">
<div className="space-y-0.5">
<p className="font-semibold capitalize">
{e.action.replace(/_/g, ' ')} - {e.entityType}
</p>
<p className="text-xs text-muted-foreground">
{formatDate(e.createdAt, 'datetime.medium')}
{e.actor ? ` · ${e.actor.name}` : ''}
</p>
</div>
{e.oldValue ? (
<details>
<summary className="cursor-pointer text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Old value
</summary>
<pre className="mt-1 max-h-60 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
{JSON.stringify(e.oldValue, null, 2)}
</pre>
</details>
) : null}
{e.newValue ? (
<details open>
<summary className="cursor-pointer text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
New value
</summary>
<pre className="mt-1 max-h-60 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
{JSON.stringify(e.newValue, null, 2)}
</pre>
</details>
) : null}
{e.metadata ? (
<details>
<summary className="cursor-pointer text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Metadata
</summary>
<pre className="mt-1 max-h-60 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
{JSON.stringify(e.metadata, null, 2)}
</pre>
</details>
) : null}
{e.ipAddress || e.userAgent ? (
<dl className="grid grid-cols-[88px_1fr] gap-x-2 gap-y-1 text-[11px]">
{e.ipAddress ? (
<>
<dt className="font-semibold text-muted-foreground">IP address</dt>
<dd className="font-mono">{e.ipAddress}</dd>
</>
) : null}
{e.userAgent ? (
<>
<dt className="font-semibold text-muted-foreground">User agent</dt>
<dd className="font-mono break-all">{e.userAgent}</dd>
</>
) : null}
</dl>
) : null}
</div>
</PopoverContent>
</Popover>
);
},
size: 80,
@@ -391,7 +447,7 @@ export function AuditLogList() {
variant="gradient"
/>
<div className="mt-4 flex flex-wrap items-end gap-3">
<div className="mt-4 flex flex-wrap items-end gap-x-4 gap-y-3">
<div className="space-y-1.5">
<Label htmlFor="audit-search" className="text-xs">
Search
@@ -533,7 +589,7 @@ export function AuditLogList() {
</Label>
<DatePicker
id="audit-from"
className="w-44 h-9"
className="w-52 h-9"
value={dateFrom}
onChange={setDateFrom}
/>
@@ -543,7 +599,7 @@ export function AuditLogList() {
<Label htmlFor="audit-to" className="text-xs">
To
</Label>
<DatePicker id="audit-to" className="w-44 h-9" value={dateTo} onChange={setDateTo} />
<DatePicker id="audit-to" className="w-52 h-9" value={dateTo} onChange={setDateTo} />
</div>
{/* M-AU03: CSV export inherits the current filter set. The
@@ -629,73 +685,6 @@ export function AuditLogList() {
</Button>
</div>
) : null}
<Sheet open={!!detailEntry} onOpenChange={(o) => !o && setDetailEntry(null)}>
<SheetContent side="right" className="overflow-y-auto sm:max-w-xl">
{detailEntry ? (
<>
<SheetHeader>
<SheetTitle>
{detailEntry.action.replace(/_/g, ' ')} - {detailEntry.entityType}
</SheetTitle>
<SheetDescription>
{formatDate(detailEntry.createdAt, 'datetime.medium')}
{detailEntry.actor ? ` · ${detailEntry.actor.name}` : ''}
</SheetDescription>
</SheetHeader>
<div className="space-y-4 pt-4 text-sm">
{detailEntry.oldValue ? (
<details>
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Old value
</summary>
<pre className="mt-1 max-h-80 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
{JSON.stringify(detailEntry.oldValue, null, 2)}
</pre>
</details>
) : null}
{detailEntry.newValue ? (
<details open>
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-wide text-muted-foreground">
New value
</summary>
<pre className="mt-1 max-h-80 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
{JSON.stringify(detailEntry.newValue, null, 2)}
</pre>
</details>
) : null}
{detailEntry.metadata ? (
<details>
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Metadata
</summary>
<pre className="mt-1 max-h-80 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
{JSON.stringify(detailEntry.metadata, null, 2)}
</pre>
</details>
) : null}
{detailEntry.ipAddress || detailEntry.userAgent ? (
<dl className="grid grid-cols-[110px_1fr] gap-x-3 gap-y-1 text-xs">
{detailEntry.ipAddress ? (
<>
<dt className="font-semibold text-muted-foreground">IP address</dt>
<dd className="font-mono">{detailEntry.ipAddress}</dd>
</>
) : null}
{detailEntry.userAgent ? (
<>
<dt className="font-semibold text-muted-foreground">User agent</dt>
<dd className="font-mono break-all">{detailEntry.userAgent}</dd>
</>
) : null}
</dl>
) : null}
</div>
</>
) : null}
</SheetContent>
</Sheet>
</div>
);
}

View File

@@ -14,6 +14,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client';
import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
import { cn } from '@/lib/utils';
import { STAGE_BADGE, STAGE_LABELS, safeStage } from '@/components/clients/pipeline-constants';
@@ -39,9 +40,9 @@ function InterestRowItem({
}) {
const stage = safeStage(interest.pipelineStage);
const berthLabel = interest.berthMooringNumber
? `Berth ${interest.berthMooringNumber}`
: 'General interest';
const berthDisplay =
deriveInterestBerthLabel(interest.berthMoorings) ?? interest.berthMooringNumber;
const berthLabel = berthDisplay ? `Berth ${berthDisplay}` : 'General interest';
const yachtLabel = interest.yachtName ?? null;
@@ -200,9 +201,12 @@ function InterestPreviewSheet({
const reached = (target: PipelineStage) =>
stageIdx !== -1 && PIPELINE_STAGES.indexOf(target) <= stageIdx;
const showingBerthDisplay = showing
? (deriveInterestBerthLabel(showing.berthMoorings) ?? showing.berthMooringNumber)
: null;
const berthLabel = showing
? showing.berthMooringNumber
? `Berth ${showing.berthMooringNumber}`
? showingBerthDisplay
? `Berth ${showingBerthDisplay}`
: 'General interest'
: '';
const yachtLabel = showing?.yachtName ?? null;

View File

@@ -9,6 +9,7 @@ import { formatDistanceToNowStrict } from 'date-fns';
import { apiFetch } from '@/lib/api/client';
import { Skeleton } from '@/components/ui/skeleton';
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
import { cn } from '@/lib/utils';
import {
PIPELINE_STAGES,
@@ -27,6 +28,7 @@ export interface ClientInterestRow {
updatedAt: string;
dateLastContact: string | null;
berthMooringNumber?: string | null;
berthMoorings?: string[];
yachtName?: string | null;
/** Requirements surfaced on the Client Overview panel - "Wants L × W × D
* · Source" lets reps see what the deal is looking for without drilling
@@ -184,9 +186,8 @@ function HeroVariant({ clientId, portSlug }: { clientId: string; portSlug: strin
}
const stage = safeStage(top.pipelineStage);
const berthLabel = top.berthMooringNumber
? `Berth ${top.berthMooringNumber}`
: 'General interest';
const topBerthDisplay = deriveInterestBerthLabel(top.berthMoorings) ?? top.berthMooringNumber;
const berthLabel = topBerthDisplay ? `Berth ${topBerthDisplay}` : 'General interest';
const detailsHref = `/${portSlug}/interests/${top.id}` as Route;
return (
@@ -298,9 +299,8 @@ function PanelVariant({ clientId, portSlug }: { clientId: string; portSlug: stri
<ul className="space-y-2">
{sorted.map((i) => {
const stage = safeStage(i.pipelineStage);
const berthLabel = i.berthMooringNumber
? `Berth ${i.berthMooringNumber}`
: 'General interest';
const rowBerthDisplay = deriveInterestBerthLabel(i.berthMoorings) ?? i.berthMooringNumber;
const berthLabel = rowBerthDisplay ? `Berth ${rowBerthDisplay}` : 'General interest';
const href = `/${portSlug}/interests/${i.id}` as Route;
return (
<li key={i.id}>

View File

@@ -13,17 +13,16 @@ interface BerthStatusResponse {
available: number;
underOffer: number;
sold: number;
maintenance: number;
};
}
// Brand-aligned palette. Order matches the legend reading order
// (positive → in-progress → closed → exception).
// (positive → in-progress → closed). Mirrors BERTH_STATUSES in
// src/lib/constants.ts (canonical 3-status enum).
const SEGMENTS = [
{ key: 'available', label: 'Available', color: 'hsl(213 55% 56%)' },
{ key: 'underOffer', label: 'Under offer', color: 'hsl(38 92% 50%)' },
{ key: 'sold', label: 'Sold', color: 'hsl(142 70% 40%)' },
{ key: 'maintenance', label: 'Maintenance', color: 'hsl(228 10% 60%)' },
] as const;
/**

View File

@@ -171,7 +171,7 @@ export function DashboardShell({
<div className="flex items-center gap-2">
<DateRangePicker value={range} onChange={setRange} />
<CustomizeWidgetsMenu />
<ExportDashboardPdfButton />
<ExportDashboardPdfButton initialRange={range} />
</div>
}
/>

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
import { ChevronDown, ChevronRight, FileText, Plus, Upload } from 'lucide-react';
import { ChevronDown, ChevronRight, FileText, Folder, Lock, Plus, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
@@ -236,6 +236,10 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
key={selectedFolderId ?? 'root'}
portSlug={portSlug}
folderId={selectedFolderId}
childFolders={
typeof selectedFolderId === 'string' ? (selectedFolder?.children ?? []) : (tree ?? [])
}
onFolderSelect={handleFolderSelect}
/>
</FolderDropZone>
)}
@@ -297,9 +301,19 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
interface FlatFolderListingProps {
portSlug: string;
folderId: string | null;
/** Direct children of the currently-viewed folder. Rendered above the
* document list as clickable cards so the rep can drill into subfolders
* from the main content area, not only the sidebar tree. */
childFolders?: FolderNode[];
onFolderSelect?: (id: string | null | undefined) => void;
}
function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
function FlatFolderListing({
portSlug,
folderId,
childFolders = [],
onFolderSelect,
}: FlatFolderListingProps) {
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<string | undefined>(undefined);
const [expandedDocId, setExpandedDocId] = useState<string | null>(null);
@@ -444,13 +458,38 @@ function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
})()}
</div>
{childFolders.length > 0 ? (
<div>
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground mb-1.5">
Subfolders
</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{childFolders.map((sub) => (
<button
key={sub.id}
type="button"
onClick={() => onFolderSelect?.(sub.id)}
className="flex items-center gap-2 rounded-md border bg-white px-3 py-2 text-sm text-left transition-colors hover:border-primary/50 hover:bg-accent/30"
title={sub.name}
>
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="truncate font-medium">{sub.name}</span>
{sub.systemManaged ? (
<Lock className="ml-auto h-3 w-3 shrink-0 text-muted-foreground" aria-hidden />
) : null}
</button>
))}
</div>
</div>
) : null}
{isLoading ? (
<ul className="rounded-md border bg-white">
{[0, 1, 2, 3, 4].map((i) => (
<li key={i} className="h-12 animate-pulse border-b last:border-b-0 bg-muted/40" />
))}
</ul>
) : documents.length === 0 ? (
) : documents.length === 0 && childFolders.length === 0 ? (
<EmptyState
icon={<FileText className="h-7 w-7" aria-hidden />}
title="No documents in this folder"

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import Link from 'next/link';
import { ClipboardSignature, FileText, Eye } from 'lucide-react';
import { ClipboardSignature, FileText, Eye, Download } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { AggregatedSection } from './aggregated-section';
@@ -10,6 +10,9 @@ import { SigningDetailsDialog } from './signing-details-dialog';
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { useAggregatedFiles, useAggregatedWorkflows } from '@/hooks/use-aggregated-listing';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { triggerUrlDownload } from '@/lib/utils/download';
import type {
AggregatedFile,
AggregatedGroup,
@@ -34,6 +37,17 @@ function mapWorkflowStatus(status: string): StatusPillStatus {
return known[status] ?? 'pending';
}
async function handleFileDownload(fileId: string) {
try {
const res = await apiFetch<{ data: { url: string; filename: string } }>(
`/api/v1/files/${fileId}/download`,
);
triggerUrlDownload(res.data.url, res.data.filename);
} catch (err) {
toastError(err);
}
}
export function EntityFolderView({ portSlug, entityType, entityId }: Props) {
const [detailsId, setDetailsId] = useState<string | null>(null);
const [previewFile, setPreviewFile] = useState<{
@@ -81,28 +95,49 @@ export function EntityFolderView({ portSlug, entityType, entityId }: Props) {
renderRow={(f: AggregatedFile, _group: AggregatedGroup<AggregatedFile>) => {
const signedFromDocumentId = f.signedFromDocumentId;
return (
<div className="flex items-center justify-between gap-2 text-sm">
<div className="group flex items-center justify-between gap-3 text-sm">
<button
type="button"
className="truncate text-left hover:text-brand hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 rounded-sm"
className="flex min-w-0 flex-1 items-center gap-2 text-left hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 rounded-sm"
onClick={() => setPreviewFile({ id: f.id, name: f.filename, mimeType: f.mimeType })}
aria-label={`Preview ${f.filename}`}
>
{f.filename}
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
<span className="truncate group-hover:underline">{f.filename}</span>
{f.interestBerthLabel && f.interestId ? (
<Link
href={`/${portSlug}/interests/${f.interestId}`}
onClick={(e) => e.stopPropagation()}
className="ml-1 inline-flex shrink-0 items-center rounded-full border bg-muted/40 px-1.5 py-px text-[10px] font-medium text-muted-foreground hover:border-primary/40 hover:bg-accent/40 hover:text-foreground"
title={`Linked to interest ${f.interestBerthLabel}`}
>
{f.interestBerthLabel}
</Link>
) : null}
</button>
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums shrink-0">
<span>{new Date(f.createdAt).toLocaleDateString('en-GB')}</span>
{signedFromDocumentId ? (
<Button
variant="ghost"
size="sm"
className="min-h-[44px] gap-1 px-2 text-xs text-brand"
className="h-8 gap-1 px-2 text-xs text-brand"
onClick={() => setDetailsId(signedFromDocumentId)}
>
<Eye className="h-3 w-3" aria-hidden />
View signing details
</Button>
) : null}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
onClick={() => void handleFileDownload(f.id)}
aria-label={`Download ${f.filename}`}
title="Download"
>
<Download className="h-3.5 w-3.5" aria-hidden />
</Button>
</div>
</div>
);

View File

@@ -31,6 +31,7 @@ import { useUIStore } from '@/stores/ui-store';
import { Input } from '@/components/ui/input';
import { CountryCombobox } from '@/components/shared/country-combobox';
import { YachtForm } from '@/components/yachts/yacht-form';
import { toast } from 'sonner';
import { toastError } from '@/lib/api/toast-error';
const DOCUMENSO_TEMPLATE_VALUE = 'documenso-template';
@@ -576,6 +577,9 @@ export function EoiGenerateDialog({
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'timeline'] }),
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'berths'] }),
]);
toast.success(
isDocumenso ? 'EOI generated and sent for signature.' : 'EOI generated. Ready to send.',
);
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate EOI');
@@ -767,12 +771,25 @@ export function EoiGenerateDialog({
</span>
) : null}
</div>
<label className="flex items-center gap-1.5 text-xs cursor-pointer">
<label
className={
link.isPrimary
? 'flex items-center gap-1.5 text-xs cursor-not-allowed opacity-70'
: 'flex items-center gap-1.5 text-xs cursor-pointer'
}
title={
link.isPrimary
? 'Primary berth is always included in the EOI bundle.'
: undefined
}
>
<Checkbox
checked={draft.isInEoiBundle}
onCheckedChange={(v) =>
setBerthFlag(link.berthId, 'isInEoiBundle', v === true)
}
checked={link.isPrimary ? true : draft.isInEoiBundle}
disabled={link.isPrimary}
onCheckedChange={(v) => {
if (link.isPrimary) return;
setBerthFlag(link.berthId, 'isInEoiBundle', v === true);
}}
/>
<span>In EOI</span>
</label>

View File

@@ -121,6 +121,14 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
return parts.join(' - ');
}, [interestData, berthsData, signedAt]);
// The title input is controlled with `displayTitle` (derived from
// either the rep's typed value or the auto-derived default). Reps
// were treating the empty field as "this needs me to type something"
// even though the placeholder showed a sensible default - now the
// default is visible inside the input itself. Typing replaces the
// default; clearing the field re-shows it.
const displayTitle = title || defaultTitle;
const mutation = useMutation<{ data?: { stageChanged?: boolean } }, Error, void>({
mutationFn: async () => {
if (!file) throw new Error('No file selected');
@@ -199,13 +207,13 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
<div>
<Label>Title (optional)</Label>
<Input
value={title}
value={displayTitle}
onChange={(e) => setTitle(e.target.value)}
placeholder={defaultTitle}
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
Leave blank to use the default shown above.
Edit the auto-filled title or clear it to restore the default.
</p>
</div>
<div>

View File

@@ -84,10 +84,10 @@ 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
// When dropping the stage back to enquiry 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 +
// enquiry 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);
@@ -97,7 +97,7 @@ export function InlineStagePicker({
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'.
// unlink-prompt when the rep drops the stage back to enquiry.
const { data: linkedBerths } = useQuery<{ data: Array<{ berthId: string }> }>({
queryKey: ['interest-berths', interestId, 'count-only'],
queryFn: () => apiFetch(`/api/v1/interests/${interestId}/berths`),

View File

@@ -80,11 +80,26 @@ export function InterestBerthStatusBanner({
if (conflicts.length === 0) return null;
const lines = conflicts.map((b, idx) => {
const q = competingQueries[idx];
const competing = (q?.data ?? []).find((c) => c.isPrimary) ?? (q?.data ?? [])[0] ?? null;
return { berth: b, competing };
});
// Wait for every per-berth competing-interest query to finish before
// committing to a count - otherwise the banner briefly reads "3 berths"
// and then shrinks to "1 berth" as queries land.
const allCompetingLoaded = competingQueries.every((q) => !q.isLoading);
if (!allCompetingLoaded) return null;
// A berth's status is 'under_offer' or 'sold' if ANY active interest -
// including this one - flagged it as is_specific_interest. When this
// interest is the only deal touching the berth, the conflict is
// self-caused and shouldn't fire the banner: filter to berths where at
// least one OTHER interest is in play.
const lines = conflicts
.map((b, idx) => {
const q = competingQueries[idx];
const competing = (q?.data ?? []).find((c) => c.isPrimary) ?? (q?.data ?? [])[0] ?? null;
return { berth: b, competing };
})
.filter((l) => l.competing !== null);
if (lines.length === 0) return null;
return (
<div
@@ -100,24 +115,22 @@ export function InterestBerthStatusBanner({
} to another deal.`
: `${lines.length} linked berths are no longer freely available.`}
</p>
{lines.some((l) => l.competing) ? (
<ul className="mt-1 space-y-0.5">
{lines.map(({ berth, competing }) =>
competing ? (
<li key={berth.id} className="text-rose-900">
<span className="font-medium">{berth.mooringNumber}:</span>{' '}
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={`/${portSlug}/interests/${competing.interestId}` as any}
className="underline-offset-2 hover:underline"
>
{competing.clientName}
</Link>
</li>
) : null,
)}
</ul>
) : null}
<ul className="mt-1 space-y-0.5">
{lines.map(({ berth, competing }) =>
competing ? (
<li key={berth.id} className="text-rose-900">
<span className="font-medium">{berth.mooringNumber}:</span>{' '}
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={`/${portSlug}/interests/${competing.interestId}` as any}
className="underline-offset-2 hover:underline"
>
{competing.clientName}
</Link>
</li>
) : null,
)}
</ul>
<p className="mt-0.5 text-rose-800">
You can still progress this interest as a backup, but the rep on the other deal owns the
primary path. If their deal falls through, this one can step in.

View File

@@ -25,6 +25,7 @@ import {
formatSource,
} from '@/lib/constants';
import { computeUrgencyBadges } from '@/components/interests/urgency';
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
import type { InterestRow } from './interest-columns';
const CATEGORY_LABELS: Record<string, string> = {
@@ -52,7 +53,8 @@ export function InterestCard({ interest, portSlug, onEdit, onArchive }: Interest
const urgencyBadges = computeUrgencyBadges(interest);
const clientName = interest.clientName ?? 'Unknown client';
const berthLabel = interest.berthMooringNumber;
const berthLabel =
deriveInterestBerthLabel(interest.berthMoorings) ?? interest.berthMooringNumber;
const lastIso = interest.dateLastContact ?? interest.updatedAt ?? null;
const lastActivity = lastIso
? formatDistanceToNowStrict(new Date(lastIso), { addSuffix: true })

View File

@@ -14,6 +14,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import { stageBadgeClass, stageLabel } from '@/lib/constants';
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
import { computeUrgencyBadges, type InterestUrgencyInput } from '@/components/interests/urgency';
export interface InterestRow {
@@ -24,6 +25,10 @@ export interface InterestRow {
yachtName?: string | null;
berthId: string | null;
berthMooringNumber: string | null;
/** Every linked berth's mooring (sorted) - drives the multi-berth list
* cell label (`A1-A3, B5`). Falls back to `berthMooringNumber` alone
* when empty/absent. */
berthMoorings?: string[];
pipelineStage: string;
leadCategory: string | null;
source: string | null;
@@ -172,7 +177,9 @@ export function getInterestColumns({
accessorKey: 'berthMooringNumber',
header: 'Berth',
cell: ({ row }) => {
if (!row.original.berthId || !row.original.berthMooringNumber) {
const label =
deriveInterestBerthLabel(row.original.berthMoorings) ?? row.original.berthMooringNumber;
if (!row.original.berthId || !label) {
return <span className="text-muted-foreground">-</span>;
}
return (
@@ -181,7 +188,7 @@ export function getInterestColumns({
className="text-primary hover:underline text-sm"
onClick={(e) => e.stopPropagation()}
>
{row.original.berthMooringNumber}
{label}
</Link>
);
},

View File

@@ -36,6 +36,7 @@ import { MultiEoiChip } from '@/components/interests/multi-eoi-chip';
import { DealPulseChip } from '@/components/interests/deal-pulse-chip';
import { apiFetch } from '@/lib/api/client';
import { formatOutcome } from '@/lib/constants';
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
import { cn } from '@/lib/utils';
const OUTCOME_BADGE: Record<string, { label: string; className: string }> = {
@@ -85,6 +86,10 @@ interface InterestDetailHeaderProps {
activeReminderCount?: number;
berthId: string | null;
berthMooringNumber: string | null;
/** Every linked berth's mooring (sorted) - drives the multi-berth
* header label (`Berth A1-A3, B5`). When absent or empty, falls back
* to the primary mooring alone. */
berthMoorings?: string[];
yachtId: string | null;
pipelineStage: string;
leadCategory: string | null;
@@ -184,8 +189,14 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
},
});
// Multi-berth header label: prefer the full berth-range derived from
// every linked mooring, fall back to the primary alone for older
// payloads that haven't been re-fetched yet.
const berthDisplayLabel =
deriveInterestBerthLabel(interest.berthMoorings) ?? interest.berthMooringNumber;
const meta: Array<{ key: string; node: React.ReactNode }> = [];
if (interest.berthMooringNumber) {
if (berthDisplayLabel) {
meta.push({
key: 'berth',
node: (
@@ -193,7 +204,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
href={`/${portSlug}/berths/${interest.berthId}`}
className="text-foreground hover:underline"
>
Berth {interest.berthMooringNumber}
Berth {berthDisplayLabel}
</Link>
),
});

View File

@@ -12,6 +12,7 @@ import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provid
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
import { apiFetch } from '@/lib/api/client';
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
interface InterestData {
id: string;
@@ -44,6 +45,10 @@ interface InterestData {
} | null;
berthId: string | null;
berthMooringNumber: string | null;
/** Every linked berth's mooring (sorted). Drives the multi-berth label
* rendered in the breadcrumb + header. Falls back to the primary
* mooring alone when empty/absent. */
berthMoorings?: string[];
/** Linked yacht - null until the rep ties one to the deal. Required to
* leave Enquiry; surfaced inline in the stage picker as a prereq. */
yachtId: string | null;
@@ -132,7 +137,8 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
data.clientId && data.clientName
? [{ label: data.clientName, href: `/${portSlug}/clients/${data.clientId}` }]
: [],
current: data.berthMooringNumber ?? 'Interest',
current:
deriveInterestBerthLabel(data.berthMoorings) ?? data.berthMooringNumber ?? 'Interest',
}
: null,
);

View File

@@ -115,6 +115,13 @@ export function InterestList() {
} = usePaginatedQuery<InterestRow>({
queryKey: ['interests'],
endpoint: '/api/v1/interests',
// Surface the active sort visibly on the column header. The API
// already defaults to updatedAt desc when no sort param is sent, but
// without this the table renders with no active-sort indicator and
// the rep can't tell what ordering is in play. Newly added / edited
// deals bubble to the top of the list - the most useful default for
// triage.
initialSort: { field: 'updatedAt', direction: 'desc' },
filterDefinitions: interestFilterDefinitions,
});

View File

@@ -16,6 +16,7 @@ import {
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useDebounce } from '@/hooks/use-debounce';
import { apiFetch } from '@/lib/api/client';
import { stageLabelFor } from '@/lib/constants';
import { cn } from '@/lib/utils';
interface InterestOption {
@@ -59,7 +60,7 @@ export function InterestPicker({
if (!value) return placeholder;
const match = options.find((o) => o.id === value);
if (!match) return `Interest ${value.slice(0, 8)}`;
if (match.clientName) return `${match.clientName} - ${match.pipelineStage ?? 'open'}`;
if (match.clientName) return `${match.clientName} - ${stageLabelFor(match.pipelineStage)}`;
return `Interest ${match.id.slice(0, 8)}`;
})();

View File

@@ -14,6 +14,7 @@ interface InterestRow {
id: string;
clientName: string | null;
berthMooringNumber: string | null;
berthMoorings?: string[];
leadCategory: string | null;
pipelineStage: string;
updatedAt: string;

View File

@@ -5,11 +5,15 @@ import { CSS } from '@dnd-kit/utilities';
import { differenceInDays } from 'date-fns';
import { Badge } from '@/components/ui/badge';
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
interface PipelineCardProps {
id: string;
clientName: string | null;
berthMooringNumber: string | null;
/** Every linked berth's mooring (sorted) - drives the multi-berth label.
* Falls back to `berthMooringNumber` alone when empty / absent. */
berthMoorings?: string[];
leadCategory: string | null;
updatedAt: string | Date;
}
@@ -24,6 +28,7 @@ export function PipelineCard({
id,
clientName,
berthMooringNumber,
berthMoorings,
leadCategory,
updatedAt,
}: PipelineCardProps) {
@@ -38,6 +43,7 @@ export function PipelineCard({
};
const daysInStage = differenceInDays(new Date(), new Date(updatedAt));
const berthLabel = deriveInterestBerthLabel(berthMoorings) ?? berthMooringNumber;
return (
<div
@@ -49,9 +55,7 @@ export function PipelineCard({
>
<p className="text-sm font-medium truncate">{clientName ?? 'Unknown client'}</p>
{berthMooringNumber && (
<p className="text-xs text-muted-foreground">Berth: {berthMooringNumber}</p>
)}
{berthLabel && <p className="text-xs text-muted-foreground">Berth: {berthLabel}</p>}
<div className="flex items-center justify-between gap-2">
{leadCategory && (

View File

@@ -10,6 +10,7 @@ interface ColumnItem {
id: string;
clientName: string | null;
berthMooringNumber: string | null;
berthMoorings?: string[];
leadCategory: string | null;
updatedAt: string | Date;
}
@@ -45,6 +46,7 @@ export function PipelineColumn({ stage, label, items }: PipelineColumnProps) {
id={item.id}
clientName={item.clientName}
berthMooringNumber={item.berthMooringNumber}
berthMoorings={item.berthMoorings}
leadCategory={item.leadCategory}
updatedAt={item.updatedAt}
/>

View File

@@ -27,6 +27,7 @@ import {
import { triggerBlobDownload } from '@/lib/utils/download';
import { usePermissions } from '@/hooks/use-permissions';
import { resolvePortIdFromSlug } from '@/lib/api/client';
import { rangeToBounds, type DateRange } from '@/lib/analytics/range';
import { SavedTemplatesPicker, type SavedTemplate } from './saved-templates-picker';
import { PdfPreviewModal } from './pdf-preview-modal';
@@ -51,22 +52,40 @@ function toIsoLocal(d: Date): string {
* Permission-gated client-side on `reports.export`; the server route
* re-checks via withPermission so a tampered client can't bypass.
*/
export function ExportDashboardPdfButton({ className }: { className?: string } = {}) {
export function ExportDashboardPdfButton({
className,
initialRange,
}: {
className?: string;
/** The dashboard's currently-active range. When supplied, drives the
* dialog's initial dateFrom / dateTo so the rep doesn't re-pick a
* range they just chose on the dashboard. Falls back to last 30 days
* when omitted (still useful for ad-hoc reports). */
initialRange?: DateRange;
} = {}) {
const { can } = usePermissions();
const [open, setOpen] = useState(false);
const [title, setTitle] = useState(`Report - ${new Date().toLocaleDateString('en-GB')}`);
const [selected, setSelected] = useState<PdfDashboardWidgetId[]>(
PDF_DASHBOARD_WIDGETS.map((w) => w.id),
);
// Default report window = last 30 days. Many of the new widgets
// (period cohorts, occupancy timeline) require the window;
// populating with sensible defaults means the rep gets a useful
// report on first export without picking dates.
const today = new Date();
const last30 = new Date(today);
last30.setDate(last30.getDate() - 30);
const [dateFrom, setDateFrom] = useState(toIsoLocal(last30));
const [dateTo, setDateTo] = useState(toIsoLocal(today));
// Default report window: honour the dashboard's active range when one
// was passed in (rep already chose a window upstream); otherwise default
// to last 30 days. Period-cohort + occupancy-timeline widgets require
// the window, so populating with sensible defaults means the rep gets a
// useful report on first export without re-picking dates.
const initialBounds = (() => {
if (initialRange) {
const { from, to } = rangeToBounds(initialRange);
return { from: toIsoLocal(from), to: toIsoLocal(to) };
}
const today = new Date();
const last30 = new Date(today);
last30.setDate(last30.getDate() - 30);
return { from: toIsoLocal(last30), to: toIsoLocal(today) };
})();
const [dateFrom, setDateFrom] = useState(initialBounds.from);
const [dateTo, setDateTo] = useState(initialBounds.to);
const [loading, setLoading] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
@@ -279,12 +298,12 @@ export function ExportDashboardPdfButton({ className }: { className?: string } =
<div className="flex items-center gap-1.5">
<span className="font-medium">{w.label}</span>
{w.isChart ? (
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wide text-primary">
<span className="shrink-0 whitespace-nowrap rounded-full bg-primary/10 px-1.5 py-px text-[8px] font-medium uppercase tracking-wide leading-none text-primary">
chart
</span>
) : null}
{w.requiresPeriod ? (
<span className="rounded-full bg-amber-100 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wide text-amber-800">
<span className="shrink-0 whitespace-nowrap rounded-full bg-amber-100 px-1.5 py-px text-[8px] font-medium uppercase tracking-wide leading-none text-amber-800">
needs date range
</span>
) : null}

View File

@@ -17,12 +17,16 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { useDebounce } from '@/hooks/use-debounce';
import { apiFetch } from '@/lib/api/client';
import { stageLabel } from '@/lib/constants';
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
import { cn } from '@/lib/utils';
interface InterestOption {
id: string;
clientName: string | null;
berthMooringNumber: string | null;
/** Every linked berth's mooring (sorted). Drives the multi-berth label
* rendered in the picker option row. */
berthMoorings?: string[];
pipelineStage: string;
}
@@ -71,7 +75,8 @@ export function InterestPicker({
const labelFor = (o: InterestOption) => {
const parts = [o.clientName ?? 'Unknown client'];
if (o.berthMooringNumber) parts.push(`Berth ${o.berthMooringNumber}`);
const berthLabel = deriveInterestBerthLabel(o.berthMoorings) ?? o.berthMooringNumber;
if (berthLabel) parts.push(`Berth ${berthLabel}`);
parts.push(stageLabel(o.pipelineStage));
return parts.join(' · ');
};

View File

@@ -34,7 +34,15 @@ export function SessionsList({ range }: Props) {
const pageSize = 15;
const query = useUmamiSessions(range, { page, pageSize });
const sessions = query.data?.data?.data ?? [];
const rawSessions = query.data?.data?.data ?? [];
// Umami's /sessions page isn't reliably ordered by activity timestamp -
// sort by most-recent activity (lastAt) descending so the row at the top
// is genuinely the latest session, matching the displayed timestamp.
const sessions = [...rawSessions].sort((a, b) => {
const la = a.lastAt ? new Date(a.lastAt).getTime() : 0;
const lb = b.lastAt ? new Date(b.lastAt).getTime() : 0;
return lb - la;
});
const total = query.data?.data?.count ?? 0;
const hasMore = page * pageSize < total;
@@ -83,7 +91,7 @@ export function SessionsList({ range }: Props) {
) : null}
</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground">
{s.browser} · {s.os} · {fmtTime(s.firstAt)}
{s.browser} · {s.os} · {fmtTime(s.lastAt ?? s.firstAt)}
</div>
</div>
</div>

View File

@@ -12,7 +12,6 @@
*/
import { useState, type ReactNode } from 'react';
import { toast } from 'sonner';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Globe, Info, Settings, ExternalLink } from 'lucide-react';
@@ -292,11 +291,7 @@ export function WebsiteAnalyticsShell() {
rows={allCountries.data?.data ?? null}
loading={allCountries.isLoading}
onCountryClick={(iso2) => {
const url = `/${portSlug}/clients?nationality=${encodeURIComponent(iso2)}`;
void navigator.clipboard?.writeText(window.location.origin + url);
toast.message(`${iso2} - link copied`, {
description: `Paste into the address bar to see all ${iso2} clients.`,
});
router.push(`/${portSlug}/clients?nationality=${encodeURIComponent(iso2)}` as never);
}}
/>
</>

View File

@@ -15,6 +15,7 @@ import { YachtOwnershipHistory } from '@/components/yachts/yacht-ownership-histo
import { feetToMeters, metersToFeet } from '@/components/yachts/yacht-dimensions';
import { apiFetch } from '@/lib/api/client';
import { stageLabel } from '@/lib/constants';
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
type YachtPatchField =
| 'name'
@@ -287,6 +288,7 @@ function YachtInterestsTab({ yachtId }: { yachtId: string }) {
pipelineStage: string;
clientName: string | null;
berthMooringNumber: string | null;
berthMoorings?: string[];
updatedAt: string;
}>;
}>({
@@ -320,11 +322,12 @@ function YachtInterestsTab({ yachtId }: { yachtId: string }) {
{stageLabel(i.pipelineStage)}
</span>
<span className="flex-1 truncate">{i.clientName ?? '-'}</span>
{i.berthMooringNumber && (
<span className="shrink-0 text-xs text-muted-foreground">
Berth {i.berthMooringNumber}
</span>
)}
{(() => {
const label = deriveInterestBerthLabel(i.berthMoorings) ?? i.berthMooringNumber;
return label ? (
<span className="shrink-0 text-xs text-muted-foreground">Berth {label}</span>
) : null;
})()}
</li>
))}
</ul>

View File

@@ -13,6 +13,13 @@ export interface AggregatedFile {
clientId: string | null;
companyId: string | null;
yachtId: string | null;
interestId: string | null;
/** Multi-berth label of the file's parent interest (e.g. "A1-A3, B5").
* Null when the file isn't tied to an interest or when the interest
* has no linked berths. Drives the "which deal" badge on each row in
* EntityFolderView so reps can disambiguate files on a multi-deal
* client. */
interestBerthLabel: string | null;
signedFromDocumentId: string | null;
}

View File

@@ -18,6 +18,12 @@ interface UsePaginatedQueryOptions {
endpoint: string;
initialPage?: number;
initialPageSize?: number;
/** Default sort applied when the URL has no `sort` param. Lets a list
* surface advertise its preferred ordering (e.g. interests → most-
* recently-updated first) without forcing the rep to click a header
* on each visit. The active sort still serializes to / from the URL,
* so deep-links keep working. */
initialSort?: { field: string; direction: 'asc' | 'desc' };
filterDefinitions?: FilterDefinition[];
}
@@ -26,6 +32,7 @@ export function usePaginatedQuery<T>({
endpoint,
initialPage = 1,
initialPageSize = 25,
initialSort,
filterDefinitions = [],
}: UsePaginatedQueryOptions) {
const searchParams = useSearchParams();
@@ -42,7 +49,7 @@ export function usePaginatedQuery<T>({
const [page, setPageState] = useState(pageFromUrl);
const [pageSize, setPageSizeState] = useState(pageSizeFromUrl);
const [sort, setSortState] = useState<{ field: string; direction: 'asc' | 'desc' } | undefined>(
sortFieldFromUrl ? { field: sortFieldFromUrl, direction: sortOrderFromUrl } : undefined,
sortFieldFromUrl ? { field: sortFieldFromUrl, direction: sortOrderFromUrl } : initialSort,
);
const [filters, setFiltersState] = useState<FilterValues>(() =>
deserializeFiltersFromParams(searchParams, filterDefinitions),

View File

@@ -25,7 +25,6 @@ export interface DashboardReportData {
berthStatus?: {
available: number;
underOffer: number;
maintenance: number;
sold: number;
total: number;
};
@@ -119,7 +118,7 @@ export interface DashboardReportData {
newInterestsInPeriod?: Array<{
clientName: string;
stage: string;
source: string | null;
berthLabel: string | null;
createdAt: string;
}>;
/** Berths transitioned to Sold status during the report window. */
@@ -269,11 +268,6 @@ export function DashboardReport({
String(data.berthStatus.sold),
pct(data.berthStatus.sold, data.berthStatus.total),
],
[
'Maintenance',
String(data.berthStatus.maintenance),
pct(data.berthStatus.maintenance, data.berthStatus.total),
],
]}
/>
</View>
@@ -330,7 +324,6 @@ export function DashboardReport({
{ label: 'Available', value: data.berthStatus.available, color: '#0d9488' },
{ label: 'Under offer', value: data.berthStatus.underOffer, color: '#f59e0b' },
{ label: 'Sold', value: data.berthStatus.sold, color: '#0284c7' },
{ label: 'Maintenance', value: data.berthStatus.maintenance, color: '#94a3b8' },
]}
/>
</View>
@@ -535,17 +528,14 @@ export function DashboardReport({
data.berthDemandRanking.length > 0 ? (
<View wrap={false}>
<Text style={styles.sectionTitle}>Berth demand ranking</Text>
<Text style={styles.sectionSubtitle}>
Top berths by active-interest count + heat tier (A = strongest signal).
</Text>
<Text style={styles.sectionSubtitle}>Top berths by active-interest count.</Text>
<SimpleTable
styles={styles}
headers={['Mooring', 'Active interests', 'Tier']}
widths={[40, 40, 20]}
headers={['Mooring', 'Active interests']}
widths={[50, 50]}
rows={data.berthDemandRanking.map((row) => [
row.mooringNumber,
String(row.interestCount),
row.tier,
])}
/>
</View>
@@ -557,13 +547,16 @@ export function DashboardReport({
<View wrap={false}>
<Text style={styles.sectionTitle}>Deal pulse distribution</Text>
<Text style={styles.sectionSubtitle}>
Counts of active interests in each pulse tier (hot / warm / cool / cold).
Counts of active interests in each pulse tier (Hot / Warm / Cool / Cold).
</Text>
<SimpleTable
styles={styles}
headers={['Tier', 'Count']}
widths={[70, 30]}
rows={data.dealPulseDistribution.map((row) => [row.tier, String(row.count)])}
rows={data.dealPulseDistribution.map((row) => [
row.tier ? row.tier.charAt(0).toUpperCase() + row.tier.slice(1) : row.tier,
String(row.count),
])}
/>
</View>
) : null}
@@ -632,17 +625,17 @@ export function DashboardReport({
<View wrap={false}>
<Text style={styles.sectionTitle}>New interests (in period)</Text>
<Text style={styles.sectionSubtitle}>
Interests opened during the report window, with the stage they currently sit at and
their lead source.
Interests opened during the report window, with the stage they currently sit at and the
berth(s) attached.
</Text>
<SimpleTable
styles={styles}
headers={['Client', 'Stage', 'Source', 'Opened']}
widths={[40, 22, 18, 20]}
headers={['Client', 'Stage', 'Berth', 'Opened']}
widths={[35, 22, 23, 20]}
rows={data.newInterestsInPeriod.map((r) => [
r.clientName,
stageLabel(r.stage),
r.source ?? '-',
r.berthLabel ?? '-',
new Date(r.createdAt).toLocaleDateString('en-GB'),
])}
/>

View File

@@ -1,4 +1,5 @@
import { Badge, DataTable, DocumentShell, KeyValueGrid, Section } from '@/lib/pdf/brand-kit';
import { stageLabelFor } from '@/lib/constants';
export interface ClientContact {
channel: string;
@@ -131,7 +132,7 @@ export function ClientSummaryPdf({
<Section title="Pipeline interests">
<DataTable<InterestRow>
columns={[
{ header: 'Stage', flex: 2, render: (i) => i.pipelineStage ?? 'open' },
{ header: 'Stage', flex: 2, render: (i) => stageLabelFor(i.pipelineStage) },
{ header: 'Berth', flex: 1, render: (i) => i.berthMooringNumber ?? '-' },
{ header: 'Category', flex: 2, render: (i) => i.leadCategory ?? '-' },
{ header: 'Created', flex: 1.5, render: (i) => fmtDate(i.createdAt) },

View File

@@ -6,6 +6,7 @@ import {
Section,
type BadgeTone,
} from '@/lib/pdf/brand-kit';
import { stageLabelFor } from '@/lib/constants';
export interface InterestSummaryPdfProps {
portName: string;
@@ -74,8 +75,8 @@ export function InterestSummaryPdf({
berth,
timeline,
}: InterestSummaryPdfProps) {
const stage = (interest.pipelineStage ?? 'open').toLowerCase();
const docMeta = `${client.fullName ?? 'Unknown client'} · stage: ${stage.replace('_', ' ')}`;
const stage = stageLabelFor(interest.pipelineStage);
const docMeta = `${client.fullName ?? 'Unknown client'} · stage: ${stage}`;
return (
<DocumentShell

View File

@@ -15,20 +15,20 @@ export interface OccupancyReportPdfProps {
data: OccupancyData;
}
// Mirrors BERTH_STATUSES in src/lib/constants.ts (canonical 3-status
// enum). 'reserved' and 'maintenance' were dropped from the schema; if
// pre-migration data still carries them, the label falls back to the
// raw status string via the `?? status` guard at the call site below.
const STATUS_LABELS: Record<string, string> = {
available: 'Available',
under_offer: 'Under offer',
sold: 'Sold',
reserved: 'Reserved',
maintenance: 'Maintenance',
};
const STATUS_COLORS: Record<string, string> = {
available: PDF_TOKENS.colors.success,
under_offer: PDF_TOKENS.colors.warning,
sold: PDF_TOKENS.colors.accentBlue,
reserved: PDF_TOKENS.colors.accentSlate,
maintenance: PDF_TOKENS.colors.danger,
};
export function OccupancyReportPdf({ portName, logoBuffer, data }: OccupancyReportPdfProps) {

View File

@@ -21,10 +21,14 @@ import { berths } from '@/lib/db/schema/berths';
import { documents } from '@/lib/db/schema/documents';
import { reminders } from '@/lib/db/schema/operations';
import { payments } from '@/lib/db/schema/pipeline';
import { ports } from '@/lib/db/schema/ports';
import { auditLogs } from '@/lib/db/schema/system';
import { userProfiles } from '@/lib/db/schema/users';
import { canonicalizeStage } from '@/lib/constants';
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
import { computeDealHealth } from './deal-health';
import { getAllBerthMooringsForInterests } from './interest-berths.service';
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
import {
getKpis,
getPipelineCounts,
@@ -92,6 +96,16 @@ export async function resolveDashboardReportData(
const { from: windowFrom, to: windowTo } = parseWindow(window);
const hasWindow = windowFrom !== null && windowTo !== null;
// Resolve the port's configured currency once - every money-bearing
// section reads it for the Intl.NumberFormat output. Falls back to USD
// for any port row without a default set (schema default is also USD).
const portRow = await db
.select({ defaultCurrency: ports.defaultCurrency })
.from(ports)
.where(eq(ports.id, portId))
.limit(1);
const portCurrency = portRow[0]?.defaultCurrency ?? 'USD';
// ─── KPI / summary ───────────────────────────────────────────────
if (want.has('kpi_overview')) {
data.kpis = await getKpis(portId);
@@ -232,7 +246,6 @@ export async function resolveDashboardReportData(
id: interests.id,
clientName: clients.fullName,
stage: interests.pipelineStage,
source: interests.source,
createdAt: interests.createdAt,
})
.from(interests)
@@ -247,10 +260,14 @@ export async function resolveDashboardReportData(
)
.orderBy(desc(interests.createdAt))
.limit(50);
// Resolve berth moorings per interest in one batched round-trip so
// the "Berth" column renders the same multi-berth label idiom as
// every other interest-row surface (`A1-A3, B5`).
const allMooringsMap = await getAllBerthMooringsForInterests(rows.map((r) => r.id));
data.newInterestsInPeriod = rows.map((r) => ({
clientName: r.clientName,
stage: r.stage,
source: r.source ?? null,
berthLabel: deriveInterestBerthLabel(allMooringsMap.get(r.id)),
createdAt: r.createdAt.toISOString(),
}));
}
@@ -396,7 +413,7 @@ export async function resolveDashboardReportData(
const buckets: Record<string, number> = { hot: 0, warm: 0, cold: 0 };
for (const r of rows) {
const health = computeDealHealth({
pipelineStage: r.pipelineStage ?? 'open',
pipelineStage: canonicalizeStage(r.pipelineStage),
outcome: r.outcome,
archivedAt: r.archivedAt ? r.archivedAt.toISOString() : null,
dateFirstContact: r.dateFirstContact ? r.dateFirstContact.toISOString() : null,
@@ -572,7 +589,7 @@ export async function resolveDashboardReportData(
data.revenueForecast = {
grossValue: forecast.totalGrossValue,
weightedValue: forecast.totalWeightedValue,
currency: 'EUR',
currency: portCurrency,
};
}
@@ -629,10 +646,12 @@ export async function resolveDashboardReportData(
gross: s.grossValue,
weighted: s.weightedValue,
deals: s.count,
// The forecast service doesn't return a port-currency hint;
// default to EUR which matches the seeded berths schema. A
// multi-currency-aware breakdown would need extra plumbing.
currency: 'EUR',
// The forecast service doesn't return a per-stage currency hint;
// every stage rolls up under the port's configured defaultCurrency
// (ports.default_currency). Multi-currency-per-stage rollups would
// need extra plumbing - until then a single port currency drives
// the whole breakdown to match the dashboard tile.
currency: portCurrency,
}));
}

View File

@@ -15,7 +15,7 @@ import { residentialClients, residentialInterests } from '@/lib/db/schema/reside
import { ports } from '@/lib/db/schema/ports';
import { systemSettings, auditLogs } from '@/lib/db/schema/system';
import { userProfiles } from '@/lib/db/schema/users';
import { PIPELINE_STAGES, STAGE_WEIGHTS } from '@/lib/constants';
import { PIPELINE_STAGES, STAGE_WEIGHTS, canonicalizeStage } from '@/lib/constants';
import { activeInterestsWhere } from '@/lib/services/active-interest';
import { convert as convertCurrency } from '@/lib/services/currency';
@@ -205,7 +205,7 @@ export async function getRevenueForecast(portId: string, range?: { from: Date; t
> = {};
for (const row of interestRows) {
const stage = row.pipelineStage ?? 'open';
const stage = canonicalizeStage(row.pipelineStage);
const price = row.berthPrice ? parseFloat(String(row.berthPrice)) : 0;
const weight = weights[stage] ?? 0;
const weighted = price * weight;
@@ -261,7 +261,6 @@ export async function getBerthStatusDistribution(portId: string) {
available: counts['available'] ?? 0,
underOffer: counts['under_offer'] ?? 0,
sold: counts['sold'] ?? 0,
maintenance: counts['maintenance'] ?? 0,
};
}

View File

@@ -18,6 +18,7 @@ import { env } from '@/lib/env';
import { buildStoragePath } from '@/lib/minio';
import { getStorageBackend } from '@/lib/storage';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { canonicalizeStage, type PipelineStage } from '@/lib/constants';
import { CodedError, ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
@@ -172,29 +173,34 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
});
// Two concerns to keep separate:
// 1. Document metadata - always write `dateEoiSigned` + `eoiStatus`
// from the upload. Even if the rep already advanced the stage
// manually, the paper signing event needs a recorded date so
// downstream surfaces (SkipAheadBanner, milestone strip, EOI
// merge fields) reflect reality. Honour an existing
// dateEoiSigned (don't overwrite if already set - covers the
// case where the rep is uploading evidence for an event whose
// date was already backfilled).
// 2. Stage advance - only when the deal hasn't reached eoi_signed
// yet. Bypasses canTransitionStage because the operator just
// brought concrete proof.
// 1. Document metadata - always write dateEoiSigned, eoiStatus, and
// eoiDocStatus from the upload. Even if the rep already advanced
// the stage manually, the paper signing event needs a recorded
// date + doc-status badge so downstream surfaces (SkipAheadBanner,
// milestone strip, EOI merge fields, signing-progress chip) reflect
// reality. Honour an existing dateEoiSigned - covers the case where
// the rep is uploading evidence for an event whose date was already
// backfilled.
// 2. Stage advance - in the 7-stage pipeline (PIPELINE_STAGES), every
// pre-EOI stage (enquiry / qualified / nurturing) should flip to
// 'eoi' on signing. The doc-status 'signed' carries the within-stage
// sub-state. Bypasses canTransitionStage because the operator just
// brought concrete proof. Idempotent at the 'eoi' / 'reservation' /
// 'deposit_paid' / 'contract' stages (stays put).
const stageBeforeAdvance = interest.pipelineStage as PipelineStage | null | undefined;
const canonicalStage = canonicalizeStage(stageBeforeAdvance);
const shouldAdvanceStage =
interest.pipelineStage === 'open' ||
interest.pipelineStage === 'details_sent' ||
interest.pipelineStage === 'in_communication' ||
interest.pipelineStage === 'eoi_sent';
canonicalStage === 'enquiry' ||
canonicalStage === 'qualified' ||
canonicalStage === 'nurturing';
await tx
.update(interests)
.set({
dateEoiSigned: interest.dateEoiSigned ?? input.signedAt ?? new Date(),
eoiStatus: 'signed',
...(shouldAdvanceStage ? { pipelineStage: 'eoi_signed' as const } : {}),
eoiDocStatus: 'signed',
...(shouldAdvanceStage ? { pipelineStage: 'eoi' as const } : {}),
updatedAt: new Date(),
})
.where(eq(interests.id, interestId));
@@ -203,7 +209,7 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
documentId: doc.id,
fileId: fileRecord.id,
stageChanged: shouldAdvanceStage,
newStage: shouldAdvanceStage ? ('eoi_signed' as const) : interest.pipelineStage,
newStage: shouldAdvanceStage ? ('eoi' as const) : canonicalStage,
};
});

View File

@@ -16,7 +16,9 @@ import {
PREVIEWABLE_MIMES,
bufferMatchesMime,
} from '@/lib/constants/file-validation';
import { getAllBerthMooringsForInterests } from '@/lib/services/interest-berths.service';
import { generateStorageKey, sanitizeFilename } from '@/lib/services/storage';
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
import type { UploadFileInput, UpdateFileInput, ListFilesInput } from '@/lib/validators/files';
import { documentFolders } from '@/lib/db/schema/documents';
import { clients } from '@/lib/db/schema/clients';
@@ -334,6 +336,13 @@ export async function getFileById(id: string, portId: string) {
*/
export type AggregatedFileRow = Omit<typeof files.$inferSelect, 'storagePath' | 'storageBucket'> & {
signedFromDocumentId: string | null;
/** When the file is tagged to an interest, the multi-berth label of
* that interest (e.g. "A1-A3, B5") - or null. Resolved in
* listFilesAggregated* via one batched mooring lookup per request.
* Lets EntityFolderView render a per-row "which deal" badge so reps
* can tell apart files that all look identical on a multi-deal
* client. */
interestBerthLabel: string | null;
};
export interface AggregatedFileGroup {
@@ -443,7 +452,13 @@ export async function listFilesAggregatedByEntity(
columns: { id: true, clientId: true },
});
if (!interest) return { groups: [] };
return listFilesAggregatedForInterest(portId, interest.id, interest.clientId ?? null);
const result = await listFilesAggregatedForInterest(
portId,
interest.id,
interest.clientId ?? null,
);
await attachInterestBerthLabels(result.groups);
return result;
}
const related = await collectRelatedEntities(portId, entityType, entityId);
@@ -499,6 +514,8 @@ export async function listFilesAggregatedByEntity(
});
}
await attachInterestBerthLabels(groups);
return { groups };
}
@@ -688,6 +705,9 @@ async function fetchGroupRows(
// Reverse-link: if any document row has this file as its signed_file_id,
// surface that document's id.
signedFromDocumentId: documents.id,
// interestBerthLabel is filled post-fetch via a single batched
// getAllBerthMooringsForInterests call at the entry-point level so
// we don't N+1 the moorings join inside each group.
})
.from(files)
.leftJoin(documents, eq(documents.signedFileId, files.id))
@@ -708,7 +728,37 @@ async function fetchGroupRows(
.from(files)
.where(and(eq(files.portId, portId), predicate));
return { rows, total: Number(countRow?.count ?? 0) };
// interestBerthLabel filled by the entry-point post-pass (see
// attachInterestBerthLabels below); default to null inside this row.
const rowsWithDefault: AggregatedFileRow[] = rows.map((r) => ({
...r,
interestBerthLabel: null,
}));
return { rows: rowsWithDefault, total: Number(countRow?.count ?? 0) };
}
/**
* Mutate the rows of every group in-place to fill `interestBerthLabel`
* from a single batched mooring lookup. Called by both interest- and
* entity-aggregation entry-points so EntityFolderView gets the "which
* deal" badge without an N+1 join.
*/
async function attachInterestBerthLabels(groups: AggregatedFileGroup[]): Promise<void> {
const interestIds = new Set<string>();
for (const g of groups) {
for (const f of g.files) {
if (f.interestId) interestIds.add(f.interestId);
}
}
if (interestIds.size === 0) return;
const mooringsMap = await getAllBerthMooringsForInterests(Array.from(interestIds));
for (const g of groups) {
for (const f of g.files) {
if (!f.interestId) continue;
f.interestBerthLabel = deriveInterestBerthLabel(mooringsMap.get(f.interestId));
}
}
}
function dedupeBy<T, K>(items: T[], key: (t: T) => K): T[] {

View File

@@ -101,6 +101,41 @@ export async function getPrimaryBerthsForInterests(
return out;
}
/**
* Map { interestId → mooring numbers[] } for a batch of interest ids.
* Used by list/kanban/header surfaces that need to render the full
* berth-range label (`A1-A3, B5`) rather than just the primary mooring.
* One round-trip; siblings the primary-only aggregator above.
*
* Mooring numbers come back sorted lexically; the consumer formatter
* (`deriveInterestBerthLabel` / `formatBerthRange`) re-sorts by
* prefix+number for range collapsing. Null mooring numbers (orphaned
* junction rows where the berth was hard-deleted) are filtered out.
*/
export async function getAllBerthMooringsForInterests(
interestIds: string[],
): Promise<Map<string, string[]>> {
if (interestIds.length === 0) return new Map();
const rows = await db
.select({
interestId: interestBerths.interestId,
mooringNumber: berths.mooringNumber,
})
.from(interestBerths)
.leftJoin(berths, eq(berths.id, interestBerths.berthId))
.where(inArray(interestBerths.interestId, interestIds))
.orderBy(berths.mooringNumber);
const out = new Map<string, string[]>();
for (const r of rows) {
if (!r.mooringNumber) continue;
const existing = out.get(r.interestId);
if (existing) existing.push(r.mooringNumber);
else out.set(r.interestId, [r.mooringNumber]);
}
return out;
}
/** Berth metadata surfaced alongside each junction row by {@link listBerthsForInterest}.
* All berth-derived fields are nullable so an orphaned junction row (berth
* hard-deleted out from under the link) still renders rather than vanishing. */
@@ -256,6 +291,15 @@ export async function upsertInterestBerthTx(
if (opts.isSpecificInterest !== undefined)
setForUpdate.isSpecificInterest = opts.isSpecificInterest;
if (opts.isInEoiBundle !== undefined) setForUpdate.isInEoiBundle = opts.isInEoiBundle;
// Invariant: primary berth is ALWAYS in the EOI bundle. The primary IS
// the canonical "berth for this deal" - excluding it from the signed
// envelope is semantically nonsense. If the caller is setting the row
// to primary OR opting to take out of the EOI bundle, force the bundle
// flag back on whenever the row is also (about to be) primary.
const willBePrimary = opts.isPrimary === true;
if (willBePrimary && opts.isInEoiBundle === false) {
setForUpdate.isInEoiBundle = true;
}
if (opts.addedBy !== undefined) setForUpdate.addedBy = opts.addedBy;
if (opts.notes !== undefined) setForUpdate.notes = opts.notes;
// Bypass fields move as a unit - either we set all three to record a bypass
@@ -283,6 +327,11 @@ export async function upsertInterestBerthTx(
// non-primary rows default to FALSE so the public map doesn't
// light up extra berths.
const isPrimary = opts.isPrimary ?? false;
// Force is_in_eoi_bundle=true when this row is the primary: the EOI
// bundle MUST cover the deal's canonical berth, regardless of what
// the caller passed. Non-primary rows still default to true (rep can
// opt out per-berth) but primary is non-negotiable.
const isInEoiBundle = isPrimary ? true : (opts.isInEoiBundle ?? true);
const [row] = await tx
.insert(interestBerths)
.values({
@@ -290,7 +339,7 @@ export async function upsertInterestBerthTx(
berthId,
isPrimary,
isSpecificInterest: opts.isSpecificInterest ?? isPrimary,
isInEoiBundle: opts.isInEoiBundle ?? true,
isInEoiBundle,
addedBy: opts.addedBy,
notes: opts.notes,
eoiBypassReason: setForUpdate.eoiBypassReason ?? null,

View File

@@ -22,6 +22,7 @@ import { evaluateRule } from '@/lib/services/berth-rules-engine';
import { notifyNextInLine } from '@/lib/services/next-in-line-notify.service';
import { logger } from '@/lib/logger';
import {
getAllBerthMooringsForInterests,
getPrimaryBerth,
getPrimaryBerthsForInterests,
listBerthsForInterest,
@@ -163,6 +164,11 @@ export interface BoardInterestRow {
id: string;
clientName: string | null;
berthMooringNumber: string | null;
/** Every linked berth's mooring on this interest (sorted). Consumers
* pass this through `deriveInterestBerthLabel` for the header / card
* display so multi-berth interests render as `A1-A3, B5` rather than
* just the primary mooring. */
berthMoorings: string[];
leadCategory: string | null;
pipelineStage: string;
updatedAt: Date;
@@ -262,13 +268,20 @@ export async function listInterestsForBoard(
// Primary-berth resolution stays in the junction-aware service so the
// board sees the same "the berth for this deal" as every other surface.
const primaryBerthMap = await getPrimaryBerthsForInterests(data.map((r) => r.id));
// All-berth aggregator runs in parallel; both come from the same
// interest_berths table so the round-trips are independent.
const interestIds = data.map((r) => r.id);
const [primaryBerthMap, allBerthMooringsMap] = await Promise.all([
getPrimaryBerthsForInterests(interestIds),
getAllBerthMooringsForInterests(interestIds),
]);
return {
data: data.map((r) => ({
id: r.id,
clientName: r.clientName ?? null,
berthMooringNumber: primaryBerthMap.get(r.id)?.mooringNumber ?? null,
berthMoorings: allBerthMooringsMap.get(r.id) ?? [],
leadCategory: r.leadCategory ?? null,
pipelineStage: r.pipelineStage,
updatedAt: r.updatedAt,
@@ -405,7 +418,12 @@ export async function listInterests(portId: string, query: ListInterestsInput) {
// Primary-berth lookup via the interest_berths junction. Single round-trip
// by interestId list - see plan §3.4: every "the berth for this interest"
// surface resolves through getPrimaryBerth(...) rather than a column read.
const primaryBerthMap = await getPrimaryBerthsForInterests(interestIds);
// Sibling all-mooring aggregator runs in parallel so the list endpoint
// can surface multi-berth labels (A1-A3, B5) without a second waterfall.
const [primaryBerthMap, allBerthMooringsMap] = await Promise.all([
getPrimaryBerthsForInterests(interestIds),
getAllBerthMooringsForInterests(interestIds),
]);
if (yachtIds.length > 0) {
const yachtRows = await db
@@ -453,6 +471,7 @@ export async function listInterests(portId: string, query: ListInterestsInput) {
clientName: clientsMap[i.clientId as string] ?? null,
berthId: primary?.berthId ?? null,
berthMooringNumber: primary?.mooringNumber ?? null,
berthMoorings: allBerthMooringsMap.get(i.id as string) ?? [],
yachtName: i.yachtId ? (yachtsMap[i.yachtId as string] ?? null) : null,
tags: tagsByInterestId[i.id as string] ?? [],
notesCount: notesCountByInterestId[i.id as string] ?? 0,
@@ -515,9 +534,15 @@ export async function getInterestById(id: string, portId: string) {
.limit(1);
// Primary berth comes from the interest_berths junction (plan §3.4).
const primaryBerth = await getPrimaryBerth(interest.id);
// All linked moorings come from the same junction in one go - powers
// the multi-berth label rendered on every "interest header" surface.
const [primaryBerth, allMooringsMap] = await Promise.all([
getPrimaryBerth(interest.id),
getAllBerthMooringsForInterests([interest.id]),
]);
const berthId = primaryBerth?.berthId ?? null;
const berthMooringNumber = primaryBerth?.mooringNumber ?? null;
const berthMoorings = allMooringsMap.get(interest.id) ?? [];
// Total linked-berth count powers the "Berth Interest" milestone on
// the OverviewTab - first thing the rep needs to capture, especially
@@ -666,6 +691,7 @@ export async function getInterestById(id: string, portId: string) {
clientHasAddress: !!addressRow,
berthId,
berthMooringNumber,
berthMoorings,
linkedBerthCount,
tags: tagRows,
notesCount,
@@ -712,14 +738,33 @@ export async function createInterest(portId: string, data: CreateInterestInput,
const resolvedReminderDays =
interestData.reminderDays ?? (resolvedReminderEnabled ? reminderConfig.defaultDays : null);
// Auto-assign to the port's default owner when the caller omits assignedTo.
// Setting is stored as `{ userId: "..." }` so other surfaces can extend it
// with round-robin / quota rules later without breaking this code path.
// Resolve the deal owner. Three-tier chain:
// 1. Explicit `data.assignedTo` from the caller (rep picked an
// assignee in the create form).
// 2. Port's `default_new_interest_owner` setting (used for round-
// robin / "front desk owns all new leads" rules).
// 3. Auto-assign to the creating user when they're a regular role
// (sales rep, sales manager, etc.). Skipped for super-admins who
// often create on behalf of other reps - they'd otherwise hijack
// every new lead. Falls back to null (Unassigned) when none of
// the above resolve.
let resolvedAssignedTo = interestData.assignedTo ?? null;
if (resolvedAssignedTo === null && !('assignedTo' in interestData)) {
const defaultOwner = await getSetting('default_new_interest_owner', portId);
const v = defaultOwner?.value as { userId?: string } | null | undefined;
if (v?.userId) resolvedAssignedTo = v.userId;
if (v?.userId) {
resolvedAssignedTo = v.userId;
} else {
// Tier 3: auto-assign to creator unless they're a super-admin.
const [profile] = await db
.select({ isSuperAdmin: userProfiles.isSuperAdmin })
.from(userProfiles)
.where(eq(userProfiles.userId, meta.userId))
.limit(1);
if (profile && !profile.isSuperAdmin) {
resolvedAssignedTo = meta.userId;
}
}
}
const result = await withTransaction(async (tx) => {
@@ -1133,10 +1178,11 @@ export async function advanceStageIfBehind(
return false;
}
// yachtId gate: changeInterestStage requires a yacht before leaving `open`.
// EOI events imply a yacht is in the picture, but if the data is missing we
// bail rather than throw - the EOI itself shouldn't fail because of this.
if (existing.pipelineStage === 'open' && !existing.yachtId) {
// yachtId gate: changeInterestStage requires a yacht before leaving the
// initial enquiry stage. EOI events imply a yacht is in the picture, but
// if the data is missing we bail rather than throw - the EOI itself
// shouldn't fail because of this.
if (existing.pipelineStage === 'enquiry' && !existing.yachtId) {
return false;
}

View File

@@ -89,10 +89,14 @@ export const SETTING_KEYS = {
// Ignored entirely on v1 instances.
documensoSigningOrder: 'documenso_signing_order',
// v2-only override of the post-signing redirect URL set on documentMeta.
// Falls back to the embedded signing host (or APP_URL) when unset. Use
// this to land signed clients on /portal/eoi-complete (or wherever
// makes sense for the workflow).
// Resolver chain: explicit override -> port's public_site_url -> null
// (let Documenso use its own default). Lets signers land on the port's
// marketing site by default without each admin having to configure two
// settings.
documensoRedirectUrl: 'documenso_redirect_url',
// Per-port public marketing-site URL. Used by signing-redirect
// fallback, email CTAs, and some templates.
publicSiteUrl: 'public_site_url',
// Branding
brandingLogoUrl: 'branding_logo_url',
@@ -396,6 +400,7 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
approverUserId,
signingOrder,
redirectUrlOverride,
publicSiteUrl,
] = await Promise.all([
readSetting<string>(SETTING_KEYS.documensoApiUrlOverride, portId),
readSetting<string>(SETTING_KEYS.documensoApiKeyOverride, portId),
@@ -419,6 +424,7 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
readSetting<string>(SETTING_KEYS.documensoApproverUserId, portId),
readSetting<'PARALLEL' | 'SEQUENTIAL'>(SETTING_KEYS.documensoSigningOrder, portId),
readSetting<string>(SETTING_KEYS.documensoRedirectUrl, portId),
readSetting<string>(SETTING_KEYS.publicSiteUrl, portId),
]);
// Determine the resolution source for the two credentials. Used by
@@ -457,7 +463,12 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
developerUserId: developerUserId ?? null,
approverUserId: approverUserId ?? null,
signingOrder: signingOrder ?? null,
redirectUrl: redirectUrlOverride ?? null,
// Resolution chain: explicit Documenso override → port's marketing
// site URL → null (Documenso falls back to its own default, which is
// typically the configured APP_URL = the CRM login - not what we want
// for signers). The marketing-site fallback means operators who set
// public_site_url (most do) automatically get sensible signer landing.
redirectUrl: redirectUrlOverride ?? publicSiteUrl ?? null,
};
}

View File

@@ -230,7 +230,7 @@ export async function createPublicInterest(
clientId,
yachtId,
source: 'website',
pipelineStage: 'open',
pipelineStage: 'enquiry',
})
.returning();

View File

@@ -0,0 +1,40 @@
/**
* Shared helper that turns an interest's full berth list into the display
* label surfaced everywhere the record is named (header, kanban card, list
* rows, search results, picker chips). Mirrors the EOI / Documents-Hub
* idiom: consecutive runs collapse to a hyphenated range, separate runs
* comma-join, and an over-cap fallback degrades to "<first> + N more".
*
* deriveInterestBerthLabel([]) -> null
* deriveInterestBerthLabel(['A1']) -> 'A1'
* deriveInterestBerthLabel(['A1','A2','A3']) -> 'A1-A3'
* deriveInterestBerthLabel(['A1','A3']) -> 'A1, A3'
* deriveInterestBerthLabel(['A1','A3','B5','B6']) -> 'A1, A3, B5-B6'
* deriveInterestBerthLabel(['A1','A3','A5','A7','A9','A11'])
* -> 'A1 + 5 more'
*
* Truncation triggers when, post-range-collapse, the segment count exceeds
* MAX_SEGMENTS - keeps the button / header from overflowing.
*/
import { formatBerthRange } from '@/lib/templates/berth-range';
const MAX_SEGMENTS = 5;
export function deriveInterestBerthLabel(
mooringNumbers: readonly (string | null | undefined)[] | null | undefined,
): string | null {
if (!mooringNumbers) return null;
const clean = mooringNumbers.filter((m): m is string => !!m && m.trim().length > 0);
if (clean.length === 0) return null;
const compact = formatBerthRange(clean);
if (!compact) return null;
const segments = compact.split(', ');
if (segments.length <= MAX_SEGMENTS) return compact;
// Over-cap: degrade to "first + N more" against the total berth count.
const first = segments[0]!;
const remaining = clean.length - 1;
return `${first} + ${remaining} more`;
}