Files
pn-new-crm/src/components/search/mobile-search-overlay.tsx
Matt 04a594963f feat: round 2 — stage prompts, berth header, EOI inline edit, measurement units
Berth surfaces
- New compact mooring-chip header (colored plate + status pill, dock-label
  in tooltip) replaces the redundant "Berth B1 / Sold / B DOCK" stack
- Berth list gains a "Latest deal stage" column showing the most-advanced
  pipeline stage of any active linked interest (server-aggregated, ranks by
  PIPELINE_STAGES index)
- "Linked prospect" Select on the status-change dialog rebuilt as a Command
  combobox: search, recent-first sort, stage-coloured pills

Pipeline UX
- Reverting an interest to Open with linked berths now prompts: keep the
  links, unlink and reset, or cancel. Silent when no berths are linked
- Activity feed + entity-activity feed normalise enum field values via
  STAGE_LABELS / formatSource: "deposit_10pct → contract_sent" reads as
  "10% Deposit → Contract Sent"

EOI generate dialog
- Inline-editable rows for client name, nationality (country combobox), and
  yacht name — pencil affordance saves directly via clients/yachts PATCH
- Replaces the single "Edit on client's page" link with two contextual links
  framed by short copy explaining what's inline vs what needs the canonical
  page
- Backend EoiContext now includes client.id + yacht.id so the dialog can
  PATCH without an extra round-trip

Company form
- New "Connections" section lets the rep attach members (clients) and yachts
  during create. Yacht attach uses the existing transfer endpoint so audit
  log + ownership history capture the change
- Inline "+ New client" / "+ New yacht" buttons open the canonical forms
  stacked over the company sheet
- After save, the form chains to a yacht pull-in prompt (if any attached
  client owns yachts not yet linked) and an optional "Create interest" step
  pre-filled with the first attached client

Admin
- /admin landing gains a searchable index — typed query flattens groups into
  a result list matching label + description + group title
- "Documenso & EOI" card relabelled to "EOI signing service" (consistent
  with the user-facing language rename from round 1)

Measurement units (migration 0053)
- interests gains desired_*_m columns + desired_*_unit discriminators so
  the rep's literal entry (ft OR m) is preserved verbatim instead of being
  reconstructed from a single canonical column on every render
- yachts + berths gain matching *_unit columns alongside their existing
  ft + m pairs; defaults to 'ft' so legacy rows still render normally
- Interest form POST/PATCH now sends both ft + m + unit; computed m is
  derived from the ft canonical to keep the recommender SQL unchanged

Misc
- Active-deals tile + topbar type their Link href as `Route` instead of `any`
- Unused REPORT_TYPE_LABELS const dropped from generate-report-form
- Test fixtures (fill-eoi-form, documenso-payload, public-berths) updated
  to include the new id + unit fields on the EoiContext / Berth shapes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:28:22 +02:00

610 lines
22 KiB
TypeScript

'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>
);
}