feat(ui): broad consistency sweep — sources, dates, comboboxes, milestones

Mobile + responsive
- berth-form full-width on phones (was 480px fixed → overflowed iPhone)
- currency-input switched to inputMode=decimal with live thousands separator
- client-form Country/Timezone/Source/Preferred-Contact full-width <sm
- contacts row restructured so Primary toggle + Remove get their own strip
- customize-dashboard footer stacks vertically on mobile; Done full-width
- interest-form client/berth pickers no longer cmdk-filter on UUID (typing
  "Carlos" now returns Carlos Vega instead of "No clients found")

Data + consistency
- SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces
  now resolve interest/client source from one place
- INTEREST_OUTCOMES adds lost_other (picker, badge, timeline)
- Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort
- archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles
- TableBody last-row uses border-b-0 (not border-0); colored left-accent
  on the bottom berth row now renders
- Hide Invite-to-Portal until port setting === true (was !== false default-show)
- OwnerPicker primer query resolves entity name on first paint (no more
  UUID flash before the popover opens)

Terminology
- Replaced user-facing "Documenso" with "signing service" / "Generated EOI" /
  "Manual EOI" in 8 components (admin/internal references kept)
- Plainer status-change copy on berth-detail-header

Forms + editing
- InlineEditableField gained a `date` variant (native picker); applied to
  company incorporation date and ready for other YYYY-MM-DD plaintext fields
- Inline source picker on interest-tabs detail (was free text)
- TagPicker self-hides when port has no tags AND nothing is selected
- New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom)
- Compose dialog follow-up is now a toggle that reveals datetime picker

Pipeline milestones
- changeStageSchema accepts optional milestoneDate; service stamps it on the
  matching date column instead of always using now
- MilestoneAdvanceButton popover collects a back-date before stage advance
- Applied to every "Mark X manually" surface on the interest overview

EOI / linked-berths polish
- Add-bypass row aligned inline with toggle descriptions
- Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their
  legal vs. public-map consequences

Surfaces
- Companies list now has the column picker + persisted hidden-column prefs
- NotesList aggregate flag enabled on clients, companies, residential_clients
  (yachts already aggregated)

ft/m unit toggle (interim, before drift fix)
- "Berth size desired" gets a section-level ft/m toggle; per-field hint shows
  the converted value. Storage stays canonical-ft for now; the drift-safe
  persistence migration is the next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 14:50:58 +02:00
parent 638000bb58
commit 3ffee79f3f
132 changed files with 5784 additions and 997 deletions

View File

@@ -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<number>(-1);
const router = useRouter();
const queryClient = useQueryClient();
const portSlug = useUIStore((s) => s.currentPortSlug);
const wrapperRef = useRef<HTMLDivElement>(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<SearchResults['totals'] | null>(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. */}
<FilterChipRow
results={results}
totals={chipTotals}
active={activeBucket}
onChange={setActiveBucket}
disabled={query.length < 2}
@@ -337,18 +363,19 @@ export function CommandSearch() {
// ─── Filter chips ────────────────────────────────────────────────────────────
function FilterChipRow({
results,
totals,
active,
onChange,
disabled,
}: {
results: SearchResults | undefined;
/** Counts from the last "all" query, persisted so chips stay visible
* when the user narrows to a single bucket. Falls back to the current
* results.totals when no "all" snapshot exists yet. */
totals: SearchResults['totals'] | undefined;
active: BucketType | 'all';
onChange: (b: BucketType | 'all') => 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 (
<div
role="tablist"
@@ -364,10 +391,10 @@ function FilterChipRow({
All
</ChipButton>
{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 (
<ChipButton
@@ -701,6 +728,11 @@ function ResultRow({
<HighlightMatch text={row.sub} query={query} />
</div>
)}
{row.relatedVia && (
<div className="text-[11px] italic text-muted-foreground/80 truncate mt-0.5">
via {row.relatedVia.label}
</div>
)}
</div>
</button>
);
@@ -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,
});
}
}

View File

@@ -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<BucketType | 'all'>('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<number | null>(null);
const router = useRouter();
const queryClient = useQueryClient();
const portSlug = useUIStore((s) => s.currentPortSlug);
const inputRef = useRef<HTMLInputElement>(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 <TrackEntityView>.
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<SearchResults['totals'] | null>(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<HTMLInputElement>) => {
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<FlatRow[]>(
() =>
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 (
<VaulDrawer.Root
open={open}
onOpenChange={onOpenChange}
// iOS Safari fluidity recipe sourced from:
// - github.com/shadcn-ui/ui/issues/4321 (page reflow on open)
// - gracefullight.dev fix-ios-safari-scroll-issue-with-vaul-drawer
//
// - shouldScaleBackground=false: page doesn't shrink behind us;
// feels in-app rather than card-over-page.
// - repositionInputs=false: don't let Vaul jiggle the viewport
// when the input autofocuses and the keyboard appears — that
// was the source of the "scroll then jump back" we were seeing.
// Vaul still locks scroll via its modal=true default.
// - autoFocus=true: Vaul focuses the input synchronously inside
// the user-gesture frame, which is the only way iOS Safari
// will pop the keyboard on programmatic focus. The input has
// `autoFocus` set below so Vaul picks it as the target.
shouldScaleBackground={false}
repositionInputs={false}
>
<VaulDrawer.Portal>
<VaulDrawer.Overlay className="fixed inset-0 z-50 bg-black/30 backdrop-blur-sm" />
<VaulDrawer.Content
// Anchor by top + explicit height (not bottom: 0). iOS treats
// `bottom: 0` on position:fixed inconsistently when the
// keyboard is up (sometimes layout viewport, sometimes visual);
// anchoring by top + height removes that ambiguity. Height
// comes from visualViewport.height — the only iOS-reliable
// source for "visible area above keyboard". 12px gap at the
// top keeps a strip of backdrop visible.
style={
visibleHeight != null
? { top: '12px', bottom: 'auto', height: `${Math.max(0, visibleHeight - 12)}px` }
: undefined
}
className={cn(
'fixed inset-x-0 z-50 flex flex-col rounded-t-2xl',
// Fallback when visibleHeight hasn't measured yet (first
// frame, SSR): top+bottom CSS-only sizing.
visibleHeight == null && 'top-3 bottom-0',
'border-t bg-background shadow-[0_-12px_40px_-12px_rgba(0,0,0,0.25)]',
// Respect the bottom safe-area so the home indicator never
// overlaps the scroll region.
'pb-safe-bottom',
)}
>
{/* 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. */}
<VaulDrawer.Title className="sr-only">Search</VaulDrawer.Title>
{/* Drag handle — Vaul reads this as a swipe target. Centered grip
+ a small label below feels iOS-native. */}
<div className="flex flex-col items-center pt-2.5 pb-1.5">
<div className="h-1.5 w-12 rounded-full bg-muted" aria-hidden />
</div>
{/* Sticky header: input + Cancel. The Cancel slides in from the
right when the input has focus, otherwise it sits flat. */}
<div className="flex items-center gap-2 px-4 pb-3">
<label className="relative flex h-11 flex-1 items-center rounded-xl bg-muted/70 px-3 transition-colors focus-within:bg-muted">
<Search className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<input
ref={inputRef}
value={query}
onChange={(e) => 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 ? (
<button
type="button"
onClick={() => {
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"
>
<X className="size-4" />
</button>
) : null}
</label>
<button
type="button"
onClick={close}
className="text-sm font-medium text-primary active:opacity-60"
>
Cancel
</button>
</div>
{/* 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. */}
<div className="border-b pb-3">
<div className="flex gap-1.5 overflow-x-auto px-4 [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<BucketChip
label="All"
active={activeBucket === 'all'}
onClick={() => 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 (
<BucketChip
key={b.type}
label={b.label}
count={count > 0 ? count : undefined}
active={activeBucket === b.type}
onClick={() => setActiveBucket(b.type)}
/>
);
})}
</div>
</div>
{/* Results scroll region. overscroll-contain prevents the body
from rubber-banding when the user scrolls past the bottom. */}
<div className="flex-1 overflow-y-auto overscroll-contain px-2 pb-4 pt-1">
{showingEmptyHints && rows.length === 0 ? (
<EmptyHint />
) : showingEmptyHints ? (
<RowList rows={rows} query={query} onSelect={navigate} variant="empty" />
) : noResults ? (
<NoResults query={query} />
) : (
<RowList rows={rows} query={query} onSelect={navigate} variant="results" />
)}
</div>
</VaulDrawer.Content>
</VaulDrawer.Portal>
</VaulDrawer.Root>
);
}
function BucketChip({
label,
count,
active,
onClick,
}: {
label: string;
count?: number;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cn(
'shrink-0 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
active
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-background text-muted-foreground active:bg-accent active:text-accent-foreground',
)}
>
{label}
{typeof count === 'number' && <span className="ml-1 opacity-70">({count})</span>}
</button>
);
}
function EmptyHint() {
return (
<div className="flex h-full flex-col items-center gap-3 px-6 pb-12 pt-28 text-center">
<div className="flex size-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Search className="size-7" aria-hidden />
</div>
<p className="text-sm text-muted-foreground">
Search clients, yachts, interests, berths, invoices, documents paste a UUID or
invoice number to jump directly.
</p>
</div>
);
}
function NoResults({ query }: { query: string }) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 pb-12 text-center">
<p className="text-sm font-medium text-foreground">No matches for &ldquo;{query}&rdquo;</p>
<p className="text-xs text-muted-foreground">
Try a different spelling, or switch buckets above.
</p>
</div>
);
}
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 (
<div className="space-y-3">
{variant === 'empty' && recentViews.length > 0 ? (
<Section icon={<Clock className="size-3.5" />} label="Recently viewed">
{recentViews.map((row) =>
row.kind === 'recent-view' ? (
<Row
key={row.key}
onSelect={() => onSelect(row.href)}
label={row.item.label}
sub={row.item.sub}
/>
) : null,
)}
</Section>
) : null}
{variant === 'empty' && recentTerms.length > 0 ? (
<Section icon={<History className="size-3.5" />} label="Recent searches">
<div className="flex flex-wrap gap-1.5 px-2 py-1">
{recentTerms.map((row) =>
row.kind === 'recent-term' ? (
<button
key={row.key}
type="button"
onClick={() => {
// 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<HTMLInputElement>(
'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}
</button>
) : null,
)}
</div>
</Section>
) : null}
{variant === 'results' && results.length > 0 ? renderResultRows(results, query, onSelect) : null}
</div>
);
}
/**
* 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(
<div
key={`__bucket_${row.bucket}_${i}`}
className="px-3 pt-3 pb-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"
>
{BUCKET_LABELS[row.bucket] ?? row.bucket}
</div>,
);
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 ? <HighlightMatch text={row.sub} query={query} /> : null}
{row.relatedVia ? (
<span className="block text-[11px] italic text-muted-foreground/80">
via {row.relatedVia.label}
</span>
) : null}
</>
);
nodes.push(
<Row
key={row.key}
onSelect={() => onSelect(row.href)}
label={<HighlightMatch text={row.label} query={query} />}
sub={row.sub || row.relatedVia ? subContent : null}
icon={<Icon className="size-4 text-muted-foreground" aria-hidden />}
badges={row.badges}
/>,
);
} else if (row.kind === 'other-port') {
nodes.push(
<Row
key={row.key}
onSelect={() => 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<BucketType, string> = {
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 (
<section>
<div className="flex items-center gap-1.5 px-3 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{icon}
{label}
</div>
<div>{children}</div>
</section>
);
}
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 (
<button
type="button"
onClick={onSelect}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left',
'min-h-[52px] active:bg-accent',
)}
>
{icon ? <span className="shrink-0">{icon}</span> : null}
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium text-foreground">{label}</span>
{sub ? <span className="truncate text-xs text-muted-foreground">{sub}</span> : null}
</span>
{badges?.length ? (
<span className="flex shrink-0 gap-1">
{badges.map((b) => (
<span
key={b.label}
className={cn(
'rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide',
BADGE_TONE[b.tone],
)}
>
{b.label}
</span>
))}
</span>
) : null}
</button>
);
}