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>
610 lines
22 KiB
TypeScript
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 “{query}”</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>
|
|
);
|
|
}
|