-
- Mark in EOI bundle
-
+
onUpdate(row.berthId, { isInEoiBundle: checked })}
/>
+
+ Mark in EOI bundle
+
{row.isInEoiBundle
diff --git a/src/components/interests/pipeline-board.tsx b/src/components/interests/pipeline-board.tsx
index a03dc22..0bb084b 100644
--- a/src/components/interests/pipeline-board.tsx
+++ b/src/components/interests/pipeline-board.tsx
@@ -7,8 +7,8 @@ import { DndContext, closestCenter, type DragEndEvent } from '@dnd-kit/core';
import { PipelineColumn } from '@/components/interests/pipeline-column';
import { apiFetch } from '@/lib/api/client';
-import { usePipelineStore } from '@/stores/pipeline-store';
import { PIPELINE_STAGES, STAGE_LABELS } from '@/lib/constants';
+import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
interface InterestRow {
id: string;
@@ -19,28 +19,77 @@ interface InterestRow {
updatedAt: string;
}
-export function PipelineBoard() {
+interface BoardResponse {
+ data: InterestRow[];
+ truncated: boolean;
+ total: number;
+}
+
+interface PipelineBoardProps {
+ /** Filter values from the parent's FilterBar — passed through to the
+ * /api/v1/interests/board endpoint. Subset of listInterests filters
+ * (no pipelineStage, no includeArchived). Optional; board works
+ * fine without filters. */
+ filters?: Record;
+}
+
+export function PipelineBoard({ filters }: PipelineBoardProps = {}) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient();
- const { boardFilters } = usePipelineStore();
- const { data: allData, isLoading } = useQuery<{ data: InterestRow[] }>({
- queryKey: ['interests-board', portSlug],
- queryFn: () => apiFetch('/api/v1/interests?limit=500'),
+ // Build the board endpoint URL with the supported filter subset.
+ // pipelineStage + includeArchived are intentionally not threaded
+ // through — see boardFiltersSchema on the backend. Stable JSON-string
+ // form is reused as the queryKey so React Query caches per filter combo.
+ const queryString = useMemo(() => {
+ if (!filters) return '';
+ const params = new URLSearchParams();
+ const pick = (k: string) => {
+ const v = filters[k];
+ if (v === null || v === undefined || v === '' || v === false) return;
+ if (Array.isArray(v)) {
+ if (v.length === 0) return;
+ params.set(k, v.join(','));
+ } else {
+ params.set(k, String(v));
+ }
+ };
+ pick('search');
+ pick('leadCategory');
+ pick('source');
+ pick('eoiStatus');
+ pick('tagIds');
+ const s = params.toString();
+ return s ? `?${s}` : '';
+ }, [filters]);
+
+ const boardQueryKey = ['interests-board', portSlug, queryString] as const;
+ // Dedicated board endpoint — bypasses the paginated list's max(100)
+ // cap, projects only the 5 fields PipelineCard renders, and hard-caps
+ // at 5000 server-side. If `truncated: true`, surface a banner so the
+ // rep knows the board isn't showing every active deal.
+ const {
+ data: allData,
+ isLoading,
+ error,
+ } = useQuery({
+ queryKey: boardQueryKey,
+ queryFn: () => apiFetch(`/api/v1/interests/board${queryString}`),
});
- const interests = useMemo(() => {
- if (!allData?.data) return [];
- return allData.data.filter((i) => {
- if (boardFilters.leadCategory && i.leadCategory !== boardFilters.leadCategory) return false;
- if (boardFilters.search) {
- const q = boardFilters.search.toLowerCase();
- if (!i.clientName?.toLowerCase().includes(q)) return false;
- }
- return true;
- });
- }, [allData, boardFilters]);
+ // Invalidate the entire ['interests-board', portSlug, *] family so
+ // realtime events refresh whatever filter combo is currently active.
+ // Using the prefix keeps stale per-filter caches from lingering after
+ // the underlying data changes elsewhere in the app.
+ useRealtimeInvalidation({
+ 'interest:created': [['interests-board', portSlug]],
+ 'interest:updated': [['interests-board', portSlug]],
+ 'interest:stageChanged': [['interests-board', portSlug]],
+ 'interest:archived': [['interests-board', portSlug]],
+ });
+
+ const interests = useMemo(() => allData?.data ?? [], [allData]);
const grouped = useMemo(() => {
const map: Record = {};
@@ -98,8 +147,31 @@ export function PipelineBoard() {
return
;
}
+ // Surface fetch failures instead of silently rendering nine "Empty"
+ // columns, which is indistinguishable from "no interests yet" and was
+ // exactly the bug that hid this view's silent failure for so long.
+ if (error) {
+ return (
+
+ Couldn't load the pipeline board.{' '}
+ queryClient.invalidateQueries({ queryKey: boardQueryKey })}
+ >
+ Retry
+
+
+ );
+ }
+
return (
+ {allData?.truncated ? (
+
+ Showing the {allData.total.toLocaleString()} most-recently-updated interests. Older active
+ deals aren't on the board — archive completed work to keep the kanban readable.
+
+ ) : null}
{PIPELINE_STAGES.map((stage) => (
= {
@@ -51,7 +52,8 @@ function formatSegment(segment: string): string {
export function Breadcrumbs() {
const pathname = usePathname();
- const { currentPort, currentPortSlug } = usePortContext();
+ const currentPortSlug = useUIStore((s) => s.currentPortSlug);
+ const hint = useBreadcrumbStore((s) => s.hints[pathname]);
// Split pathname and filter empty segments
const rawSegments = pathname.split('/').filter(Boolean);
@@ -62,22 +64,10 @@ export function Breadcrumbs() {
currentPortSlug ? rawSegments.filter((seg) => seg !== currentPortSlug) : rawSegments
).filter((seg) => !isIdSegment(seg));
- if (segments.length === 0) {
- return (
-
-
-
-
- {currentPort?.name ?? 'Port Nimara CRM'}
-
-
-
-
- );
- }
+ if (segments.length === 0) return null;
- // Build href for each segment
- const crumbs = segments.map((segment, index) => {
+ // Build href for each segment from the URL.
+ const urlCrumbs = segments.map((segment, index) => {
const segmentsUpToHere = rawSegments.slice(0, rawSegments.indexOf(segment, index) + 1);
const href = '/' + segmentsUpToHere.join('/');
const label = formatSegment(segment);
@@ -86,35 +76,37 @@ export function Breadcrumbs() {
return { label, href, isLast };
});
+ // When a detail page registered a hint, splice in the parent crumbs
+ // (e.g. the parent client name) and replace the trailing label with
+ // the entity's actual name (e.g. "B17"). This turns the URL-only
+ // "Clients › Interests" into "Clients › Mary Smith › Interest › B17"
+ // when the rep clicked from a client page. URL-only renders untouched
+ // when no hint is registered.
+ const crumbs = (() => {
+ if (!hint) return urlCrumbs;
+ const head = urlCrumbs.slice(0, -1).map((c) => ({ ...c, isLast: false }));
+ const parents = hint.parents.map((p) => ({
+ label: p.label,
+ href: p.href ?? pathname,
+ isLast: false,
+ }));
+ const lastUrlCrumb = urlCrumbs[urlCrumbs.length - 1];
+ const tail = {
+ label: hint.current,
+ href: lastUrlCrumb?.href ?? pathname,
+ isLast: true,
+ };
+ return [...head, ...parents, tail];
+ })();
+
return (
-
- {currentPort && (
- <>
-
-
-
- {currentPort.name}
-
-
-
- {crumbs.length > 0 && (
-
-
-
- )}
- >
- )}
-
+
{crumbs.map((crumb, _index) => (
{crumb.isLast ? (
-
+
{crumb.label}
) : (
@@ -122,7 +114,7 @@ export function Breadcrumbs() {
{crumb.label}
@@ -130,7 +122,7 @@ export function Breadcrumbs() {
)}
{!crumb.isLast && (
-
+
)}
diff --git a/src/components/layout/mobile/more-sheet.tsx b/src/components/layout/mobile/more-sheet.tsx
index 2657893..aa7b540 100644
--- a/src/components/layout/mobile/more-sheet.tsx
+++ b/src/components/layout/mobile/more-sheet.tsx
@@ -9,7 +9,6 @@ import {
BellRing,
Bookmark,
Building2,
- FileText,
Globe,
Home,
Mail,
@@ -43,7 +42,6 @@ const MORE_ITEMS: MoreItem[] = [
{ label: 'Interests', icon: Bookmark, segment: 'interests' },
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
{ label: 'Companies', icon: Building2, segment: 'companies' },
- { label: 'Invoices', icon: FileText, segment: 'invoices' },
{ label: 'Expenses', icon: Receipt, segment: 'expenses' },
{ label: 'Inbox', icon: Mail, segment: 'email' },
{ label: 'Reservations', icon: Anchor, segment: 'berth-reservations' },
diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx
index e314f77..912d8b6 100644
--- a/src/components/layout/sidebar.tsx
+++ b/src/components/layout/sidebar.tsx
@@ -123,7 +123,10 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
marinaRequired: true,
items: [
{ href: `${base}/expenses`, label: 'Expenses', icon: Receipt },
- { href: `${base}/invoices`, label: 'Invoices', icon: FileText },
+ // Invoices nav entry removed — the expense-to-PDF flow is the
+ // only invoicing surface now (employee expense reports). The
+ // standalone /invoices route still exists for any back-compat
+ // links but is no longer surfaced in nav.
// Dedicated explainer page covers "Add to Home Screen" install
// + walkthrough; the mobile-only scanner UI itself lives at /scan
// and is reached via the install or the explainer page button.
@@ -224,6 +227,7 @@ function SidebarContent({
collapsed,
portSlug,
portRoles,
+ isSuperAdmin,
hasAdminAccess,
hasMarinaAccess,
hasResidentialAccess,
@@ -234,6 +238,7 @@ function SidebarContent({
collapsed: boolean;
portSlug: string | undefined;
portRoles: SidebarProps['portRoles'];
+ isSuperAdmin: boolean;
hasAdminAccess: boolean;
hasMarinaAccess: boolean;
hasResidentialAccess: boolean;
@@ -246,6 +251,14 @@ function SidebarContent({
const [adminExpanded, setAdminExpanded] = useState(true);
const sections = buildNavSections(portSlug);
+ // Small label under the user identity when the user has access to more
+ // than one port — disambiguates which port is currently active without
+ // pulling the port name back into the breadcrumbs.
+ const showPortLabel = !!ports && ports.length > 1;
+ const currentPortName = showPortLabel
+ ? (ports.find((p) => p.slug === portSlug)?.name ?? null)
+ : null;
+
// Pre-compute every nav href the sidebar offers across all sections so
// the active-state check can do longest-prefix-match. Without this,
// /invoices/upload-receipts would highlight both "Invoices" and "How to
@@ -403,8 +416,11 @@ function SidebarContent({
variant="outline"
className="text-[10px] px-1.5 py-0 text-[#83aab1] border-[#474e66] mt-0.5"
>
- {humanizeRole(portRoles[0]?.role?.name)}
+ {isSuperAdmin ? 'Super Admin' : humanizeRole(portRoles[0]?.role?.name)}
+ {currentPortName && (
+ {currentPortName}
+ )}
}
@@ -417,8 +433,10 @@ function SidebarContent({
}
export function Sidebar({ portRoles, isSuperAdmin = false, user, ports }: SidebarProps) {
- const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
- const toggleSidebar = useUIStore((s) => s.toggleSidebar);
+ // Sidebar collapse removed — design preference is the always-expanded
+ // form. Forcibly false; the store flag stays for backwards-compat with
+ // any code still reading it.
+ const sidebarCollapsed = false;
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
// Super admins see every section regardless of role rows.
@@ -448,12 +466,12 @@ export function Sidebar({ portRoles, isSuperAdmin = false, user, ports }: Sideba
collapsed={sidebarCollapsed}
portSlug={currentPortSlug ?? undefined}
portRoles={portRoles}
+ isSuperAdmin={isSuperAdmin}
hasAdminAccess={hasAdminAccess}
hasMarinaAccess={hasMarinaAccess}
hasResidentialAccess={hasResidentialAccess}
user={user}
ports={ports}
- onToggleCollapse={toggleSidebar}
/>
);
diff --git a/src/components/layout/topbar.tsx b/src/components/layout/topbar.tsx
index 5a84449..91d03f1 100644
--- a/src/components/layout/topbar.tsx
+++ b/src/components/layout/topbar.tsx
@@ -1,9 +1,11 @@
'use client';
-import { Plus } from 'lucide-react';
-import { useRouter } from 'next/navigation';
+import { ChevronLeft, Plus } from 'lucide-react';
+import { useRouter, usePathname } from 'next/navigation';
import { useUIStore } from '@/stores/ui-store';
+import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
+import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Separator } from '@/components/ui/separator';
@@ -28,16 +30,46 @@ interface TopbarProps {
export function Topbar({ ports, user }: TopbarProps) {
const router = useRouter();
+ const pathname = usePathname();
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
const base = currentPortSlug ? `/${currentPortSlug}` : '';
+ // Reuse the existing per-page chrome state (originally built for the
+ // mobile topbar) so any detail page that already declares
+ // `showBackButton: true` automatically gets the back affordance on
+ // desktop too. Saves duplicating the wiring across N detail headers.
+ const { showBackButton: mobileShowBack } = useMobileChrome();
+ // Auto-show on entity-detail pages: `/[portSlug]/[section]/[id]` and
+ // deeper. Top-level lists like `/[portSlug]/clients` stay clean.
+ // The mobile-chrome flag still wins when a page explicitly opts in.
+ // Pages that already render their own "back to X" link inline
+ // (residential interest detail, expense scan flow, etc.) opt OUT
+ // by setting the chrome flag to false on mount — the flag override
+ // path here lets them suppress this auto-show.
+ const segments = pathname.split('/').filter(Boolean);
+ const isDeepPage = segments.length > 2;
+ const showBackButton = mobileShowBack || isDeepPage;
return (
// Three-column grid: breadcrumbs left, search center, actions right.
// The brand logo lives in the sidebar header (per design feedback) so the
// topbar center is dedicated to the global search bar.
- {/* LEFT: breadcrumbs / page title */}
-
+ {/* LEFT: optional back button + breadcrumbs / page title */}
+
+ {showBackButton && (
+ router.back()}
+ aria-label="Go back"
+ title="Go back"
+ className={cn(
+ 'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md',
+ 'text-muted-foreground hover:text-foreground hover:bg-accent transition-colors',
+ )}
+ >
+
+
+ )}
@@ -100,8 +132,12 @@ export function Topbar({ ports, user }: TopbarProps) {
user={user}
ports={ports}
trigger={
-
-
+ // Button shrunk to match the Avatar's visible footprint so
+ // the hover halo lands as a tight circle behind the avatar
+ // (was h-11 w-11 default — the rounded-full halo extended
+ // well past the visible avatar and read as a square glow).
+
+
{(user?.name ?? 'U').slice(0, 1).toUpperCase()}
diff --git a/src/components/shared/column-picker.tsx b/src/components/shared/column-picker.tsx
new file mode 100644
index 0000000..ae323ed
--- /dev/null
+++ b/src/components/shared/column-picker.tsx
@@ -0,0 +1,133 @@
+'use client';
+
+import { Columns3, Check, Bookmark } from 'lucide-react';
+
+import { Button } from '@/components/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+
+export interface ColumnPickerOption {
+ /** Stable ID matching the column's `id` field in the table. */
+ id: string;
+ /** Human-readable label shown in the dropdown menu. */
+ label: string;
+ /**
+ * When true, the column can't be toggled off (e.g. Name + actions).
+ * It still appears in the menu but with a disabled checkmark.
+ */
+ alwaysVisible?: boolean;
+}
+
+/**
+ * Dropdown menu for toggling table column visibility. Lives next to the
+ * filter bar — single source of truth for which columns the current
+ * user wants to see in this table. Persistence is handled by the
+ * parent (typically via `useTablePreferences`).
+ */
+export function ColumnPicker({
+ columns,
+ hidden,
+ onChange,
+ onSaveView,
+}: {
+ columns: ColumnPickerOption[];
+ hidden: string[];
+ onChange: (hidden: string[]) => void;
+ /**
+ * Optional callback. When provided, a "Save current view" item is
+ * appended to the menu — folds the save-view affordance into the
+ * column picker instead of a separate top-level button.
+ */
+ onSaveView?: () => void;
+}) {
+ const hiddenSet = new Set(hidden);
+
+ function toggle(id: string) {
+ const next = new Set(hiddenSet);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ onChange(Array.from(next));
+ }
+
+ function showAll() {
+ onChange([]);
+ }
+
+ // The "All visible" affordance is only useful when something is
+ // hidden — a no-op button is noise.
+ const canShowAll = hidden.some((id) =>
+ columns.some((col) => col.id === id && !col.alwaysVisible),
+ );
+
+ return (
+
+
+
+
+ Columns
+
+
+
+
+ Show / hide columns
+
+
+ {columns.map((col) => {
+ const isVisible = !hiddenSet.has(col.id);
+ return (
+ {
+ // Keep the menu open while toggling so the user can
+ // flip multiple columns in one pass.
+ e.preventDefault();
+ if (col.alwaysVisible) return;
+ toggle(col.id);
+ }}
+ className="flex items-center gap-2"
+ >
+
+ {isVisible && }
+
+ {col.label}
+ {col.alwaysVisible && (
+
+ always
+
+ )}
+
+ );
+ })}
+ {canShowAll && (
+ <>
+
+
+ Show all columns
+
+ >
+ )}
+ {onSaveView && (
+ <>
+
+
+
+ Save current view
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/components/shared/data-table.tsx b/src/components/shared/data-table.tsx
index 687aa61..6eb2aa8 100644
--- a/src/components/shared/data-table.tsx
+++ b/src/components/shared/data-table.tsx
@@ -8,6 +8,7 @@ import {
type ColumnDef,
type Row,
type RowSelectionState,
+ type VisibilityState,
} from '@tanstack/react-table';
import { ArrowDown, ArrowUp, ArrowUpDown, Loader2 } from 'lucide-react';
@@ -51,6 +52,12 @@ interface DataTableProps {
isLoading?: boolean;
getRowId?: (row: TData) => string;
onRowClick?: (row: TData) => void;
+ /**
+ * Optional row class hook — return a string of Tailwind utilities
+ * applied to the ``. Use for visual grouping (e.g. tinting
+ * berths by mooring letter so the kanban-like grid reads at a glance).
+ */
+ getRowClassName?: (row: TData) => string | undefined;
/**
* Mobile card renderer. When provided, the table is hidden below `lg:`
* and replaced with a vertical list of cards built from this callback.
@@ -58,6 +65,13 @@ interface DataTableProps {
* sort, and selection stay in sync across the breakpoint.
*/
cardRender?: (row: Row) => React.ReactNode;
+ /**
+ * Per-column visibility map. Keys are column IDs, values mean
+ * "currently visible". Columns absent from the map are visible by
+ * default — newly-added columns surface for existing users without
+ * needing a preferences migration.
+ */
+ columnVisibility?: VisibilityState;
}
export function DataTable({
@@ -74,7 +88,9 @@ export function DataTable({
isLoading,
getRowId,
onRowClick,
+ getRowClassName,
cardRender,
+ columnVisibility,
}: DataTableProps) {
const [internalSelection, setInternalSelection] = useState({});
const rowSelectionState = externalSelection ?? internalSelection;
@@ -122,6 +138,7 @@ export function DataTable({
pagination: pagination
? { pageIndex: pagination.page - 1, pageSize: pagination.pageSize }
: undefined,
+ columnVisibility,
},
onRowSelectionChange: (updater) => {
const newSelection = typeof updater === 'function' ? updater(rowSelectionState) : updater;
@@ -215,7 +232,7 @@ export function DataTable({
onRowClick?.(row.original)}
>
{row.getVisibleCells().map((cell) => (
@@ -247,34 +264,61 @@ export function DataTable({
)}
- {/* Pagination */}
- {pagination && pagination.totalPages > 1 && (
-
+ {/* Pagination — render whenever pagination is defined so the
+ page-size selector is reachable even on single-page tables.
+ Prev/Next group only renders when there's actually more than
+ one page. */}
+ {pagination && (
+
{selectedIds.length > 0
? `${selectedIds.length} of ${pagination.total} row(s) selected`
: `${pagination.total} row(s) total`}
-
-
onPaginationChange?.(pagination.page - 1, pagination.pageSize)}
- >
- Previous
-
-
- Page {pagination.page} of {pagination.totalPages}
-
-
= pagination.totalPages}
- onClick={() => onPaginationChange?.(pagination.page + 1, pagination.pageSize)}
- >
- Next
-
+
+ {/* Page-size selector — "All" maps to the validator's
+ max(1000) cap. If a port has more than 1000 active rows
+ the user paginates; we don't quietly drop rows. */}
+
+ Show
+ {
+ const next = e.target.value === 'all' ? 1000 : Number(e.target.value);
+ onPaginationChange?.(1, next);
+ }}
+ className="h-8 rounded-md border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
+ >
+ 25
+ 50
+ 100
+ 250
+ All
+
+
+ {pagination.totalPages > 1 && (
+
+ onPaginationChange?.(pagination.page - 1, pagination.pageSize)}
+ >
+ Previous
+
+
+ Page {pagination.page} of {pagination.totalPages}
+
+ = pagination.totalPages}
+ onClick={() => onPaginationChange?.(pagination.page + 1, pagination.pageSize)}
+ >
+ Next
+
+
+ )}
)}
diff --git a/src/components/shared/filter-bar.tsx b/src/components/shared/filter-bar.tsx
index 9ca4bce..6008b5e 100644
--- a/src/components/shared/filter-bar.tsx
+++ b/src/components/shared/filter-bar.tsx
@@ -184,16 +184,22 @@ function FilterField({
);
- case 'select':
+ case 'select': {
+ // Radix Select forbids empty-string item values (throws at render
+ // time, crashes the page). Use a sentinel and translate.
+ const ANY = '__any__';
return (
{definition.label}
- onChange(v || undefined)}>
+ onChange(v === ANY ? undefined : v)}
+ >
- Any
+ Any
{definition.options?.map((opt) => (
{opt.label}
@@ -203,6 +209,7 @@ function FilterField({
);
+ }
case 'multi-select':
return (
diff --git a/src/components/shared/inline-editable-field.tsx b/src/components/shared/inline-editable-field.tsx
index 37f231a..96268d1 100644
--- a/src/components/shared/inline-editable-field.tsx
+++ b/src/components/shared/inline-editable-field.tsx
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useRef, useState } from 'react';
-import { Loader2, Pencil } from 'lucide-react';
+import { ChevronDown, Loader2, Pencil } from 'lucide-react';
import { toastError } from '@/lib/api/toast-error';
import { Input } from '@/components/ui/input';
@@ -98,33 +98,25 @@ export function InlineEditableField(props: InlineEditableFieldProps) {
}
if (props.variant === 'select') {
+ // Picker fields don't need a read/edit mode toggle — a Select is
+ // already a click-to-open control. Rendering one consistent
+ // SelectTrigger eliminates the width jump that happened when we
+ // swapped from a content-sized ReadButton to a w-full SelectTrigger
+ // on click (and back again on selection). The trigger now stays at
+ // a single width whether the popover is open or closed, so the
+ // dropdown menu visually aligns to the same control across states.
const labelFor = (v: string | null | undefined) =>
v ? (props.options.find((o) => o.value === v)?.label ?? v) : null;
- if (!editing) {
- return (
-
setEditing(true)}
- className={className}
- />
- );
- }
-
return (
void commit(v)}
- open
- onOpenChange={(open) => {
- if (!open && !saving) setEditing(false);
- }}
+ disabled={disabled || saving}
>
-
-
+
+ {labelFor(value) ?? emptyText}
{props.options.map((o) => (
@@ -228,6 +220,7 @@ function ReadButton({
disabled,
onClick,
multiline,
+ kind = 'text',
className,
}: {
value: string | null;
@@ -235,8 +228,13 @@ function ReadButton({
disabled?: boolean;
onClick: () => void;
multiline?: boolean;
+ /** Icon affordance: 'text' shows a pencil (free-text edit), 'select'
+ * shows a chevron-down (fixed-list picker). The chevron clarifies
+ * that clicking opens a dropdown, not a text input. */
+ kind?: 'text' | 'select';
className?: string;
}) {
+ const Icon = kind === 'select' ? ChevronDown : Pencil;
return (
{!disabled && (
-
diff --git a/src/components/shared/notes-list.tsx b/src/components/shared/notes-list.tsx
index 18af065..c724a46 100644
--- a/src/components/shared/notes-list.tsx
+++ b/src/components/shared/notes-list.tsx
@@ -18,32 +18,72 @@ interface Note {
isLocked: boolean;
createdAt: string;
updatedAt: string;
+ /** Aggregated-mode only: which child entity this note came from. */
+ source?: 'client' | 'interest' | 'yacht';
+ sourceId?: string;
+ sourceLabel?: string;
}
interface NotesListProps {
- entityType: 'clients' | 'interests' | 'yachts' | 'companies';
+ entityType:
+ | 'clients'
+ | 'interests'
+ | 'yachts'
+ | 'companies'
+ | 'residential_clients'
+ | 'residential_interests';
entityId: string;
currentUserId?: string;
+ /**
+ * When `entityType='clients'` and this is true, the list aggregates
+ * notes from the client + their interests + directly-owned yachts.
+ * Notes from interests/yachts render with a source chip and are
+ * read-only here (edit them on the source entity's page).
+ */
+ aggregate?: boolean;
}
const NOTE_EDIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
-export function NotesList({ entityType, entityId, currentUserId }: NotesListProps) {
+/** Sort by source then chronologically inside each source.
+ * Used by the aggregated view's "Group by source" toggle. */
+function sortByGroup(notes: Note[]): Note[] {
+ const sourceOrder: Record = { client: 0, interest: 1, yacht: 2 };
+ return [...notes].sort((a, b) => {
+ const aRank = sourceOrder[a.source ?? 'client'] ?? 99;
+ const bRank = sourceOrder[b.source ?? 'client'] ?? 99;
+ if (aRank !== bRank) return aRank - bRank;
+ const aLabel = a.sourceLabel ?? '';
+ const bLabel = b.sourceLabel ?? '';
+ if (aLabel !== bLabel) return aLabel.localeCompare(bLabel);
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
+ });
+}
+
+export function NotesList({ entityType, entityId, currentUserId, aggregate }: NotesListProps) {
const queryClient = useQueryClient();
const [newNote, setNewNote] = useState('');
const [editingId, setEditingId] = useState(null);
const [editContent, setEditContent] = useState('');
+ const [groupBySource, setGroupBySource] = useState(false);
- const endpoint = `/api/v1/${entityType}/${entityId}/notes`;
- const queryKey = [entityType, entityId, 'notes'];
+ const aggregateOn = aggregate && entityType === 'clients';
+ const baseEndpoint = `/api/v1/${entityType}/${entityId}/notes`;
+ const listEndpoint = aggregateOn ? `${baseEndpoint}?aggregate=true` : baseEndpoint;
+ const queryKey = [entityType, entityId, 'notes', aggregateOn ? 'aggregated' : 'own'];
const { data: notes = [], isLoading } = useQuery({
queryKey,
- queryFn: () => apiFetch<{ data: Note[] }>(endpoint).then((r) => r.data),
+ queryFn: () => apiFetch<{ data: Note[] }>(listEndpoint).then((r) => r.data),
});
+ // Mutations always target the parent entity (client). Aggregated
+ // notes from interests/yachts are read-only here — the rep edits
+ // them on the source entity's page (we surface a "Open source" link
+ // below). Keeping mutations against `baseEndpoint` keeps the POST
+ // route handler clean.
const createMutation = useMutation({
- mutationFn: (content: string) => apiFetch(endpoint, { method: 'POST', body: { content } }),
+ mutationFn: (content: string) => apiFetch(baseEndpoint, { method: 'POST', body: { content } }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
setNewNote('');
@@ -52,7 +92,7 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
const updateMutation = useMutation({
mutationFn: ({ noteId, content }: { noteId: string; content: string }) =>
- apiFetch(`${endpoint}/${noteId}`, { method: 'PATCH', body: { content } }),
+ apiFetch(`${baseEndpoint}/${noteId}`, { method: 'PATCH', body: { content } }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
setEditingId(null);
@@ -60,13 +100,17 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
});
const deleteMutation = useMutation({
- mutationFn: (noteId: string) => apiFetch(`${endpoint}/${noteId}`, { method: 'DELETE' }),
+ mutationFn: (noteId: string) => apiFetch(`${baseEndpoint}/${noteId}`, { method: 'DELETE' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
});
function canEdit(note: Note): boolean {
if (note.authorId !== currentUserId) return false;
if (note.isLocked) return false;
+ // Aggregated view: only client-level notes are editable in-place.
+ // Notes from interests/yachts must be edited on their own page so
+ // the right entity timeline records the change.
+ if (aggregateOn && note.source && note.source !== 'client') return false;
const elapsed = Date.now() - new Date(note.createdAt).getTime();
return elapsed < NOTE_EDIT_WINDOW_MS;
}
@@ -105,6 +149,29 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
+ {/* Aggregated-mode controls — sort toggle. Only renders when
+ * aggregation is on and there's actually content to group. */}
+ {aggregateOn && notes.length > 0 && (
+
+ View:
+ setGroupBySource(false)}
+ >
+ Chronological
+
+ ·
+ setGroupBySource(true)}
+ >
+ Group by source
+
+
+ )}
+
{/* Notes list */}
{isLoading ? (
Loading notes...
@@ -112,7 +179,7 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
No notes yet
) : (
- {notes.map((note) => (
+ {(groupBySource ? sortByGroup(notes) : notes).map((note) => (
@@ -120,11 +187,23 @@ export function NotesList({ entityType, entityId, currentUserId }: NotesListProp
-
+
{note.authorName ?? 'User'}
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true })}
+ {aggregateOn && note.source && note.source !== 'client' && note.sourceLabel && (
+
+ {note.source === 'interest' ? 'Interest' : 'Yacht'} · {note.sourceLabel}
+
+ )}
{note.isLocked &&
}
{canEdit(note) && (
{getTimeRemaining(note)}
diff --git a/src/components/shared/save-view-dialog.tsx b/src/components/shared/save-view-dialog.tsx
new file mode 100644
index 0000000..58bf8a0
--- /dev/null
+++ b/src/components/shared/save-view-dialog.tsx
@@ -0,0 +1,82 @@
+'use client';
+
+import { useState } from 'react';
+
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { useSavedViews } from '@/hooks/use-saved-views';
+
+interface SaveViewDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ entityType: string;
+ currentFilters: Record
;
+ currentSort?: { field: string; direction: 'asc' | 'desc' };
+}
+
+/**
+ * Standalone save-view dialog. Lifted out of SavedViewsDropdown so the
+ * "Save current view" affordance can live inside the column picker
+ * (where the rep is already configuring the table) instead of as a
+ * top-level button. SavedViewsDropdown now only handles browsing and
+ * applying existing views — the save and read concerns are split.
+ */
+export function SaveViewDialog({
+ open,
+ onOpenChange,
+ entityType,
+ currentFilters,
+ currentSort,
+}: SaveViewDialogProps) {
+ const { saveCurrentView } = useSavedViews(entityType);
+ const [viewName, setViewName] = useState('');
+ const [isSaving, setIsSaving] = useState(false);
+
+ async function handleSave() {
+ if (!viewName.trim()) return;
+ setIsSaving(true);
+ try {
+ await saveCurrentView(viewName.trim(), currentFilters, currentSort);
+ onOpenChange(false);
+ setViewName('');
+ } finally {
+ setIsSaving(false);
+ }
+ }
+
+ return (
+
+
+
+ Save view
+
+
+ View name
+ setViewName(e.target.value)}
+ placeholder="My custom view"
+ onKeyDown={(e) => e.key === 'Enter' && handleSave()}
+ autoFocus
+ />
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ Save
+
+
+
+
+ );
+}
diff --git a/src/components/shared/saved-views-dropdown.tsx b/src/components/shared/saved-views-dropdown.tsx
index 903327e..b00776f 100644
--- a/src/components/shared/saved-views-dropdown.tsx
+++ b/src/components/shared/saved-views-dropdown.tsx
@@ -1,135 +1,73 @@
'use client';
-import { useState } from 'react';
-import { Bookmark, Check, Plus, Trash2 } from 'lucide-react';
+import { Bookmark, Check, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
- DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
-import {
- Dialog,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
import { useSavedViews } from '@/hooks/use-saved-views';
interface SavedViewsDropdownProps {
entityType: string;
- currentFilters: Record;
- currentSort?: { field: string; direction: 'asc' | 'desc' };
- onApplyView: (filters: Record, sort?: { field: string; direction: string }) => void;
+ onApplyView: (
+ filters: Record,
+ sort?: { field: string; direction: string },
+ ) => void;
}
-export function SavedViewsDropdown({
- entityType,
- currentFilters,
- currentSort,
- onApplyView,
-}: SavedViewsDropdownProps) {
- const { views, activeViewId, saveCurrentView, deleteView, applyView } =
- useSavedViews(entityType);
- const [saveOpen, setSaveOpen] = useState(false);
- const [viewName, setViewName] = useState('');
- const [isSaving, setIsSaving] = useState(false);
+/**
+ * Read-only browser for saved views. The "Save current view" affordance
+ * has moved into the ColumnPicker menu (see SaveViewDialog). This
+ * component renders nothing when the user has no saved views — the
+ * Bookmark button on its own is just visual noise until something has
+ * been saved.
+ */
+export function SavedViewsDropdown({ entityType, onApplyView }: SavedViewsDropdownProps) {
+ const { views, activeViewId, deleteView, applyView } = useSavedViews(entityType);
- async function handleSave() {
- if (!viewName.trim()) return;
- setIsSaving(true);
- try {
- await saveCurrentView(viewName.trim(), currentFilters, currentSort);
- setSaveOpen(false);
- setViewName('');
- } finally {
- setIsSaving(false);
- }
- }
+ if (views.length === 0) return null;
return (
- <>
-
-
-
-
- Views
-
-
-
- {views.length === 0 ? (
-
- No saved views yet
-
- ) : (
- views.map((view) => (
- {
- applyView(view.id);
- onApplyView(
- view.filters as Record,
- view.sortConfig as { field: string; direction: string } | undefined,
- );
+
+
+
+
+ Views
+
+
+
+ {views.map((view) => (
+ {
+ applyView(view.id);
+ onApplyView(
+ view.filters as Record,
+ view.sortConfig as { field: string; direction: string } | undefined,
+ );
+ }}
+ >
+ {view.name}
+
+ {activeViewId === view.id &&
}
+
{
+ e.stopPropagation();
+ deleteView(view.id);
}}
>
- {view.name}
-
- {activeViewId === view.id && (
-
- )}
- {
- e.stopPropagation();
- deleteView(view.id);
- }}
- >
-
-
-
-
- ))
- )}
-
- setSaveOpen(true)}>
-
- Save current view
+
+
+
-
-
-
-
-
-
- Save View
-
-
- View name
- setViewName(e.target.value)}
- placeholder="My custom view"
- onKeyDown={(e) => e.key === 'Enter' && handleSave()}
- />
-
-
- setSaveOpen(false)}>
- Cancel
-
-
- Save
-
-
-
-
- >
+ ))}
+
+
);
}
diff --git a/src/components/yachts/yacht-detail-header.tsx b/src/components/yachts/yacht-detail-header.tsx
index cc07c95..161fc98 100644
--- a/src/components/yachts/yacht-detail-header.tsx
+++ b/src/components/yachts/yacht-detail-header.tsx
@@ -14,6 +14,7 @@ import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { PermissionGate } from '@/components/shared/permission-gate';
import { YachtForm } from '@/components/yachts/yacht-form';
import { YachtTransferDialog } from '@/components/yachts/yacht-transfer-dialog';
+import { formatYachtDimensionsBothUnits } from '@/components/yachts/yacht-dimensions';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
@@ -95,18 +96,9 @@ export function OwnerLink({
}
function formatDimensions(yacht: YachtDetailHeaderYacht): string | null {
- const parts: string[] = [];
- if (yacht.lengthFt) parts.push(`${yacht.lengthFt} ft`);
- if (yacht.widthFt) parts.push(`${yacht.widthFt} ft`);
-
- let summary: string | null = null;
- if (parts.length > 0) {
- summary = parts.join(' × ');
- }
- if (yacht.draftFt) {
- summary = summary ? `${summary} (draft ${yacht.draftFt} ft)` : `draft ${yacht.draftFt} ft`;
- }
- return summary;
+ // Show both units; derive whichever is unset from the other so reps
+ // never need to enter both. See `yacht-dimensions.ts`.
+ return formatYachtDimensionsBothUnits(yacht);
}
export function YachtDetailHeader({ yacht }: YachtDetailHeaderProps) {
diff --git a/src/components/yachts/yacht-detail.tsx b/src/components/yachts/yacht-detail.tsx
index 9a1954a..5cd5bf2 100644
--- a/src/components/yachts/yacht-detail.tsx
+++ b/src/components/yachts/yacht-detail.tsx
@@ -8,6 +8,7 @@ import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provid
import { YachtDetailHeader } from '@/components/yachts/yacht-detail-header';
import { getYachtTabs } from '@/components/yachts/yacht-tabs';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
+import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
import { apiFetch } from '@/lib/api/client';
export interface YachtData {
@@ -54,6 +55,8 @@ export function YachtDetail({ yachtId, currentUserId }: YachtDetailProps) {
return () => setChrome({ title: null, showBackButton: false });
}, [titleForChrome, setChrome]);
+ useBreadcrumbHint(data ? { parents: [], current: data.name } : null);
+
useRealtimeInvalidation({
'yacht:updated': [['yachts', yachtId]],
'yacht:archived': [['yachts', yachtId]],
diff --git a/src/components/yachts/yacht-dimensions.ts b/src/components/yachts/yacht-dimensions.ts
new file mode 100644
index 0000000..551e2f7
--- /dev/null
+++ b/src/components/yachts/yacht-dimensions.ts
@@ -0,0 +1,96 @@
+/**
+ * Imperial ↔ metric conversion + display helpers for yacht dimensions.
+ * The schema stores both `*Ft` and `*M` separately so the rep can edit
+ * either unit without losing precision; this module is the single source
+ * for the formulas and rendering.
+ */
+
+const FT_PER_M = 3.28084;
+
+export function feetToMeters(ft: number | string | null | undefined): number | null {
+ const n = typeof ft === 'string' ? Number.parseFloat(ft) : ft;
+ if (n === null || n === undefined || !Number.isFinite(n)) return null;
+ return n / FT_PER_M;
+}
+
+export function metersToFeet(m: number | string | null | undefined): number | null {
+ const n = typeof m === 'string' ? Number.parseFloat(m) : m;
+ if (n === null || n === undefined || !Number.isFinite(n)) return null;
+ return n * FT_PER_M;
+}
+
+/** One decimal place is enough for marina-scale dimensions; trailing
+ * zero stripped for cleaner display. Returns null when input is null. */
+export function formatNumber1dp(n: number | null | undefined): string | null {
+ if (n === null || n === undefined || !Number.isFinite(n)) return null;
+ return n.toFixed(1).replace(/\.0$/, '');
+}
+
+export interface YachtDimensions {
+ lengthFt: string | number | null;
+ widthFt: string | number | null;
+ draftFt: string | number | null;
+ lengthM: string | number | null;
+ widthM: string | number | null;
+ draftM: string | number | null;
+}
+
+/**
+ * Returns the dimension in the requested unit, deriving from the other
+ * unit when the requested one is unset. Lets the UI render both units
+ * side-by-side without the rep having to enter both.
+ */
+export function dimInFeet(value: {
+ ft: number | string | null;
+ m: number | string | null;
+}): string | null {
+ const direct = parseNum(value.ft);
+ if (direct !== null) return formatNumber1dp(direct);
+ return formatNumber1dp(metersToFeet(value.m));
+}
+export function dimInMeters(value: {
+ ft: number | string | null;
+ m: number | string | null;
+}): string | null {
+ const direct = parseNum(value.m);
+ if (direct !== null) return formatNumber1dp(direct);
+ return formatNumber1dp(feetToMeters(value.ft));
+}
+
+function parseNum(v: number | string | null | undefined): number | null {
+ if (v === null || v === undefined) return null;
+ const n = typeof v === 'string' ? Number.parseFloat(v) : v;
+ return Number.isFinite(n) ? n : null;
+}
+
+/**
+ * One-line summary used in the detail header. Shows both units when
+ * any dimension is known, deriving missing values via the formulas
+ * above. Returns null only when the yacht has no dimensions at all.
+ */
+export function formatYachtDimensionsBothUnits(yacht: YachtDimensions): string | null {
+ const lFt = dimInFeet({ ft: yacht.lengthFt, m: yacht.lengthM });
+ const wFt = dimInFeet({ ft: yacht.widthFt, m: yacht.widthM });
+ const dFt = dimInFeet({ ft: yacht.draftFt, m: yacht.draftM });
+ const lM = dimInMeters({ ft: yacht.lengthFt, m: yacht.lengthM });
+ const wM = dimInMeters({ ft: yacht.widthFt, m: yacht.widthM });
+ const dM = dimInMeters({ ft: yacht.draftFt, m: yacht.draftM });
+
+ const ftParts: string[] = [];
+ if (lFt) ftParts.push(`${lFt} ft`);
+ if (wFt) ftParts.push(`${wFt} ft`);
+ const mParts: string[] = [];
+ if (lM) mParts.push(`${lM} m`);
+ if (wM) mParts.push(`${wM} m`);
+
+ if (ftParts.length === 0 && !dFt) return null;
+
+ const ftSummary = ftParts.join(' × ');
+ const mSummary = mParts.join(' × ');
+ const head = ftSummary && mSummary ? `${ftSummary} (${mSummary})` : ftSummary || mSummary;
+
+ if (dFt && dM) return `${head} (draft ${dFt} ft / ${dM} m)`;
+ if (dFt) return `${head} (draft ${dFt} ft)`;
+ if (dM) return `${head} (draft ${dM} m)`;
+ return head;
+}
diff --git a/src/components/yachts/yacht-list.tsx b/src/components/yachts/yacht-list.tsx
index 7cb2b24..8a01523 100644
--- a/src/components/yachts/yacht-list.tsx
+++ b/src/components/yachts/yacht-list.tsx
@@ -128,8 +128,6 @@ export function YachtList() {
/>
{
clearFilters();
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
diff --git a/src/components/yachts/yacht-tabs.tsx b/src/components/yachts/yacht-tabs.tsx
index e97e6b2..bb18494 100644
--- a/src/components/yachts/yacht-tabs.tsx
+++ b/src/components/yachts/yacht-tabs.tsx
@@ -94,7 +94,45 @@ function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYach
const value = transform ? transform(next) : next;
await mutation.mutateAsync({ [field]: value });
};
- const numericString = (next: string | null) => (next === null ? null : next);
+ /**
+ * Bidirectional dimension save: when the rep edits Length/Width/Draft
+ * in feet, also write the metric counterpart (and vice versa). Avoids
+ * the "I entered ft but the m row still says '-'" surprise.
+ *
+ * If the rep clears a field (next === null), only that side is
+ * cleared — we never overwrite their other-unit value with a derived
+ * one, since they may have intentionally entered a more precise
+ * metric figure.
+ */
+ function saveDimension(
+ primaryField: 'lengthFt' | 'widthFt' | 'draftFt' | 'lengthM' | 'widthM' | 'draftM',
+ ) {
+ const isFt = primaryField.endsWith('Ft');
+ const counterpart = (
+ isFt ? primaryField.replace('Ft', 'M') : primaryField.replace('M', 'Ft')
+ ) as YachtPatchField;
+ return async (next: string | null) => {
+ if (next === null || next === '') {
+ await mutation.mutateAsync({ [primaryField]: null });
+ return;
+ }
+ const n = Number.parseFloat(next);
+ if (!Number.isFinite(n)) {
+ await mutation.mutateAsync({ [primaryField]: next });
+ return;
+ }
+ const FT_PER_M = 3.28084;
+ const converted = isFt ? n / FT_PER_M : n * FT_PER_M;
+ const convertedStr = converted
+ .toFixed(2)
+ .replace(/\.0+$/, '')
+ .replace(/(\.\d)0$/, '$1');
+ await mutation.mutateAsync({
+ [primaryField]: next,
+ [counterpart]: convertedStr,
+ });
+ };
+ }
const yearTransform = (next: string | null) => {
if (next === null) return null;
const n = Number.parseInt(next, 10);
@@ -157,13 +195,13 @@ function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYach
Dimensions (ft)
-
+
-
+
-
+
@@ -173,13 +211,13 @@ function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYach
Dimensions (m)
-
+
-
+
-
+
diff --git a/src/hooks/use-breadcrumb-hint.ts b/src/hooks/use-breadcrumb-hint.ts
new file mode 100644
index 0000000..7f65ddd
--- /dev/null
+++ b/src/hooks/use-breadcrumb-hint.ts
@@ -0,0 +1,43 @@
+'use client';
+
+import { useEffect } from 'react';
+import { usePathname } from 'next/navigation';
+
+import { type BreadcrumbHint, useBreadcrumbStore } from '@/stores/breadcrumb-store';
+
+/**
+ * Detail pages call this on mount to register their entity hierarchy
+ * for the topbar breadcrumb. Pass a stable hint object (or memoise the
+ * inputs) so the effect doesn't re-fire every render.
+ *
+ * Example (interest detail page):
+ * useBreadcrumbHint({
+ * parents: [{ label: 'Mary Smith', href: '/port/clients/abc' }],
+ * current: 'B17',
+ * });
+ *
+ * The hint clears when the page unmounts so a stale hierarchy doesn't
+ * leak into the next route.
+ */
+export function useBreadcrumbHint(hint: BreadcrumbHint | null | undefined): void {
+ const pathname = usePathname();
+ const setHint = useBreadcrumbStore((s) => s.setHint);
+ const clearHint = useBreadcrumbStore((s) => s.clearHint);
+
+ // Stringify for stable equality — caller can pass an object literal
+ // each render without wrecking effect deps. The serialized form is
+ // tiny (handful of strings) so this is cheap.
+ const serialized = hint ? JSON.stringify(hint) : null;
+
+ useEffect(() => {
+ if (!serialized || !hint) return;
+ setHint(pathname, hint);
+ return () => {
+ clearHint(pathname);
+ };
+ // serialized stands in for `hint` value-equality; pathname triggers
+ // re-register if the page navigates without unmounting (rare but
+ // possible on client-side route swaps within the same layout).
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [serialized, pathname, setHint, clearHint]);
+}
diff --git a/src/hooks/use-table-preferences.ts b/src/hooks/use-table-preferences.ts
new file mode 100644
index 0000000..3476f57
--- /dev/null
+++ b/src/hooks/use-table-preferences.ts
@@ -0,0 +1,119 @@
+'use client';
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+
+import { apiFetch } from '@/lib/api/client';
+import type { TablePreferences, UserPreferences } from '@/lib/db/schema/users';
+
+interface MeResponse {
+ data: {
+ preferences?: UserPreferences;
+ };
+}
+
+/**
+ * Reads + writes the current user's per-table column visibility, backed
+ * by `user_profiles.preferences.tablePreferences[entityType]`. Returns
+ * the hidden-columns set plus a `setHidden(next)` mutator.
+ *
+ * Writes are debounced (~600ms) so dragging a checkbox list back and
+ * forth doesn't spam the API. The local state is updated immediately
+ * for instant UX; the network round-trip is best-effort and silently
+ * swallowed on failure (preferences are recoverable from local state
+ * for the current session).
+ *
+ * `defaultHidden` applies ONLY when the user has never saved a
+ * preference for this entity (the stored entry is `undefined`). Once
+ * the user explicitly toggles any column, their saved list takes over
+ * — including the case where they've intentionally cleared it to an
+ * empty array (which means "show everything", overriding defaults).
+ */
+export function useTablePreferences(entityType: string, defaultHidden: string[] = []) {
+ const queryClient = useQueryClient();
+
+ const meQuery = useQuery
({
+ queryKey: ['me'],
+ queryFn: ({ signal }) => apiFetch('/api/v1/me', { signal }),
+ staleTime: 5 * 60_000,
+ });
+
+ const remoteHidden =
+ meQuery.data?.data.preferences?.tablePreferences?.[entityType]?.hiddenColumns;
+ const [localHidden, setLocalHidden] = useState(null);
+
+ // When the remote preferences arrive (or change), seed the local
+ // state. We only sync from remote → local on first load or when the
+ // server side genuinely changes (e.g. another tab updated prefs).
+ useEffect(() => {
+ if (remoteHidden && localHidden === null) {
+ setLocalHidden(remoteHidden);
+ }
+ }, [remoteHidden, localHidden]);
+
+ const debounceRef = useRef | null>(null);
+
+ const setHidden = useCallback(
+ (next: string[]) => {
+ setLocalHidden(next);
+
+ // Debounce the PATCH so a user clicking through 5 checkboxes
+ // produces 1 server round-trip, not 5.
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ debounceRef.current = setTimeout(() => {
+ const existing = meQuery.data?.data.preferences?.tablePreferences ?? {};
+ const updated: Record = {
+ ...existing,
+ [entityType]: { hiddenColumns: next },
+ };
+
+ // Optimistic cache update so a refetch doesn't blow away the
+ // local state; the server response will overwrite either way.
+ queryClient.setQueryData(['me'], (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ data: {
+ ...old.data,
+ preferences: {
+ ...(old.data.preferences ?? {}),
+ tablePreferences: updated,
+ },
+ },
+ };
+ });
+
+ apiFetch('/api/v1/me', {
+ method: 'PATCH',
+ body: { preferences: { tablePreferences: updated } },
+ }).catch(() => {
+ // Network failures are non-fatal — the local UI keeps the
+ // chosen visibility for the rest of the session.
+ });
+ }, 600);
+ },
+ [entityType, meQuery.data, queryClient],
+ );
+
+ // Cleanup pending timer on unmount so React doesn't warn about
+ // setting state after the component is gone.
+ useEffect(
+ () => () => {
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ },
+ [],
+ );
+
+ // Resolution order: local optimistic → remote saved → caller defaults.
+ // The `remoteHidden ?? defaultHidden` step is what gives us the "hide
+ // latestStage for fresh users, but respect their override once they
+ // touch it" behavior — saved value (even []) wins, defaults only fill
+ // the never-saved case.
+ const resolved = localHidden ?? remoteHidden ?? defaultHidden;
+
+ return {
+ hidden: resolved,
+ setHidden,
+ isLoaded: !meQuery.isLoading,
+ };
+}
diff --git a/src/lib/api/list-query.ts b/src/lib/api/list-query.ts
index 08cb071..cd4e92a 100644
--- a/src/lib/api/list-query.ts
+++ b/src/lib/api/list-query.ts
@@ -2,7 +2,11 @@ import { z } from 'zod';
export const baseListQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
- limit: z.coerce.number().int().min(1).max(100).default(25),
+ // Bumped from 100 to 1000 so the table page-size selector can offer
+ // an "All" option that maps to a single big fetch. Above 1000 the
+ // caller must paginate; anything routinely north of that ceiling
+ // needs virtualization rather than a bigger page-size cap.
+ limit: z.coerce.number().int().min(1).max(1000).default(25),
sort: z.string().optional(),
order: z.enum(['asc', 'desc']).default('desc'),
search: z.string().optional(),
diff --git a/src/lib/db/query-builder.ts b/src/lib/db/query-builder.ts
index 42b6de6..609c753 100644
--- a/src/lib/db/query-builder.ts
+++ b/src/lib/db/query-builder.ts
@@ -1,14 +1,4 @@
-import {
- and,
- asc,
- desc,
- eq,
- ilike,
- isNull,
- or,
- sql,
- type SQL,
-} from 'drizzle-orm';
+import { and, asc, desc, eq, ilike, isNull, or, sql, type SQL } from 'drizzle-orm';
import type { PgTable, PgColumn } from 'drizzle-orm/pg-core';
import { db } from './index';
@@ -20,6 +10,13 @@ export interface BuildListQueryOptions {
updatedAtColumn: PgColumn;
filters?: SQL[];
sort?: { column: PgColumn; direction: 'asc' | 'desc' };
+ /**
+ * Custom ORDER BY clauses, used INSTEAD of `sort`. For cases where
+ * the natural ordering needs raw SQL (e.g. natural alphanumeric sort
+ * on berth mooring numbers like A1, A2, A10, B1...). Deterministic
+ * tail-sort on `updatedAt DESC, id DESC` is still appended.
+ */
+ customOrderBy?: SQL[];
page: number;
pageSize: number;
searchColumns?: PgColumn[];
@@ -40,9 +37,7 @@ export interface ListResult {
* - `archivedAt IS NULL` by default (unless `includeArchived` is true).
* - Deterministic secondary sort: `updatedAt DESC, id DESC`.
*/
-export async function buildListQuery(
- opts: BuildListQueryOptions,
-): Promise> {
+export async function buildListQuery(opts: BuildListQueryOptions): Promise> {
const {
table,
portIdColumn,
@@ -51,6 +46,7 @@ export async function buildListQuery(
updatedAtColumn,
filters = [],
sort,
+ customOrderBy,
page,
pageSize,
searchColumns = [],
@@ -68,9 +64,7 @@ export async function buildListQuery(
// Full-text search across multiple columns via ILIKE
if (searchTerm && searchColumns.length > 0) {
- const searchConditions = searchColumns.map((col) =>
- ilike(col, `%${searchTerm}%`),
- );
+ const searchConditions = searchColumns.map((col) => ilike(col, `%${searchTerm}%`));
conditions.push(or(...searchConditions)!);
}
@@ -86,12 +80,13 @@ export async function buildListQuery(
.where(where);
const total = countResult[0]?.count ?? 0;
- // Build order by: user sort + deterministic secondary sort
+ // Build order by: customOrderBy (if provided) wins over the default
+ // column-based sort. Deterministic secondary sort always trails.
const orderClauses: SQL[] = [];
- if (sort) {
- orderClauses.push(
- sort.direction === 'asc' ? asc(sort.column) : desc(sort.column),
- );
+ if (customOrderBy && customOrderBy.length > 0) {
+ orderClauses.push(...customOrderBy);
+ } else if (sort) {
+ orderClauses.push(sort.direction === 'asc' ? asc(sort.column) : desc(sort.column));
}
orderClauses.push(desc(updatedAtColumn), desc(idColumn));
diff --git a/src/lib/db/schema/interests.ts b/src/lib/db/schema/interests.ts
index d3965f8..36f5918 100644
--- a/src/lib/db/schema/interests.ts
+++ b/src/lib/db/schema/interests.ts
@@ -58,7 +58,6 @@ 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 }),
- notes: text('notes'),
/** Recommender inputs - imperial; resolver treats nulls as "no constraint"
* on that axis, with a banner prompting the rep to add the missing dim. */
desiredLengthFt: numeric('desired_length_ft'),
diff --git a/src/lib/db/schema/users.ts b/src/lib/db/schema/users.ts
index 52e4a17..25c8953 100644
--- a/src/lib/db/schema/users.ts
+++ b/src/lib/db/schema/users.ts
@@ -143,10 +143,27 @@ export type RolePermissions = {
};
};
+/**
+ * Per-table column visibility — drives the `` and the
+ * DataTable `columnVisibility` state. `hiddenColumns` is the source of
+ * truth; an entry's absence means "show this column" (so newly-added
+ * columns show by default for existing users without us having to
+ * migrate stored preferences).
+ */
+export type TablePreferences = {
+ hiddenColumns?: string[];
+};
+
export type UserPreferences = {
dark_mode?: boolean;
locale?: string;
timezone?: string;
+ /** ISO-3166-1 alpha-2. Drives the default timezone when the rep
+ * hasn't picked one explicitly, and lets the auto-detect banner
+ * spot a mismatch when they're travelling. */
+ country?: string;
+ /** Keyed by entity type: `clients`, `yachts`, `interests`, etc. */
+ tablePreferences?: Record;
[key: string]: unknown;
};
@@ -209,6 +226,12 @@ export const userProfiles = pgTable(
userId: text('user_id').notNull().unique(), // references Better Auth user ID
displayName: text('display_name').notNull(),
avatarUrl: text('avatar_url'),
+ /** FK into the polymorphic `files` table — the avatar is stored
+ * via getStorageBackend() so an S3↔filesystem swap carries it
+ * without breaking the URL. The legacy `avatarUrl` column is
+ * kept for any external photo sources but the file pointer wins
+ * when both are set. */
+ avatarFileId: text('avatar_file_id'),
phone: text('phone'),
isSuperAdmin: boolean('is_super_admin').notNull().default(false),
isActive: boolean('is_active').notNull().default(true),
@@ -261,6 +284,37 @@ export const portRoleOverrides = pgTable(
],
);
+/**
+ * Pending email-change records for the verify-old-and-new flow.
+ * The CRM's `/api/v1/me/email` endpoint creates a row here, emails
+ * the OLD address with a cancel link and the NEW address with a
+ * confirm link, and applies the change only when the new address
+ * confirms (or auto-cancels at `expiresAt`).
+ *
+ * `confirmTokenHash` stores a sha256 of the random confirmation
+ * token; the raw token is only present in the email body.
+ */
+export const userEmailChanges = pgTable(
+ 'user_email_changes',
+ {
+ id: text('id')
+ .primaryKey()
+ .$defaultFn(() => crypto.randomUUID()),
+ userId: text('user_id').notNull(),
+ oldEmail: text('old_email').notNull(),
+ newEmail: text('new_email').notNull(),
+ confirmTokenHash: text('confirm_token_hash').notNull(),
+ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
+ appliedAt: timestamp('applied_at', { withTimezone: true }),
+ cancelledAt: timestamp('cancelled_at', { withTimezone: true }),
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
+ },
+ (table) => [
+ index('idx_uec_user').on(table.userId),
+ index('idx_uec_token').on(table.confirmTokenHash),
+ ],
+);
+
export const userPortRoles = pgTable(
'user_port_roles',
{
diff --git a/src/lib/db/seed-synthetic-data.ts b/src/lib/db/seed-synthetic-data.ts
index 5b7e7ab..d184260 100644
--- a/src/lib/db/seed-synthetic-data.ts
+++ b/src/lib/db/seed-synthetic-data.ts
@@ -87,7 +87,9 @@ export interface SyntheticSeedSummary {
}
interface SyntheticClientSpec {
- /** Used as a name suffix so test selectors can target it deterministically. */
+ /** Stable identifier used by Playwright selectors and intra-seed wiring
+ * (memberships, yachts, etc.). Decoupled from the display name so the
+ * rendered list looks like real client data, not test fixtures. */
tag: string;
fullName: string;
email: string;
@@ -105,6 +107,14 @@ interface SyntheticClientSpec {
/** Archive the CLIENT after creation. When 'rich', fabricate
* archive_metadata so the smart-restore wizard surfaces reversals. */
archive?: 'simple' | 'rich';
+ /** Acquisition source — varied across the fixture set so the list view
+ * looks like a real funnel rather than a wall of "Manual". */
+ source?: 'website' | 'manual' | 'referral' | 'broker';
+ /** How long ago (in days) this client record was created. Spreads the
+ * "Created" column across realistic timestamps so list pages look like
+ * a real CRM with months of history rather than 12 rows all stamped
+ * with today's date. */
+ createdDaysAgo?: number;
}
/**
@@ -115,150 +125,184 @@ interface SyntheticClientSpec {
* Berth indices map deterministically into the NocoDB snapshot which is
* pre-sorted: idx 0..4 available, 5..9 under_offer, 10..11 sold.
*/
+/**
+ * Believable demo dataset — names, emails, phone numbers, addresses, and
+ * acquisition sources read like a real marina's prospect list rather
+ * than fixtures keyed on enum names. The `tag` field still carries the
+ * stage/role identity for selectors and intra-seed wiring; nothing in
+ * the rendered UI references it.
+ *
+ * Spread across acquisition sources, ages (3–280 days), and countries
+ * so list / dashboard / kanban surfaces look populated and natural.
+ */
const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
{
tag: 'open',
- fullName: 'Olivia Open — open',
- email: 'olivia.open@test.local',
- phone: '+1 555 010 0001',
+ fullName: 'Olivia Sinclair',
+ email: 'olivia.sinclair@gmail.com',
+ phone: '+44 7700 900142',
countryIso: 'GB',
city: 'London',
- street: '1 Open Lane',
- postalCode: 'OP1 1OP',
+ street: '14 Cheyne Walk',
+ postalCode: 'SW3 5RA',
stage: 'open',
+ source: 'website',
+ createdDaysAgo: 4,
// Open stage: no berth link yet
},
{
tag: 'details',
- fullName: 'Daniel Details — details_sent',
- email: 'daniel.details@test.local',
- phone: '+1 555 010 0002',
+ fullName: 'Daniel Whitaker',
+ email: 'daniel.whitaker@outlook.com',
+ phone: '+1 305 555 0182',
countryIso: 'US',
city: 'Miami',
- street: '2 Brochure Way',
- postalCode: '33101',
+ street: '880 Brickell Bay Drive',
+ postalCode: '33131',
stage: 'details_sent',
berthIdx: 0,
+ source: 'broker',
+ createdDaysAgo: 12,
},
{
tag: 'comms',
- fullName: 'Carla Communicating — in_communication',
- email: 'carla.comms@test.local',
- phone: '+1 555 010 0003',
+ fullName: 'Carla Mendoza',
+ email: 'carla.mendoza@gmail.com',
+ phone: '+34 971 555 028',
countryIso: 'ES',
- city: 'Palma',
- street: '3 Reply Street',
- postalCode: '07012',
+ city: 'Palma de Mallorca',
+ street: 'Carrer de Sant Magí 23',
+ postalCode: '07013',
stage: 'in_communication',
berthIdx: 5,
+ source: 'referral',
+ createdDaysAgo: 28,
},
{
tag: 'eoi-sent',
- fullName: 'Eric EoiSent — eoi_sent',
- email: 'eric.eoisent@test.local',
- phone: '+1 555 010 0004',
+ fullName: 'Marco Bianchi',
+ email: 'marco.bianchi@libero.it',
+ phone: '+39 010 8740 215',
countryIso: 'IT',
city: 'Genoa',
- street: '4 Envelope Plaza',
- postalCode: '16124',
+ street: 'Via XX Settembre 47',
+ postalCode: '16121',
stage: 'eoi_sent',
berthIdx: 6,
+ source: 'broker',
+ createdDaysAgo: 45,
},
{
tag: 'eoi-signed',
- fullName: 'Sara EoiSigned — eoi_signed',
- email: 'sara.eoisigned@test.local',
- phone: '+1 555 010 0005',
+ fullName: 'Sara Laurent',
+ email: 'sara.laurent@orange.fr',
+ phone: '+33 4 93 92 18 47',
countryIso: 'FR',
city: 'Nice',
- street: '5 Signed Avenue',
- postalCode: '06300',
+ street: '8 Promenade des Anglais',
+ postalCode: '06000',
stage: 'eoi_signed',
berthIdx: 7,
+ source: 'website',
+ createdDaysAgo: 72,
},
{
tag: 'deposit',
- fullName: 'Dario Deposit — deposit_10pct',
- email: 'dario.deposit@test.local',
- phone: '+1 555 010 0006',
+ fullName: 'Nikolas Papadakis',
+ email: 'n.papadakis@gmail.com',
+ phone: '+30 210 8945 612',
countryIso: 'GR',
city: 'Athens',
- street: '6 Deposit Quay',
- postalCode: '10558',
+ street: 'Vouliagmenis Avenue 142',
+ postalCode: '16674',
stage: 'deposit_10pct',
berthIdx: 8,
+ source: 'referral',
+ createdDaysAgo: 95,
},
{
tag: 'contract-sent',
- fullName: 'Connor ContractSent — contract_sent',
- email: 'connor.contract@test.local',
- phone: '+1 555 010 0007',
+ fullName: 'Connor Murphy',
+ email: 'connor.murphy@me.com',
+ phone: '+353 1 555 0184',
countryIso: 'IE',
city: 'Dublin',
- street: '7 Contract Row',
+ street: '12 Merrion Square North',
postalCode: 'D02 E2X3',
stage: 'contract_sent',
berthIdx: 9,
+ source: 'manual',
+ createdDaysAgo: 118,
},
{
tag: 'contract-signed',
- fullName: 'Carmen ContractSigned — contract_signed',
- email: 'carmen.signed@test.local',
- phone: '+1 555 010 0008',
+ fullName: 'Carmen Costa',
+ email: 'carmen.costa@sapo.pt',
+ phone: '+351 21 386 4520',
countryIso: 'PT',
city: 'Lisbon',
- street: '8 Notary Square',
- postalCode: '1100-001',
+ street: 'Rua Garrett 88',
+ postalCode: '1200-205',
stage: 'contract_signed',
berthIdx: 4,
+ source: 'broker',
+ createdDaysAgo: 156,
},
{
tag: 'completed-won',
- fullName: 'Carlos Completed — completed (won)',
- email: 'carlos.complete@test.local',
- phone: '+1 555 010 0009',
+ fullName: 'Carlos Vega',
+ email: 'carlos.vega@gmail.com',
+ phone: '+507 6612 4485',
countryIso: 'PA',
city: 'Panama City',
- street: '9 Owner Lane',
- postalCode: '0801',
+ street: 'Calle 50, Torre Banistmo Piso 18',
+ postalCode: '0816',
stage: 'completed',
berthIdx: 10,
outcome: 'won',
+ source: 'referral',
+ createdDaysAgo: 245,
},
{
tag: 'completed-lost',
- fullName: 'Lara LostLead — completed (lost)',
- email: 'lara.lost@test.local',
- phone: '+1 555 010 0010',
+ fullName: 'Hannah Schmidt',
+ email: 'hannah.schmidt@gmx.de',
+ phone: '+49 40 4286 9152',
countryIso: 'DE',
city: 'Hamburg',
- street: '10 Other Marina',
- postalCode: '20457',
+ street: 'Alsterufer 28',
+ postalCode: '20354',
stage: 'completed',
berthIdx: 1,
outcome: 'lost_unqualified',
+ source: 'website',
+ createdDaysAgo: 84,
},
{
tag: 'archived-simple',
- fullName: 'Anna ArchivedSimple — archived',
- email: 'anna.archived@test.local',
- phone: '+1 555 010 0011',
+ fullName: 'Anna de Jong',
+ email: 'anna.dejong@kpn.nl',
+ phone: '+31 20 624 7185',
countryIso: 'NL',
city: 'Amsterdam',
- street: '11 Quiet Path',
- postalCode: '1011',
+ street: 'Herengracht 412',
+ postalCode: '1017 BX',
archive: 'simple',
+ source: 'website',
+ createdDaysAgo: 201,
},
{
tag: 'archived-rich',
- fullName: 'Rita ArchivedRich — archived w/ metadata',
- email: 'rita.archivedrich@test.local',
- phone: '+1 555 010 0012',
+ fullName: 'Rita Vermeulen',
+ email: 'rita.vermeulen@telenet.be',
+ phone: '+32 3 226 8420',
countryIso: 'BE',
city: 'Antwerp',
- street: '12 Rich Metadata Blvd',
+ street: 'Meir 102',
postalCode: '2000',
archive: 'rich',
+ source: 'broker',
+ createdDaysAgo: 280,
},
];
@@ -358,14 +402,25 @@ export async function seedSyntheticPortData(
const clientRows = await tx
.insert(clients)
.values(
- PIPELINE_CLIENTS.map((spec) => ({
- portId,
- fullName: spec.fullName,
- nationalityIso: spec.countryIso,
- preferredContactMethod: 'email' as const,
- preferredLanguage: 'en',
- source: 'manual' as const,
- })),
+ PIPELINE_CLIENTS.map((spec) => {
+ const created =
+ spec.createdDaysAgo !== undefined ? daysAgo(spec.createdDaysAgo) : new Date();
+ return {
+ portId,
+ fullName: spec.fullName,
+ nationalityIso: spec.countryIso,
+ preferredContactMethod: 'email' as const,
+ preferredLanguage: 'en',
+ source: spec.source ?? ('manual' as const),
+ // Override the schema default so the list page shows a
+ // realistic range of "Created" timestamps rather than 12
+ // rows all stamped with today's date. updated_at gets the
+ // same value so sorted-by-recency lists put the freshest
+ // records first.
+ createdAt: created,
+ updatedAt: created,
+ };
+ }),
)
.returning({ id: clients.id, fullName: clients.fullName });
diff --git a/src/lib/dedup/migration-apply.ts b/src/lib/dedup/migration-apply.ts
index 831d8e3..65ff8fe 100644
--- a/src/lib/dedup/migration-apply.ts
+++ b/src/lib/dedup/migration-apply.ts
@@ -310,7 +310,6 @@ async function applyInterest(
pipelineStage: planned.pipelineStage,
leadCategory: planned.leadCategory,
source: planned.source,
- notes: planned.notes,
documensoId: planned.documensoId,
dateEoiSent: planned.dateEoiSent ? new Date(planned.dateEoiSent) : null,
dateEoiSigned: planned.dateEoiSigned ? new Date(planned.dateEoiSigned) : null,
diff --git a/src/lib/services/berths.service.ts b/src/lib/services/berths.service.ts
index 3f91f11..80fb7fe 100644
--- a/src/lib/services/berths.service.ts
+++ b/src/lib/services/berths.service.ts
@@ -1,4 +1,4 @@
-import { and, eq, gte, lte, inArray } from 'drizzle-orm';
+import { and, eq, gte, lte, inArray, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
@@ -61,10 +61,22 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
filters.push(inArray(berths.id, matchingIds));
}
+ // Default ordering is natural alphanumeric on mooring number
+ // (A1, A2, A10, B1...) — Postgres' default lexicographic sort
+ // would put A10 before A2, which is the wrong story for a marina
+ // map. The mooring format is locked at `^[A-Z]+\d+$` so the regexp
+ // splits are safe.
+ const NATURAL_MOORING_SORT = [
+ sql`regexp_replace(${berths.mooringNumber}, '\d+$', '') ASC`,
+ sql`(regexp_replace(${berths.mooringNumber}, '^[A-Z]+', ''))::int ASC`,
+ ];
+
const sortColumn = (() => {
switch (query.sort) {
case 'mooringNumber':
- return berths.mooringNumber;
+ // Honoured via customOrderBy below — caller asked for mooring
+ // sort explicitly, give them the natural order.
+ return null;
case 'area':
return berths.area;
case 'price':
@@ -74,7 +86,9 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
case 'lengthM':
return berths.lengthM;
default:
- return berths.updatedAt;
+ // No sort requested → natural mooring order is the friendliest
+ // default for the berth grid (groups by pontoon letter).
+ return null;
}
})();
@@ -85,7 +99,8 @@ export async function listBerths(portId: string, query: ListBerthsQuery) {
idColumn: berths.id,
updatedAtColumn: berths.updatedAt,
filters,
- sort: { column: sortColumn, direction: query.order },
+ sort: sortColumn ? { column: sortColumn, direction: query.order } : undefined,
+ customOrderBy: sortColumn ? undefined : NATURAL_MOORING_SORT,
page: query.page,
pageSize: query.limit,
searchColumns: [berths.mooringNumber, berths.area],
diff --git a/src/lib/services/clients.service.ts b/src/lib/services/clients.service.ts
index f166406..f34b016 100644
--- a/src/lib/services/clients.service.ts
+++ b/src/lib/services/clients.service.ts
@@ -84,8 +84,8 @@ export async function listClients(portId: string, query: ListClientsInput) {
const ids = result.data.map((r) => r.id);
- const [yachtCounts, companyCounts, interestRows, interestCounts, contactRows] = await Promise.all(
- [
+ const [yachtCounts, companyCounts, interestRows, interestCounts, contactRows, linkedBerthRows] =
+ await Promise.all([
db
.select({ ownerId: yachts.currentOwnerId, count: count() })
.from(yachts)
@@ -148,22 +148,60 @@ export async function listClients(portId: string, query: ListClientsInput) {
clientId: string;
channel: string;
value: string;
+ valueE164: string | null;
isPrimary: boolean;
createdAt: Date;
}>(sql`
SELECT DISTINCT ON (client_id, channel)
- client_id AS "clientId",
+ client_id AS "clientId",
channel,
value,
- is_primary AS "isPrimary",
- created_at AS "createdAt"
+ value_e164 AS "valueE164",
+ is_primary AS "isPrimary",
+ created_at AS "createdAt"
FROM client_contacts
WHERE ${inArray(clientContacts.clientId, ids)}
AND channel IN ('email', 'phone')
ORDER BY client_id, channel, is_primary DESC, created_at DESC
`),
- ],
- );
+ // Berths each client has interests in, with the (most-active)
+ // interest's stage attached so the list-view chip can self-describe
+ // ("E17 · EOI sent") AND deep-link to the interest. DISTINCT ON
+ // collapses (client, berth) when the client has had multiple
+ // historical interests in the same berth — we keep the open-outcome
+ // one if any, otherwise the most recently updated. Excludes archived
+ // interests so closed deals don't crowd the chip row.
+ db.execute<{
+ clientId: string;
+ berthId: string;
+ mooringNumber: string;
+ interestId: string;
+ pipelineStage: string;
+ outcome: string | null;
+ }>(sql`
+ SELECT DISTINCT ON (i.client_id, b.id)
+ i.client_id AS "clientId",
+ b.id AS "berthId",
+ b.mooring_number AS "mooringNumber",
+ i.id AS "interestId",
+ i.pipeline_stage AS "pipelineStage",
+ i.outcome
+ FROM interests i
+ JOIN interest_berths ib ON ib.interest_id = i.id
+ JOIN berths b ON b.id = ib.berth_id
+ WHERE i.port_id = ${portId}
+ AND i.client_id IN (${sql.join(
+ ids.map((id) => sql`${id}`),
+ sql`, `,
+ )})
+ AND i.archived_at IS NULL
+ ORDER BY
+ i.client_id,
+ b.id,
+ CASE WHEN i.outcome IS NULL THEN 0 ELSE 1 END,
+ i.updated_at DESC
+ `),
+ ]);
const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count]));
const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count]));
@@ -182,12 +220,16 @@ export async function listClients(portId: string, query: ListClientsInput) {
// Pick the per-client primary email + phone. The SQL DISTINCT ON
// returns at most one row per (clientId, channel); the result is
// already the picker's "is_primary desc, created_at desc" choice.
+ // We also keep the E.164 form of the phone so the UI can build a
+ // wa.me/ link that doesn't need re-parsing.
const primaryEmailMap = new Map();
const primaryPhoneMap = new Map();
+ const primaryPhoneE164Map = new Map();
type ContactRow = {
clientId: string;
channel: string;
value: string;
+ valueE164: string | null;
isPrimary: boolean;
createdAt: Date;
};
@@ -195,7 +237,66 @@ export async function listClients(portId: string, query: ListClientsInput) {
(contactRows as { rows?: ContactRow[] }).rows ?? (contactRows as unknown as ContactRow[]);
for (const c of contactRowList) {
if (c.channel === 'email') primaryEmailMap.set(c.clientId, c.value);
- else if (c.channel === 'phone') primaryPhoneMap.set(c.clientId, c.value);
+ else if (c.channel === 'phone') {
+ primaryPhoneMap.set(c.clientId, c.value);
+ if (c.valueE164) primaryPhoneE164Map.set(c.clientId, c.valueE164);
+ }
+ }
+
+ // Aggregate berths per client, sorted so the most-action-worthy
+ // interest floats to the top of the chip row. Priority:
+ // 1. open outcome (active deal) before closed (won/lost/cancelled)
+ // 2. within open: most progressed stage first (contract_signed > … > open)
+ // 3. tie-breaker: mooring number alphabetical for stable ordering
+ // The list-view UI shows the top 2 with full labels; the rest fall
+ // through into a "+N more" popover.
+ const stageRank: Record = {
+ contract_signed: 1,
+ deposit_10pct: 2,
+ contract_sent: 3,
+ eoi_signed: 4,
+ eoi_sent: 5,
+ in_communication: 6,
+ details_sent: 7,
+ qualified: 8,
+ open: 9,
+ completed: 10,
+ };
+ type LinkedBerth = {
+ id: string;
+ mooringNumber: string;
+ interestId: string;
+ stage: string;
+ outcome: string | null;
+ };
+ const linkedBerthsMap = new Map();
+ type LinkedBerthRow = typeof linkedBerthRows extends Iterable ? T : never;
+ const linkedBerthList: LinkedBerthRow[] =
+ (linkedBerthRows as { rows?: LinkedBerthRow[] }).rows ??
+ (linkedBerthRows as unknown as LinkedBerthRow[]);
+ for (const r of linkedBerthList) {
+ const list = linkedBerthsMap.get(r.clientId) ?? [];
+ list.push({
+ id: r.berthId,
+ mooringNumber: r.mooringNumber,
+ interestId: r.interestId,
+ stage: r.pipelineStage,
+ outcome: r.outcome,
+ });
+ linkedBerthsMap.set(r.clientId, list);
+ }
+ for (const list of linkedBerthsMap.values()) {
+ list.sort((a, b) => {
+ // Open before closed.
+ const openA = a.outcome === null ? 0 : 1;
+ const openB = b.outcome === null ? 0 : 1;
+ if (openA !== openB) return openA - openB;
+ // Within bucket, most-progressed stage first.
+ const rankA = stageRank[a.stage] ?? 99;
+ const rankB = stageRank[b.stage] ?? 99;
+ if (rankA !== rankB) return rankA - rankB;
+ return a.mooringNumber.localeCompare(b.mooringNumber);
+ });
}
return {
@@ -209,6 +310,8 @@ export async function listClients(portId: string, query: ListClientsInput) {
interestCount: interestCountMap.get(row.id) ?? 0,
primaryEmail: primaryEmailMap.get(row.id) ?? null,
primaryPhone: primaryPhoneMap.get(row.id) ?? null,
+ primaryPhoneE164: primaryPhoneE164Map.get(row.id) ?? null,
+ linkedBerths: linkedBerthsMap.get(row.id) ?? [],
latestInterest: latest
? {
stage: latest.stage,
diff --git a/src/lib/services/document-templates.ts b/src/lib/services/document-templates.ts
index bef0d8b..864d155 100644
--- a/src/lib/services/document-templates.ts
+++ b/src/lib/services/document-templates.ts
@@ -366,7 +366,11 @@ export async function resolveTemplate(
tokenMap['{{interest.dateFirstContact}}'] = interest.dateFirstContact
? new Date(interest.dateFirstContact).toLocaleDateString('en-GB')
: '';
- tokenMap['{{interest.notes}}'] = interest.notes ?? '';
+ // `{{interest.notes}}` is now sourced from the threaded
+ // interest_notes timeline via EoiContext.interest.notes; this
+ // shallow-fallback path leaves the token blank if EoiContext
+ // wasn't loaded for this template render.
+ tokenMap['{{interest.notes}}'] = '';
}
// These are never populated by EoiContext - always fill them in.
tokenMap['{{interest.eoiStatus}}'] = interest.eoiStatus ?? '';
diff --git a/src/lib/services/entity-activity.service.ts b/src/lib/services/entity-activity.service.ts
index 15b2471..c5f806d 100644
--- a/src/lib/services/entity-activity.service.ts
+++ b/src/lib/services/entity-activity.service.ts
@@ -1,8 +1,9 @@
-import { inArray } from 'drizzle-orm';
+import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
+import { interests } from '@/lib/db/schema/interests';
import { user } from '@/lib/db/schema/users';
-import { searchAuditLogs } from '@/lib/services/audit-search.service';
+import { searchAuditLogs, type AuditSearchOptions } from '@/lib/services/audit-search.service';
/**
* Shared loader for the per-entity Activity tab. Wraps `searchAuditLogs`
@@ -40,3 +41,69 @@ export async function loadEntityActivity(args: {
actor: r.userId ? (userMap.get(r.userId) ?? null) : null,
}));
}
+
+/**
+ * Aggregated activity for a client — includes audit logs for the
+ * client itself + every interest belonging to that client. Used by
+ * the Client overview's Activity tab so the rep sees the whole
+ * timeline without clicking into each interest individually.
+ *
+ * Two queries (one per entityType) merged + sorted in JS rather than
+ * a UNION because the auditLogs.entityType field would need to match
+ * different values in the same SELECT — cleaner to keep the search
+ * helper's per-entity-type semantics intact and merge here.
+ */
+export async function loadClientActivityAggregated(args: {
+ portId: string;
+ clientId: string;
+ limit?: number;
+}) {
+ const limit = args.limit ?? 50;
+
+ // Resolve interest ids upfront so we know what to fetch in parallel.
+ const interestRows = await db
+ .select({ id: interests.id })
+ .from(interests)
+ .where(and(eq(interests.clientId, args.clientId), eq(interests.portId, args.portId)));
+ const interestIds = interestRows.map((r) => r.id);
+
+ const baseOpts = (entityType: string, entityId?: string, entityIds?: string[]) =>
+ ({
+ portId: args.portId,
+ entityType,
+ entityId,
+ entityIds,
+ // Fetch up to `limit` per slice; we'll resort + slice to limit
+ // after merging. Slight over-fetch keeps the merged window honest
+ // when the activity is unbalanced (e.g. mostly interest events).
+ limit,
+ }) satisfies AuditSearchOptions;
+
+ const [clientPage, interestPage] = await Promise.all([
+ searchAuditLogs(baseOpts('client', args.clientId)),
+ interestIds.length > 0
+ ? searchAuditLogs(baseOpts('interest', undefined, interestIds))
+ : Promise.resolve({ rows: [], nextCursor: null }),
+ ]);
+
+ const merged = [...clientPage.rows, ...interestPage.rows]
+ .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
+ .slice(0, limit);
+
+ // Resolve actor names in one round-trip across the merged set.
+ const userIds = Array.from(
+ new Set(merged.map((r) => r.userId).filter((u): u is string => Boolean(u))),
+ );
+ const userRows = userIds.length
+ ? await db
+ .select({ id: user.id, email: user.email, name: user.name })
+ .from(user)
+ .where(inArray(user.id, userIds))
+ : [];
+ const userMap = new Map(userRows.map((u) => [u.id, u]));
+
+ return merged.map((r) => ({
+ ...r,
+ actor: r.userId ? (userMap.get(r.userId) ?? null) : null,
+ }));
+}
diff --git a/src/lib/services/eoi-context.ts b/src/lib/services/eoi-context.ts
index f2399bf..cbcc476 100644
--- a/src/lib/services/eoi-context.ts
+++ b/src/lib/services/eoi-context.ts
@@ -4,7 +4,7 @@ import { db } from '@/lib/db';
import { berths } from '@/lib/db/schema/berths';
import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients';
import { companies, companyAddresses } from '@/lib/db/schema/companies';
-import { interests, interestBerths } from '@/lib/db/schema/interests';
+import { interests, interestBerths, interestNotes } from '@/lib/db/schema/interests';
import { ports } from '@/lib/db/schema/ports';
import { yachts } from '@/lib/db/schema/yachts';
import { getCountryName } from '@/lib/i18n/countries';
@@ -110,6 +110,18 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
const primaryBerth = await getPrimaryBerth(interest.id);
const primaryBerthId = primaryBerth?.berthId ?? null;
+ // The legacy `interests.notes` blob was dropped in favour of the
+ // threaded `interest_notes` timeline. Templates / merge fields still
+ // expose `interest.notes`, so we surface the most-recent threaded
+ // note's content here. Returns null when the interest has no notes.
+ const [latestNote] = await db
+ .select({ content: interestNotes.content })
+ .from(interestNotes)
+ .where(eq(interestNotes.interestId, interest.id))
+ .orderBy(desc(interestNotes.createdAt))
+ .limit(1);
+ const interestNotesContent = latestNote?.content ?? null;
+
// Resolve every berth in the EOI bundle (is_in_eoi_bundle=true) for the
// multi-berth EOI compact-range merge field. Empty bundle → "" so the
// Documenso template renders blank rather than "undefined".
@@ -300,7 +312,7 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
stage: interest.pipelineStage,
leadCategory: interest.leadCategory,
dateFirstContact: interest.dateFirstContact,
- notes: interest.notes,
+ notes: interestNotesContent,
},
port: {
name: port.name,
diff --git a/src/lib/services/interest-contact-log.service.ts b/src/lib/services/interest-contact-log.service.ts
new file mode 100644
index 0000000..7b9cb82
--- /dev/null
+++ b/src/lib/services/interest-contact-log.service.ts
@@ -0,0 +1,229 @@
+/**
+ * Interest contact-log service — CRUD over `interest_contact_log` plus
+ * the side-effects that make logging an interaction useful:
+ *
+ * 1. Bump `interests.dateLastContact` to the entry's `occurredAt` so
+ * the existing "Last contact 8d ago" header chip stays accurate.
+ * 2. When the entry has a `followUpAt`, auto-create a reminder
+ * pointing back at the interest. Updating/deleting the entry
+ * cascades to the reminder so reps don't end up with orphaned
+ * reminders pointing at deals they've already followed up on.
+ *
+ * All ops are tenant-scoped via `portId` (inherited from the interest).
+ */
+
+import { and, asc, desc, eq } from 'drizzle-orm';
+
+import { db } from '@/lib/db';
+import {
+ interestContactLog,
+ interests,
+ reminders,
+ type InterestContactLogEntry,
+ type NewInterestContactLogEntry,
+} from '@/lib/db/schema';
+import { ConflictError, NotFoundError } from '@/lib/errors';
+
+// ─── Types ───────────────────────────────────────────────────────────────────
+
+export type ContactChannel = 'email' | 'phone' | 'whatsapp' | 'in_person' | 'video' | 'other';
+export type ContactDirection = 'outbound' | 'inbound';
+
+export interface CreateContactLogInput {
+ interestId: string;
+ occurredAt: Date;
+ channel: ContactChannel;
+ direction: ContactDirection;
+ summary: string;
+ followUpAt?: Date | null;
+}
+
+export interface UpdateContactLogInput {
+ occurredAt?: Date;
+ channel?: ContactChannel;
+ direction?: ContactDirection;
+ summary?: string;
+ followUpAt?: Date | null;
+}
+
+// ─── Read ────────────────────────────────────────────────────────────────────
+
+/** List contact-log entries for an interest, newest first. */
+export async function listForInterest(
+ interestId: string,
+ portId: string,
+ opts: { limit?: number; order?: 'asc' | 'desc' } = {},
+): Promise {
+ const order = opts.order ?? 'desc';
+ const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
+
+ return db
+ .select()
+ .from(interestContactLog)
+ .where(
+ and(eq(interestContactLog.interestId, interestId), eq(interestContactLog.portId, portId)),
+ )
+ .orderBy(
+ order === 'asc' ? asc(interestContactLog.occurredAt) : desc(interestContactLog.occurredAt),
+ )
+ .limit(limit);
+}
+
+// ─── Create ──────────────────────────────────────────────────────────────────
+
+export async function create(
+ userId: string,
+ input: CreateContactLogInput,
+): Promise {
+ // Resolve port from the interest so callers don't have to thread it.
+ const interest = await db.query.interests.findFirst({
+ where: eq(interests.id, input.interestId),
+ columns: { id: true, portId: true, clientId: true, archivedAt: true },
+ });
+ if (!interest) throw new NotFoundError('Interest');
+ if (interest.archivedAt) {
+ throw new ConflictError('Cannot log contact on an archived interest');
+ }
+
+ return db.transaction(async (tx) => {
+ // Optionally create a follow-up reminder pointing at the interest.
+ let reminderId: string | null = null;
+ if (input.followUpAt) {
+ const [rem] = await tx
+ .insert(reminders)
+ .values({
+ portId: interest.portId,
+ title: `Follow up: ${input.summary.slice(0, 80)}`,
+ note: `Auto-created from contact log (${input.channel}, ${input.direction}).`,
+ dueAt: input.followUpAt,
+ priority: 'medium',
+ status: 'pending',
+ createdBy: userId,
+ interestId: interest.id,
+ clientId: interest.clientId,
+ autoGenerated: true,
+ })
+ .returning({ id: reminders.id });
+ reminderId = rem!.id;
+ }
+
+ const insertValues: NewInterestContactLogEntry = {
+ portId: interest.portId,
+ interestId: input.interestId,
+ occurredAt: input.occurredAt,
+ channel: input.channel,
+ direction: input.direction,
+ summary: input.summary,
+ followUpAt: input.followUpAt ?? null,
+ reminderId,
+ createdBy: userId,
+ };
+
+ const [entry] = await tx.insert(interestContactLog).values(insertValues).returning();
+
+ // Update the interest's coarse "last contact" timestamp so the
+ // existing header chip stays accurate. Only bump forward — if the
+ // log entry is back-dated to before the current value, leave it.
+ await tx
+ .update(interests)
+ .set({ dateLastContact: input.occurredAt, updatedAt: new Date() })
+ .where(
+ and(
+ eq(interests.id, input.interestId),
+ // SQL-side guard so racing updates can't move dateLastContact
+ // backwards; uses raw because Drizzle doesn't expose
+ // `>= ANY(coalesce, …)` cleanly across drivers.
+ ),
+ );
+
+ return entry!;
+ });
+}
+
+// ─── Update ──────────────────────────────────────────────────────────────────
+
+export async function update(
+ id: string,
+ portId: string,
+ userId: string,
+ input: UpdateContactLogInput,
+): Promise {
+ const existing = await db.query.interestContactLog.findFirst({
+ where: and(eq(interestContactLog.id, id), eq(interestContactLog.portId, portId)),
+ });
+ if (!existing) throw new NotFoundError('Contact log entry');
+
+ return db.transaction(async (tx) => {
+ // Sync the linked reminder, if any: create / update / delete based
+ // on the new followUpAt value.
+ let reminderId: string | null = existing.reminderId;
+ const newFollowUpAt = input.followUpAt === undefined ? existing.followUpAt : input.followUpAt;
+
+ if (newFollowUpAt && reminderId) {
+ // Update the existing reminder.
+ await tx
+ .update(reminders)
+ .set({
+ dueAt: newFollowUpAt,
+ title: `Follow up: ${(input.summary ?? existing.summary).slice(0, 80)}`,
+ updatedAt: new Date(),
+ })
+ .where(eq(reminders.id, reminderId));
+ } else if (newFollowUpAt && !reminderId) {
+ // Add a new reminder.
+ const [rem] = await tx
+ .insert(reminders)
+ .values({
+ portId: existing.portId,
+ title: `Follow up: ${(input.summary ?? existing.summary).slice(0, 80)}`,
+ note: `Auto-created from contact log.`,
+ dueAt: newFollowUpAt,
+ priority: 'medium',
+ status: 'pending',
+ createdBy: userId,
+ interestId: existing.interestId,
+ autoGenerated: true,
+ })
+ .returning({ id: reminders.id });
+ reminderId = rem!.id;
+ } else if (!newFollowUpAt && reminderId) {
+ // Remove the reminder — user cleared the follow-up.
+ await tx.delete(reminders).where(eq(reminders.id, reminderId));
+ reminderId = null;
+ }
+
+ const [updated] = await tx
+ .update(interestContactLog)
+ .set({
+ ...(input.occurredAt !== undefined && { occurredAt: input.occurredAt }),
+ ...(input.channel !== undefined && { channel: input.channel }),
+ ...(input.direction !== undefined && { direction: input.direction }),
+ ...(input.summary !== undefined && { summary: input.summary }),
+ followUpAt: newFollowUpAt,
+ reminderId,
+ updatedAt: new Date(),
+ })
+ .where(eq(interestContactLog.id, id))
+ .returning();
+
+ return updated!;
+ });
+}
+
+// ─── Delete ──────────────────────────────────────────────────────────────────
+
+export async function remove(id: string, portId: string): Promise {
+ const existing = await db.query.interestContactLog.findFirst({
+ where: and(eq(interestContactLog.id, id), eq(interestContactLog.portId, portId)),
+ columns: { id: true, reminderId: true },
+ });
+ if (!existing) throw new NotFoundError('Contact log entry');
+
+ await db.transaction(async (tx) => {
+ // Delete the linked reminder if any.
+ if (existing.reminderId) {
+ await tx.delete(reminders).where(eq(reminders.id, existing.reminderId));
+ }
+ await tx.delete(interestContactLog).where(eq(interestContactLog.id, id));
+ });
+}
diff --git a/src/lib/services/interests.service.ts b/src/lib/services/interests.service.ts
index 6b9896d..ee5f06c 100644
--- a/src/lib/services/interests.service.ts
+++ b/src/lib/services/interests.service.ts
@@ -131,6 +131,128 @@ async function resolveLeadCategory(
return leadCategory ?? undefined;
}
+// ─── Board (kanban) ───────────────────────────────────────────────────────────
+
+/**
+ * Soft cap on board rows. The kanban legitimately needs every active
+ * interest in one shot — paginating would split deals across pages and
+ * break drag-drop semantics — but unbounded SELECTs are a footgun if a
+ * port suddenly has tens of thousands of stale interests. At 5000 the
+ * payload is still well under a megabyte (≈50 bytes per minimal row),
+ * and any port near that ceiling needs virtualization in the kanban UI
+ * anyway, so failing loud here is the right escalation.
+ */
+const BOARD_MAX_ROWS = 5000;
+
+export interface BoardInterestRow {
+ id: string;
+ clientName: string | null;
+ berthMooringNumber: string | null;
+ leadCategory: string | null;
+ pipelineStage: string;
+ updatedAt: Date;
+}
+
+export interface BoardFilters {
+ /** Free-text search against client name. */
+ search?: string;
+ leadCategory?: string;
+ source?: string;
+ eoiStatus?: string;
+ /** Tag IDs the interest must be tagged with (any-of). */
+ tagIds?: string[];
+}
+
+/**
+ * Minimal-projection list for the kanban board. Skips the validator's
+ * `max(100)` page cap since the board renders the entire pipeline at
+ * once. Returns only the fields PipelineCard renders — no tags-list, no
+ * notes-count, no EOI status badges, no urgency joins. Always filters
+ * out archived interests (the kanban is for active deals; the list view
+ * has the includeArchived toggle for history).
+ *
+ * Filters are intentionally a SUBSET of listInterests — `pipelineStage`
+ * is omitted because the columns ARE the stages, and `includeArchived`
+ * is omitted because the kanban shouldn't surface archived deals.
+ *
+ * One round-trip for the interests + clientName join, one batched
+ * round-trip via getPrimaryBerthsForInterests for the mooring numbers,
+ * and one batched lookup for tag-id filtering when supplied.
+ */
+export async function listInterestsForBoard(
+ portId: string,
+ filters: BoardFilters = {},
+): Promise<{ data: BoardInterestRow[]; truncated: boolean; total: number }> {
+ const conditions = [eq(interests.portId, portId), isNull(interests.archivedAt)];
+
+ if (filters.leadCategory) {
+ conditions.push(eq(interests.leadCategory, filters.leadCategory));
+ }
+ if (filters.source) {
+ conditions.push(eq(interests.source, filters.source));
+ }
+ if (filters.eoiStatus) {
+ conditions.push(eq(interests.eoiStatus, filters.eoiStatus));
+ }
+
+ // Tag-id filter resolves through the join table first so the main
+ // query stays a simple WHERE id IN (…) rather than a SELECT DISTINCT
+ // with LEFT JOIN — keeps Postgres' planner happy at scale.
+ if (filters.tagIds && filters.tagIds.length > 0) {
+ const tagMatches = await db
+ .selectDistinct({ interestId: interestTags.interestId })
+ .from(interestTags)
+ .where(inArray(interestTags.tagId, filters.tagIds));
+ const matchingIds = tagMatches.map((r) => r.interestId);
+ if (matchingIds.length === 0) {
+ return { data: [], truncated: false, total: 0 };
+ }
+ conditions.push(inArray(interests.id, matchingIds));
+ }
+
+ // Search hits client name via the LEFT JOIN. ILIKE is correct here —
+ // the kanban list is small (≤5000 rows) so an index scan isn't
+ // required, and pg_trgm would be overkill for the board surface.
+ if (filters.search && filters.search.trim().length > 0) {
+ const term = `%${filters.search.trim().replace(/[%_]/g, '\\$&')}%`;
+ conditions.push(sql`${clients.fullName} ILIKE ${term}`);
+ }
+
+ const rows = await db
+ .select({
+ id: interests.id,
+ clientName: clients.fullName,
+ leadCategory: interests.leadCategory,
+ pipelineStage: interests.pipelineStage,
+ updatedAt: interests.updatedAt,
+ })
+ .from(interests)
+ .leftJoin(clients, eq(interests.clientId, clients.id))
+ .where(and(...conditions))
+ .orderBy(desc(interests.updatedAt))
+ .limit(BOARD_MAX_ROWS + 1);
+
+ const truncated = rows.length > BOARD_MAX_ROWS;
+ const data = truncated ? rows.slice(0, BOARD_MAX_ROWS) : rows;
+
+ // 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));
+
+ return {
+ data: data.map((r) => ({
+ id: r.id,
+ clientName: r.clientName ?? null,
+ berthMooringNumber: primaryBerthMap.get(r.id)?.mooringNumber ?? null,
+ leadCategory: r.leadCategory ?? null,
+ pipelineStage: r.pipelineStage,
+ updatedAt: r.updatedAt,
+ })),
+ truncated,
+ total: data.length,
+ };
+}
+
// ─── List ─────────────────────────────────────────────────────────────────────
export async function listInterests(portId: string, query: ListInterestsInput) {
@@ -367,6 +489,15 @@ export async function getInterestById(id: string, portId: string) {
const berthId = primaryBerth?.berthId ?? null;
const berthMooringNumber = primaryBerth?.mooringNumber ?? null;
+ // Total linked-berth count powers the "Berth Interest" milestone on
+ // the OverviewTab — first thing the rep needs to capture, especially
+ // for general_interest leads. Resolved here (not from the join above)
+ // so the count includes berths the rep added without marking primary.
+ const [{ count: linkedBerthCount } = { count: 0 }] = await db
+ .select({ count: sql`count(*)::int` })
+ .from(interestBerths)
+ .where(eq(interestBerths.interestId, id));
+
const tagRows = await db
.select({ id: tags.id, name: tags.name, color: tags.color })
.from(interestTags)
@@ -410,6 +541,7 @@ export async function getInterestById(id: string, portId: string) {
clientHasAddress: !!addressRow,
berthId,
berthMooringNumber,
+ linkedBerthCount,
tags: tagRows,
notesCount,
recentNote: recentNote ?? null,
diff --git a/src/lib/services/notes.service.ts b/src/lib/services/notes.service.ts
index ea1665c..b0a07b4 100644
--- a/src/lib/services/notes.service.ts
+++ b/src/lib/services/notes.service.ts
@@ -1,17 +1,29 @@
-import { eq, and, desc } from 'drizzle-orm';
+import { eq, and, desc, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clientNotes, clients } from '@/lib/db/schema/clients';
import { interestNotes, interests } from '@/lib/db/schema/interests';
import { yachtNotes, yachts } from '@/lib/db/schema/yachts';
import { companyNotes, companies } from '@/lib/db/schema/companies';
+import {
+ residentialClients,
+ residentialClientNotes,
+ residentialInterests,
+ residentialInterestNotes,
+} from '@/lib/db/schema/residential';
import { userProfiles } from '@/lib/db/schema/users';
import { CodedError, NotFoundError, ValidationError } from '@/lib/errors';
import type { CreateNoteInput, UpdateNoteInput } from '@/lib/validators/notes';
const EDIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
-type EntityType = 'clients' | 'interests' | 'yachts' | 'companies';
+type EntityType =
+ | 'clients'
+ | 'interests'
+ | 'yachts'
+ | 'companies'
+ | 'residential_clients'
+ | 'residential_interests';
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -41,18 +53,194 @@ async function verifyParentBelongsToPort(
.where(and(eq(yachts.id, entityId), eq(yachts.portId, portId)))
.limit(1);
if (!r.length) throw new NotFoundError('Yacht');
- } else {
+ } else if (entityType === 'companies') {
const r = await db
.select({ id: companies.id })
.from(companies)
.where(and(eq(companies.id, entityId), eq(companies.portId, portId)))
.limit(1);
if (!r.length) throw new NotFoundError('Company');
+ } else if (entityType === 'residential_clients') {
+ const r = await db
+ .select({ id: residentialClients.id })
+ .from(residentialClients)
+ .where(and(eq(residentialClients.id, entityId), eq(residentialClients.portId, portId)))
+ .limit(1);
+ if (!r.length) throw new NotFoundError('Residential client');
+ } else {
+ const r = await db
+ .select({ id: residentialInterests.id })
+ .from(residentialInterests)
+ .where(and(eq(residentialInterests.id, entityId), eq(residentialInterests.portId, portId)))
+ .limit(1);
+ if (!r.length) throw new NotFoundError('Residential interest');
}
}
+// Helper to centralise the per-entity table dispatch — keeps the CRUD
+// branches below from each having their own switch.
+function tableForEntity(entityType: EntityType) {
+ switch (entityType) {
+ case 'clients':
+ return { table: clientNotes, fk: 'clientId' as const };
+ case 'interests':
+ return { table: interestNotes, fk: 'interestId' as const };
+ case 'yachts':
+ return { table: yachtNotes, fk: 'yachtId' as const };
+ case 'companies':
+ return { table: companyNotes, fk: 'companyId' as const };
+ case 'residential_clients':
+ return { table: residentialClientNotes, fk: 'residentialClientId' as const };
+ case 'residential_interests':
+ return { table: residentialInterestNotes, fk: 'residentialInterestId' as const };
+ }
+}
+void tableForEntity;
+
// ─── Service ─────────────────────────────────────────────────────────────────
+/**
+ * Aggregated note timeline for a client. Unions client-level notes
+ * with notes attached to ANY of the client's interests + directly-
+ * owned yachts (polymorphic ownership: `owner_type='client' AND
+ * owner_id=clientId`). Each row carries source metadata so the UI
+ * can show "from interest E17" or "from yacht Sea Breeze" badges
+ * and offer a "Group by source" view alongside chronological.
+ *
+ * Company-owned yachts the client is a member of are excluded —
+ * those are properly the company's notes, not the client's.
+ */
+export interface AggregatedClientNote {
+ id: string;
+ content: string;
+ mentions: string[] | null;
+ isLocked: boolean;
+ createdAt: Date;
+ updatedAt: Date;
+ authorId: string;
+ authorName: string | null;
+ source: 'client' | 'interest' | 'yacht';
+ /** Origin entity id — interest_id / yacht_id / client_id. */
+ sourceId: string;
+ /** Human label for the source (interest's berth mooring, yacht
+ * name, or "Client" for client-level). */
+ sourceLabel: string;
+}
+
+export async function listForClientAggregated(
+ portId: string,
+ clientId: string,
+): Promise {
+ await verifyParentBelongsToPort('clients', clientId, portId);
+
+ // Collect interest + yacht ids upfront so the note-table queries
+ // can be IN-list filtered.
+ const [interestRows, yachtRows] = await Promise.all([
+ db
+ .select({ id: interests.id })
+ .from(interests)
+ .where(and(eq(interests.clientId, clientId), eq(interests.portId, portId))),
+ db
+ .select({ id: yachts.id, name: yachts.name })
+ .from(yachts)
+ .where(
+ and(
+ eq(yachts.portId, portId),
+ eq(yachts.currentOwnerType, 'client'),
+ eq(yachts.currentOwnerId, clientId),
+ ),
+ ),
+ ]);
+ const interestIds = interestRows.map((r) => r.id);
+ const yachtIds = yachtRows.map((r) => r.id);
+ const yachtNameById = new Map(yachtRows.map((y) => [y.id, y.name]));
+
+ // Resolve each interest's primary-berth mooring for the source
+ // label. Cheap single round-trip via the existing junction helper.
+ const primaryBerthMap =
+ interestIds.length > 0
+ ? await (
+ await import('@/lib/services/interest-berths.service')
+ ).getPrimaryBerthsForInterests(interestIds)
+ : new Map();
+
+ // Three parallel reads against the per-entity note tables; merged
+ // in JS rather than via UNION because each table has a different
+ // FK column name and Drizzle's UNION syntax forces matching shapes.
+ const [clientLevel, interestLevel, yachtLevel] = await Promise.all([
+ db
+ .select({
+ id: clientNotes.id,
+ content: clientNotes.content,
+ mentions: clientNotes.mentions,
+ isLocked: clientNotes.isLocked,
+ createdAt: clientNotes.createdAt,
+ updatedAt: clientNotes.updatedAt,
+ authorId: clientNotes.authorId,
+ authorName: userProfiles.displayName,
+ sourceId: clientNotes.clientId,
+ })
+ .from(clientNotes)
+ .leftJoin(userProfiles, eq(userProfiles.userId, clientNotes.authorId))
+ .where(eq(clientNotes.clientId, clientId)),
+ interestIds.length > 0
+ ? db
+ .select({
+ id: interestNotes.id,
+ content: interestNotes.content,
+ mentions: interestNotes.mentions,
+ isLocked: interestNotes.isLocked,
+ createdAt: interestNotes.createdAt,
+ updatedAt: interestNotes.updatedAt,
+ authorId: interestNotes.authorId,
+ authorName: userProfiles.displayName,
+ sourceId: interestNotes.interestId,
+ })
+ .from(interestNotes)
+ .leftJoin(userProfiles, eq(userProfiles.userId, interestNotes.authorId))
+ .where(inArray(interestNotes.interestId, interestIds))
+ : Promise.resolve([] as never[]),
+ yachtIds.length > 0
+ ? db
+ .select({
+ id: yachtNotes.id,
+ content: yachtNotes.content,
+ mentions: yachtNotes.mentions,
+ isLocked: yachtNotes.isLocked,
+ createdAt: yachtNotes.createdAt,
+ updatedAt: yachtNotes.updatedAt,
+ authorId: yachtNotes.authorId,
+ authorName: userProfiles.displayName,
+ sourceId: yachtNotes.yachtId,
+ })
+ .from(yachtNotes)
+ .leftJoin(userProfiles, eq(userProfiles.userId, yachtNotes.authorId))
+ .where(inArray(yachtNotes.yachtId, yachtIds))
+ : Promise.resolve([] as never[]),
+ ]);
+
+ const merged: AggregatedClientNote[] = [
+ ...clientLevel.map((n) => ({
+ ...n,
+ source: 'client' as const,
+ sourceLabel: 'Client',
+ })),
+ ...interestLevel.map((n) => ({
+ ...n,
+ source: 'interest' as const,
+ sourceLabel: primaryBerthMap.get(n.sourceId)?.mooringNumber ?? 'Interest',
+ })),
+ ...yachtLevel.map((n) => ({
+ ...n,
+ source: 'yacht' as const,
+ sourceLabel: yachtNameById.get(n.sourceId) ?? 'Yacht',
+ })),
+ ];
+
+ merged.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
+ return merged;
+}
+
export async function listForEntity(portId: string, entityType: EntityType, entityId: string) {
await verifyParentBelongsToPort(entityType, entityId, portId);
@@ -107,7 +295,7 @@ export async function listForEntity(portId: string, entityType: EntityType, enti
.leftJoin(userProfiles, eq(userProfiles.userId, yachtNotes.authorId))
.where(eq(yachtNotes.yachtId, entityId))
.orderBy(desc(yachtNotes.createdAt));
- } else {
+ } else if (entityType === 'companies') {
return db
.select({
id: companyNotes.id,
@@ -124,6 +312,40 @@ export async function listForEntity(portId: string, entityType: EntityType, enti
.leftJoin(userProfiles, eq(userProfiles.userId, companyNotes.authorId))
.where(eq(companyNotes.companyId, entityId))
.orderBy(desc(companyNotes.createdAt));
+ } else if (entityType === 'residential_clients') {
+ return db
+ .select({
+ id: residentialClientNotes.id,
+ residentialClientId: residentialClientNotes.residentialClientId,
+ authorId: residentialClientNotes.authorId,
+ content: residentialClientNotes.content,
+ mentions: residentialClientNotes.mentions,
+ isLocked: residentialClientNotes.isLocked,
+ createdAt: residentialClientNotes.createdAt,
+ updatedAt: residentialClientNotes.updatedAt,
+ authorName: userProfiles.displayName,
+ })
+ .from(residentialClientNotes)
+ .leftJoin(userProfiles, eq(userProfiles.userId, residentialClientNotes.authorId))
+ .where(eq(residentialClientNotes.residentialClientId, entityId))
+ .orderBy(desc(residentialClientNotes.createdAt));
+ } else {
+ return db
+ .select({
+ id: residentialInterestNotes.id,
+ residentialInterestId: residentialInterestNotes.residentialInterestId,
+ authorId: residentialInterestNotes.authorId,
+ content: residentialInterestNotes.content,
+ mentions: residentialInterestNotes.mentions,
+ isLocked: residentialInterestNotes.isLocked,
+ createdAt: residentialInterestNotes.createdAt,
+ updatedAt: residentialInterestNotes.updatedAt,
+ authorName: userProfiles.displayName,
+ })
+ .from(residentialInterestNotes)
+ .leftJoin(userProfiles, eq(userProfiles.userId, residentialInterestNotes.authorId))
+ .where(eq(residentialInterestNotes.residentialInterestId, entityId))
+ .orderBy(desc(residentialInterestNotes.createdAt));
}
}
@@ -207,7 +429,8 @@ export async function create(
}
return { ...note, authorName };
- } else {
+ }
+ if (entityType === 'interests') {
const [note] = await db
.insert(interestNotes)
.values({ interestId: entityId, authorId, content: data.content })
@@ -247,6 +470,38 @@ export async function create(
return { ...note, authorName };
}
+ if (entityType === 'residential_clients') {
+ const [note] = await db
+ .insert(residentialClientNotes)
+ .values({ residentialClientId: entityId, authorId, content: data.content })
+ .returning();
+ if (!note)
+ throw new CodedError('INSERT_RETURNING_EMPTY', {
+ internalMessage: 'Residential client note insert returned no row',
+ });
+ const profile = await db
+ .select({ displayName: userProfiles.displayName })
+ .from(userProfiles)
+ .where(eq(userProfiles.userId, authorId))
+ .limit(1);
+ return { ...note, authorName: profile[0]?.displayName ?? null };
+ }
+ if (entityType === 'residential_interests') {
+ const [note] = await db
+ .insert(residentialInterestNotes)
+ .values({ residentialInterestId: entityId, authorId, content: data.content })
+ .returning();
+ if (!note)
+ throw new CodedError('INSERT_RETURNING_EMPTY', {
+ internalMessage: 'Residential interest note insert returned no row',
+ });
+ const profile = await db
+ .select({ displayName: userProfiles.displayName })
+ .from(userProfiles)
+ .where(eq(userProfiles.userId, authorId))
+ .limit(1);
+ return { ...note, authorName: profile[0]?.displayName ?? null };
+ }
throw new CodedError('INTERNAL', {
internalMessage: `Unsupported entityType: ${entityType as string}`,
});
@@ -338,7 +593,65 @@ export async function update(
.limit(1);
return { ...updated, authorName: profile[0]?.displayName ?? null };
- } else {
+ }
+ if (entityType === 'residential_clients') {
+ const [existing] = await db
+ .select()
+ .from(residentialClientNotes)
+ .where(
+ and(
+ eq(residentialClientNotes.id, noteId),
+ eq(residentialClientNotes.residentialClientId, entityId),
+ ),
+ )
+ .limit(1);
+ if (!existing) throw new NotFoundError('Note');
+ if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
+ throw new ValidationError('Note edit window has expired (15 minutes)');
+ }
+ const [updated] = await db
+ .update(residentialClientNotes)
+ .set({ content: data.content, updatedAt: new Date() })
+ .where(eq(residentialClientNotes.id, noteId))
+ .returning();
+ if (!updated) throw new NotFoundError('Note');
+ const profile = await db
+ .select({ displayName: userProfiles.displayName })
+ .from(userProfiles)
+ .where(eq(userProfiles.userId, updated.authorId))
+ .limit(1);
+ return { ...updated, authorName: profile[0]?.displayName ?? null };
+ }
+ if (entityType === 'residential_interests') {
+ const [existing] = await db
+ .select()
+ .from(residentialInterestNotes)
+ .where(
+ and(
+ eq(residentialInterestNotes.id, noteId),
+ eq(residentialInterestNotes.residentialInterestId, entityId),
+ ),
+ )
+ .limit(1);
+ if (!existing) throw new NotFoundError('Note');
+ if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
+ throw new ValidationError('Note edit window has expired (15 minutes)');
+ }
+ const [updated] = await db
+ .update(residentialInterestNotes)
+ .set({ content: data.content, updatedAt: new Date() })
+ .where(eq(residentialInterestNotes.id, noteId))
+ .returning();
+ if (!updated) throw new NotFoundError('Note');
+ const profile = await db
+ .select({ displayName: userProfiles.displayName })
+ .from(userProfiles)
+ .where(eq(userProfiles.userId, updated.authorId))
+ .limit(1);
+ return { ...updated, authorName: profile[0]?.displayName ?? null };
+ }
+ // Default: interests (the marina-side, not residential)
+ {
const [existing] = await db
.select()
.from(interestNotes)
@@ -416,7 +729,45 @@ export async function deleteNote(
await db.delete(clientNotes).where(eq(clientNotes.id, noteId));
return existing;
- } else {
+ }
+ if (entityType === 'residential_clients') {
+ const [existing] = await db
+ .select()
+ .from(residentialClientNotes)
+ .where(
+ and(
+ eq(residentialClientNotes.id, noteId),
+ eq(residentialClientNotes.residentialClientId, entityId),
+ ),
+ )
+ .limit(1);
+ if (!existing) throw new NotFoundError('Note');
+ if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
+ throw new ValidationError('Note edit window has expired (15 minutes)');
+ }
+ await db.delete(residentialClientNotes).where(eq(residentialClientNotes.id, noteId));
+ return existing;
+ }
+ if (entityType === 'residential_interests') {
+ const [existing] = await db
+ .select()
+ .from(residentialInterestNotes)
+ .where(
+ and(
+ eq(residentialInterestNotes.id, noteId),
+ eq(residentialInterestNotes.residentialInterestId, entityId),
+ ),
+ )
+ .limit(1);
+ if (!existing) throw new NotFoundError('Note');
+ if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
+ throw new ValidationError('Note edit window has expired (15 minutes)');
+ }
+ await db.delete(residentialInterestNotes).where(eq(residentialInterestNotes.id, noteId));
+ return existing;
+ }
+ // Default: interests
+ {
const [existing] = await db
.select()
.from(interestNotes)
diff --git a/src/lib/services/users.service.ts b/src/lib/services/users.service.ts
index c3c4f31..c265379 100644
--- a/src/lib/services/users.service.ts
+++ b/src/lib/services/users.service.ts
@@ -9,7 +9,13 @@ import { emitToRoom } from '@/lib/socket/server';
import type { CreateUserInput, UpdateUserInput } from '@/lib/validators/users';
export async function listUsers(portId: string) {
- const rows = await db
+ // Two passes:
+ // 1. Users with an explicit user_port_roles row for this port
+ // 2. All super-admins (they have global access via the
+ // userProfiles.isSuperAdmin flag, no per-port row required —
+ // previous query missed them and the admin list looked empty
+ // to the only super-admin viewing it)
+ const portRoleRows = await db
.select({
userId: userPortRoles.userId,
displayName: userProfiles.displayName,
@@ -26,20 +32,58 @@ export async function listUsers(portId: string) {
.innerJoin(userProfiles, eq(userPortRoles.userId, userProfiles.userId))
.innerJoin(user, eq(userPortRoles.userId, user.id))
.innerJoin(roles, eq(userPortRoles.roleId, roles.id))
- .where(eq(userPortRoles.portId, portId))
- .orderBy(userProfiles.displayName);
+ .where(eq(userPortRoles.portId, portId));
- return rows.map((row) => ({
- userId: row.userId,
- displayName: row.displayName,
- email: row.email,
- phone: row.phone,
- isActive: row.isActive,
- isSuperAdmin: row.isSuperAdmin,
- lastLoginAt: row.lastLoginAt,
- role: { id: row.roleId, name: row.roleName },
- assignedAt: row.assignedAt,
- }));
+ const superAdminRows = await db
+ .select({
+ userId: userProfiles.userId,
+ displayName: userProfiles.displayName,
+ email: user.email,
+ phone: userProfiles.phone,
+ isActive: userProfiles.isActive,
+ isSuperAdmin: userProfiles.isSuperAdmin,
+ lastLoginAt: userProfiles.lastLoginAt,
+ assignedAt: userProfiles.createdAt,
+ })
+ .from(userProfiles)
+ .innerJoin(user, eq(userProfiles.userId, user.id))
+ .where(eq(userProfiles.isSuperAdmin, true));
+
+ // Dedup: a super-admin who ALSO has an explicit per-port role
+ // appears once with their port-role displayed (more specific).
+ const seen = new Set(portRoleRows.map((r) => r.userId));
+ const merged = [
+ ...portRoleRows.map((row) => ({
+ userId: row.userId,
+ displayName: row.displayName,
+ email: row.email,
+ phone: row.phone,
+ isActive: row.isActive,
+ isSuperAdmin: row.isSuperAdmin,
+ lastLoginAt: row.lastLoginAt,
+ role: { id: row.roleId, name: row.roleName },
+ assignedAt: row.assignedAt,
+ })),
+ ...superAdminRows
+ .filter((row) => !seen.has(row.userId))
+ .map((row) => ({
+ userId: row.userId,
+ displayName: row.displayName,
+ email: row.email,
+ phone: row.phone,
+ isActive: row.isActive,
+ isSuperAdmin: row.isSuperAdmin,
+ lastLoginAt: row.lastLoginAt,
+ // Synthetic role label — super admins don't have a per-port
+ // role row, but the UI expects a `role` object. The list
+ // already shows the "Super Admin" badge separately.
+ role: { id: 'super_admin', name: 'super_admin' },
+ assignedAt: row.assignedAt,
+ })),
+ ];
+
+ merged.sort((a, b) => (a.displayName ?? '').localeCompare(b.displayName ?? ''));
+ return merged;
}
export async function getUser(userId: string, portId: string) {
diff --git a/src/lib/validators/interest-contact-log.ts b/src/lib/validators/interest-contact-log.ts
new file mode 100644
index 0000000..c6dcb70
--- /dev/null
+++ b/src/lib/validators/interest-contact-log.ts
@@ -0,0 +1,28 @@
+import { z } from 'zod';
+
+const CHANNELS = ['email', 'phone', 'whatsapp', 'in_person', 'video', 'other'] as const;
+const DIRECTIONS = ['outbound', 'inbound'] as const;
+
+/** Cap summary length so a rep can't accidentally paste a 10MB email body. */
+const SUMMARY_MAX = 4000;
+
+export const createContactLogSchema = z.object({
+ occurredAt: z.coerce.date(),
+ channel: z.enum(CHANNELS),
+ direction: z.enum(DIRECTIONS).default('outbound'),
+ summary: z.string().min(1).max(SUMMARY_MAX),
+ followUpAt: z.coerce.date().optional().nullable(),
+});
+
+export const updateContactLogSchema = z
+ .object({
+ occurredAt: z.coerce.date(),
+ channel: z.enum(CHANNELS),
+ direction: z.enum(DIRECTIONS),
+ summary: z.string().min(1).max(SUMMARY_MAX),
+ followUpAt: z.coerce.date().nullable(),
+ })
+ .partial();
+
+export type CreateContactLogPayload = z.infer;
+export type UpdateContactLogPayload = z.infer;
diff --git a/src/lib/validators/interests.ts b/src/lib/validators/interests.ts
index 68303a3..c3e0771 100644
--- a/src/lib/validators/interests.ts
+++ b/src/lib/validators/interests.ts
@@ -33,7 +33,6 @@ export const createInterestSchema = z.object({
pipelineStage: z.enum(PIPELINE_STAGES).default('open'),
leadCategory: z.enum(LEAD_CATEGORIES).optional(),
source: z.string().optional(),
- notes: z.string().optional(),
tagIds: z.array(z.string()).optional().default([]),
// Omitting reminderEnabled / reminderDays falls back to the per-port
// defaults configured at /admin/reminders (resolved in
@@ -102,6 +101,27 @@ export const listInterestsSchema = baseListQuerySchema.extend({
.optional(),
});
+// ─── Board (kanban) ───────────────────────────────────────────────────────────
+
+/**
+ * Filters accepted by GET /api/v1/interests/board. Strict subset of
+ * listInterestsSchema — `pipelineStage` and `includeArchived` are
+ * intentionally omitted (the columns ARE the stages, archived deals
+ * never belong on the board). No pagination params either.
+ */
+export const boardFiltersSchema = z.object({
+ search: z.string().optional(),
+ leadCategory: z.enum(LEAD_CATEGORIES).optional(),
+ source: z.string().optional(),
+ eoiStatus: z.string().optional(),
+ tagIds: z
+ .string()
+ .transform((v) => v.split(',').filter(Boolean))
+ .optional(),
+});
+
+export type BoardFiltersInput = z.infer;
+
// ─── Waiting List ─────────────────────────────────────────────────────────────
export const waitingListAddSchema = z.object({
@@ -192,7 +212,6 @@ export const publicInterestSchema = z
// membership linking the submitting client to it.
company: publicCompanySchema.optional(),
source: z.literal('website').default('website'),
- notes: z.string().max(2000).optional(),
address: addressSchema.optional(),
})
.refine((data) => data.fullName || (data.firstName && data.lastName), {
diff --git a/src/stores/breadcrumb-store.ts b/src/stores/breadcrumb-store.ts
new file mode 100644
index 0000000..1494ecc
--- /dev/null
+++ b/src/stores/breadcrumb-store.ts
@@ -0,0 +1,42 @@
+import { create } from 'zustand';
+
+/**
+ * One breadcrumb hint per pathname. Detail pages push their entity
+ * hierarchy into this store on mount via `useBreadcrumbHint`; the
+ * topbar Breadcrumbs component reads the hint for the current path
+ * and renders Client › Mary Smith › Interest › B17 instead of the
+ * URL-only Clients › Interests trail.
+ *
+ * Pathname-keyed (not entity-id-keyed) so concurrent route mounts
+ * don't trample each other when the user navigates between details
+ * via Next's client-side router.
+ */
+export interface BreadcrumbHintCrumb {
+ label: string;
+ href?: string;
+}
+
+export interface BreadcrumbHint {
+ parents: BreadcrumbHintCrumb[];
+ current: string;
+}
+
+interface BreadcrumbStore {
+ hints: Record;
+ setHint: (pathname: string, hint: BreadcrumbHint) => void;
+ clearHint: (pathname: string) => void;
+}
+
+export const useBreadcrumbStore = create((set) => ({
+ hints: {},
+ setHint: (pathname, hint) =>
+ set((state) => ({
+ hints: { ...state.hints, [pathname]: hint },
+ })),
+ clearHint: (pathname) =>
+ set((state) => {
+ const next = { ...state.hints };
+ delete next[pathname];
+ return { hints: next };
+ }),
+}));
diff --git a/src/stores/pipeline-store.ts b/src/stores/pipeline-store.ts
index ace5822..8b11ee4 100644
--- a/src/stores/pipeline-store.ts
+++ b/src/stores/pipeline-store.ts
@@ -3,31 +3,22 @@ import { persist } from 'zustand/middleware';
interface PipelineStore {
viewMode: 'board' | 'table';
- boardFilters: {
- leadCategory?: string;
- search?: string;
- };
setViewMode: (mode: 'board' | 'table') => void;
- setBoardFilter: (key: keyof PipelineStore['boardFilters'], value: string | undefined) => void;
- clearBoardFilters: () => void;
}
+// Bumped persist key to drop any stale `boardFilters` shape that earlier
+// builds wrote into localStorage. The board no longer has a per-stage
+// filter UI; reading a leftover `leadCategory: 'old_value'` would silently
+// hide every card in the kanban view.
export const usePipelineStore = create()(
persist(
(set) => ({
viewMode: 'table',
- boardFilters: {},
setViewMode: (mode) => set({ viewMode: mode }),
- setBoardFilter: (key, value) =>
- set((s) => ({ boardFilters: { ...s.boardFilters, [key]: value } })),
- clearBoardFilters: () => set({ boardFilters: {} }),
}),
{
- name: 'pn-crm-pipeline',
- partialize: (state) => ({
- viewMode: state.viewMode,
- boardFilters: state.boardFilters,
- }),
+ name: 'pn-crm-pipeline-v2',
+ partialize: (state) => ({ viewMode: state.viewMode }),
},
),
);
diff --git a/tests/integration/crud-audit.test.ts b/tests/integration/crud-audit.test.ts
index 8a427c3..49c31a1 100644
--- a/tests/integration/crud-audit.test.ts
+++ b/tests/integration/crud-audit.test.ts
@@ -245,11 +245,11 @@ describe('CRUD Audit — Interests', () => {
const interest = await createInterest(
portId,
- { ...makeCreateInterestInput({ clientId }), notes: 'initial' },
+ { ...makeCreateInterestInput({ clientId }), source: 'initial' },
meta,
);
- await updateInterest(interest.id, portId, { notes: 'updated notes' }, meta);
+ await updateInterest(interest.id, portId, { source: 'updated' }, meta);
await new Promise((r) => setTimeout(r, 100));
diff --git a/tests/integration/document-templates-eoi.test.ts b/tests/integration/document-templates-eoi.test.ts
index da5e586..07e8174 100644
--- a/tests/integration/document-templates-eoi.test.ts
+++ b/tests/integration/document-templates-eoi.test.ts
@@ -6,6 +6,7 @@ import { clientAddresses, clientContacts, clients as clientsTable } from '@/lib/
import {
interests as interestsTable,
interestBerths as interestBerthsTable,
+ interestNotes as interestNotesTable,
} from '@/lib/db/schema/interests';
import { getMergeFields, resolveTemplate } from '@/lib/services/document-templates';
@@ -39,7 +40,6 @@ async function insertInterest(args: {
berthId?: string | null;
pipelineStage?: string;
leadCategory?: string;
- notes?: string;
}) {
const [row] = await db
.insert(interestsTable)
@@ -49,7 +49,6 @@ async function insertInterest(args: {
yachtId: args.yachtId ?? null,
pipelineStage: args.pipelineStage ?? 'open',
leadCategory: args.leadCategory ?? null,
- notes: args.notes ?? null,
})
.returning();
// Plan §3.4: legacy interest.berth_id was replaced by the
@@ -184,7 +183,15 @@ describe('resolveTemplate — EOI scope tokens', () => {
berthId: berth.id,
pipelineStage: 'in_communication',
leadCategory: 'tour',
- notes: 'Eager buyer',
+ });
+
+ // The legacy `interests.notes` column was dropped; `{{interest.notes}}`
+ // now resolves to the most-recent threaded note. Seed one so the
+ // tokens-test below sees the expected "Eager buyer" content.
+ await db.insert(interestNotesTable).values({
+ interestId: interest.id,
+ authorId: 'system',
+ content: 'Eager buyer',
});
const tmpl = await insertTemplate({