Bypass EOI for this berth
{row.eoiBypassReason ? (
diff --git a/src/components/layout/mobile/mobile-bottom-tabs.tsx b/src/components/layout/mobile/mobile-bottom-tabs.tsx
index be3af2fd..7704fbb6 100644
--- a/src/components/layout/mobile/mobile-bottom-tabs.tsx
+++ b/src/components/layout/mobile/mobile-bottom-tabs.tsx
@@ -2,7 +2,7 @@
import Link from 'next/link';
import { usePathname } from 'next/navigation';
-import { Anchor, FileSignature, LayoutDashboard, Menu, Users } from 'lucide-react';
+import { Anchor, LayoutDashboard, Menu, Search, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -12,35 +12,26 @@ type TabSpec = {
segment: string; // route segment after /[portSlug]/
};
-// Bottom nav ordering, left → right:
-// Dashboard - daily overview
-// Berths - marina inventory grid (touches sales + ops both)
-// Clients - the address book / dedup surface (centered: it's the
-// primary mental anchor for "find this person", with
-// interests living as a tab on the client detail rather
-// than a peer in the bottom nav)
-// Documents - signature tracking (chase signers, EOI queue)
-// More - overflow drawer (Interests, Yachts, Companies, …)
-//
-// Interests is intentionally NOT in the bottom row - having both Clients
-// and Interests as peer tabs created a Clients-vs-Interests confusion
-// for sales reps, and the per-client interests tab + the new bottom-sheet
-// drawer cover the day-to-day deal review without needing a dedicated tab.
-// Yachts stays out for the same reason as before: it's an asset record
-// most often reached from inside an interest or client, not browsed.
-const TABS: TabSpec[] = [
+// Left-of-center: Dashboard, Clients. Right-of-center: Berths, More.
+// Search occupies the center slot. Documents demoted to the MoreSheet —
+// reps reach docs less often than berths during a walking inventory check,
+// and pinned-to-client documents are accessed via the client detail anyway.
+const TABS_LEFT: TabSpec[] = [
{ label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' },
- { label: 'Berths', icon: Anchor, segment: 'berths' },
{ label: 'Clients', icon: Users, segment: 'clients' },
- { label: 'Documents', icon: FileSignature, segment: 'documents' },
];
-export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) {
- const pathname = usePathname();
+const TABS_RIGHT: TabSpec[] = [
+ { label: 'Berths', icon: Anchor, segment: 'berths' },
+];
- // Derive the active port slug from the URL so tab links always target the
- // current port, even after a port-switch. The dashboard route shape is
- // /[portSlug]/, so the slug is the first non-empty path segment.
+interface MobileBottomTabsProps {
+ onMoreClick: () => void;
+ onSearchClick: () => void;
+}
+
+export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTabsProps) {
+ const pathname = usePathname();
const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara';
function isActive(segment: string): boolean {
@@ -53,41 +44,42 @@ export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) {
className={cn(
'fixed bottom-0 inset-x-0 z-40 bg-background border-t border-border',
'pb-safe-bottom',
- 'grid grid-cols-5',
+ // 5 equal-flex slots.
+ 'flex items-end',
)}
>
- {TABS.map((tab) => {
- const active = isActive(tab.segment);
- const Icon = tab.icon;
- return (
-
- {/* Subtle pill background behind the icon when active. Keeps the
- tab grid alignment intact while giving the eye an anchor. */}
-
-
- {tab.label}
-
- );
- })}
+ {TABS_LEFT.map((tab) => (
+
+ ))}
+
+ {/* Search button — styled identically to the other navbar tabs. */}
+
+
+ Search
+
+
+ {TABS_RIGHT.map((tab) => (
+
+ ))}
+
More
@@ -95,3 +87,41 @@ export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) {
);
}
+
+function NavTab({
+ tab,
+ portSlug,
+ active,
+}: {
+ tab: TabSpec;
+ portSlug: string;
+ active: boolean;
+}) {
+ const Icon = tab.icon;
+ return (
+
+ {/* iOS-native active indicator: a 2px accent bar at the top of
+ the active tab. Cleaner than a colored pill — relies on the
+ icon + label color change (text-primary above) to do the
+ primary signaling, with this bar adding just enough visual
+ anchor to read as "selected". */}
+
+
+ {tab.label}
+
+ );
+}
diff --git a/src/components/layout/mobile/mobile-layout.tsx b/src/components/layout/mobile/mobile-layout.tsx
index 1e0994c3..fcafa353 100644
--- a/src/components/layout/mobile/mobile-layout.tsx
+++ b/src/components/layout/mobile/mobile-layout.tsx
@@ -7,6 +7,7 @@ import { MobileLayoutProvider } from './mobile-layout-provider';
import { MobileTopbar } from './mobile-topbar';
import { MobileBottomTabs } from './mobile-bottom-tabs';
import { MoreSheet } from './more-sheet';
+import { MobileSearchOverlay } from '@/components/search/mobile-search-overlay';
/**
* Mobile shell: fixed compact topbar + scrollable content + fixed bottom tab
@@ -17,6 +18,7 @@ import { MoreSheet } from './more-sheet';
*/
export function MobileLayout({ children }: { children: ReactNode }) {
const [moreOpen, setMoreOpen] = useState(false);
+ const [searchOpen, setSearchOpen] = useState(false);
return (
@@ -33,8 +35,12 @@ export function MobileLayout({ children }: { children: ReactNode }) {
>
{children}
- setMoreOpen(true)} />
+ setMoreOpen(true)}
+ onSearchClick={() => setSearchOpen(true)}
+ />
+
);
diff --git a/src/components/layout/mobile/more-sheet.tsx b/src/components/layout/mobile/more-sheet.tsx
index 23bd215c..5c307b7a 100644
--- a/src/components/layout/mobile/more-sheet.tsx
+++ b/src/components/layout/mobile/more-sheet.tsx
@@ -5,19 +5,20 @@ import { usePathname } from 'next/navigation';
import {
Anchor,
BarChart3,
- Bell,
- BellRing,
Bookmark,
Building2,
+ FileSignature,
Globe,
Home,
+ Inbox,
Receipt,
Settings,
Shield,
- ShieldAlert,
Ship,
} from 'lucide-react';
+import { useQuery } from '@tanstack/react-query';
+
import {
Drawer,
DrawerContent,
@@ -26,6 +27,7 @@ import {
DrawerClose,
} from '@/components/shared/drawer';
import { useUmamiActive } from '@/components/website-analytics/use-website-analytics';
+import { apiFetch } from '@/lib/api/client';
type MoreItem = {
label: string;
@@ -33,32 +35,50 @@ type MoreItem = {
segment: string;
};
-// Order: most-likely overflow targets first. Interests is here (rather
-// than the bottom row) to dodge the Clients-vs-Interests UX confusion;
-// reps reach the active deals via the Interests tab on a client detail
-// (or via the new bottom-sheet drawer). Yachts is asset-record traffic
-// best reached contextually from inside an interest or client.
+type MoreGroup = {
+ label: string;
+ items: MoreItem[];
+};
+
+// Logical grouping (vs alphabetical or frequency-ranked): keeps a stable
+// spatial layout — reps' muscle memory survives — while making the
+// "kind of thing" each tile is explicit. Three sections:
+// - Records: entity lists (people, vessels, properties)
+// - Operations: daily-use action surfaces
+// - Configuration: port-level setup, hidden from most reps
//
-// Inbox is intentionally absent — the email/threading inbox feature was
-// deferred (see sidebar.tsx). Re-add this entry once IMAP/SMTP wiring
-// + Google OAuth review are done. Website analytics is filtered below
-// when Umami isn't configured for this port.
-const MORE_ITEMS: MoreItem[] = [
- { label: 'Interests', icon: Bookmark, segment: 'interests' },
- { label: 'Yachts', icon: Ship, segment: 'yachts' },
- { label: 'Companies', icon: Building2, segment: 'companies' },
- { label: 'Expenses', icon: Receipt, segment: 'expenses' },
- { label: 'Reservations', icon: Anchor, segment: 'berth-reservations' },
- // Notifications themselves live on the topbar bell — this entry deep-links
- // to the notification panel inside user-settings (collapsed in 2026-05-09).
- { label: 'Notification preferences', icon: BellRing, segment: 'settings#notifications' },
- { label: 'Residential', icon: Home, segment: 'residential/clients' },
- { label: 'Website analytics', icon: Globe, segment: 'website-analytics' },
- { label: 'Alerts', icon: ShieldAlert, segment: 'alerts' },
- { label: 'Reports', icon: BarChart3, segment: 'reports' },
- { label: 'Reminders', icon: Bell, segment: 'reminders' },
- { label: 'Settings', icon: Settings, segment: 'settings' },
- { label: 'Admin', icon: Shield, segment: 'admin' },
+// Interests stays here (not bottom nav) to dodge the Clients-vs-
+// Interests UX confusion. Inbox replaces the previously-separate
+// Alerts + Reminders entries (merged 2026-05-11). Website analytics
+// and Reservations are filtered out below when not applicable.
+const MORE_GROUPS: MoreGroup[] = [
+ {
+ label: 'Records',
+ items: [
+ { label: 'Documents', icon: FileSignature, segment: 'documents' },
+ { label: 'Interests', icon: Bookmark, segment: 'interests' },
+ { label: 'Yachts', icon: Ship, segment: 'yachts' },
+ { label: 'Companies', icon: Building2, segment: 'companies' },
+ { label: 'Residential', icon: Home, segment: 'residential/clients' },
+ ],
+ },
+ {
+ label: 'Operations',
+ items: [
+ { label: 'Alerts & Reminders', icon: Inbox, segment: 'inbox' },
+ { label: 'Expenses', icon: Receipt, segment: 'expenses' },
+ { label: 'Reservations', icon: Anchor, segment: 'berth-reservations' },
+ { label: 'Reports', icon: BarChart3, segment: 'reports' },
+ ],
+ },
+ {
+ label: 'Configuration',
+ items: [
+ { label: 'Website analytics', icon: Globe, segment: 'website-analytics' },
+ { label: 'Settings', icon: Settings, segment: 'settings' },
+ { label: 'Admin', icon: Shield, segment: 'admin' },
+ ],
+ },
];
export function MoreSheet({
@@ -74,10 +94,30 @@ export function MoreSheet({
// Hide "Website analytics" if Umami isn't wired up for this port — the
// dedicated tile on the dashboard already does the same.
const umami = useUmamiActive('today');
- const umamiConfigured = umami.data?.error !== 'umami_not_configured';
- const items = MORE_ITEMS.filter(
- (item) => item.segment !== 'website-analytics' || umamiConfigured,
- );
+ const umamiConfigured = !umami.isLoading && umami.data?.notConfigured !== true;
+
+ // Hide "Reservations" until at least one exists for this port — until the
+ // marina has confirmed bookings, the page is empty and surfaces nothing
+ // useful. Cheap count via pageSize=1; cached 5 min so opening the sheet
+ // repeatedly doesn't refetch.
+ const reservations = useQuery<{ pagination?: { total: number } }>({
+ queryKey: ['berth-reservations', 'sheet-count'],
+ queryFn: () => apiFetch('/api/v1/berth-reservations?pageSize=1'),
+ staleTime: 5 * 60_000,
+ enabled: open,
+ });
+ const hasReservations =
+ !reservations.isLoading && (reservations.data?.pagination?.total ?? 0) > 0;
+
+ // Per-group filter: keep only the items relevant to this port's state.
+ const groups = MORE_GROUPS.map((g) => ({
+ ...g,
+ items: g.items.filter((item) => {
+ if (item.segment === 'website-analytics') return umamiConfigured;
+ if (item.segment === 'berth-reservations') return hasReservations;
+ return true;
+ }),
+ })).filter((g) => g.items.length > 0);
return (
@@ -85,28 +125,36 @@ export function MoreSheet({
More
-
- {items.map((item) => {
- const Icon = item.icon;
- return (
-
-
-
-
- {item.label}
-
-
-
- );
- })}
-
+
+ {groups.map((group) => (
+
+
+ {group.label}
+
+
+ {group.items.map((item) => {
+ const Icon = item.icon;
+ return (
+
+
+
+
+ {item.label}
+
+
+
+ );
+ })}
+
+
+ ))}
+
);
diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx
index 62a3e736..b139aef8 100644
--- a/src/components/layout/sidebar.tsx
+++ b/src/components/layout/sidebar.tsx
@@ -13,7 +13,7 @@ import {
Building2,
Receipt,
FileText,
- Bell,
+ Inbox,
Camera,
Globe,
Settings,
@@ -156,10 +156,12 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
title: 'Communication',
marinaRequired: true,
items: [
- // Email tab removed: we deferred building a full inbox/threading
+ // Email tab removed: deferred building a full inbox/threading
// feature (would require Google OAuth + scope review + IMAP
- // syncing infra). Reminders stays since it's already wired up.
- { href: `${base}/reminders`, label: 'Reminders', icon: Bell },
+ // syncing infra). This entry routes to the merged
+ // Alerts + Reminders surface (2026-05-11) — explicit name so
+ // reps don't mistake it for an email inbox.
+ { href: `${base}/inbox`, label: 'Alerts & Reminders', icon: Inbox },
],
},
{
@@ -188,8 +190,8 @@ function NavItemLink({
href={item.href as any}
className={cn(
'relative flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-all duration-150',
- 'text-[#cdcfd6] hover:bg-[#171f35] hover:text-white',
- active && 'text-white pl-[14px]',
+ 'text-slate-700 hover:bg-accent hover:text-foreground',
+ active && 'bg-accent text-foreground pl-[14px]',
collapsed && 'justify-center px-2',
)}
>
@@ -202,7 +204,7 @@ function NavItemLink({
@@ -252,7 +254,7 @@ function SidebarContent({
const [adminExpanded, setAdminExpanded] = useState(true);
const sections = buildNavSections(portSlug);
const umami = useUmamiActive('today');
- const umamiConfigured = umami.data?.error !== 'umami_not_configured';
+ const umamiConfigured = !umami.isLoading && umami.data?.notConfigured !== true;
// Small label under the user identity when the user has access to more
// than one port — disambiguates which port is currently active without
@@ -283,12 +285,13 @@ function SidebarContent({
// compete with the logo for attention.
return (
-
- {/* Brand header - logo centered (large when expanded, smaller when
- collapsed). Collapse toggle floats top-right as a tiny chevron. */}
+
+ {/* Brand header - logo centered. Soft hairline below echoes the
+ inter-section separators in the nav so the eye reads the logo
+ as a distinct top-row, not a floating element. */}
@@ -297,7 +300,7 @@ function SidebarContent({
alt="Port Nimara"
width={collapsed ? 40 : 72}
height={collapsed ? 40 : 72}
- className="rounded-full shadow-md ring-2 ring-white/20"
+ className="rounded-full shadow-sm"
unoptimized
priority
/>
@@ -307,7 +310,7 @@ function SidebarContent({
onClick={onToggleCollapse}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
className={cn(
- 'absolute right-2 flex h-6 w-6 items-center justify-center rounded-md text-[#83aab1] hover:bg-[#171f35] hover:text-white transition-colors',
+ 'absolute right-2 flex h-6 w-6 items-center justify-center rounded-md text-slate-400 hover:bg-slate-100 hover:text-slate-700 transition-colors',
collapsed ? 'top-1' : 'top-2',
)}
>
@@ -333,13 +336,13 @@ function SidebarContent({
{!collapsed && (
-
+
{section.title}
{section.adminRequired && (
setAdminExpanded((v) => !v)}
- className="text-[#71768a] hover:text-[#cdcfd6] transition-colors"
+ className="text-slate-400 hover:text-slate-700 transition-colors"
>
{adminExpanded ? (
@@ -363,7 +366,7 @@ function SidebarContent({
))}
)}
-
+
);
})}
@@ -374,7 +377,7 @@ function SidebarContent({
user can click their name/avatar to access Profile / Settings /
port-switcher / sign-out. The same UserMenu component drives the
top-right avatar dropdown, so the menu items stay consistent. */}
-
+
{collapsed ? (
@@ -404,26 +407,26 @@ function SidebarContent({
-
+
{(user?.name ?? 'U').slice(0, 1).toUpperCase()}
-
+
{user?.name ?? 'User'}
{isSuperAdmin ? 'Super Admin' : humanizeRole(portRoles[0]?.role?.name)}
{currentPortName && (
-
{currentPortName}
+
{currentPortName}
)}
@@ -461,10 +464,9 @@ export function Sidebar({ portRoles, isSuperAdmin = false, user, ports }: Sideba
return (
New
-
+
Create
+ {/* Each item routes to the list page with ?create=1 so the
+ relevant create sheet pops automatically (see
+ useCreateFromUrl). The legacy `/clients/new`-style routes
+ this menu used to push to landed on the dynamic detail
+ page with id="new" and silently 404'd. */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
- router.push(`${base}/clients/new` as any)}>
+ router.push(`${base}/clients?create=1` as any)}>
New Client
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
- router.push(`${base}/interests/new` as any)}>
+ router.push(`${base}/yachts?create=1` as any)}>
+ New Yacht
+
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
+ router.push(`${base}/companies?create=1` as any)}>
+ New Company
+
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
+ router.push(`${base}/interests?create=1` as any)}>
New Interest
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
- router.push(`${base}/expenses/new` as any)}>
+ router.push(`${base}/expenses?create=1` as any)}>
New Expense
+ {/* /reminders 301s to /inbox#reminders (the merged page) and
+ the server redirect strips the query string, so point
+ straight at the new path. The Reminders section's
+ useCreateFromUrl handler still picks up ?create=1. */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
- router.push(`${base}/reminders/new` as any)}>
+ router.push(`${base}/inbox?create=1#reminders` as any)}>
New Reminder
diff --git a/src/components/notifications/reminder-digest-form.tsx b/src/components/notifications/reminder-digest-form.tsx
index cd41777e..5fa6ced0 100644
--- a/src/components/notifications/reminder-digest-form.tsx
+++ b/src/components/notifications/reminder-digest-form.tsx
@@ -29,6 +29,7 @@ interface ReminderPrefs {
interface UserPrefsResponse {
reminders?: ReminderPrefs;
timezone?: string;
+ portReminderDigestEnabled?: boolean;
}
const DAYS = [
@@ -96,6 +97,10 @@ export function ReminderDigestForm() {
);
}
+ if (!data?.portReminderDigestEnabled) {
+ return null;
+ }
+
return (
diff --git a/src/components/reminders/reminder-form.tsx b/src/components/reminders/reminder-form.tsx
index 4cef2754..4d9540c4 100644
--- a/src/components/reminders/reminder-form.tsx
+++ b/src/components/reminders/reminder-form.tsx
@@ -13,6 +13,9 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
+import { ClientPicker } from '@/components/shared/client-picker';
+import { InterestPicker } from '@/components/shared/interest-picker';
+import { BerthPicker } from '@/components/shared/berth-picker';
import { apiFetch } from '@/lib/api/client';
import { usePermissions } from '@/hooks/use-permissions';
@@ -172,7 +175,9 @@ export function ReminderForm({
/>
-
+ {/* 2fr/1fr split — the datetime-local control needs more room
+ for "MM/DD/YYYY HH:MM AM" than a 4-item priority Select. */}
+
Due Date & Time
-
Assign To
-
+ Assign to user
+ setAssignedTo(v === '__self__' ? '' : v)}
+ >
- Myself
+ {/* Radix Select forbids empty-string values, so use a
+ sentinel here and map back to '' in the handler. */}
+ Myself
{users.map((u) => (
{u.displayName}
@@ -220,27 +230,36 @@ export function ReminderForm({
)}
diff --git a/src/components/reminders/reminder-list.tsx b/src/components/reminders/reminder-list.tsx
index 37761c72..3ac15c13 100644
--- a/src/components/reminders/reminder-list.tsx
+++ b/src/components/reminders/reminder-list.tsx
@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
-import { Plus, CheckCircle2, Clock, XCircle, AlertTriangle, Bell } from 'lucide-react';
+import { Plus, CheckCircle2, Clock, Pencil, XCircle, AlertTriangle, Bell } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { useParams } from 'next/navigation';
@@ -11,6 +11,12 @@ import { PageHeader } from '@/components/shared/page-header';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
import {
Select,
SelectContent,
@@ -19,6 +25,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
+import { useCreateFromUrl } from '@/hooks/use-create-from-url';
import { usePermissions } from '@/hooks/use-permissions';
import { ReminderCard } from './reminder-card';
import { ReminderForm } from './reminder-form';
@@ -59,10 +66,20 @@ const STATUS_CONFIG = {
dismissed: { label: 'Dismissed', icon: XCircle },
} as const;
-export function ReminderList() {
+interface ReminderListProps {
+ /**
+ * Embedded mode (used by the Inbox page) drops the PageHeader and
+ * surfaces the "New Reminder" button inline so the section can render
+ * alongside the Alerts section without duplicating page chrome.
+ */
+ embedded?: boolean;
+}
+
+export function ReminderList({ embedded = false }: ReminderListProps = {}) {
const [reminders, setReminders] = useState([]);
const [loading, setLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false);
+ useCreateFromUrl(() => setFormOpen(true));
const [editingReminder, setEditingReminder] = useState(null);
const [snoozingId, setSnoozingId] = useState(null);
const [viewMode, setViewMode] = useState<'my' | 'all'>('my');
@@ -203,41 +220,97 @@ export function ReminderList() {
return null;
}
return (
-
- handleComplete(row.original.id)}
- >
-
-
- setSnoozingId(row.original.id)}>
-
-
- handleDismiss(row.original.id)}
- >
-
-
-
+
+
+
+
+ handleComplete(row.original.id)}
+ >
+
+
+
+ Mark complete
+
+
+
+ setSnoozingId(row.original.id)}
+ >
+
+
+
+ Snooze
+
+
+
+ {
+ setEditingReminder(row.original);
+ setFormOpen(true);
+ }}
+ >
+
+
+
+ Edit
+
+
+
+ handleDismiss(row.original.id)}
+ >
+
+
+
+ Dismiss
+
+
+
);
},
enableSorting: false,
- size: 120,
+ size: 160,
},
];
return (
-
{
+ setEditingReminder(null);
+ setFormOpen(true);
+ }}
+ >
+
+ New Reminder
+
+ }
+ />
+ ) : (
+
{
setEditingReminder(null);
setFormOpen(true);
@@ -246,8 +319,8 @@ export function ReminderList() {
New Reminder
- }
- />
+
+ )}
{/* Wrap on phone widths so the priority filter doesn't get pushed
off-screen by the My/All tabs + status filter taking the full row. */}
diff --git a/src/components/reports/generate-report-form.tsx b/src/components/reports/generate-report-form.tsx
index 67122f6f..d4673242 100644
--- a/src/components/reports/generate-report-form.tsx
+++ b/src/components/reports/generate-report-form.tsx
@@ -17,11 +17,49 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { apiFetch } from '@/lib/api/client';
import type { RequestReportInput } from '@/lib/validators/reports';
-const REPORT_TYPE_LABELS: Record = {
- pipeline: 'Pipeline Summary',
- revenue: 'Revenue Report',
- activity: 'Activity Log',
- occupancy: 'Berth Occupancy',
+interface ReportTypeMeta {
+ label: string;
+ subtitle: string;
+ contents: string[];
+}
+
+const REPORT_TYPES: Record = {
+ pipeline: {
+ label: 'Pipeline Summary',
+ subtitle: 'Interest counts by stage and conversion rates',
+ contents: [
+ 'Active (non-archived) interests grouped by pipeline stage',
+ 'Stage-to-stage drop-off counts',
+ 'Open vs. won vs. lost roll-up at the bottom',
+ ],
+ },
+ revenue: {
+ label: 'Revenue Report',
+ subtitle: 'Berth-price totals rolled up by pipeline stage',
+ contents: [
+ 'Sum of primary-berth prices grouped by stage',
+ 'Pulled from each interest’s primary berth link (non-primary junctions ignored)',
+ 'Sold-stage total reflects realised revenue; earlier stages are forecast',
+ ],
+ },
+ activity: {
+ label: 'Activity Log',
+ subtitle: 'Audit events across the port for a date range',
+ contents: [
+ 'Audit log entries (create / update / delete) per entity',
+ 'Filtered to the selected date range — defaults to last 30 days',
+ 'Includes actor name, entity type, and action verb',
+ ],
+ },
+ occupancy: {
+ label: 'Berth Occupancy',
+ subtitle: 'Berth counts by status',
+ contents: [
+ 'Berths grouped by status: Available, Under Offer, Sold',
+ 'Per-dock breakdown using the mooring-letter prefix',
+ 'Total port utilisation percentage at the top',
+ ],
+ },
};
export function GenerateReportForm() {
@@ -74,13 +112,26 @@ export function GenerateReportForm() {
- {Object.entries(REPORT_TYPE_LABELS).map(([value, label]) => (
-
- {label}
+ {Object.entries(REPORT_TYPES).map(([value, meta]) => (
+
+
+ {meta.label}
+ {meta.subtitle}
+
))}
+ {reportType && REPORT_TYPES[reportType] ? (
+
+
{REPORT_TYPES[reportType].subtitle}
+
+ {REPORT_TYPES[reportType].contents.map((line) => (
+ {line}
+ ))}
+
+
+ ) : null}
@@ -94,7 +145,7 @@ export function GenerateReportForm() {
/>
-
+
diff --git a/src/components/residential/residential-client-tabs.tsx b/src/components/residential/residential-client-tabs.tsx
index e90b370b..21c3f021 100644
--- a/src/components/residential/residential-client-tabs.tsx
+++ b/src/components/residential/residential-client-tabs.tsx
@@ -15,6 +15,7 @@ import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
import { NotesList } from '@/components/shared/notes-list';
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
import { apiFetch } from '@/lib/api/client';
+import { SOURCES } from '@/lib/constants';
import type { CountryCode } from '@/lib/i18n/countries';
interface ResidentialInterestSummary {
@@ -62,13 +63,7 @@ const CONTACT_OPTIONS = [
{ value: 'email', label: 'Email' },
{ value: 'phone', label: 'Phone' },
];
-const SOURCE_OPTIONS = [
- { value: 'website', label: 'Website' },
- { value: 'manual', label: 'Manual' },
- { value: 'referral', label: 'Referral' },
- { value: 'broker', label: 'Broker' },
- { value: 'other', label: 'Other' },
-];
+const SOURCE_OPTIONS = SOURCES.map((s) => ({ value: s.value, label: s.label }));
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
@@ -117,10 +112,10 @@ export function getResidentialClientTabs({
label: 'Notes',
content: (
),
},
diff --git a/src/components/residential/residential-interest-tabs.tsx b/src/components/residential/residential-interest-tabs.tsx
index b57c2395..4dcb3594 100644
--- a/src/components/residential/residential-interest-tabs.tsx
+++ b/src/components/residential/residential-interest-tabs.tsx
@@ -7,6 +7,7 @@ import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { NotesList } from '@/components/shared/notes-list';
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
import { apiFetch } from '@/lib/api/client';
+import { SOURCES } from '@/lib/constants';
interface ResidentialInterest {
id: string;
@@ -25,13 +26,7 @@ interface Args {
stageOptions: Array<{ value: string; label: string }>;
}
-const SOURCE_OPTIONS = [
- { value: 'website', label: 'Website' },
- { value: 'manual', label: 'Manual' },
- { value: 'referral', label: 'Referral' },
- { value: 'broker', label: 'Broker' },
- { value: 'other', label: 'Other' },
-];
+const SOURCE_OPTIONS = SOURCES.map((s) => ({ value: s.value, label: s.label }));
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
diff --git a/src/components/search/command-search.tsx b/src/components/search/command-search.tsx
index 1e6eabff..4318cd1c 100644
--- a/src/components/search/command-search.tsx
+++ b/src/components/search/command-search.tsx
@@ -12,6 +12,7 @@ import {
} from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
+import { useQueryClient } from '@tanstack/react-query';
import {
Anchor,
Bell,
@@ -100,6 +101,7 @@ export function CommandSearch() {
const [focusIndex, setFocusIndex] = useState
(-1);
const router = useRouter();
+ const queryClient = useQueryClient();
const portSlug = useUIStore((s) => s.currentPortSlug);
const wrapperRef = useRef(null);
@@ -113,8 +115,32 @@ export function CommandSearch() {
limit: activeBucket === 'all' ? 5 : 15,
});
+ // Persist the totals from the last "all" query so the filter chips stay
+ // populated when the user narrows to a single bucket. Without this, the
+ // narrowed query only returns counts for the active bucket and every
+ // other chip would vanish — making it impossible to swap between
+ // filters without clearing back to "All" first.
+ const lastAllTotalsRef = useRef(null);
+ useEffect(() => {
+ if (activeBucket === 'all' && results?.totals) {
+ lastAllTotalsRef.current = results.totals;
+ }
+ }, [activeBucket, results]);
+ const chipTotals: SearchResults['totals'] | undefined =
+ activeBucket === 'all' ? results?.totals : (lastAllTotalsRef.current ?? results?.totals);
+
const showDropdown = focused;
+ // CommandSearch lives in the header and persists across navigations,
+ // so its React Query cache never sees a remount. Invalidate the
+ // recently-viewed + recent-terms queries whenever the dropdown opens
+ // so the user sees fresh data after navigating around the app.
+ useEffect(() => {
+ if (!showDropdown) return;
+ queryClient.invalidateQueries({ queryKey: ['search', 'recently-viewed'] });
+ queryClient.invalidateQueries({ queryKey: ['search', 'recent-terms'] });
+ }, [showDropdown, queryClient]);
+
// Cmd/Ctrl+K focuses the input from anywhere on the page.
useEffect(() => {
function onKeyDown(e: globalThis.KeyboardEvent) {
@@ -287,7 +313,7 @@ export function CommandSearch() {
>
{/* Filter chip row — always visible while the dropdown is open. */}
void;
disabled: boolean;
}) {
- // Show a chip for every bucket so the user can browse the search
- // surface even with no query; counts only render when results exist.
return (
{BUCKETS.map((b) => {
- const count = results?.totals?.[b.type] ?? 0;
- // Hide chips for buckets the current user can't see (count === 0
- // when the bucket query was permission-skipped) — but only after
- // a query has run, otherwise we'd hide every chip on first paint.
+ const count = totals?.[b.type] ?? 0;
+ // Hide chips for buckets with zero matches in the last "all"
+ // snapshot — keeps the row tight and avoids dead-end clicks.
+ // Always show the active chip + every chip before a query has run.
if (!disabled && count === 0 && active !== b.type) return null;
return (
)}
+ {row.relatedVia && (
+
+ via {row.relatedVia.label}
+
+ )}
);
@@ -759,9 +791,9 @@ function BucketSection({
// ─── Flat-row construction (drives keyboard nav + ARIA) ──────────────────────
-type ResultBadge = { label: string; tone: 'neutral' | 'warning' | 'success' | 'danger' };
+export type ResultBadge = { label: string; tone: 'neutral' | 'warning' | 'success' | 'danger' };
-type FlatRow =
+export type FlatRow =
| {
kind: 'recent-view';
key: string;
@@ -783,6 +815,9 @@ type FlatRow =
sub: string | null;
href: string;
badges?: ResultBadge[];
+ /** Provenance hint when the row was surfaced via graph expansion.
+ * Rendered as a subtle "via Berth A10" line below the sub. */
+ relatedVia?: { type: string; label: string } | null;
}
| {
kind: 'other-port';
@@ -791,7 +826,7 @@ type FlatRow =
href: string;
};
-interface BuildFlatRowsArgs {
+export interface BuildFlatRowsArgs {
query: string;
results: SearchResults | undefined;
recentlyViewed: RecentlyViewedItem[];
@@ -800,7 +835,7 @@ interface BuildFlatRowsArgs {
portSlug: string | null;
}
-function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
+export function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
const { query, results, recentlyViewed, recentSearches, activeBucket, portSlug } = args;
const rows: FlatRow[] = [];
@@ -839,6 +874,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
sub: c.matchedContact ?? null,
href: `/${portSlug}/clients/${c.id}`,
badges: c.archivedAt ? [{ label: 'Archived', tone: 'neutral' }] : undefined,
+ relatedVia: c.relatedVia ?? null,
});
}
}
@@ -866,6 +902,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
label: y.name,
sub,
href: `/${portSlug}/yachts/${y.id}`,
+ relatedVia: y.relatedVia ?? null,
});
}
}
@@ -880,6 +917,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
label: co.name,
sub,
href: `/${portSlug}/companies/${co.id}`,
+ relatedVia: co.relatedVia ?? null,
});
}
}
@@ -903,6 +941,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
sub: i.berthMooringNumber,
href: `/${portSlug}/interests/${i.id}`,
badges: badges.length > 0 ? badges : undefined,
+ relatedVia: i.relatedVia ?? null,
});
}
}
@@ -942,6 +981,7 @@ function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] {
sub,
href: `/${portSlug}/berths/${b.id}`,
badges: badges.length > 0 ? badges : undefined,
+ relatedVia: b.relatedVia ?? null,
});
}
}
diff --git a/src/components/search/mobile-search-overlay.tsx b/src/components/search/mobile-search-overlay.tsx
new file mode 100644
index 00000000..ce63a195
--- /dev/null
+++ b/src/components/search/mobile-search-overlay.tsx
@@ -0,0 +1,607 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { useQueryClient } from '@tanstack/react-query';
+import { Drawer as VaulDrawer } from 'vaul';
+import { Clock, History, Search, X } from 'lucide-react';
+
+import { apiFetch } from '@/lib/api/client';
+import { cn } from '@/lib/utils';
+import { useSearch, type BucketType, type SearchResults } from '@/hooks/use-search';
+import { useUIStore } from '@/stores/ui-store';
+import { buildFlatRows, type FlatRow } from './command-search';
+import { HighlightMatch } from './highlight-match';
+
+// Match the desktop bucket order — feels consistent when reps switch contexts.
+const BUCKETS: { type: BucketType; label: string }[] = [
+ { type: 'clients', label: 'Clients' },
+ { type: 'yachts', label: 'Yachts' },
+ { type: 'companies', label: 'Companies' },
+ { type: 'interests', label: 'Interests' },
+ { type: 'berths', label: 'Berths' },
+ { type: 'documents', label: 'Documents' },
+ { type: 'invoices', label: 'Invoices' },
+ { type: 'reminders', label: 'Reminders' },
+];
+
+const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+const INVOICE_RE = /^INV-\d{6}-\d+$/i;
+function looksLikePastedId(input: string): boolean {
+ const trimmed = input.trim();
+ return UUID_RE.test(trimmed) || INVOICE_RE.test(trimmed);
+}
+
+const BADGE_TONE: Record<'neutral' | 'warning' | 'success' | 'danger', string> = {
+ neutral: 'bg-muted text-muted-foreground',
+ warning: 'bg-amber-100 text-amber-900',
+ success: 'bg-emerald-100 text-emerald-900',
+ danger: 'bg-red-100 text-red-900',
+};
+
+interface MobileSearchOverlayProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function MobileSearchOverlay({ open, onOpenChange }: MobileSearchOverlayProps) {
+ const [query, setQuery] = useState('');
+ const [activeBucket, setActiveBucket] = useState('all');
+ // Tracks the visible-above-keyboard height. iOS Safari ignores
+ // keyboard area in `dvh`, so we use the visualViewport API directly:
+ // visualViewport.height is the actual visible area in CSS pixels,
+ // updates in real time as the keyboard rises/falls.
+ const [visibleHeight, setVisibleHeight] = useState(null);
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const portSlug = useUIStore((s) => s.currentPortSlug);
+ const inputRef = useRef(null);
+
+ // The overlay is mounted once at the layout root, so the recently-
+ // viewed query won't refetch via the usual mount path. Bump it every
+ // time the drawer opens — the user is about to look at it, and the
+ // staleTime cache may have missed an entity view that happened in a
+ // route that doesn't render .
+ useEffect(() => {
+ if (!open) return;
+ queryClient.invalidateQueries({ queryKey: ['search', 'recently-viewed'] });
+ queryClient.invalidateQueries({ queryKey: ['search', 'recent-terms'] });
+ }, [open, queryClient]);
+
+ useEffect(() => {
+ if (!open) {
+ setVisibleHeight(null);
+ return;
+ }
+ const vv = window.visualViewport;
+ if (!vv) return;
+ const update = () => setVisibleHeight(vv.height);
+ update();
+ vv.addEventListener('resize', update);
+ vv.addEventListener('scroll', update);
+ return () => {
+ vv.removeEventListener('resize', update);
+ vv.removeEventListener('scroll', update);
+ };
+ }, [open]);
+
+ const { results, isFetching, recentSearches, recentlyViewed } = useSearch(query, {
+ type: activeBucket === 'all' ? undefined : activeBucket,
+ limit: activeBucket === 'all' ? 5 : 25,
+ });
+
+ // Persist counts from the last "all" query so chip counts stay visible
+ // when the user narrows to a single bucket. Narrowed queries only
+ // return counts for the active bucket, which would otherwise wipe the
+ // counts off every other chip the moment the user taps one.
+ const lastAllTotalsRef = useRef(null);
+ useEffect(() => {
+ if (activeBucket === 'all' && results?.totals) {
+ lastAllTotalsRef.current = results.totals;
+ }
+ }, [activeBucket, results]);
+ const chipTotals: SearchResults['totals'] | undefined =
+ activeBucket === 'all' ? results?.totals : (lastAllTotalsRef.current ?? results?.totals);
+
+ // Auto-focus is delegated to Vaul's `autoFocus` + the input's
+ // `autoFocus` attribute (synchronous in-gesture, which iOS Safari
+ // requires before it'll pop the keyboard on programmatic focus).
+ // A useEffect setTimeout was the previous approach but broke the
+ // user-gesture chain — input was focused, keyboard stayed hidden.
+
+ // Body scroll lock is delegated to Vaul (modal=true + noBodyStyles=false
+ // defaults). Manual position:fixed locking caused a visible scroll-then-
+ // jump on iOS Safari because the body briefly snaps to scrollY=0 after
+ // being taken out of flow, before the negative-top compensation paints.
+ // Vaul handles the lock natively via overflow:hidden which doesn't
+ // remove the body from flow. The trick to avoid Vaul's iOS scroll-lock
+ // race is `repositionInputs={false}` on the Drawer.Root (set below).
+
+ // Reset query when the drawer closes. Without this, reopening the
+ // overlay would flash stale results before the empty state renders.
+ useEffect(() => {
+ if (!open) {
+ setQuery('');
+ setActiveBucket('all');
+ }
+ }, [open]);
+
+ const close = useCallback(() => {
+ onOpenChange(false);
+ inputRef.current?.blur();
+ }, [onOpenChange]);
+
+ const navigate = useCallback(
+ (path: string) => {
+ close();
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ router.push(path as any);
+ },
+ [close, router],
+ );
+
+ // Paste a UUID or invoice number → jump straight to the entity.
+ const onPaste = useCallback(
+ async (e: React.ClipboardEvent) => {
+ const pasted = e.clipboardData.getData('text').trim();
+ if (!looksLikePastedId(pasted)) return;
+ try {
+ const res = await apiFetch<{ found: boolean; href: string | null }>(
+ `/api/v1/search/resolve-id?id=${encodeURIComponent(pasted)}`,
+ );
+ if (res.found && res.href) {
+ e.preventDefault();
+ navigate(res.href);
+ }
+ } catch {
+ // Best-effort — fall through to text search.
+ }
+ },
+ [navigate],
+ );
+
+ const rows = useMemo(
+ () =>
+ buildFlatRows({
+ query,
+ results,
+ recentlyViewed,
+ recentSearches,
+ activeBucket,
+ portSlug,
+ }),
+ [query, results, recentlyViewed, recentSearches, activeBucket, portSlug],
+ );
+
+ const showingEmptyHints = query.length < 2;
+ const noResults = !showingEmptyHints && rows.length === 0 && !isFetching;
+
+ return (
+
+
+
+
+ {/* Visually-hidden title for screen readers. Radix Dialog (which
+ Vaul wraps) requires a DialogTitle in the accessibility tree;
+ without this, the console throws an a11y violation. */}
+ Search
+
+ {/* Drag handle — Vaul reads this as a swipe target. Centered grip
+ + a small label below feels iOS-native. */}
+
+
+ {/* Sticky header: input + Cancel. The Cancel slides in from the
+ right when the input has focus, otherwise it sits flat. */}
+
+
+
+ setQuery(e.target.value)}
+ onPaste={onPaste}
+ placeholder="Search clients, yachts, interests…"
+ aria-label="Search"
+ inputMode="search"
+ enterKeyHint="search"
+ autoCapitalize="off"
+ autoCorrect="off"
+ spellCheck={false}
+ className={cn(
+ 'ml-2 h-full w-full min-w-0 bg-transparent text-base outline-none',
+ 'placeholder:text-muted-foreground',
+ )}
+ />
+ {query.length > 0 ? (
+ {
+ setQuery('');
+ inputRef.current?.focus();
+ }}
+ aria-label="Clear search"
+ className="ml-1 inline-flex size-7 shrink-0 items-center justify-center rounded-full text-muted-foreground active:bg-foreground/10"
+ >
+
+
+ ) : null}
+
+
+ Cancel
+
+
+
+ {/* Bucket chips: horizontally scrollable so all buckets fit no
+ matter the phone width. "All" is sticky-left so it's always
+ one tap away when the user is deep in a bucket. */}
+
+
+ setActiveBucket('all')}
+ />
+ {BUCKETS.map((b) => {
+ const count = chipTotals?.[b.type] ?? 0;
+ // Hide chips with zero matches in the last "all" snapshot,
+ // unless this is the currently active chip. Always show all
+ // before a query has run (chipTotals undefined → count 0
+ // and active 'all' means none get hidden).
+ if (query.length >= 2 && count === 0 && activeBucket !== b.type) {
+ return null;
+ }
+ return (
+ 0 ? count : undefined}
+ active={activeBucket === b.type}
+ onClick={() => setActiveBucket(b.type)}
+ />
+ );
+ })}
+
+
+
+ {/* Results scroll region. overscroll-contain prevents the body
+ from rubber-banding when the user scrolls past the bottom. */}
+
+ {showingEmptyHints && rows.length === 0 ? (
+
+ ) : showingEmptyHints ? (
+
+ ) : noResults ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
+
+function BucketChip({
+ label,
+ count,
+ active,
+ onClick,
+}: {
+ label: string;
+ count?: number;
+ active: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+ {label}
+ {typeof count === 'number' && ({count}) }
+
+ );
+}
+
+function EmptyHint() {
+ return (
+
+
+
+
+
+ Search clients, yachts, interests, berths, invoices, documents — paste a UUID or
+ invoice number to jump directly.
+
+
+ );
+}
+
+function NoResults({ query }: { query: string }) {
+ return (
+
+
No matches for “{query}”
+
+ Try a different spelling, or switch buckets above.
+
+
+ );
+}
+
+function RowList({
+ rows,
+ query,
+ onSelect,
+ variant,
+}: {
+ rows: FlatRow[];
+ query: string;
+ onSelect: (href: string) => void;
+ variant: 'empty' | 'results';
+}) {
+ // Split rows by section header — "Recently viewed", "Recent searches",
+ // "Results". Headers live inside the row list so they scroll with their
+ // content (instead of sticky-positioning, which adds visual noise).
+ const recentViews = rows.filter((r) => r.kind === 'recent-view');
+ const recentTerms = rows.filter((r) => r.kind === 'recent-term');
+ const results = rows.filter((r) => r.kind === 'result' || r.kind === 'other-port');
+
+ return (
+
+ {variant === 'empty' && recentViews.length > 0 ? (
+
} label="Recently viewed">
+ {recentViews.map((row) =>
+ row.kind === 'recent-view' ? (
+
onSelect(row.href)}
+ label={row.item.label}
+ sub={row.item.sub}
+ />
+ ) : null,
+ )}
+
+ ) : null}
+
+ {variant === 'empty' && recentTerms.length > 0 ? (
+ } label="Recent searches">
+
+ {recentTerms.map((row) =>
+ row.kind === 'recent-term' ? (
+ {
+ // Recent-term taps populate the input rather than
+ // navigating — the rep usually wants to refine, not
+ // jump straight back to the previous result.
+ const input = document.querySelector(
+ 'input[aria-label="Search"]',
+ );
+ if (input) {
+ input.value = row.term;
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ input.focus();
+ }
+ }}
+ className="rounded-full border border-border bg-muted/40 px-3 py-1 text-xs text-muted-foreground active:bg-accent active:text-accent-foreground"
+ >
+ {row.term}
+
+ ) : null,
+ )}
+
+
+ ) : null}
+
+ {variant === 'results' && results.length > 0 ? renderResultRows(results, query, onSelect) : null}
+
+ );
+}
+
+/**
+ * Walk the flat result rows, inserting a small section header above the
+ * first row of each bucket so reps know exactly what kind of entity
+ * each result points to ("CLIENTS", "INTERESTS", "BERTHS", …). Bucket
+ * order follows `buildFlatRows`'s ordering — most-likely matches first.
+ */
+function renderResultRows(
+ rows: FlatRow[],
+ query: string,
+ onSelect: (path: string) => void,
+): React.ReactNode[] {
+ const nodes: React.ReactNode[] = [];
+ let lastBucket: BucketType | null = null;
+ rows.forEach((row, i) => {
+ if (row.kind === 'result' && row.bucket !== lastBucket) {
+ nodes.push(
+
+ {BUCKET_LABELS[row.bucket] ?? row.bucket}
+
,
+ );
+ lastBucket = row.bucket;
+ } else if (row.kind === 'other-port' && lastBucket !== null) {
+ // Reset bucket tracker so re-grouping works on subsequent results.
+ lastBucket = null;
+ }
+
+ if (row.kind === 'result') {
+ const Icon = row.icon;
+ const subContent = (
+ <>
+ {row.sub ? : null}
+ {row.relatedVia ? (
+
+ via {row.relatedVia.label}
+
+ ) : null}
+ >
+ );
+ nodes.push(
+ onSelect(row.href)}
+ label={ }
+ sub={row.sub || row.relatedVia ? subContent : null}
+ icon={ }
+ badges={row.badges}
+ />,
+ );
+ } else if (row.kind === 'other-port') {
+ nodes.push(
+ onSelect(row.href)}
+ label={row.item.label}
+ sub={`${row.item.portName} · other port`}
+ />,
+ );
+ }
+ });
+ return nodes;
+}
+
+/** Human-readable bucket labels for the section-header rows. */
+const BUCKET_LABELS: Record = {
+ clients: 'Clients',
+ residentialClients: 'Residential clients',
+ yachts: 'Yachts',
+ companies: 'Companies',
+ interests: 'Interests',
+ residentialInterests: 'Residential interests',
+ berths: 'Berths',
+ invoices: 'Invoices',
+ expenses: 'Expenses',
+ documents: 'Documents',
+ files: 'Files',
+ reminders: 'Reminders',
+ brochures: 'Brochures',
+ tags: 'Tags',
+ navigation: 'Settings & navigation',
+ notes: 'Notes',
+};
+
+function Section({
+ icon,
+ label,
+ children,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {icon}
+ {label}
+
+ {children}
+
+ );
+}
+
+function Row({
+ onSelect,
+ label,
+ sub,
+ icon,
+ badges,
+}: {
+ onSelect: () => void;
+ label: React.ReactNode;
+ sub?: React.ReactNode;
+ icon?: React.ReactNode;
+ badges?: { label: string; tone: 'neutral' | 'warning' | 'success' | 'danger' }[];
+}) {
+ return (
+
+ {icon ? {icon} : null}
+
+ {label}
+ {sub ? {sub} : null}
+
+ {badges?.length ? (
+
+ {badges.map((b) => (
+
+ {b.label}
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/settings/dashboard-widgets-card.tsx b/src/components/settings/dashboard-widgets-card.tsx
new file mode 100644
index 00000000..b952de8c
--- /dev/null
+++ b/src/components/settings/dashboard-widgets-card.tsx
@@ -0,0 +1,77 @@
+'use client';
+
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Switch } from '@/components/ui/switch';
+import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets';
+
+/**
+ * Per-user toggle list for dashboard widgets. The dashboard reads the
+ * same `useDashboardWidgets` hook, so flipping a switch here causes the
+ * dashboard to reflow on the next visit (or instantly if the user has
+ * both pages open in different tabs — TanStack Query's optimistic
+ * update + invalidate handles the cache sync).
+ *
+ * Mounted from UserSettings under the id `dashboard` so the dashboard
+ * "Customize" button can deep-link via `/settings#dashboard`.
+ */
+export function DashboardWidgetsCard() {
+ const { allWidgets, visibility, setVisible, setAll, isSaving } = useDashboardWidgets();
+
+ const visibleCount = Object.values(visibility).filter(Boolean).length;
+ const allVisible = visibleCount === allWidgets.length;
+ const allHidden = visibleCount === 0;
+
+ return (
+
+
+
+
+ Dashboard widgets
+
+ Pick which cards show up on your dashboard. Hidden cards leave no empty space — the
+ layout reflows to fill the available width.
+
+
+
+ setAll(true)}
+ disabled={allVisible || isSaving}
+ >
+ Show all
+
+ setAll(false)}
+ disabled={allHidden || isSaving}
+ >
+ Hide all
+
+
+
+
+
+ {allWidgets.map((w) => (
+
+
+
{w.label}
+
{w.description}
+
+
setVisible(w.id, checked)}
+ />
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/settings/user-settings.tsx b/src/components/settings/user-settings.tsx
index 3273e410..7f9d0dd7 100644
--- a/src/components/settings/user-settings.tsx
+++ b/src/components/settings/user-settings.tsx
@@ -15,6 +15,7 @@ import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
import { ImageCropperDialog } from '@/components/shared/image-cropper-dialog';
import { NotificationPreferencesForm } from '@/components/notifications/notification-preferences-form';
import { ReminderDigestForm } from '@/components/notifications/reminder-digest-form';
+import { DashboardWidgetsCard } from '@/components/settings/dashboard-widgets-card';
import { apiFetch } from '@/lib/api/client';
import { primaryTimezoneFor } from '@/lib/i18n/timezones';
import type { CountryCode } from '@/lib/i18n/countries';
@@ -186,7 +187,7 @@ export function UserSettings() {
-
+
Profile
@@ -318,6 +319,8 @@ export function UserSettings() {
+
+
Account
diff --git a/src/components/shared/berth-picker.tsx b/src/components/shared/berth-picker.tsx
new file mode 100644
index 00000000..ed2ab963
--- /dev/null
+++ b/src/components/shared/berth-picker.tsx
@@ -0,0 +1,187 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+import { Check, ChevronsUpDown } from 'lucide-react';
+import { useQuery } from '@tanstack/react-query';
+
+import { Button } from '@/components/ui/button';
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/components/ui/command';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import { useDebounce } from '@/hooks/use-debounce';
+import { apiFetch } from '@/lib/api/client';
+import { cn } from '@/lib/utils';
+
+interface BerthOption {
+ id: string;
+ mooringNumber: string;
+ area: string | null;
+ status: string;
+}
+
+interface BerthPickerProps {
+ value: string | null;
+ onChange: (berthId: string | null) => void;
+ /** When set, the dropdown is scoped to berths linked through any of
+ * this client's interests (via interest_berths.primary). Other berths
+ * are hidden so the picker mirrors the relationship the rep is
+ * already building. */
+ clientId?: string | null;
+ placeholder?: string;
+ disabled?: boolean;
+}
+
+/**
+ * Searchable berth picker. Free-text search when no client is selected;
+ * scoped to a client's primary-berth set when `clientId` is provided.
+ *
+ * The scoped query fetches the client's interests (limit 25) and
+ * intersects on `berthId`, which mirrors the relationship semantics the
+ * rest of the CRM uses ("berths that show up on this client's deals").
+ */
+export function BerthPicker({
+ value,
+ onChange,
+ clientId,
+ placeholder = 'Select berth...',
+ disabled,
+}: BerthPickerProps) {
+ const [open, setOpen] = useState(false);
+ const [search, setSearch] = useState('');
+ const debounced = useDebounce(search, 300);
+
+ // Free-text search path — used when there's no clientId scope.
+ const { data: searchData } = useQuery<{ data: BerthOption[] }>({
+ queryKey: ['berth-picker', 'search', debounced],
+ queryFn: () => {
+ const params = new URLSearchParams({ page: '1', limit: '10', order: 'asc' });
+ // The list endpoint doesn't accept `search`, so we filter
+ // client-side; pulling a larger page lets the typeahead feel
+ // responsive without round-tripping per keystroke.
+ params.set('limit', '50');
+ return apiFetch(`/api/v1/berths?${params.toString()}`);
+ },
+ enabled: open && !clientId,
+ });
+
+ // Scoped path — pull this client's interests (with their primary
+ // berth) and dedupe the berth set.
+ const { data: clientInterests } = useQuery<{
+ data: Array<{ berthId: string | null; berthMooringNumber: string | null }>;
+ }>({
+ queryKey: ['berth-picker', 'client', clientId],
+ queryFn: () => {
+ const params = new URLSearchParams({
+ page: '1',
+ limit: '25',
+ order: 'desc',
+ includeArchived: 'false',
+ clientId: clientId!,
+ });
+ return apiFetch(`/api/v1/interests?${params.toString()}`);
+ },
+ enabled: open && !!clientId,
+ });
+
+ const options: BerthOption[] = useMemo(() => {
+ if (clientId) {
+ const rows = clientInterests?.data ?? [];
+ const seen = new Set();
+ const out: BerthOption[] = [];
+ for (const r of rows) {
+ if (!r.berthId || seen.has(r.berthId)) continue;
+ seen.add(r.berthId);
+ out.push({
+ id: r.berthId,
+ mooringNumber: r.berthMooringNumber ?? '',
+ area: null,
+ status: '',
+ });
+ }
+ if (!debounced) return out;
+ const q = debounced.toLowerCase();
+ return out.filter((b) => b.mooringNumber.toLowerCase().includes(q));
+ }
+ const rows = searchData?.data ?? [];
+ if (!debounced) return rows;
+ const q = debounced.toLowerCase();
+ return rows.filter((b) => b.mooringNumber.toLowerCase().includes(q));
+ }, [clientId, clientInterests, searchData, debounced]);
+
+ const labelFor = (o: BerthOption) =>
+ o.area ? `Berth ${o.mooringNumber} · ${o.area}` : `Berth ${o.mooringNumber}`;
+
+ const selectedLabel = (() => {
+ if (!value) return placeholder;
+ const match = options.find((o) => o.id === value);
+ return match ? labelFor(match) : `Berth ${value.slice(0, 8)}`;
+ })();
+
+ return (
+ // `modal` is required when this picker is rendered inside a Sheet /
+ // Dialog — without it the CommandInput stays focus-blocked by the
+ // outer Sheet's focus trap and clicks/typing are silently dropped.
+
+
+
+ {selectedLabel}
+
+
+
+
+
+
+
+
+ {clientId ? 'No berths linked to this client.' : 'No berths found.'}
+
+
+ {value ? (
+ {
+ onChange(null);
+ setOpen(false);
+ }}
+ className="text-muted-foreground"
+ >
+ Clear selection
+
+ ) : null}
+ {options.map((o) => (
+ {
+ onChange(o.id);
+ setOpen(false);
+ }}
+ >
+
+ {labelFor(o)}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/components/shared/client-picker.tsx b/src/components/shared/client-picker.tsx
index c3f7155a..3e6d6e7f 100644
--- a/src/components/shared/client-picker.tsx
+++ b/src/components/shared/client-picker.tsx
@@ -58,7 +58,10 @@ export function ClientPicker({
})();
return (
-
+ // `modal` is required when this picker is rendered inside a Sheet /
+ // Dialog — without it the CommandInput stays focus-blocked by the
+ // outer Sheet's focus trap and clicks/typing are silently dropped.
+
No clients found.
+ {value ? (
+ {
+ onChange(null);
+ setOpen(false);
+ }}
+ className="text-muted-foreground"
+ >
+ Clear selection
+
+ ) : null}
{options.map((c) => (
o.code === value) : undefined;
return (
-
+ // modal: required when this combobox is nested inside a Sheet
+ // (Radix Dialog). Without it, the parent Dialog's pointer-events
+ // handling swallows the trigger's tap on iOS Safari — same fix
+ // pattern as TimezoneCombobox.
+
(
- ({ value, onChange, currency = 'USD', className, ...props }, ref) => {
+ ({ value, onChange, currency = 'USD', className, onBlur, onFocus, ...props }, ref) => {
const symbol = currencySymbol(currency);
- const display = value === null || value === undefined || value === '' ? '' : String(value);
+ const [display, setDisplay] = React.useState(() =>
+ value === null || value === undefined || value === '' ? '' : formatGrouped(value),
+ );
+ const focusedRef = React.useRef(false);
+
+ // Re-sync the display when the controlled value changes externally (form
+ // reset, parent-driven update). Skip while the input is focused so we
+ // don't fight the user's keystrokes.
+ React.useEffect(() => {
+ if (focusedRef.current) return;
+ if (value === null || value === undefined || value === '') {
+ setDisplay('');
+ } else {
+ setDisplay(formatGrouped(value));
+ }
+ }, [value]);
return (
@@ -43,19 +100,29 @@ export const CurrencyInput = React.forwardRef
{
- const raw = e.target.value;
- if (raw === '') {
- onChange(null);
- return;
+ const { display: nextDisplay, numeric } = parseTyped(e.target.value);
+ setDisplay(nextDisplay);
+ onChange(numeric);
+ }}
+ onFocus={(e) => {
+ focusedRef.current = true;
+ onFocus?.(e);
+ }}
+ onBlur={(e) => {
+ focusedRef.current = false;
+ // On blur, canonicalize to a clean grouped representation so the
+ // user sees the final value rather than any half-typed state.
+ if (value === null || value === undefined || value === '') {
+ setDisplay('');
+ } else {
+ setDisplay(formatGrouped(value));
}
- const n = Number(raw);
- onChange(Number.isFinite(n) ? n : null);
+ onBlur?.(e);
}}
className={cn('pl-9 tabular-nums', className)}
{...props}
diff --git a/src/components/shared/data-table.tsx b/src/components/shared/data-table.tsx
index 6eb2aa80..dfbf0df7 100644
--- a/src/components/shared/data-table.tsx
+++ b/src/components/shared/data-table.tsx
@@ -65,6 +65,15 @@ interface DataTableProps {
* sort, and selection stay in sync across the breakpoint.
*/
cardRender?: (row: Row) => React.ReactNode;
+ /**
+ * Optional grouping key for the mobile card list. When set, consecutive
+ * rows that share the same returned key are visually grouped under a
+ * header showing the key. Rendered only on mobile (next to cardRender);
+ * the desktop table is unaffected. Useful for berths-by-area,
+ * documents-by-folder, etc. — pre-sort the data on the same key so
+ * adjacent rows already share groups.
+ */
+ mobileGroupBy?: (row: TData) => string | null | undefined;
/**
* Per-column visibility map. Keys are column IDs, values mean
* "currently visible". Columns absent from the map are visible by
@@ -90,6 +99,7 @@ export function DataTable({
onRowClick,
getRowClassName,
cardRender,
+ mobileGroupBy,
columnVisibility,
}: DataTableProps) {
const [internalSelection, setInternalSelection] = useState({});
@@ -259,7 +269,30 @@ export function DataTable({
{emptyState ?? 'No results.'}
) : (
- rows.map((row) => {cardRender(row)} )
+ (() => {
+ // Walk rows once, emitting a section header every time
+ // the groupBy key changes. Keeps the existing flex-col gap-2
+ // rhythm; the header sits above the first card of each group
+ // with a faint top divider for visual rest between blocks.
+ let lastGroup: string | null | undefined;
+ const nodes: React.ReactNode[] = [];
+ rows.forEach((row, i) => {
+ const group = mobileGroupBy ? mobileGroupBy(row.original) : undefined;
+ if (mobileGroupBy && group !== lastGroup) {
+ nodes.push(
+
+
+ {group ?? 'Other'}
+
+
+ ,
+ );
+ lastGroup = group;
+ }
+ nodes.push({cardRender(row)} );
+ });
+ return nodes;
+ })()
)}
)}
diff --git a/src/components/shared/drawer.tsx b/src/components/shared/drawer.tsx
index 4b73cc49..1daf5315 100644
--- a/src/components/shared/drawer.tsx
+++ b/src/components/shared/drawer.tsx
@@ -5,11 +5,27 @@ import { Drawer as VaulDrawer } from 'vaul';
import { cn } from '@/lib/utils';
+// Default `shouldScaleBackground` to FALSE for smoother drag animations.
+// Scaling the underlying page during the swipe rasterises a heavy DOM
+// (dashboard widgets, charts, queries firing) into a composited layer
+// every frame, which stutters on mid-tier phones. The bg-black/60
+// overlay alone provides enough depth signal. Individual call sites can
+// still opt back in if they have a lightweight page underneath.
+//
+// Also default `repositionInputs={false}` — when the drawer has form
+// inputs, Vaul's viewport repositioning logic conflicts with iOS's
+// keyboard handling and produces the visible scroll-then-jump we hit
+// in the search overlay.
const Drawer = ({
- shouldScaleBackground = true,
+ shouldScaleBackground = false,
+ repositionInputs = false,
...props
}: React.ComponentProps) => (
-
+
);
Drawer.displayName = 'Drawer';
diff --git a/src/components/shared/inline-editable-field.tsx b/src/components/shared/inline-editable-field.tsx
index 96268d17..2f650a67 100644
--- a/src/components/shared/inline-editable-field.tsx
+++ b/src/components/shared/inline-editable-field.tsx
@@ -22,6 +22,13 @@ interface SelectOption {
interface BaseProps {
value: string | null | undefined;
+ /**
+ * Optional formatted version shown in display mode only. The edit
+ * input still works against the raw `value` (so the input shows the
+ * editable raw number, not the formatted string). Useful for
+ * currency, percentages, etc.
+ */
+ displayValue?: string | null;
onSave: (next: string | null) => Promise;
placeholder?: string;
emptyText?: string;
@@ -43,7 +50,15 @@ interface TextareaProps extends BaseProps {
rows?: number;
}
-export type InlineEditableFieldProps = TextProps | SelectFieldProps | TextareaProps;
+interface DateProps extends BaseProps {
+ variant: 'date';
+ /** Optional min/max bounds in YYYY-MM-DD form (e.g. for incorporation dates that
+ * can't be in the future). */
+ min?: string;
+ max?: string;
+}
+
+export type InlineEditableFieldProps = TextProps | SelectFieldProps | TextareaProps | DateProps;
/**
* Click-to-edit field used in detail panels. Shows the value as plain text
@@ -51,7 +66,15 @@ export type InlineEditableFieldProps = TextProps | SelectFieldProps | TextareaPr
* Enter/blur and cancels on Escape.
*/
export function InlineEditableField(props: InlineEditableFieldProps) {
- const { value, onSave, placeholder, emptyText = '-', className, disabled } = props;
+ const {
+ value,
+ displayValue,
+ onSave,
+ placeholder,
+ emptyText = '-',
+ className,
+ disabled,
+ } = props;
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value ?? '');
const [saving, setSaving] = useState(false);
@@ -131,11 +154,42 @@ export function InlineEditableField(props: InlineEditableFieldProps) {
);
}
+ if (props.variant === 'date') {
+ // Native date input: the browser provides the calendar UI, ISO-formatted
+ // value (YYYY-MM-DD) keeps the backend payload uniform. Saves on change
+ // (no extra blur tap on mobile) and on Enter; Escape reverts.
+ return (
+
+ {
+ const next = e.target.value;
+ setDraft(next);
+ void commit(next);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ cancel();
+ }
+ }}
+ disabled={saving || disabled}
+ className="h-8 text-sm w-auto"
+ />
+ {saving && }
+
+ );
+ }
+
if (props.variant === 'textarea') {
if (!editing) {
return (
setEditing(true)}
@@ -178,6 +232,7 @@ export function InlineEditableField(props: InlineEditableFieldProps) {
return (
setEditing(true)}
@@ -216,6 +271,7 @@ export function InlineEditableField(props: InlineEditableFieldProps) {
function ReadButton({
value,
+ displayValue,
emptyText,
disabled,
onClick,
@@ -224,6 +280,8 @@ function ReadButton({
className,
}: {
value: string | null;
+ /** Optional formatted version for display only (currency, percent, etc.) */
+ displayValue?: string | null;
emptyText: string;
disabled?: boolean;
onClick: () => void;
@@ -258,7 +316,7 @@ function ReadButton({
!value && 'text-muted-foreground',
)}
>
- {value ?? emptyText}
+ {value ? (displayValue ?? value) : emptyText}
{!disabled && (
{!disabled && (
-
+
)}
);
diff --git a/src/components/shared/interest-picker.tsx b/src/components/shared/interest-picker.tsx
new file mode 100644
index 00000000..17e5567d
--- /dev/null
+++ b/src/components/shared/interest-picker.tsx
@@ -0,0 +1,143 @@
+'use client';
+
+import { useState } from 'react';
+import { Check, ChevronsUpDown } from 'lucide-react';
+import { useQuery } from '@tanstack/react-query';
+
+import { Button } from '@/components/ui/button';
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/components/ui/command';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import { useDebounce } from '@/hooks/use-debounce';
+import { apiFetch } from '@/lib/api/client';
+import { cn } from '@/lib/utils';
+
+interface InterestOption {
+ id: string;
+ clientName: string | null;
+ berthMooringNumber: string | null;
+ pipelineStage: string;
+}
+
+interface InterestPickerProps {
+ value: string | null;
+ onChange: (interestId: string | null) => void;
+ /** When set, only this client's interests are listed. */
+ clientId?: string | null;
+ placeholder?: string;
+ disabled?: boolean;
+}
+
+/**
+ * Searchable interest picker. Mirrors ClientPicker. When `clientId` is
+ * provided the dropdown scopes to that client — so picking the client
+ * first naturally narrows the interest options.
+ */
+export function InterestPicker({
+ value,
+ onChange,
+ clientId,
+ placeholder = 'Select interest...',
+ disabled,
+}: InterestPickerProps) {
+ const [open, setOpen] = useState(false);
+ const [search, setSearch] = useState('');
+ const debounced = useDebounce(search, 300);
+
+ const { data } = useQuery<{ data: InterestOption[] }>({
+ queryKey: ['interest-picker', clientId ?? null, debounced],
+ queryFn: () => {
+ const params = new URLSearchParams({
+ page: '1',
+ limit: '10',
+ order: 'desc',
+ includeArchived: 'false',
+ });
+ if (debounced) params.set('search', debounced);
+ if (clientId) params.set('clientId', clientId);
+ return apiFetch(`/api/v1/interests?${params.toString()}`);
+ },
+ enabled: open,
+ });
+
+ const options = data?.data ?? [];
+
+ const labelFor = (o: InterestOption) => {
+ const parts = [o.clientName ?? 'Unknown client'];
+ if (o.berthMooringNumber) parts.push(`Berth ${o.berthMooringNumber}`);
+ parts.push(o.pipelineStage.replace(/_/g, ' '));
+ return parts.join(' · ');
+ };
+
+ const selectedLabel = (() => {
+ if (!value) return placeholder;
+ const match = options.find((o) => o.id === value);
+ return match ? labelFor(match) : `Interest ${value.slice(0, 8)}`;
+ })();
+
+ return (
+ // `modal` is required when this picker is rendered inside a Sheet /
+ // Dialog — without it the CommandInput stays focus-blocked by the
+ // outer Sheet's focus trap and clicks/typing are silently dropped.
+
+
+
+ {selectedLabel}
+
+
+
+
+
+
+
+ No interests found.
+
+ {value ? (
+ {
+ onChange(null);
+ setOpen(false);
+ }}
+ className="text-muted-foreground"
+ >
+ Clear selection
+
+ ) : null}
+ {options.map((o) => (
+ {
+ onChange(o.id);
+ setOpen(false);
+ }}
+ >
+
+ {labelFor(o)}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/components/shared/list-card.tsx b/src/components/shared/list-card.tsx
index ae4a6f70..861abbf0 100644
--- a/src/components/shared/list-card.tsx
+++ b/src/components/shared/list-card.tsx
@@ -31,8 +31,8 @@ interface ListCardProps {
* Shared shell for every mobile list card. Wraps the body in a Link to the
* detail page, paints an optional status accent bar on the left edge, and
* exposes a top-right slot for an actions menu. Touch/hover feedback comes
- * from a soft `hover:bg-muted/30` + `active:bg-muted/50` tint, no shadow
- * shifts (which feel jittery on mobile).
+ * from a soft brand-blue tint via `hover:bg-accent/40` + `active:bg-accent`,
+ * no shadow shifts (which feel jittery on mobile).
*/
export function ListCard({
href,
@@ -52,7 +52,7 @@ export function ListCard({
diff --git a/src/components/shared/owner-picker.tsx b/src/components/shared/owner-picker.tsx
index 5b368159..5ead8b23 100644
--- a/src/components/shared/owner-picker.tsx
+++ b/src/components/shared/owner-picker.tsx
@@ -64,10 +64,35 @@ export function OwnerPicker({
const options = data?.data ?? [];
- // Selected display label - show entity's name from current options if
- // available, otherwise a truncated id fallback.
+ // Resolve the current value's display name even before the picker is opened.
+ // Without this primer query the trigger button rendered "Client <8-char-id>"
+ // on first paint and only filled in the real name after the user opened the
+ // dropdown (which kicked the list query). The lookup hits a per-id endpoint
+ // when possible and falls back to scanning the cached options array.
+ const valueLookupEndpoint = value
+ ? value.type === 'client'
+ ? `/api/v1/clients/${value.id}`
+ : `/api/v1/companies/${value.id}`
+ : null;
+
+ const { data: valueDetail } = useQuery<{
+ data: { id: string; name?: string | null; fullName?: string | null };
+ }>({
+ queryKey: ['owner-picker-resolve', value?.type, value?.id],
+ queryFn: () => apiFetch(valueLookupEndpoint!),
+ enabled: !!value && !!valueLookupEndpoint,
+ staleTime: 60_000,
+ });
+
+ // Selected display label - prefer the resolved entity name; fall back to a
+ // truncated id only when both the primer query and the options list miss.
const selectedLabel = (() => {
if (!value) return placeholder;
+ if (valueDetail?.data) {
+ const name =
+ value.type === 'client' ? valueDetail.data.fullName : valueDetail.data.name;
+ if (name) return name;
+ }
const match = options.find((o) => o.id === value.id);
if (match) {
return type === 'client'
@@ -80,7 +105,7 @@ export function OwnerPicker({
})();
return (
-
+
void;
+ id?: string;
+ disabled?: boolean;
+ /** Custom-input placeholder, defaults to "e.g. 21". */
+ placeholder?: string;
+ className?: string;
+}
+
+/**
+ * Days-from-now reminder picker. Quick-pick chips for the four or five
+ * cadences reps actually use (1 day, 3, 1 week, 2 weeks, 1 month) plus a
+ * custom integer input for everything else. Clearer than a raw number field
+ * and surfaces the common cases without the rep having to type.
+ *
+ * Storage is still a single integer (number of days), so callers can keep
+ * their existing form/zod shape unchanged.
+ */
+export function ReminderDaysInput({
+ value,
+ onChange,
+ id,
+ disabled,
+ placeholder = 'e.g. 21',
+ className,
+}: ReminderDaysInputProps) {
+ const isPreset = typeof value === 'number' && (PRESETS as readonly number[]).includes(value);
+ const [customStr, setCustomStr] = React.useState(() =>
+ !isPreset && typeof value === 'number' ? String(value) : '',
+ );
+
+ // Sync external value → custom input when it changes to a non-preset.
+ React.useEffect(() => {
+ if (typeof value === 'number' && !(PRESETS as readonly number[]).includes(value)) {
+ setCustomStr(String(value));
+ } else if (value == null) {
+ setCustomStr('');
+ }
+ }, [value]);
+
+ return (
+
+
+ {PRESETS.map((days) => (
+ onChange(days)}
+ className={cn(
+ 'rounded-full border px-3 py-1 text-xs font-medium transition-colors',
+ value === days
+ ? 'border-primary bg-primary text-primary-foreground'
+ : 'border-border bg-background text-foreground hover:bg-accent',
+ disabled && 'cursor-not-allowed opacity-60',
+ )}
+ aria-pressed={value === days}
+ >
+ {labelFor(days)}
+
+ ))}
+
+
{
+ const raw = e.target.value;
+ setCustomStr(raw);
+ if (raw === '') {
+ onChange(null);
+ return;
+ }
+ const n = Number.parseInt(raw, 10);
+ if (Number.isFinite(n) && n > 0) onChange(n);
+ }}
+ />
+
+ );
+}
+
+function labelFor(days: number): string {
+ if (days === 1) return '1 day';
+ if (days === 7) return '1 week';
+ if (days === 14) return '2 weeks';
+ if (days === 30) return '1 month';
+ return `${days} days`;
+}
diff --git a/src/components/shared/subdivision-combobox.tsx b/src/components/shared/subdivision-combobox.tsx
index 81a215d2..9b676dd5 100644
--- a/src/components/shared/subdivision-combobox.tsx
+++ b/src/components/shared/subdivision-combobox.tsx
@@ -75,7 +75,7 @@ export function SubdivisionCombobox({
else triggerLabel = placeholder;
return (
-
+
;
+ // If the port has no tags configured AND the rep also hasn't selected any
+ // (e.g. a tag was deleted after selection), don't render the picker at all —
+ // the affordance is noise until tags are set up under Admin → Tags.
+ if (!isLoading && tagOptions.length === 0 && selectedIds.length === 0) {
+ return null;
+ }
+
function toggleTag(tagId: string) {
if (selectedIds.includes(tagId)) {
onChange(selectedIds.filter((id) => id !== tagId));
@@ -53,7 +60,7 @@ export function TagPicker({
return (
-
+
+ // `modal` is critical for iOS Safari when this combobox is nested
+ // inside a Sheet (Radix Dialog). Without it, the parent Dialog's
+ // pointer-events handling can swallow the trigger's touch event,
+ // so tapping the button does nothing on iPhone. modal=true makes
+ // Radix isolate the Popover's pointer context from the parent.
+
) => (
);
diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx
index e502c1b2..3c165e51 100644
--- a/src/components/ui/table.tsx
+++ b/src/components/ui/table.tsx
@@ -23,7 +23,11 @@ const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
-
+ // Strip only the bottom border on the last row (the visual table-divider).
+ // The shorter `border-0` form also nuked left/right borders, which broke
+ // the colored left-accent (e.g. mooring-letter tone in berth-list) — that
+ // accent is added via row className and counts on the left border surviving.
+
));
TableBody.displayName = 'TableBody';
diff --git a/src/components/yachts/yacht-list.tsx b/src/components/yachts/yacht-list.tsx
index 8a01523c..9844886f 100644
--- a/src/components/yachts/yacht-list.tsx
+++ b/src/components/yachts/yacht-list.tsx
@@ -27,6 +27,7 @@ import { YachtCard } from '@/components/yachts/yacht-card';
import { YachtForm } from '@/components/yachts/yacht-form';
import { yachtFilterDefinitions } from '@/components/yachts/yacht-filters';
import { getYachtColumns, type YachtRow } from '@/components/yachts/yacht-columns';
+import { useCreateFromUrl } from '@/hooks/use-create-from-url';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
@@ -37,6 +38,7 @@ export function YachtList() {
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
+ useCreateFromUrl(() => setCreateOpen(true));
const [editYacht, setEditYacht] = useState(null);
const [archiveYacht, setArchiveYacht] = useState(null);
const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>(
diff --git a/src/components/yachts/yacht-picker.tsx b/src/components/yachts/yacht-picker.tsx
index 2b8aba1f..fb8a076b 100644
--- a/src/components/yachts/yacht-picker.tsx
+++ b/src/components/yachts/yacht-picker.tsx
@@ -79,7 +79,7 @@ export function YachtPicker({
})();
return (
-
+
void): void {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+
+ useEffect(() => {
+ if (searchParams.get('create') !== '1') return;
+ onOpen();
+ const params = new URLSearchParams(searchParams.toString());
+ params.delete('create');
+ const newUrl = params.toString() ? `?${params.toString()}` : window.location.pathname;
+ // typedRoutes can't statically validate a same-route replace; cast is safe.
+ router.replace(newUrl as never);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [searchParams]);
+}
diff --git a/src/hooks/use-dashboard-integrations.ts b/src/hooks/use-dashboard-integrations.ts
new file mode 100644
index 00000000..f257226d
--- /dev/null
+++ b/src/hooks/use-dashboard-integrations.ts
@@ -0,0 +1,45 @@
+'use client';
+
+import { useUmamiActive } from '@/components/website-analytics/use-website-analytics';
+import type { WidgetIntegration } from '@/components/dashboard/widget-registry';
+
+/**
+ * Returns availability for each external integration a dashboard widget
+ * might depend on. Lets the widget picker hide options whose underlying
+ * service isn't wired up — so reps don't enable widgets that'd render
+ * nothing.
+ *
+ * Add a new integration by:
+ * 1. Extending `WidgetIntegration` in the registry.
+ * 2. Probing the service here (cheap query is fine — the hook already
+ * ships its own network call so adding another doesn't change the
+ * cost model).
+ * 3. Returning the boolean in the map.
+ *
+ * `loading: true` is treated as "available" so widgets don't flash off
+ * during initial hydration.
+ */
+export function useDashboardIntegrations(): {
+ loading: boolean;
+ available: Record;
+} {
+ const umami = useUmamiActive('today');
+ // Same probe the sidebar uses — `notConfigured: true` is the explicit
+ // signal the server returns when the integration isn't wired up.
+ const umamiAvailable = umami.isLoading
+ ? true
+ : (umami.data as { notConfigured?: boolean } | undefined)?.notConfigured !== true;
+
+ // Documenso has no dashboard widgets yet — wire a real probe when the
+ // first one lands. Assuming available for now keeps the map honest if
+ // a Documenso widget is added before this hook is updated.
+ const documensoAvailable = true;
+
+ return {
+ loading: umami.isLoading,
+ available: {
+ umami: umamiAvailable,
+ documenso: documensoAvailable,
+ },
+ };
+}
diff --git a/src/hooks/use-dashboard-widgets.ts b/src/hooks/use-dashboard-widgets.ts
new file mode 100644
index 00000000..953a9191
--- /dev/null
+++ b/src/hooks/use-dashboard-widgets.ts
@@ -0,0 +1,143 @@
+'use client';
+
+import { useMemo } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import { apiFetch } from '@/lib/api/client';
+import { DASHBOARD_WIDGETS, type DashboardWidget } from '@/components/dashboard/widget-registry';
+import { useDashboardIntegrations } from '@/hooks/use-dashboard-integrations';
+
+interface PreferencesResponse {
+ data?: {
+ dashboardWidgets?: Record;
+ // Other fields exist (timezone, locale, …) but we don't need them
+ // here — the typed access is intentionally narrow.
+ };
+}
+
+/**
+ * Returns the dashboard widget list filtered by the user's visibility
+ * preferences and exposes a toggle. Single source of truth for "what's
+ * showing on the dashboard right now" — used by both `DashboardShell`
+ * and the settings UI.
+ *
+ * Stored shape: `preferences.dashboardWidgets: { [widgetId]: boolean }`.
+ * Missing keys fall back to the registry's `defaultVisible`, so a newly
+ * added widget surfaces for everyone without a migration.
+ */
+export function useDashboardWidgets() {
+ const queryClient = useQueryClient();
+ const integrations = useDashboardIntegrations();
+
+ const { data, isLoading } = useQuery({
+ queryKey: ['me', 'preferences', 'dashboard-widgets'],
+ queryFn: () => apiFetch('/api/v1/users/me/preferences'),
+ staleTime: 60_000,
+ });
+
+ // The registry is the universe of declared widgets. `availableWidgets`
+ // is the universe filtered down to what actually CAN render right now
+ // (i.e. its required integration is connected). The picker iterates
+ // this list, and the visible-widgets render path filters off the same
+ // list so flipping on a widget whose service isn't wired up does
+ // nothing silently — the toggle simply isn't shown.
+ const availableWidgets: DashboardWidget[] = useMemo(
+ () =>
+ DASHBOARD_WIDGETS.filter(
+ (w) => !w.requires || integrations.available[w.requires],
+ ),
+ [integrations],
+ );
+
+ const visibility: Record = useMemo(() => {
+ const stored = data?.data?.dashboardWidgets ?? {};
+ const merged: Record = {};
+ for (const w of availableWidgets) {
+ merged[w.id] = stored[w.id] ?? w.defaultVisible;
+ }
+ return merged;
+ }, [data, availableWidgets]);
+
+ const visibleWidgets: DashboardWidget[] = useMemo(
+ () => availableWidgets.filter((w) => visibility[w.id]),
+ [availableWidgets, visibility],
+ );
+
+ /**
+ * Persists a single widget's visibility. Optimistically updates the
+ * cache so the dashboard reflows instantly; the server PATCH races in
+ * the background. On failure the cache invalidates and re-reads the
+ * authoritative value.
+ */
+ const mutation = useMutation({
+ mutationFn: async (next: Record) =>
+ apiFetch('/api/v1/users/me/preferences', {
+ method: 'PATCH',
+ body: { dashboardWidgets: next },
+ }),
+ onMutate: async (next) => {
+ await queryClient.cancelQueries({ queryKey: ['me', 'preferences', 'dashboard-widgets'] });
+ const previous = queryClient.getQueryData([
+ 'me',
+ 'preferences',
+ 'dashboard-widgets',
+ ]);
+ queryClient.setQueryData(
+ ['me', 'preferences', 'dashboard-widgets'],
+ (old) => ({
+ data: { ...(old?.data ?? {}), dashboardWidgets: next },
+ }),
+ );
+ return { previous };
+ },
+ onError: (_err, _next, ctx) => {
+ if (ctx?.previous) {
+ queryClient.setQueryData(['me', 'preferences', 'dashboard-widgets'], ctx.previous);
+ }
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: ['me', 'preferences', 'dashboard-widgets'] });
+ },
+ });
+
+ function setVisible(id: string, visible: boolean) {
+ mutation.mutate({ ...visibility, [id]: visible });
+ }
+
+ function setAll(visible: boolean) {
+ const next: Record = {};
+ for (const w of availableWidgets) next[w.id] = visible;
+ mutation.mutate(next);
+ }
+
+ /**
+ * Restores each widget's visibility to its registry `defaultVisible`.
+ * Different from `setAll(true)` — it keeps the "off by default" widgets
+ * (KPI tiles, Berth Status donut, Source Conversion, Hot Deals) off so
+ * reps end up with the original out-of-the-box dashboard. Scoped to
+ * `availableWidgets` so disconnected integrations don't sneak in.
+ */
+ function resetToDefaults() {
+ const next: Record = {};
+ for (const w of availableWidgets) next[w.id] = w.defaultVisible;
+ mutation.mutate(next);
+ }
+
+ return {
+ isLoading,
+ /**
+ * Widgets that can render right now (registry minus those whose
+ * required integration isn't connected). Use this for the picker
+ * AND for the dashboard render — both surfaces stay in sync.
+ */
+ allWidgets: availableWidgets,
+ /** Visible widgets, in registry order. */
+ visibleWidgets,
+ /** Map of widgetId → visible. Use for switch state binding. */
+ visibility,
+ setVisible,
+ setAll,
+ resetToDefaults,
+ isSaving: mutation.isPending,
+ };
+}
diff --git a/src/hooks/use-search.ts b/src/hooks/use-search.ts
index f41e59e0..e19c07be 100644
--- a/src/hooks/use-search.ts
+++ b/src/hooks/use-search.ts
@@ -25,12 +25,24 @@ export type BucketType =
| 'navigation'
| 'notes';
+/**
+ * Provenance hint for a result row that surfaced via graph expansion
+ * rather than a direct match against the query. Rendered as a "via X"
+ * subtitle by the result-row UI.
+ */
+export interface RelatedVia {
+ type: 'berth' | 'interest' | 'client' | 'yacht' | 'company';
+ id: string;
+ label: string;
+}
+
export interface ClientResult {
id: string;
fullName: string;
matchedContact: string | null;
matchedContactChannel: 'email' | 'phone' | 'whatsapp' | null;
archivedAt: string | null;
+ relatedVia?: RelatedVia | null;
}
export interface ResidentialClientResult {
id: string;
@@ -46,6 +58,7 @@ export interface YachtResult {
hullNumber: string | null;
registration: string | null;
archivedAt: string | null;
+ relatedVia?: RelatedVia | null;
}
export interface CompanyResult {
id: string;
@@ -54,6 +67,7 @@ export interface CompanyResult {
taxId: string | null;
matchedField: 'name' | 'legalName' | 'taxId' | 'billingEmail' | 'registrationNumber' | null;
archivedAt: string | null;
+ relatedVia?: RelatedVia | null;
}
export interface InterestResult {
id: string;
@@ -61,6 +75,7 @@ export interface InterestResult {
berthMooringNumber: string | null;
pipelineStage: string;
outcome: string | null;
+ relatedVia?: RelatedVia | null;
}
export interface ResidentialInterestResult {
id: string;
@@ -73,6 +88,7 @@ export interface BerthResult {
area: string | null;
status: string;
linkedInterestCount: number;
+ relatedVia?: RelatedVia | null;
}
export interface InvoiceResult {
id: string;
diff --git a/src/hooks/use-track-entity-view.ts b/src/hooks/use-track-entity-view.ts
index 107f4ebd..2270c5f2 100644
--- a/src/hooks/use-track-entity-view.ts
+++ b/src/hooks/use-track-entity-view.ts
@@ -1,6 +1,7 @@
'use client';
import { useEffect } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
import { trackEntityView } from '@/hooks/use-search';
@@ -10,11 +11,11 @@ import { trackEntityView } from '@/hooks/use-search';
* the call when `id` is falsy (e.g. during a transitional render before
* the data has loaded).
*
- * Uses a JSON-stringified deps array so re-renders with the same
- * (type, id) don't re-fire the network call. The fire-and-forget
- * tracking endpoint debounces server-side too (Redis ZADD upserts the
- * same member with a fresh score), but skipping the redundant fetch
- * keeps the network panel tidy.
+ * After the track POST resolves, invalidates the recently-viewed query
+ * so the search dropdown re-fetches the freshly-updated list. Without
+ * this, the overlay's query cache (mounted once at the layout root)
+ * stays frozen on whatever it had at first paint — typically empty —
+ * and the user never sees the entity they just opened.
*/
export function useTrackEntityView(
type:
@@ -30,8 +31,12 @@ export function useTrackEntityView(
| 'document',
id: string | null | undefined,
): void {
+ const queryClient = useQueryClient();
+
useEffect(() => {
if (!id) return;
- void trackEntityView(type, id);
- }, [type, id]);
+ void trackEntityView(type, id).then(() => {
+ queryClient.invalidateQueries({ queryKey: ['search', 'recently-viewed'] });
+ });
+ }, [type, id, queryClient]);
}
diff --git a/src/lib/analytics/range.ts b/src/lib/analytics/range.ts
index 87a6aa12..4e403d14 100644
--- a/src/lib/analytics/range.ts
+++ b/src/lib/analytics/range.ts
@@ -33,6 +33,18 @@ export function isCustomRange(range: DateRange): range is CustomDateRange {
return typeof range === 'object' && range.kind === 'custom';
}
+/**
+ * Filename-safe slug for the active range. Used by chart exports so a
+ * PNG/CSV download lands as `pipeline-funnel-30d.png` rather than
+ * `pipeline-funnel-[object Object].png` (which is what a raw template
+ * literal does to a CustomDateRange — Chrome then strips the name and
+ * the file falls back to the blob URL's UUID with no extension).
+ */
+export function rangeToSlug(range: DateRange): string {
+ if (isCustomRange(range)) return `${range.from}_${range.to}`;
+ return range;
+}
+
/**
* Resolve any DateRange (preset or custom) to a concrete {from, to} pair.
* - Preset ranges anchor `to` at "now" and `from` at `now - N days`.
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 29909ff5..fe12fcba 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -168,11 +168,39 @@ export const BERTH_ACCESS_OPTIONS = [
'Car (3.5t) to Vessel',
] as const;
-/** Helper to map a readonly enum tuple into shadcn `` `{value,label}` objects. */
+/**
+ * Map a readonly enum tuple into shadcn `` `{value, label}` objects.
+ * `value` is the raw enum string (what the API expects); `label` is a
+ * human-formatted version (underscores → spaces, Title Case) so reps
+ * see "Under Offer" instead of "under_offer" in dropdowns. Specific
+ * acronyms keep their canonical casing.
+ */
+const LABEL_OVERRIDES: Record = {
+ // 3-letter acronyms — preserve all-caps where the enum stores lowercase.
+ vhf: 'VHF',
+ eoi: 'EOI',
+ nda: 'NDA',
+ // Status enums where the natural title-cased form differs slightly.
+ under_offer: 'Under Offer',
+ fixed_term: 'Fixed Term',
+ reservation_agreement: 'Reservation Agreement',
+ hot_lead: 'Hot Lead',
+ general_interest: 'General Interest',
+ specific_qualified: 'Specific Qualified',
+};
+
+function humanizeEnum(raw: string): string {
+ const override = LABEL_OVERRIDES[raw.toLowerCase()];
+ if (override) return override;
+ return raw
+ .replace(/_/g, ' ')
+ .replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
export function toSelectOptions(
values: T,
-): Array<{ value: T[number]; label: T[number] }> {
- return values.map((v) => ({ value: v, label: v }));
+): Array<{ value: T[number]; label: string }> {
+ return values.map((v) => ({ value: v, label: humanizeEnum(v) }));
}
// ─── Lead Categories ─────────────────────────────────────────────────────────
@@ -181,6 +209,34 @@ export const LEAD_CATEGORIES = ['general_interest', 'specific_qualified', 'hot_l
export type LeadCategory = (typeof LEAD_CATEGORIES)[number];
+// ─── Sources (interests + clients + residential) ─────────────────────────────
+// Single source of truth for the source dropdown. Keep these in lockstep
+// across forms, inline-edit selects, list-column labels and chart bucketing
+// so values written from one surface render with the same label on another.
+
+export const SOURCES = [
+ { value: 'website', label: 'Website' },
+ { value: 'manual', label: 'Manual' },
+ { value: 'referral', label: 'Referral' },
+ { value: 'broker', label: 'Broker' },
+ { value: 'other', label: 'Other' },
+] as const;
+
+export type SourceValue = (typeof SOURCES)[number]['value'];
+
+export const SOURCE_LABELS: Record = SOURCES.reduce(
+ (acc, s) => ({ ...acc, [s.value]: s.label }),
+ {} as Record,
+);
+
+/** Returns the canonical label for a stored source value, falling back to a
+ * Title-Case rendering of the raw string for legacy / free-text values. */
+export function formatSource(source: string | null | undefined): string | null {
+ if (!source) return null;
+ if (source in SOURCE_LABELS) return SOURCE_LABELS[source as SourceValue];
+ return source.charAt(0).toUpperCase() + source.slice(1);
+}
+
// ─── Document Types ──────────────────────────────────────────────────────────
export const DOCUMENT_TYPES = ['eoi', 'contract', 'nda', 'reservation_agreement', 'other'] as const;
diff --git a/src/lib/db/schema/users.ts b/src/lib/db/schema/users.ts
index 05a70ee2..66d04993 100644
--- a/src/lib/db/schema/users.ts
+++ b/src/lib/db/schema/users.ts
@@ -165,6 +165,13 @@ export type UserPreferences = {
country?: string;
/** Keyed by entity type: `clients`, `yachts`, `interests`, etc. */
tablePreferences?: Record;
+ /**
+ * Dashboard widget visibility, keyed by widget id from the registry
+ * in `src/components/dashboard/widget-registry.ts`. Missing keys fall
+ * back to `defaultVisible` from the registry — so adding a new widget
+ * surfaces it for everyone without a migration. `false` hides it.
+ */
+ dashboardWidgets?: Record;
[key: string]: unknown;
};
diff --git a/src/lib/services/berths.service.ts b/src/lib/services/berths.service.ts
index 42c06a26..a1f274da 100644
--- a/src/lib/services/berths.service.ts
+++ b/src/lib/services/berths.service.ts
@@ -10,7 +10,9 @@ import { NotFoundError, ValidationError } from '@/lib/errors';
import { buildListQuery } from '@/lib/db/query-builder';
import { emitToRoom } from '@/lib/socket/server';
import { setEntityTags } from '@/lib/services/entity-tags.helper';
+import { getPortBerthsDefaultCurrency } from '@/lib/services/port-config';
import { ConflictError } from '@/lib/errors';
+import { sortByMooring } from '@/lib/utils/mooring-sort';
import type {
CreateBerthInput,
UpdateBerthInput,
@@ -487,6 +489,12 @@ export async function createBerth(portId: string, data: CreateBerthInput, meta:
throw new ConflictError(`Berth "${data.mooringNumber}" already exists in this port`);
}
+ // Caller-specified currency wins; otherwise inherit the port's admin-
+ // configured default (system_settings.berths_default_currency, USD if
+ // unset). Lets a multi-currency portfolio be modelled cleanly without
+ // forcing reps to pick a currency on every new-berth form.
+ const resolvedCurrency = data.priceCurrency ?? (await getPortBerthsDefaultCurrency(portId));
+
const [berth] = await db
.insert(berths)
.values({
@@ -501,7 +509,7 @@ export async function createBerth(portId: string, data: CreateBerthInput, meta:
draftFt: data.draftFt?.toString(),
draftM: data.draftM?.toString(),
price: data.price?.toString(),
- priceCurrency: data.priceCurrency ?? 'USD',
+ priceCurrency: resolvedCurrency,
tenureType: data.tenureType ?? 'permanent',
mooringType: data.mooringType,
powerCapacity: data.powerCapacity?.toString(),
@@ -563,7 +571,10 @@ export async function deleteBerth(id: string, portId: string, meta: AuditMeta) {
// ─── Options ──────────────────────────────────────────────────────────────────
export async function getBerthOptions(portId: string) {
- return db
+ // DB-side `ORDER BY mooring_number` is lexicographic (A1, A10, A11, A2…).
+ // Natural-sort in JS so dropdowns surface them as reps read them: A1, A2,
+ // …, A10, A11. See compareMooringNumbers for the prefix/index split.
+ const rows = await db
.select({
id: berths.id,
mooringNumber: berths.mooringNumber,
@@ -571,6 +582,6 @@ export async function getBerthOptions(portId: string) {
status: berths.status,
})
.from(berths)
- .where(eq(berths.portId, portId))
- .orderBy(berths.mooringNumber);
+ .where(eq(berths.portId, portId));
+ return sortByMooring(rows, (r) => r.mooringNumber);
}
diff --git a/src/lib/services/companies.service.ts b/src/lib/services/companies.service.ts
index 3f0faaef..4121f8f5 100644
--- a/src/lib/services/companies.service.ts
+++ b/src/lib/services/companies.service.ts
@@ -27,7 +27,7 @@ import type {
ListCompaniesInput,
} from '@/lib/validators/companies';
-type CreateCompanyInput = z.input;
+type CreateCompanyInput = z.output;
export type { Company };
diff --git a/src/lib/services/dashboard.service.ts b/src/lib/services/dashboard.service.ts
index eeb1d9b7..0979ff81 100644
--- a/src/lib/services/dashboard.service.ts
+++ b/src/lib/services/dashboard.service.ts
@@ -1,9 +1,14 @@
-import { and, count, desc, eq, isNull, sql } from 'drizzle-orm';
+import { and, count, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clients } from '@/lib/db/schema/clients';
+import { yachts } from '@/lib/db/schema/yachts';
+import { companies } from '@/lib/db/schema/companies';
import { interests, interestBerths } from '@/lib/db/schema/interests';
import { berths } from '@/lib/db/schema/berths';
+import { invoices, expenses } from '@/lib/db/schema/financial';
+import { documents } from '@/lib/db/schema/documents';
+import { reminders } from '@/lib/db/schema/operations';
import { systemSettings, auditLogs } from '@/lib/db/schema/system';
import { PIPELINE_STAGES, STAGE_WEIGHTS } from '@/lib/constants';
@@ -156,6 +161,119 @@ export async function getRevenueForecast(portId: string) {
};
}
+// ─── Compact widget queries ───────────────────────────────────────────────────
+
+/**
+ * Berth status split for the donut widget. Returns counts plus the total
+ * so the chart can show "12 of 47 sold" alongside the segment percentage.
+ */
+export async function getBerthStatusDistribution(portId: string) {
+ const rows = await db
+ .select({ status: berths.status, c: sql`count(*)::int` })
+ .from(berths)
+ .where(eq(berths.portId, portId))
+ .groupBy(berths.status);
+
+ const counts: Record = {};
+ for (const r of rows) counts[r.status] = r.c;
+ const total = Object.values(counts).reduce((a, b) => a + b, 0);
+
+ return {
+ total,
+ available: counts['available'] ?? 0,
+ underOffer: counts['under_offer'] ?? 0,
+ sold: counts['sold'] ?? 0,
+ maintenance: counts['maintenance'] ?? 0,
+ };
+}
+
+/**
+ * Top 5 active interests closest to closing — ranked by pipeline stage
+ * (further = closer to closing) with most-recent activity as a
+ * tiebreaker. Surfaces the deals reps should actually be chasing on the
+ * dashboard without making them open the pipeline board.
+ */
+export async function getHotDeals(portId: string, limit = 5) {
+ // Stage rank: bigger = closer to closing.
+ const rank = sql`CASE ${interests.pipelineStage}
+ WHEN 'completed' THEN 8
+ WHEN 'contract_signed' THEN 7
+ WHEN 'contract_sent' THEN 6
+ WHEN 'deposit_10' THEN 5
+ WHEN 'eoi_signed' THEN 4
+ WHEN 'eoi_sent' THEN 3
+ WHEN 'in_comms' THEN 2
+ WHEN 'details_sent' THEN 1
+ ELSE 0
+ END`;
+
+ const rows = await db
+ .select({
+ id: interests.id,
+ stage: interests.pipelineStage,
+ clientName: clients.fullName,
+ mooring: berths.mooringNumber,
+ lastContact: interests.dateLastContact,
+ updatedAt: interests.updatedAt,
+ rank,
+ })
+ .from(interests)
+ .innerJoin(clients, eq(interests.clientId, clients.id))
+ .leftJoin(
+ interestBerths,
+ and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
+ )
+ .leftJoin(berths, eq(interestBerths.berthId, berths.id))
+ .where(
+ and(
+ eq(interests.portId, portId),
+ isNull(interests.archivedAt),
+ isNull(interests.outcome), // exclude won/lost — they're not "closing"
+ ),
+ )
+ .orderBy(desc(rank), desc(interests.updatedAt))
+ .limit(limit);
+
+ return rows.map((r) => ({
+ id: r.id,
+ stage: r.stage,
+ clientName: r.clientName,
+ mooringNumber: r.mooring,
+ lastContact: r.lastContact ? r.lastContact.toISOString() : null,
+ }));
+}
+
+/**
+ * Source-conversion breakdown for the marketing widget. Returns per-
+ * source totals (active + won + lost) and a derived conversion rate so
+ * reps see which channels deliver buyers vs tire-kickers — orthogonal
+ * to the existing "lead source attribution" chart which only counts
+ * inbound volume.
+ */
+export async function getSourceConversion(portId: string) {
+ const rows = await db
+ .select({
+ source: interests.source,
+ total: sql`count(*)::int`,
+ won: sql`sum(case when ${interests.outcome} = 'won' then 1 else 0 end)::int`,
+ lost: sql`sum(case when ${interests.outcome} = 'lost' then 1 else 0 end)::int`,
+ })
+ .from(interests)
+ .where(and(eq(interests.portId, portId), isNull(interests.archivedAt)))
+ .groupBy(interests.source);
+
+ return rows
+ .filter((r) => r.source)
+ .map((r) => ({
+ source: r.source!,
+ total: r.total,
+ won: r.won,
+ lost: r.lost,
+ conversionRate: r.total > 0 ? r.won / r.total : 0,
+ }))
+ .sort((a, b) => b.total - a.total);
+}
+
// ─── Recent Activity ──────────────────────────────────────────────────────────
export async function getRecentActivity(portId: string, limit = 20) {
@@ -166,6 +284,9 @@ export async function getRecentActivity(portId: string, limit = 20) {
entityType: auditLogs.entityType,
entityId: auditLogs.entityId,
userId: auditLogs.userId,
+ fieldChanged: auditLogs.fieldChanged,
+ oldValue: auditLogs.oldValue,
+ newValue: auditLogs.newValue,
metadata: auditLogs.metadata,
createdAt: auditLogs.createdAt,
})
@@ -174,5 +295,121 @@ export async function getRecentActivity(portId: string, limit = 20) {
.orderBy(desc(auditLogs.createdAt))
.limit(limit);
- return rows;
+ // Resolve a human label per row (client name, yacht name, invoice number,
+ // …). The dashboard widget previously rendered the bare UUID prefix which
+ // told reps nothing about which entity was touched. We batch one SELECT
+ // per entityType, capping at the row set's natural size (<= `limit`).
+ const byType = new Map>();
+ for (const r of rows) {
+ if (!r.entityId) continue;
+ if (!byType.has(r.entityType)) byType.set(r.entityType, new Set());
+ byType.get(r.entityType)!.add(r.entityId);
+ }
+
+ const labels = new Map(); // `${type}:${id}` → label
+
+ async function loadLabels(
+ type: string,
+ fetcher: (ids: string[]) => Promise,
+ pick: (row: T) => string,
+ ) {
+ const ids = Array.from(byType.get(type) ?? []);
+ if (ids.length === 0) return;
+ const fetched = await fetcher(ids);
+ for (const row of fetched) labels.set(`${type}:${row.id}`, pick(row));
+ }
+
+ await Promise.all([
+ loadLabels(
+ 'client',
+ (ids) =>
+ db
+ .select({ id: clients.id, name: clients.fullName })
+ .from(clients)
+ .where(and(eq(clients.portId, portId), inArray(clients.id, ids))),
+ (r) => r.name,
+ ),
+ loadLabels(
+ 'yacht',
+ (ids) =>
+ db
+ .select({ id: yachts.id, name: yachts.name })
+ .from(yachts)
+ .where(and(eq(yachts.portId, portId), inArray(yachts.id, ids))),
+ (r) => r.name,
+ ),
+ loadLabels(
+ 'company',
+ (ids) =>
+ db
+ .select({ id: companies.id, name: companies.name })
+ .from(companies)
+ .where(and(eq(companies.portId, portId), inArray(companies.id, ids))),
+ (r) => r.name,
+ ),
+ loadLabels(
+ 'interest',
+ (ids) =>
+ db
+ .select({ id: interests.id, clientName: clients.fullName })
+ .from(interests)
+ .innerJoin(clients, eq(interests.clientId, clients.id))
+ .where(and(eq(interests.portId, portId), inArray(interests.id, ids))),
+ (r) => r.clientName,
+ ),
+ loadLabels(
+ 'berth',
+ (ids) =>
+ db
+ .select({ id: berths.id, mooring: berths.mooringNumber })
+ .from(berths)
+ .where(and(eq(berths.portId, portId), inArray(berths.id, ids))),
+ (r) => `Berth ${r.mooring}`,
+ ),
+ loadLabels(
+ 'invoice',
+ (ids) =>
+ db
+ .select({ id: invoices.id, num: invoices.invoiceNumber })
+ .from(invoices)
+ .where(and(eq(invoices.portId, portId), inArray(invoices.id, ids))),
+ (r) => r.num,
+ ),
+ loadLabels(
+ 'expense',
+ (ids) =>
+ db
+ .select({
+ id: expenses.id,
+ desc: expenses.description,
+ vendor: expenses.establishmentName,
+ })
+ .from(expenses)
+ .where(and(eq(expenses.portId, portId), inArray(expenses.id, ids))),
+ (r) => r.desc ?? r.vendor ?? 'Expense',
+ ),
+ loadLabels(
+ 'document',
+ (ids) =>
+ db
+ .select({ id: documents.id, title: documents.title })
+ .from(documents)
+ .where(and(eq(documents.portId, portId), inArray(documents.id, ids))),
+ (r) => r.title,
+ ),
+ loadLabels(
+ 'reminder',
+ (ids) =>
+ db
+ .select({ id: reminders.id, title: reminders.title })
+ .from(reminders)
+ .where(and(eq(reminders.portId, portId), inArray(reminders.id, ids))),
+ (r) => r.title,
+ ),
+ ]);
+
+ return rows.map((r) => ({
+ ...r,
+ label: r.entityId ? (labels.get(`${r.entityType}:${r.entityId}`) ?? null) : null,
+ }));
}
diff --git a/src/lib/services/interests.service.ts b/src/lib/services/interests.service.ts
index 60ad642c..174a8280 100644
--- a/src/lib/services/interests.service.ts
+++ b/src/lib/services/interests.service.ts
@@ -778,13 +778,16 @@ export async function changeInterestStage(
.where(and(eq(interests.id, id), eq(interests.portId, portId)))
.returning();
- // BR-133: Auto-populate milestones based on stage
+ // BR-133: Auto-populate milestones based on stage. The rep can override the
+ // stamp via `milestoneDate` when they're back-dating a real event (e.g.
+ // "deposit landed yesterday"); we still default to now when omitted.
+ const milestoneDate = data.milestoneDate ? new Date(data.milestoneDate) : new Date();
const milestoneUpdates: Record = {};
- if (data.pipelineStage === 'eoi_sent') milestoneUpdates.dateEoiSent = new Date();
- if (data.pipelineStage === 'eoi_signed') milestoneUpdates.dateEoiSigned = new Date();
- if (data.pipelineStage === 'deposit_10pct') milestoneUpdates.dateDepositReceived = new Date();
- if (data.pipelineStage === 'contract_sent') milestoneUpdates.dateContractSent = new Date();
- if (data.pipelineStage === 'contract_signed') milestoneUpdates.dateContractSigned = new Date();
+ if (data.pipelineStage === 'eoi_sent') milestoneUpdates.dateEoiSent = milestoneDate;
+ if (data.pipelineStage === 'eoi_signed') milestoneUpdates.dateEoiSigned = milestoneDate;
+ if (data.pipelineStage === 'deposit_10pct') milestoneUpdates.dateDepositReceived = milestoneDate;
+ if (data.pipelineStage === 'contract_sent') milestoneUpdates.dateContractSent = milestoneDate;
+ if (data.pipelineStage === 'contract_signed') milestoneUpdates.dateContractSigned = milestoneDate;
if (Object.keys(milestoneUpdates).length > 0) {
await db
.update(interests)
diff --git a/src/lib/services/port-config.ts b/src/lib/services/port-config.ts
index 6e03ef4b..96354142 100644
--- a/src/lib/services/port-config.ts
+++ b/src/lib/services/port-config.ts
@@ -101,6 +101,9 @@ export const SETTING_KEYS = {
reminderDigestEnabled: 'reminder_digest_enabled',
reminderDigestTime: 'reminder_digest_time',
reminderDigestTimezone: 'reminder_digest_timezone',
+
+ // Berths
+ berthsDefaultCurrency: 'berths_default_currency',
} as const;
// ─── Helper ──────────────────────────────────────────────────────────────────
@@ -442,6 +445,16 @@ const DEFAULT_REMINDER: PortReminderConfig = {
digestTimezone: 'Europe/Warsaw',
};
+/**
+ * Port-level default currency for newly-created berths. Per-berth
+ * `priceCurrency` overrides this when set. Defaults to USD because
+ * 95% of marinas in the rollout target are USD-denominated.
+ */
+export async function getPortBerthsDefaultCurrency(portId: string): Promise {
+ const value = await readSetting(SETTING_KEYS.berthsDefaultCurrency, portId);
+ return (value ?? 'USD').trim().toUpperCase() || 'USD';
+}
+
export async function getPortReminderConfig(portId: string): Promise {
const [defaultDays, defaultEnabled, digestEnabled, digestTime, digestTimezone] =
await Promise.all([
diff --git a/src/lib/services/search.service.ts b/src/lib/services/search.service.ts
index 1142cbfe..cbed3266 100644
--- a/src/lib/services/search.service.ts
+++ b/src/lib/services/search.service.ts
@@ -48,12 +48,25 @@ import type { RolePermissions } from '@/lib/db/schema/users';
// ─── Types ────────────────────────────────────────────────────────────────────
+/**
+ * Provenance hint for a result row that was surfaced via graph expansion
+ * rather than a direct query match. The frontend renders this as a
+ * subtitle, e.g. "via Berth A10". `null` (or absent) means the row is
+ * a direct match against the user's query.
+ */
+export interface RelatedVia {
+ type: 'berth' | 'interest' | 'client' | 'yacht' | 'company';
+ id: string;
+ label: string;
+}
+
export interface ClientResult {
id: string;
fullName: string;
matchedContact: string | null;
matchedContactChannel: 'email' | 'phone' | 'whatsapp' | null;
archivedAt: string | null;
+ relatedVia?: RelatedVia | null;
}
export interface ResidentialClientResult {
@@ -71,6 +84,7 @@ export interface InterestResult {
berthMooringNumber: string | null;
pipelineStage: string;
outcome: string | null;
+ relatedVia?: RelatedVia | null;
}
export interface ResidentialInterestResult {
@@ -85,6 +99,7 @@ export interface BerthResult {
area: string | null;
status: string;
linkedInterestCount: number;
+ relatedVia?: RelatedVia | null;
}
export interface YachtResult {
@@ -93,6 +108,7 @@ export interface YachtResult {
hullNumber: string | null;
registration: string | null;
archivedAt: string | null;
+ relatedVia?: RelatedVia | null;
}
export interface CompanyResult {
@@ -102,6 +118,7 @@ export interface CompanyResult {
taxId: string | null;
matchedField: 'name' | 'legalName' | 'taxId' | 'billingEmail' | 'registrationNumber' | null;
archivedAt: string | null;
+ relatedVia?: RelatedVia | null;
}
export interface InvoiceResult {
@@ -716,10 +733,60 @@ async function searchResidentialInterests(
}
async function searchBerths(portId: string, query: string, limit: number): Promise {
- // Trigram (`%`) is the canonical mooring-number search — it tolerates
- // a hyphen or wrong leading-zero. Fallback to ILIKE for `area`.
- const ilikePattern = `%${query}%`;
+ // Mooring numbers are short alphanumeric codes (A1, B12, E18) where
+ // prefix-on-number expansion produces confusing UX — typing "A1"
+ // when A1 exists shouldn't *also* surface A10, A11, A12. Reps know
+ // mooring numbers and almost always type them in full.
+ //
+ // Strategy: if an exact mooring-number match exists for the query,
+ // return ONLY that one row. Otherwise fall back to letter-prefix +
+ // number-prefix matching (so typing "A" returns the whole A dock,
+ // typing "A1" with no A1 in the DB returns A10/A11/A12, etc.).
+ // Area-name matches are also folded into the fallback.
+ const trimmed = query.trim();
+ const m = /^([A-Za-z]*)(\d*)$/.exec(trimmed);
+ const letterPart = (m?.[1] ?? '').toUpperCase();
+ const numberPart = m?.[2] ?? '';
+ const isStructured = letterPart.length > 0 || numberPart.length > 0;
+ const ilikePattern = `%${trimmed}%`;
+ const prefixPattern = `${trimmed}%`;
+
+ // First: try for an exact match. Cheap — uses the unique-index on
+ // (port_id, mooring_number).
+ const exact = await db.execute<{
+ id: string;
+ mooring_number: string;
+ area: string | null;
+ status: string;
+ linked_interest_count: string;
+ }>(sql`
+ SELECT
+ b.id, b.mooring_number, b.area, b.status,
+ (
+ SELECT COUNT(*)::text FROM interest_berths ib
+ JOIN interests i ON ib.interest_id = i.id
+ WHERE ib.berth_id = b.id AND i.archived_at IS NULL
+ ) AS linked_interest_count
+ FROM berths b
+ WHERE b.port_id = ${portId}
+ AND UPPER(b.mooring_number) = ${trimmed.toUpperCase()}
+ LIMIT 1
+ `);
+
+ const exactRows = Array.from(exact);
+ if (exactRows.length > 0) {
+ return exactRows.map((r) => ({
+ id: r.id,
+ mooringNumber: r.mooring_number,
+ area: r.area ?? null,
+ status: r.status,
+ linkedInterestCount: Number(r.linked_interest_count) || 0,
+ }));
+ }
+
+ // No exact match — fall back to letter+number-prefix matching plus
+ // a generic area/ILIKE fallback for non-structured queries.
const rows = await db.execute<{
id: string;
mooring_number: string;
@@ -728,30 +795,33 @@ async function searchBerths(portId: string, query: string, limit: number): Promi
linked_interest_count: string;
}>(sql`
SELECT
- b.id,
- b.mooring_number,
- b.area,
- b.status,
+ b.id, b.mooring_number, b.area, b.status,
(
- SELECT COUNT(*)::text
- FROM interest_berths ib
+ SELECT COUNT(*)::text FROM interest_berths ib
JOIN interests i ON ib.interest_id = i.id
WHERE ib.berth_id = b.id AND i.archived_at IS NULL
) AS linked_interest_count
FROM berths b
WHERE b.port_id = ${portId}
AND (
- b.mooring_number ILIKE ${ilikePattern}
- OR b.mooring_number % ${query}
+ ${
+ isStructured
+ ? sql`(
+ regexp_replace(b.mooring_number, '[0-9]+$', '') = ${letterPart}
+ AND regexp_replace(b.mooring_number, '^[A-Za-z]+', '') LIKE ${numberPart + '%'}
+ )`
+ : sql`FALSE`
+ }
+ OR b.mooring_number ILIKE ${prefixPattern}
OR b.area ILIKE ${ilikePattern}
)
ORDER BY
CASE
- WHEN b.mooring_number ILIKE ${query + '%'} THEN 1
- WHEN b.mooring_number ILIKE ${ilikePattern} THEN 2
+ WHEN b.mooring_number ILIKE ${prefixPattern} THEN 1
+ WHEN b.area ILIKE ${prefixPattern} THEN 2
ELSE 3
END,
- similarity(b.mooring_number, ${query}) DESC,
+ length(b.mooring_number),
b.mooring_number
LIMIT ${limit}
`);
@@ -1245,6 +1315,429 @@ async function searchOtherPorts(
// ─── Public entrypoint ──────────────────────────────────────────────────────
+/**
+ * Graph expansion — for every direct match in a search, fetch the
+ * 1-hop related entities and add them to the appropriate bucket.
+ *
+ * Berth match → its interests + their clients + their yachts
+ * Interest match → its berth + client + yacht
+ * Client match → their interests + their owned yachts + companies
+ * they're members of
+ * Yacht match → its interests + its owner (client/company)
+ * Company match → its members (clients) + their interests
+ *
+ * Depth limited to 1 hop to avoid quadratic fan-out. Each expansion row
+ * carries a `relatedVia` hint so the UI can show "via Berth A10" beneath
+ * the row's title.
+ *
+ * Rows that are already a direct match are NOT duplicated — the dedupe
+ * runs on `id`. Direct matches always take precedence (their relatedVia
+ * stays unset).
+ */
+async function expandGraph(
+ portId: string,
+ direct: {
+ berthIds: string[];
+ interestIds: string[];
+ clientIds: string[];
+ yachtIds: string[];
+ companyIds: string[];
+ },
+ perBucketCap: number,
+): Promise<{
+ interests: InterestResult[];
+ clients: ClientResult[];
+ yachts: YachtResult[];
+ companies: CompanyResult[];
+ berths: BerthResult[];
+}> {
+ // Helper: SQL-safe ANY() needs a non-empty array; bail early otherwise.
+ const hasAny = (arr: string[]) => arr.length > 0;
+
+ // ─── Berth → Interests (and their clients + yachts) ─────────────────
+ const interestsFromBerths = hasAny(direct.berthIds)
+ ? await db.execute<{
+ id: string;
+ client_name: string;
+ mooring_number: string;
+ pipeline_stage: string;
+ outcome: string | null;
+ via_berth_id: string;
+ via_berth_label: string;
+ }>(sql`
+ SELECT
+ i.id,
+ c.full_name AS client_name,
+ b.mooring_number,
+ i.pipeline_stage,
+ i.outcome,
+ b.id AS via_berth_id,
+ b.mooring_number AS via_berth_label
+ FROM interest_berths ib
+ JOIN interests i ON ib.interest_id = i.id
+ JOIN clients c ON i.client_id = c.id
+ JOIN berths b ON ib.berth_id = b.id
+ WHERE ib.berth_id IN (${sql.join(direct.berthIds.map((id) => sql`${id}`), sql`, `)})
+ AND i.port_id = ${portId}
+ AND i.archived_at IS NULL
+ ORDER BY ib.is_primary DESC, i.created_at DESC
+ LIMIT ${perBucketCap * direct.berthIds.length}
+ `)
+ : [];
+
+ // ─── Interest → Berth, Client, Yacht ─────────────────────────────────
+ // For interests that matched directly, surface their connected berth +
+ // client + yacht as related entries in those buckets.
+ const fromInterests = hasAny(direct.interestIds)
+ ? await db.execute<{
+ interest_id: string;
+ client_id: string;
+ client_name: string;
+ yacht_id: string | null;
+ yacht_name: string | null;
+ berth_id: string | null;
+ mooring_number: string | null;
+ berth_area: string | null;
+ berth_status: string | null;
+ }>(sql`
+ SELECT
+ i.id AS interest_id,
+ c.id AS client_id,
+ c.full_name AS client_name,
+ y.id AS yacht_id,
+ y.name AS yacht_name,
+ b.id AS berth_id,
+ b.mooring_number,
+ b.area AS berth_area,
+ b.status AS berth_status
+ FROM interests i
+ JOIN clients c ON i.client_id = c.id
+ LEFT JOIN yachts y ON i.yacht_id = y.id
+ LEFT JOIN LATERAL (
+ SELECT b.* FROM interest_berths ib2
+ JOIN berths b ON ib2.berth_id = b.id
+ WHERE ib2.interest_id = i.id
+ ORDER BY ib2.is_primary DESC
+ LIMIT 1
+ ) b ON TRUE
+ WHERE i.id IN (${sql.join(direct.interestIds.map((id) => sql`${id}`), sql`, `)})
+ AND i.port_id = ${portId}
+ `)
+ : [];
+
+ // ─── Client → Interests, Owned Yachts, Member Companies ──────────────
+ const fromClients = hasAny(direct.clientIds)
+ ? await Promise.all([
+ // Their interests
+ db.execute<{
+ id: string;
+ client_id: string;
+ client_name: string;
+ mooring_number: string | null;
+ pipeline_stage: string;
+ outcome: string | null;
+ }>(sql`
+ SELECT i.id, i.client_id, c.full_name AS client_name,
+ b.mooring_number, i.pipeline_stage, i.outcome
+ FROM interests i
+ JOIN clients c ON i.client_id = c.id
+ LEFT JOIN LATERAL (
+ SELECT b.mooring_number FROM interest_berths ib
+ JOIN berths b ON ib.berth_id = b.id
+ WHERE ib.interest_id = i.id
+ ORDER BY ib.is_primary DESC LIMIT 1
+ ) b ON TRUE
+ WHERE i.client_id IN (${sql.join(direct.clientIds.map((id) => sql`${id}`), sql`, `)})
+ AND i.port_id = ${portId}
+ AND i.archived_at IS NULL
+ ORDER BY i.created_at DESC
+ LIMIT ${perBucketCap * direct.clientIds.length}
+ `),
+ // Yachts they own (current_owner_type='client')
+ db.execute<{
+ id: string;
+ name: string;
+ hull_number: string | null;
+ registration: string | null;
+ archived_at: string | null;
+ owner_id: string;
+ owner_name: string;
+ }>(sql`
+ SELECT y.id, y.name, y.hull_number, y.registration, y.archived_at::text,
+ c.id AS owner_id, c.full_name AS owner_name
+ FROM yachts y
+ JOIN clients c ON y.current_owner_id = c.id
+ WHERE y.current_owner_type = 'client'
+ AND y.current_owner_id IN (${sql.join(direct.clientIds.map((id) => sql`${id}`), sql`, `)})
+ AND y.port_id = ${portId}
+ ORDER BY y.name
+ LIMIT ${perBucketCap * direct.clientIds.length}
+ `),
+ // Companies they're members of
+ db.execute<{
+ id: string;
+ name: string;
+ legal_name: string | null;
+ tax_id: string | null;
+ archived_at: string | null;
+ via_client_id: string;
+ via_client_name: string;
+ }>(sql`
+ SELECT co.id, co.name, co.legal_name, co.tax_id, co.archived_at::text,
+ c.id AS via_client_id, c.full_name AS via_client_name
+ FROM company_memberships cm
+ JOIN companies co ON cm.company_id = co.id
+ JOIN clients c ON cm.client_id = c.id
+ WHERE cm.client_id IN (${sql.join(direct.clientIds.map((id) => sql`${id}`), sql`, `)})
+ AND cm.end_date IS NULL
+ AND co.port_id = ${portId}
+ ORDER BY co.name
+ LIMIT ${perBucketCap * direct.clientIds.length}
+ `),
+ ])
+ : [[], [], []];
+
+ // ─── Yacht → Interests, Owner ───────────────────────────────────────
+ const fromYachts = hasAny(direct.yachtIds)
+ ? await Promise.all([
+ // Interests on these yachts
+ db.execute<{
+ id: string;
+ client_name: string;
+ mooring_number: string | null;
+ pipeline_stage: string;
+ outcome: string | null;
+ via_yacht_id: string;
+ via_yacht_name: string;
+ }>(sql`
+ SELECT i.id, c.full_name AS client_name,
+ b.mooring_number, i.pipeline_stage, i.outcome,
+ y.id AS via_yacht_id, y.name AS via_yacht_name
+ FROM interests i
+ JOIN clients c ON i.client_id = c.id
+ JOIN yachts y ON i.yacht_id = y.id
+ LEFT JOIN LATERAL (
+ SELECT b.mooring_number FROM interest_berths ib
+ JOIN berths b ON ib.berth_id = b.id
+ WHERE ib.interest_id = i.id
+ ORDER BY ib.is_primary DESC LIMIT 1
+ ) b ON TRUE
+ WHERE i.yacht_id IN (${sql.join(direct.yachtIds.map((id) => sql`${id}`), sql`, `)})
+ AND i.port_id = ${portId}
+ AND i.archived_at IS NULL
+ ORDER BY i.created_at DESC
+ LIMIT ${perBucketCap * direct.yachtIds.length}
+ `),
+ // Owners (client + company variants via polymorphic FKs)
+ db.execute<{
+ yacht_id: string;
+ yacht_name: string;
+ owner_type: string;
+ owner_id: string;
+ owner_label: string;
+ }>(sql`
+ SELECT y.id AS yacht_id, y.name AS yacht_name,
+ y.current_owner_type AS owner_type,
+ COALESCE(c.id, co.id) AS owner_id,
+ COALESCE(c.full_name, co.name) AS owner_label
+ FROM yachts y
+ LEFT JOIN clients c
+ ON y.current_owner_type = 'client' AND y.current_owner_id = c.id
+ LEFT JOIN companies co
+ ON y.current_owner_type = 'company' AND y.current_owner_id = co.id
+ WHERE y.id IN (${sql.join(direct.yachtIds.map((id) => sql`${id}`), sql`, `)})
+ AND y.port_id = ${portId}
+ AND y.current_owner_id IS NOT NULL
+ `),
+ ])
+ : [[], []];
+
+ // ─── Company → Members (Clients), their Interests ────────────────────
+ const fromCompanies = hasAny(direct.companyIds)
+ ? await Promise.all([
+ db.execute<{
+ id: string;
+ full_name: string;
+ archived_at: string | null;
+ via_company_id: string;
+ via_company_name: string;
+ }>(sql`
+ SELECT c.id, c.full_name, c.archived_at::text,
+ co.id AS via_company_id, co.name AS via_company_name
+ FROM company_memberships cm
+ JOIN clients c ON cm.client_id = c.id
+ JOIN companies co ON cm.company_id = co.id
+ WHERE cm.company_id IN (${sql.join(direct.companyIds.map((id) => sql`${id}`), sql`, `)})
+ AND cm.end_date IS NULL
+ AND c.port_id = ${portId}
+ ORDER BY c.full_name
+ LIMIT ${perBucketCap * direct.companyIds.length}
+ `),
+ ])
+ : [[]];
+
+ // ─── Marshal into bucket-shaped result rows ──────────────────────────
+ const expandedInterests: InterestResult[] = [];
+ const expandedClients: ClientResult[] = [];
+ const expandedYachts: YachtResult[] = [];
+ const expandedCompanies: CompanyResult[] = [];
+ const expandedBerths: BerthResult[] = [];
+
+ // From berths
+ for (const r of Array.from(interestsFromBerths)) {
+ expandedInterests.push({
+ id: r.id,
+ clientName: r.client_name,
+ berthMooringNumber: r.mooring_number,
+ pipelineStage: r.pipeline_stage,
+ outcome: r.outcome,
+ relatedVia: { type: 'berth', id: r.via_berth_id, label: `Berth ${r.via_berth_label}` },
+ });
+ }
+
+ // From interests (the matched row's client, yacht, berth)
+ for (const r of Array.from(fromInterests)) {
+ if (r.client_id) {
+ expandedClients.push({
+ id: r.client_id,
+ fullName: r.client_name,
+ matchedContact: null,
+ matchedContactChannel: null,
+ archivedAt: null,
+ relatedVia: { type: 'interest', id: r.interest_id, label: `Interest · ${r.client_name}` },
+ });
+ }
+ if (r.yacht_id) {
+ expandedYachts.push({
+ id: r.yacht_id,
+ name: r.yacht_name ?? '(unnamed yacht)',
+ hullNumber: null,
+ registration: null,
+ archivedAt: null,
+ relatedVia: { type: 'interest', id: r.interest_id, label: `Interest · ${r.client_name}` },
+ });
+ }
+ if (r.berth_id) {
+ expandedBerths.push({
+ id: r.berth_id,
+ mooringNumber: r.mooring_number ?? '',
+ area: r.berth_area,
+ status: r.berth_status ?? 'available',
+ linkedInterestCount: 0,
+ relatedVia: { type: 'interest', id: r.interest_id, label: `Interest · ${r.client_name}` },
+ });
+ }
+ }
+
+ // From clients
+ const [clientInterests, clientYachts, clientCompanies] = fromClients;
+ for (const r of Array.from(clientInterests)) {
+ expandedInterests.push({
+ id: r.id,
+ clientName: r.client_name,
+ berthMooringNumber: r.mooring_number,
+ pipelineStage: r.pipeline_stage,
+ outcome: r.outcome,
+ relatedVia: { type: 'client', id: r.client_id, label: r.client_name },
+ });
+ }
+ for (const r of Array.from(clientYachts)) {
+ expandedYachts.push({
+ id: r.id,
+ name: r.name,
+ hullNumber: r.hull_number,
+ registration: r.registration,
+ archivedAt: r.archived_at,
+ relatedVia: { type: 'client', id: r.owner_id, label: r.owner_name },
+ });
+ }
+ for (const r of Array.from(clientCompanies)) {
+ expandedCompanies.push({
+ id: r.id,
+ name: r.name,
+ legalName: r.legal_name,
+ taxId: r.tax_id,
+ matchedField: null,
+ archivedAt: r.archived_at,
+ relatedVia: { type: 'client', id: r.via_client_id, label: r.via_client_name },
+ });
+ }
+
+ // From yachts
+ const [yachtInterests, yachtOwners] = fromYachts;
+ for (const r of Array.from(yachtInterests)) {
+ expandedInterests.push({
+ id: r.id,
+ clientName: r.client_name,
+ berthMooringNumber: r.mooring_number,
+ pipelineStage: r.pipeline_stage,
+ outcome: r.outcome,
+ relatedVia: { type: 'yacht', id: r.via_yacht_id, label: r.via_yacht_name },
+ });
+ }
+ for (const r of Array.from(yachtOwners)) {
+ if (!r.owner_id) continue;
+ if (r.owner_type === 'client') {
+ expandedClients.push({
+ id: r.owner_id,
+ fullName: r.owner_label,
+ matchedContact: null,
+ matchedContactChannel: null,
+ archivedAt: null,
+ relatedVia: { type: 'yacht', id: r.yacht_id, label: r.yacht_name },
+ });
+ } else if (r.owner_type === 'company') {
+ expandedCompanies.push({
+ id: r.owner_id,
+ name: r.owner_label,
+ legalName: null,
+ taxId: null,
+ matchedField: null,
+ archivedAt: null,
+ relatedVia: { type: 'yacht', id: r.yacht_id, label: r.yacht_name },
+ });
+ }
+ }
+
+ // From companies
+ const [companyMembers] = fromCompanies;
+ for (const r of Array.from(companyMembers)) {
+ expandedClients.push({
+ id: r.id,
+ fullName: r.full_name,
+ matchedContact: null,
+ matchedContactChannel: null,
+ archivedAt: r.archived_at,
+ relatedVia: { type: 'company', id: r.via_company_id, label: r.via_company_name },
+ });
+ }
+
+ return {
+ interests: expandedInterests,
+ clients: expandedClients,
+ yachts: expandedYachts,
+ companies: expandedCompanies,
+ berths: expandedBerths,
+ };
+}
+
+/**
+ * Merge direct-match rows with graph-expansion rows. Direct matches
+ * (those without `relatedVia` set) take precedence — if a row appears
+ * in both, the direct version wins. Direct matches sort before
+ * related matches.
+ */
+function mergeWithExpansion<
+ T extends { id: string; relatedVia?: RelatedVia | null },
+>(direct: T[], expansion: T[], cap: number): T[] {
+ const seen = new Set(direct.map((r) => r.id));
+ const merged = [
+ ...direct.map((r) => ({ ...r, relatedVia: null as RelatedVia | null })),
+ ...expansion.filter((r) => !seen.has(r.id) && (seen.add(r.id), true)),
+ ];
+ return merged.slice(0, cap);
+}
+
/**
* Returns a populated `SearchResults` for the given port + query. All
* unrequested or permission-denied buckets come back as empty arrays so
@@ -1252,6 +1745,10 @@ async function searchOtherPorts(
*
* Per-bucket queries are run in parallel via `Promise.all` — total
* latency is bounded by the single slowest bucket.
+ *
+ * Graph expansion: after the direct-match phase, related entities are
+ * fetched in a single second pass (`expandGraph`) so reps searching for
+ * one entity see everything connected to it. See expandGraph docstring.
*/
export async function search(
portId: string,
@@ -1263,8 +1760,20 @@ export async function search(
if (!query || query.trim().length < 1) return empty;
// Single-bucket mode (used by /search?type=clients) — skip everything
- // else for speed.
- if (opts.type) return runSingleBucket(portId, query, limit, opts);
+ // else for speed. Graph-expansion buckets (clients, yachts, companies,
+ // interests, berths) fall through to the full pipeline below so that
+ // related-via matches survive the chip narrow — otherwise typing
+ // "carlos vega" with the Yachts chip selected would return zero rows
+ // even though the All chip shows "Yachts (1)" (the yacht owned by
+ // Carlos, surfaced through expandGraph). We trim to the requested
+ // bucket at the end.
+ type GraphBucket = 'clients' | 'yachts' | 'companies' | 'interests' | 'berths';
+ const GRAPH_BUCKETS: GraphBucket[] = ['clients', 'yachts', 'companies', 'interests', 'berths'];
+ const narrowTo: GraphBucket | null =
+ opts.type && (GRAPH_BUCKETS as readonly string[]).includes(opts.type)
+ ? (opts.type as GraphBucket)
+ : null;
+ if (opts.type && !narrowTo) return runSingleBucket(portId, query, limit, opts);
const wantEmail = looksLikeEmail(query);
const wantPhone = normalizePhoneQuery(query) !== null;
@@ -1350,17 +1859,43 @@ export async function search(
void wantEmail;
void wantPhone;
+ // ─── Phase 2: graph expansion ───────────────────────────────────────
+ // For every direct match, fetch its 1-hop related entities so reps
+ // who search "A10" see the linked interests/clients/yachts/companies
+ // surface alongside the berth. See `expandGraph` docstring for the
+ // relationship map and per-bucket caps.
+ const expanded = await expandGraph(
+ portId,
+ {
+ berthIds: berths.map((b) => b.id),
+ interestIds: interests.map((i) => i.id),
+ clientIds: clients.map((c) => c.id),
+ yachtIds: yachts.map((y) => y.id),
+ companyIds: companies.map((c) => c.id),
+ },
+ limit,
+ );
+
const apply = (rows: T[]) =>
applyAffinity(rows, opts.recentlyTouchedIds);
+ // Merge direct matches with expansion rows; direct rows always win
+ // ties and sort first. Each bucket caps at `limit * 2` so reps still
+ // see the full direct-match set plus a healthy expansion tail.
+ const mergedClients = mergeWithExpansion(clients, expanded.clients, limit * 2);
+ const mergedInterests = mergeWithExpansion(interests, expanded.interests, limit * 2);
+ const mergedYachts = mergeWithExpansion(yachts, expanded.yachts, limit * 2);
+ const mergedCompanies = mergeWithExpansion(companies, expanded.companies, limit * 2);
+ const mergedBerths = mergeWithExpansion(berths, expanded.berths, limit * 2);
+
const result: SearchResults = {
- clients: apply(clients),
+ clients: apply(mergedClients),
residentialClients: apply(residentialClients),
- yachts: apply(yachts),
- companies: apply(companies),
- interests: apply(interests),
+ yachts: apply(mergedYachts),
+ companies: apply(mergedCompanies),
+ interests: apply(mergedInterests),
residentialInterests: apply(residentialInterests),
- berths: apply(berths),
+ berths: apply(mergedBerths),
invoices: apply(invoices),
expenses: apply(expenses),
documents: apply(documents),
@@ -1371,13 +1906,13 @@ export async function search(
navigation,
notes,
totals: {
- clients: clients.length,
+ clients: mergedClients.length,
residentialClients: residentialClients.length,
- yachts: yachts.length,
- companies: companies.length,
- interests: interests.length,
+ yachts: mergedYachts.length,
+ companies: mergedCompanies.length,
+ interests: mergedInterests.length,
residentialInterests: residentialInterests.length,
- berths: berths.length,
+ berths: mergedBerths.length,
invoices: invoices.length,
expenses: expenses.length,
documents: documents.length,
@@ -1391,6 +1926,22 @@ export async function search(
otherPorts: otherPorts.length > 0 ? otherPorts : undefined,
};
+ // When narrowing to a graph bucket, zero out every other bucket so the
+ // dropdown only renders the chosen one. Totals for the other buckets
+ // stay populated so the chip row still shows their counts — the client
+ // already snapshots the last "all" totals separately, but keeping them
+ // here means a direct API hit with ?type=yachts still sees all chip
+ // counts for free.
+ if (narrowTo) {
+ const keep = narrowTo;
+ return {
+ ...emptyResults(),
+ [keep]: result[keep],
+ totals: result.totals,
+ otherPorts: result.otherPorts,
+ } as SearchResults;
+ }
+
return result;
}
diff --git a/src/lib/utils/currency.ts b/src/lib/utils/currency.ts
index e0b22fcb..3da44214 100644
--- a/src/lib/utils/currency.ts
+++ b/src/lib/utils/currency.ts
@@ -42,8 +42,18 @@ export function formatCurrency(
if (amount === null || amount === undefined || amount === '') return '';
const value = typeof amount === 'number' ? amount : Number(amount);
if (!Number.isFinite(value)) return '';
- const code = (currency ?? 'USD').toUpperCase();
- const { locale = 'en-US', minFractionDigits = 2, maxFractionDigits = 2 } = options;
+ // Defensive: trim + uppercase + fallback to USD when blank. Legacy
+ // NocoDB rows sometimes contain whitespace/empty strings rather than
+ // proper ISO codes, which would make Intl.NumberFormat throw.
+ const rawCode = (currency ?? 'USD').trim().toUpperCase();
+ const code = rawCode === '' ? 'USD' : rawCode;
+ // If the caller only specifies max (common: maxFractionDigits: 0 for
+ // whole-currency display), clamp min down to match — otherwise
+ // `toLocaleString` throws "Computed minimumFractionDigits is larger
+ // than maximumFractionDigits". The same defensive clamp protects
+ // Intl.NumberFormat too.
+ const { locale = 'en-US', maxFractionDigits = 2 } = options;
+ const minFractionDigits = options.minFractionDigits ?? Math.min(2, maxFractionDigits);
try {
return new Intl.NumberFormat(locale, {
style: 'currency',
@@ -52,10 +62,15 @@ export function formatCurrency(
maximumFractionDigits: maxFractionDigits,
}).format(value);
} catch {
- // Unknown currency code — degrade to a bare number with the code
- // appended rather than throwing. Keeps display robust against any
- // legacy NocoDB rows that smuggled non-ISO strings into the column.
- return `${value.toFixed(maxFractionDigits)} ${code}`;
+ // Unknown currency code — format the number with thousand
+ // separators (still useful) and append the code as a suffix rather
+ // than letting Intl crash the render. Keeps display robust against
+ // any legacy NocoDB rows that smuggled non-ISO strings.
+ const formatted = value.toLocaleString(locale, {
+ minimumFractionDigits: minFractionDigits,
+ maximumFractionDigits: maxFractionDigits,
+ });
+ return `${formatted} ${code}`;
}
}
diff --git a/src/lib/utils/mooring-sort.ts b/src/lib/utils/mooring-sort.ts
new file mode 100644
index 00000000..c1a99eaf
--- /dev/null
+++ b/src/lib/utils/mooring-sort.ts
@@ -0,0 +1,27 @@
+/**
+ * Natural-sort comparator for mooring numbers (`^[A-Z]+\d+$`, e.g. A1, B12,
+ * E18). Plain alphabetic sort orders them A1, A10, A11, A2 because '1' < '2'
+ * in lexicographic terms; this comparator splits the prefix from the index so
+ * A2 lands before A10 the way reps expect.
+ *
+ * Stable across the CRM: use this anywhere mooring numbers are presented in
+ * a list (dropdowns, table sorts, public-map ordering, berth options).
+ */
+const MOORING_PATTERN = /^([A-Z]+)(\d+)$/;
+
+export function compareMooringNumbers(a: string, b: string): number {
+ const ma = MOORING_PATTERN.exec(a);
+ const mb = MOORING_PATTERN.exec(b);
+ // Fall back to plain locale compare for anything that doesn't match the
+ // canonical shape so we never throw on unexpected input.
+ if (!ma || !mb) return a.localeCompare(b);
+ const [, prefixA, numA] = ma;
+ const [, prefixB, numB] = mb;
+ if (prefixA !== prefixB) return (prefixA ?? '').localeCompare(prefixB ?? '');
+ return Number(numA) - Number(numB);
+}
+
+/** Sort an array of objects by their mooring-number field, in place safe. */
+export function sortByMooring(items: T[], pick: (item: T) => string): T[] {
+ return [...items].sort((a, b) => compareMooringNumbers(pick(a), pick(b)));
+}
diff --git a/src/lib/validators/companies.ts b/src/lib/validators/companies.ts
index 55319cd7..e3ea193f 100644
--- a/src/lib/validators/companies.ts
+++ b/src/lib/validators/companies.ts
@@ -2,6 +2,10 @@ import { z } from 'zod';
import { baseListQuerySchema } from '@/lib/api/list-query';
import { optionalCountryIsoSchema, optionalSubdivisionIsoSchema } from '@/lib/validators/i18n';
+// react-hook-form posts empty strings for unfilled inputs; treat those
+// as "not provided" before `.email()` / `.date()` validators fire.
+const emptyToUndef = (v: unknown) => (v === '' ? undefined : v);
+
export const createCompanySchema = z.object({
name: z.string().min(1).max(200),
legalName: z.string().optional(),
@@ -11,9 +15,9 @@ export const createCompanySchema = z.object({
incorporationCountryIso: optionalCountryIsoSchema.optional(),
/** ISO 3166-2 state/province of incorporation. */
incorporationSubdivisionIso: optionalSubdivisionIsoSchema.optional(),
- incorporationDate: z.coerce.date().optional(),
+ incorporationDate: z.preprocess(emptyToUndef, z.coerce.date().optional()),
status: z.enum(['active', 'dissolved']).optional().default('active'),
- billingEmail: z.string().email().optional(),
+ billingEmail: z.preprocess(emptyToUndef, z.string().email().optional()),
notes: z.string().optional(),
tagIds: z.array(z.string()).optional().default([]),
});
diff --git a/src/lib/validators/interests.ts b/src/lib/validators/interests.ts
index c3e07711..7451cebb 100644
--- a/src/lib/validators/interests.ts
+++ b/src/lib/validators/interests.ts
@@ -59,6 +59,14 @@ export const changeStageSchema = z.object({
* to hold the `interests.override_stage` permission. Reason becomes
* required when override=true (recorded in the audit log). */
override: z.boolean().optional(),
+ /** Optional ISO date (YYYY-MM-DD or full ISO timestamp) to stamp on the
+ * matching milestone column instead of "now". Used when a rep marks a
+ * milestone manually (e.g. deposit received yesterday) so the recorded
+ * date reflects the real event instead of the click time. */
+ milestoneDate: z
+ .string()
+ .regex(/^\d{4}-\d{2}-\d{2}(T.*)?$/)
+ .optional(),
});
// ─── Outcome (Won / Lost) ─────────────────────────────────────────────────────
@@ -68,6 +76,7 @@ export const INTEREST_OUTCOMES = [
'lost_other_marina',
'lost_unqualified',
'lost_no_response',
+ 'lost_other',
'cancelled',
] as const;
diff --git a/src/lib/validators/user-preferences.ts b/src/lib/validators/user-preferences.ts
index 40b4d372..109075fd 100644
--- a/src/lib/validators/user-preferences.ts
+++ b/src/lib/validators/user-preferences.ts
@@ -15,6 +15,13 @@ export const updateUserPreferencesSchema = z.object({
locale: z.string().optional(),
timezone: z.string().optional(),
reminders: reminderPreferencesSchema.optional(),
+ /**
+ * Widget id → visible flag. Persists which dashboard cards the user
+ * wants to see; missing ids fall back to registry defaults. Kept loose
+ * (record-of-bool) so adding a new widget doesn't require a validator
+ * update.
+ */
+ dashboardWidgets: z.record(z.string(), z.boolean()).optional(),
});
export type UpdateUserPreferencesInput = z.infer;
diff --git a/src/lib/validators/yachts.ts b/src/lib/validators/yachts.ts
index 03825e7c..0df63e05 100644
--- a/src/lib/validators/yachts.ts
+++ b/src/lib/validators/yachts.ts
@@ -6,6 +6,15 @@ export const ownerRefSchema = z.object({
id: z.string().min(1),
});
+// Numeric columns on the yachts table accept a stringified decimal or
+// null. The form posts empty strings for unfilled fields, which Postgres
+// rejects with `invalid input syntax for type numeric: ""`. Strip empty
+// strings here so the service can confidently `?? null` them.
+const optionalNumericString = z
+ .string()
+ .optional()
+ .transform((v) => (v === '' || v === undefined ? undefined : v));
+
export const createYachtSchema = z.object({
name: z.string().min(1).max(200),
hullNumber: z.string().optional(),
@@ -15,12 +24,12 @@ export const createYachtSchema = z.object({
builder: z.string().optional(),
model: z.string().optional(),
hullMaterial: z.string().optional(),
- lengthFt: z.string().optional(),
- widthFt: z.string().optional(),
- draftFt: z.string().optional(),
- lengthM: z.string().optional(),
- widthM: z.string().optional(),
- draftM: z.string().optional(),
+ lengthFt: optionalNumericString,
+ widthFt: optionalNumericString,
+ draftFt: optionalNumericString,
+ lengthM: optionalNumericString,
+ widthM: optionalNumericString,
+ draftM: optionalNumericString,
owner: ownerRefSchema, // required; yacht must have an owner
status: z.enum(['active', 'retired', 'sold_away']).optional().default('active'),
notes: z.string().optional(),
diff --git a/tests/unit/services/companies.test.ts b/tests/unit/services/companies.test.ts
index 60c21c3b..63d50ae8 100644
--- a/tests/unit/services/companies.test.ts
+++ b/tests/unit/services/companies.test.ts
@@ -20,7 +20,7 @@ describe('companies.service — createCompany', () => {
const company = await createCompany(
port.id,
- { name: 'Aegean Holdings' },
+ { name: 'Aegean Holdings', status: 'active', tagIds: [] },
makeAuditMeta({ portId: port.id }),
);
@@ -32,10 +32,10 @@ describe('companies.service — createCompany', () => {
it('rejects duplicate name case-insensitively (ConflictError)', async () => {
const port = await makePort();
- await createCompany(port.id, { name: 'Aegean Holdings' }, makeAuditMeta({ portId: port.id }));
+ await createCompany(port.id, { name: 'Aegean Holdings', status: 'active', tagIds: [] }, makeAuditMeta({ portId: port.id }));
await expect(
- createCompany(port.id, { name: 'AEGEAN HOLDINGS' }, makeAuditMeta({ portId: port.id })),
+ createCompany(port.id, { name: 'AEGEAN HOLDINGS', status: 'active', tagIds: [] }, makeAuditMeta({ portId: port.id })),
).rejects.toBeInstanceOf(ConflictError);
});
@@ -45,12 +45,12 @@ describe('companies.service — createCompany', () => {
const a = await createCompany(
portA.id,
- { name: 'Shared Name Co' },
+ { name: 'Shared Name Co', status: 'active', tagIds: [] },
makeAuditMeta({ portId: portA.id }),
);
const b = await createCompany(
portB.id,
- { name: 'Shared Name Co' },
+ { name: 'Shared Name Co', status: 'active', tagIds: [] },
makeAuditMeta({ portId: portB.id }),
);
@@ -65,7 +65,7 @@ describe('companies.service — upsertByName', () => {
const port = await makePort();
const original = await createCompany(
port.id,
- { name: 'Poseidon Maritime' },
+ { name: 'Poseidon Maritime', status: 'active', tagIds: [] },
makeAuditMeta({ portId: port.id }),
);