1 Commits

Author SHA1 Message Date
Matt Ciaccio
cad55e3565 fix(mobile): clipping, dropdown-tabs and stale phone metadata
Five mobile-UX issues caught in the 2026-05-03 audit, fixed in one pass:

1. SpecRow on berth detail clipped at right edge on phone widths.
   "Length 49.21 ft / 15 r" (the "m" cut off). Mobile-first stack:
   label on top, value full-width below; flex row only from sm up.

2. ResponsiveTabs collapsed to a Select on phone widths, which read like
   a generic dropdown and obscured the existence of peer tabs. Replaced
   with a horizontally-scrollable strip that auto-scrolls the active
   trigger into view (so the user sees neighbors and gets a discovery
   cue that more exists beyond the edge). Removes the phone-only Select
   and unifies the tab UI across viewport sizes.

3. Documents page tab strip ("All / EOI queue / Awaiting them / ...")
   overflowed the 390px viewport because the wrapper was a fixed flex
   row. Same horizontal-scroll fix as (2); inherits because Documents
   uses ResponsiveTabs.

4. Berth detail header: "Change Status" + "Edit" buttons crowded the
   area subtitle on mobile, causing "North Pier" to wrap to two lines
   ("North" / "Pier"). Stacked vertically on phone widths; from sm up
   the buttons sit beside the title block as before.

5. Empty contact rows on client detail rendered a stale "Add tag · star"
   metadata strip even when the contact value was unset, which cluttered
   the row and offered no useful action. The metadata block now only
   shows when contact.value is non-empty; the trash icon stays visible
   so users can clean up the empty placeholder.

Verification:
- pnpm exec vitest run: 858/858 passing
- pnpm exec tsc --noEmit: same 36 errors as baseline (all pre-existing
  on feat/mobile-foundation, none introduced)
- lint clean

Defers:
- Mobile More sheet last-row alignment / "Email" label specificity
- Admin index grouping (Access / System / Configuration / Content)
- Interest detail header icon labels (trophy/X discoverability)
- Pipeline funnel x-axis label abbreviations
- Reminders rail width allocation on dashboard

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:03:56 +02:00
34 changed files with 354 additions and 1173 deletions

View File

@@ -0,0 +1 @@
{"sessionId":"fd05cbd7-d695-4a70-9223-4b25f3369829","pid":88534,"acquiredAt":1776866083076}

10
.gitignore vendored
View File

@@ -28,19 +28,9 @@ docker-compose.override.yml
# Ad-hoc screenshots / scratch artifacts at repo root # Ad-hoc screenshots / scratch artifacts at repo root
/*.png /*.png
/*.jpg
# Legacy Nuxt portal — kept on disk for reference, not tracked here # Legacy Nuxt portal — kept on disk for reference, not tracked here
/client-portal/ /client-portal/
# Sister marketing site — separate Nuxt project, not part of CRM tracking
/website/
# Mobile audit screenshots — generated locally, regenerable # Mobile audit screenshots — generated locally, regenerable
/.audit/ /.audit/
/.audit-screenshots/
# Tool caches / runtime state
/.claude/
/.serena/
/ruvector.db

View File

@@ -4,7 +4,6 @@ import { headers } from 'next/headers';
import { Inter, JetBrains_Mono } from 'next/font/google'; import { Inter, JetBrains_Mono } from 'next/font/google';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import { classifyFormFactor } from '@/lib/form-factor'; import { classifyFormFactor } from '@/lib/form-factor';
import { ReactGrabViewportSync } from '@/components/dev/react-grab-viewport-sync';
import './globals.css'; import './globals.css';
const inter = Inter({ const inter = Inter({
@@ -67,7 +66,6 @@ export default async function RootLayout({ children }: { children: React.ReactNo
> >
{children} {children}
<Toaster richColors position="top-right" /> <Toaster richColors position="top-right" />
{process.env.NODE_ENV === 'development' && <ReactGrabViewportSync />}
</body> </body>
</html> </html>
); );

View File

@@ -22,11 +22,7 @@ export function AlertRail() {
<section <section
data-testid="alert-rail" data-testid="alert-rail"
aria-label="Active alerts" aria-label="Active alerts"
// `h-full` is intentional only at xl: where the parent dashboard grid className="flex h-full flex-col gap-3"
// gives this rail a sibling column whose height it should match. On
// mobile (single-column stack) there's no fixed-height context, so
// forcing 100% height makes the section overflow / look stretched.
className="flex flex-col gap-3 xl:h-full"
> >
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<h2 className="text-sm font-semibold tracking-tight">Alerts</h2> <h2 className="text-sm font-semibold tracking-tight">Alerts</h2>

View File

@@ -167,7 +167,10 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
return ( return (
<> <>
<DetailHeaderStrip> <DetailHeaderStrip>
<div className="flex items-start gap-4"> {/* Stacks vertically on phone widths so the action buttons don't
squeeze the area subtitle into a two-line wrap. From sm up the
title/area block sits side-by-side with the action buttons. */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<h1 className="hidden sm:block text-2xl font-bold text-foreground"> <h1 className="hidden sm:block text-2xl font-bold text-foreground">
@@ -182,7 +185,7 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
{berth.area && <p className="text-muted-foreground mt-1">{berth.area}</p>} {berth.area && <p className="text-muted-foreground mt-1">{berth.area}</p>}
</div> </div>
<div className="flex flex-wrap items-center gap-2 shrink-0"> <div className="flex flex-wrap items-center gap-2 sm:shrink-0">
<PermissionGate resource="berths" action="edit"> <PermissionGate resource="berths" action="edit">
<Button variant="outline" size="sm" onClick={() => setStatusOpen(true)}> <Button variant="outline" size="sm" onClick={() => setStatusOpen(true)}>
<RefreshCw className="mr-1.5 h-4 w-4" /> <RefreshCw className="mr-1.5 h-4 w-4" />

View File

@@ -48,10 +48,13 @@ type BerthData = {
function SpecRow({ label, value }: { label: string; value: React.ReactNode }) { function SpecRow({ label, value }: { label: string; value: React.ReactNode }) {
if (!value && value !== 0 && value !== false) return null; if (!value && value !== 0 && value !== false) return null;
// Mobile-first: stack vertically with label on top so long values
// (e.g. "206.69 ft / 62.99 m") never clip at the right edge.
// From `sm` (>=640px) up: switch to the original two-column layout.
return ( return (
<div className="flex justify-between py-2 text-sm"> <div className="flex flex-col gap-0.5 py-2 text-sm sm:flex-row sm:items-baseline sm:justify-between sm:gap-3">
<span className="text-muted-foreground">{label}</span> <span className="text-muted-foreground">{label}</span>
<span className="font-medium text-right max-w-[60%]">{value}</span> <span className="font-medium sm:max-w-[60%] sm:text-right">{value}</span>
</div> </div>
); );
} }

View File

@@ -25,8 +25,6 @@ export interface ClientRow {
createdAt: string; createdAt: string;
yachtCount?: number; yachtCount?: number;
companyCount?: number; companyCount?: number;
interestCount?: number;
latestInterest?: { stage: string; mooringNumber: string | null } | null;
contacts?: Array<{ channel: string; value: string; isPrimary: boolean }>; contacts?: Array<{ channel: string; value: string; isPrimary: boolean }>;
tags?: Array<{ id: string; name: string; color: string }>; tags?: Array<{ id: string; name: string; color: string }>;
} }

View File

@@ -2,8 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Archive, Mail, MessageCircle, Phone, RotateCcw } from 'lucide-react'; import { Archive, RotateCcw, Mail, Phone } from 'lucide-react';
import { format } from 'date-fns';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -13,28 +12,31 @@ import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { PortalInviteButton } from '@/components/clients/portal-invite-button'; import { PortalInviteButton } from '@/components/clients/portal-invite-button';
import { GdprExportButton } from '@/components/clients/gdpr-export-button'; import { GdprExportButton } from '@/components/clients/gdpr-export-button';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
import { getCountryName } from '@/lib/i18n/countries';
interface ClientDetailHeaderProps { interface ClientDetailHeaderProps {
client: { client: {
id: string; id: string;
fullName: string; fullName: string;
nationalityIso?: string | null; nationality?: string | null;
preferredContactMethod?: string | null;
preferredLanguage?: string | null;
timezone?: string | null;
source?: string | null;
sourceDetails?: string | null;
archivedAt?: string | null; archivedAt?: string | null;
createdAt?: string; contacts?: Array<{ channel: string; value: string; isPrimary: boolean; label?: string | null }>;
contacts?: Array<{
channel: string;
value: string;
valueE164?: string | null;
isPrimary: boolean;
label?: string | null;
}>;
tags?: Array<{ id: string; name: string; color: string }>; tags?: Array<{ id: string; name: string; color: string }>;
clientPortalEnabled?: boolean; clientPortalEnabled?: boolean;
}; };
} }
const SOURCE_LABELS: Record<string, string> = {
website: 'Website',
manual: 'Manual',
referral: 'Referral',
broker: 'Broker',
};
export function ClientDetailHeader({ client }: ClientDetailHeaderProps) { export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [archiveOpen, setArchiveOpen] = useState(false); const [archiveOpen, setArchiveOpen] = useState(false);
@@ -60,34 +62,19 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
}); });
const primaryEmail = const primaryEmail =
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ?? client.contacts?.find((c) => c.channel === 'email' && c.isPrimary) ??
client.contacts?.find((c) => c.channel === 'email')?.value; client.contacts?.find((c) => c.channel === 'email');
const primaryPhoneContact = const primaryPhone =
client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) ?? client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) ??
client.contacts?.find((c) => c.channel === 'phone'); client.contacts?.find((c) => c.channel === 'phone');
const primaryPhone = primaryPhoneContact?.value;
// wa.me requires the E.164 number without the leading "+". Strip from the
// canonical E.164 form when available; otherwise strip non-digits from the
// display value as a best-effort fallback.
const whatsappNumber = primaryPhoneContact?.valueE164
? primaryPhoneContact.valueE164.replace(/^\+/, '')
: primaryPhoneContact?.value
? primaryPhoneContact.value.replace(/[^\d]/g, '')
: null;
const country = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
const addedLabel = client.createdAt
? `Added ${format(new Date(client.createdAt), 'MMM d, yyyy')}`
: null;
const meta = [country, addedLabel].filter(Boolean) as string[];
return ( return (
<> <>
<DetailHeaderStrip> <DetailHeaderStrip>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3 flex-wrap">
<div className="min-w-0 flex-1 space-y-2"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h1 className="truncate text-lg font-bold text-foreground sm:text-2xl"> <h1 className="hidden sm:block text-2xl font-bold text-foreground truncate">
{client.fullName} {client.fullName}
</h1> </h1>
{isArchived && ( {isArchived && (
@@ -97,71 +84,31 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
)} )}
</div> </div>
{meta.length > 0 ? ( <div className="flex items-center gap-4 mt-2 flex-wrap text-sm text-muted-foreground">
<p className="text-xs text-muted-foreground sm:text-sm">{meta.join(' · ')}</p> {client.source && (
) : null} <span>
Source:{' '}
<div className="flex flex-wrap items-center gap-1.5 pt-1"> <span className="text-foreground">
{primaryEmail ? ( {SOURCE_LABELS[client.source] ?? client.source}
<Button </span>
asChild </span>
variant="outline" )}
size="sm" {primaryEmail && (
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5" <span className="flex items-center gap-1">
> <Mail className="h-3.5 w-3.5" />
<a href={`mailto:${primaryEmail}`} aria-label={`Email ${primaryEmail}`}> {primaryEmail.value}
<Mail /> </span>
Email )}
</a> {primaryPhone && (
</Button> <span className="flex items-center gap-1">
) : null} <Phone className="h-3.5 w-3.5" />
{primaryPhone ? ( {primaryPhone.value}
<Button </span>
asChild )}
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a href={`tel:${primaryPhone}`} aria-label={`Call ${primaryPhone}`}>
<Phone />
Call
</a>
</Button>
) : null}
{whatsappNumber ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`https://wa.me/${whatsappNumber}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`Message ${primaryPhone} on WhatsApp`}
>
<MessageCircle />
WhatsApp
</a>
</Button>
) : null}
{!isArchived && client.clientPortalEnabled !== false ? (
<div className="hidden sm:inline-flex">
<PortalInviteButton
clientId={client.id}
clientName={client.fullName}
defaultEmail={primaryEmail}
/>
</div>
) : null}
<div className="hidden sm:inline-flex">
<GdprExportButton clientId={client.id} />
</div>
</div> </div>
{client.tags && client.tags.length > 0 && ( {client.tags && client.tags.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1 mt-2">
{client.tags.map((tag) => ( {client.tags.map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} /> <TagBadge key={tag.id} name={tag.name} color={tag.color} />
))} ))}
@@ -169,21 +116,34 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
)} )}
</div> </div>
{/* Top-right: archive/restore as a small icon button — destructive {/* Actions */}
action sits out of the primary action flow. */} <div className="flex flex-wrap items-center gap-2">
<button {!isArchived && client.clientPortalEnabled !== false && (
type="button" <PortalInviteButton
onClick={() => setArchiveOpen(true)} clientId={client.id}
aria-label={isArchived ? 'Restore client' : 'Archive client'} clientName={client.fullName}
title={isArchived ? 'Restore client' : 'Archive client'} defaultEmail={primaryEmail?.value}
className={cn( />
'shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors',
'hover:bg-foreground/5 hover:text-foreground',
isArchived ? 'hover:text-foreground' : 'hover:text-destructive',
)} )}
> <GdprExportButton clientId={client.id} />
{isArchived ? <RotateCcw className="size-4" /> : <Archive className="size-4" />} <Button
</button> variant={isArchived ? 'outline' : 'outline'}
size="sm"
onClick={() => setArchiveOpen(true)}
>
{isArchived ? (
<>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
Restore
</>
) : (
<>
<Archive className="mr-1.5 h-3.5 w-3.5" />
Archive
</>
)}
</Button>
</div>
</div> </div>
</DetailHeaderStrip> </DetailHeaderStrip>

View File

@@ -29,8 +29,6 @@ interface ClientData {
id: string; id: string;
channel: string; channel: string;
value: string; value: string;
valueE164: string | null;
valueCountry: string | null;
label: string | null; label: string | null;
isPrimary: boolean; isPrimary: boolean;
notes: string | null; notes: string | null;

View File

@@ -339,6 +339,10 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-1">
<Label>Preferred Language</Label>
<Input {...register('preferredLanguage')} placeholder="English" />
</div>
<div className="space-y-1"> <div className="space-y-1">
<Label>Timezone</Label> <Label>Timezone</Label>
<TimezoneCombobox <TimezoneCombobox

View File

@@ -1,460 +0,0 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import type { Route } from 'next';
import { useQuery } from '@tanstack/react-query';
import { format, formatDistanceToNowStrict } from 'date-fns';
import { ArrowRight, CheckCircle2, ChevronRight, Circle, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { EmptyState } from '@/components/shared/empty-state';
import { Skeleton } from '@/components/ui/skeleton';
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/shared/drawer';
import { apiFetch } from '@/lib/api/client';
import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
import { cn } from '@/lib/utils';
import { STAGE_BADGE, STAGE_LABELS, safeStage } from '@/components/clients/pipeline-constants';
import {
StageStepper,
useClientInterests,
type ClientInterestRow,
} from '@/components/clients/client-pipeline-summary';
import { InterestForm } from '@/components/interests/interest-form';
const LEAD_CATEGORY_LABELS: Record<string, string> = {
general_interest: 'General interest',
specific_qualified: 'Specific qualified',
hot_lead: 'Hot lead',
};
function InterestRowItem({
interest,
onOpen,
}: {
interest: ClientInterestRow;
onOpen: (i: ClientInterestRow) => void;
}) {
const stage = safeStage(interest.pipelineStage);
const berthLabel = interest.berthMooringNumber
? `Berth ${interest.berthMooringNumber}`
: 'General interest';
const yachtLabel = interest.yachtName ?? null;
return (
// Tap opens a bottom-sheet preview drawer rather than navigating to the
// full interest page. The drawer covers ~80% of mobile interactions
// ("what stage is this at, when did we last touch it"). For deeper
// edits the drawer has an "Open full page" CTA.
<button
type="button"
onClick={() => onOpen(interest)}
className={cn(
'group block w-full rounded-xl border border-border bg-card p-4 text-left shadow-sm transition-all',
'hover:border-border/70 hover:shadow-md',
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
{berthLabel}
</h3>
<span
className={cn(
'shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium',
STAGE_BADGE[stage],
)}
>
{STAGE_LABELS[stage]}
</span>
</div>
{yachtLabel ? (
<p className="mt-0.5 truncate text-xs text-muted-foreground">{yachtLabel}</p>
) : null}
</div>
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</div>
<div className="mt-3">
<StageStepper current={stage} />
</div>
</button>
);
}
function lastActivityFor(interest: ClientInterestRow): string | null {
const candidates = [interest.dateLastContact, interest.updatedAt]
.filter((v): v is string => Boolean(v))
.map((v) => new Date(v).getTime())
.filter((t) => !Number.isNaN(t));
if (candidates.length === 0) return null;
return `${formatDistanceToNowStrict(new Date(Math.max(...candidates)))} ago`;
}
/** Full interest record returned by `/api/v1/interests/[id]`. Only the fields
* the drawer actually reads are typed here; the API returns more. */
interface InterestDetail {
id: string;
pipelineStage: string;
leadCategory: string | null;
source: string | null;
notes: string | null;
dateLastContact: string | null;
dateEoiSent: string | null;
dateEoiSigned: string | null;
dateDepositReceived: string | null;
dateContractSent: string | null;
dateContractSigned: string | null;
}
function useInterestDetail(id: string | null) {
return useQuery<{ data: InterestDetail }>({
queryKey: ['interest-detail-drawer', id],
queryFn: () => apiFetch<{ data: InterestDetail }>(`/api/v1/interests/${id}`),
enabled: id !== null,
// Detail rarely changes during a single drawer-open session; stale-time
// keeps re-opens snappy without preventing background refetch.
staleTime: 30_000,
});
}
/** Format a date-only or ISO timestamp as e.g. "Apr 8, 2026". Returns null for
* empty input so callers can render an "empty" state. */
function formatDate(value: string | null | undefined): string | null {
if (!value) return null;
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
return format(d, 'MMM d, yyyy');
}
/** A single milestone row inside the drawer's milestone summary. Filled
* circle when the step is done, hollow when pending. Trailing meta line
* shows the date stamp or a "pending" hint. */
function MilestoneRow({
label,
done,
date,
hint = 'pending',
}: {
label: string;
done: boolean;
date: string | null;
hint?: string;
}) {
return (
<li className="flex items-center gap-2 py-1">
{done ? (
<CheckCircle2 className="size-4 shrink-0 text-emerald-600" aria-hidden />
) : (
<Circle className="size-4 shrink-0 text-muted-foreground/40" aria-hidden />
)}
<span className={cn('flex-1 text-sm', done ? 'text-foreground' : 'text-muted-foreground')}>
{label}
</span>
<span className="text-xs text-muted-foreground tabular-nums">{date ?? hint}</span>
</li>
);
}
/**
* Bottom-sheet preview of a single interest. Designed for the mobile
* "tap an interest → see what's happening without leaving the client
* page" flow. Shows the pipeline progress, a compact milestone summary
* (EOI / Deposit / Contract), lead context, last contact, and a notes
* teaser. Tap-out / drag-down dismisses; the full edit page is one tap
* away via "Open full page →".
*/
function InterestPreviewDrawer({
interest,
portSlug,
onClose,
}: {
interest: ClientInterestRow | null;
portSlug: string;
onClose: () => void;
}) {
// Pin the most recently selected interest so the drawer stays populated
// during the close-animation tail (Vaul keeps the content mounted ~250ms
// after `open=false`). Conditional setState is safe here — the guard
// ensures it only fires when the prop actually changes to a new row.
const [pinned, setPinned] = useState<ClientInterestRow | null>(interest);
if (interest && interest !== pinned) setPinned(interest);
const showing = pinned;
const detail = useInterestDetail(showing?.id ?? null);
const fullDetail = detail.data?.data ?? null;
const open = interest !== null;
const stage = showing ? safeStage(showing.pipelineStage) : null;
const stageIdx = stage ? PIPELINE_STAGES.indexOf(stage) : -1;
const reached = (target: PipelineStage) =>
stageIdx !== -1 && PIPELINE_STAGES.indexOf(target) <= stageIdx;
const berthLabel = showing
? showing.berthMooringNumber
? `Berth ${showing.berthMooringNumber}`
: 'General interest'
: '';
const yachtLabel = showing?.yachtName ?? null;
const activity = showing ? lastActivityFor(showing) : null;
const fullHref = showing ? (`/${portSlug}/interests/${showing.id}` as Route) : ('/' as Route);
const leadLabel = fullDetail?.leadCategory
? (LEAD_CATEGORY_LABELS[fullDetail.leadCategory] ?? fullDetail.leadCategory)
: null;
const sourceLabel = fullDetail?.source
? fullDetail.source.replace(/\b\w/g, (m) => m.toUpperCase())
: null;
const lastContactDate = formatDate(fullDetail?.dateLastContact);
const notesPreview = fullDetail?.notes?.trim() || null;
return (
<Drawer
open={open}
onOpenChange={(next) => {
if (!next) onClose();
}}
>
<DrawerContent className="max-h-[85vh]">
<DrawerHeader>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<DrawerTitle className="truncate">{berthLabel}</DrawerTitle>
{yachtLabel ? (
<p className="mt-0.5 truncate text-sm text-muted-foreground">{yachtLabel}</p>
) : null}
</div>
{stage ? (
<span
className={cn(
'shrink-0 rounded-full px-2.5 py-1 text-xs font-medium',
STAGE_BADGE[stage],
)}
>
{STAGE_LABELS[stage]}
</span>
) : null}
</div>
</DrawerHeader>
<div className="space-y-5 overflow-y-auto px-4 pb-4">
{/* Pipeline-stepper segmented bar — the same primitive used on the
row card, so the at-a-glance progress hint is consistent
across surfaces. */}
{stage ? (
<div>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Pipeline progress
</p>
<StageStepper current={stage} />
</div>
) : null}
{/* Milestones — three sections matching the full interest detail
page (EOI / Deposit / Contract). Done-state is derived from
the pipeline stage so seed data without per-step dates still
renders correctly. The full milestone columns + per-step
actions live behind "Open full page". */}
<section>
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Milestones
</p>
<div className="space-y-3">
<div className="rounded-lg border border-border bg-card/50 p-3">
<p className="mb-1 text-sm font-semibold">EOI</p>
<ul>
<MilestoneRow
label="EOI sent"
done={reached('eoi_sent') || !!fullDetail?.dateEoiSent}
date={formatDate(fullDetail?.dateEoiSent)}
/>
<MilestoneRow
label="EOI signed"
done={reached('eoi_signed') || !!fullDetail?.dateEoiSigned}
date={formatDate(fullDetail?.dateEoiSigned)}
/>
</ul>
</div>
<div className="rounded-lg border border-border bg-card/50 p-3">
<p className="mb-1 text-sm font-semibold">Deposit</p>
<ul>
<MilestoneRow
label="Deposit received"
done={reached('deposit_10pct') || !!fullDetail?.dateDepositReceived}
date={formatDate(fullDetail?.dateDepositReceived)}
/>
</ul>
</div>
<div className="rounded-lg border border-border bg-card/50 p-3">
<p className="mb-1 text-sm font-semibold">Contract</p>
<ul>
<MilestoneRow
label="Contract sent"
done={reached('contract_sent') || !!fullDetail?.dateContractSent}
date={formatDate(fullDetail?.dateContractSent)}
/>
<MilestoneRow
label="Contract signed"
done={reached('contract_signed') || !!fullDetail?.dateContractSigned}
date={formatDate(fullDetail?.dateContractSigned)}
/>
</ul>
</div>
</div>
</section>
{/* Compact key/value pairs — lead category, source, last contact,
activity. Each row collapses cleanly when its value is
missing so the drawer scales from sparse seed data to full
records without empty placeholders. */}
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5 text-sm">
{leadLabel ? (
<>
<dt className="text-muted-foreground">Lead</dt>
<dd className="text-right font-medium">{leadLabel}</dd>
</>
) : null}
{sourceLabel ? (
<>
<dt className="text-muted-foreground">Source</dt>
<dd className="text-right font-medium">{sourceLabel}</dd>
</>
) : null}
{lastContactDate ? (
<>
<dt className="text-muted-foreground">Last contact</dt>
<dd className="text-right font-medium">{lastContactDate}</dd>
</>
) : null}
{activity ? (
<>
<dt className="text-muted-foreground">Last activity</dt>
<dd className="text-right font-medium">{activity}</dd>
</>
) : null}
</dl>
{notesPreview ? (
<section>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Notes
</p>
<p className="line-clamp-3 text-sm text-foreground/90 whitespace-pre-wrap">
{notesPreview}
</p>
</section>
) : null}
<Button asChild className="w-full" size="lg">
<Link href={fullHref}>
Open full page
<ArrowRight className="ml-1.5 size-4" aria-hidden />
</Link>
</Button>
</div>
</DrawerContent>
</Drawer>
);
}
function InterestSkeleton() {
return (
<div className="rounded-xl border border-border bg-card p-4 shadow-sm">
<Skeleton className="h-4 w-32" />
<Skeleton className="mt-2 h-3 w-24" />
<Skeleton className="mt-3 h-2 w-48" />
</div>
);
}
interface ClientInterestsTabProps {
clientId: string;
}
export function ClientInterestsTab({ clientId }: ClientInterestsTabProps) {
const routeParams = useParams<{ portSlug: string }>();
const portSlug = routeParams?.portSlug ?? '';
const [createOpen, setCreateOpen] = useState(false);
const [previewInterest, setPreviewInterest] = useState<ClientInterestRow | null>(null);
const { data, isLoading, isError } = useClientInterests(clientId);
if (isLoading) {
return (
<div className="space-y-3">
<InterestSkeleton />
<InterestSkeleton />
</div>
);
}
if (isError) {
return <p className="text-sm text-destructive">Could not load interests for this client.</p>;
}
const interests = data?.data ?? [];
if (interests.length === 0) {
return (
<>
<EmptyState
title="No interests yet"
description="When this client expresses interest in a berth, the sales process will appear here."
action={{
label: 'Add interest',
onClick: () => setCreateOpen(true),
}}
/>
<InterestForm open={createOpen} onOpenChange={setCreateOpen} defaultClientId={clientId} />
</>
);
}
const active = interests.filter((i) => !i.archivedAt);
const archived = interests.filter((i) => i.archivedAt);
return (
<div className="space-y-6">
<div className="flex justify-end">
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1.5 size-3.5" />
Add interest
</Button>
</div>
{active.length > 0 ? (
<div className="space-y-3">
{active.map((i) => (
<InterestRowItem key={i.id} interest={i} onOpen={setPreviewInterest} />
))}
</div>
) : null}
{archived.length > 0 ? (
<div className="space-y-3">
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Archived
</h4>
<div className="space-y-3 opacity-60">
{archived.map((i) => (
<InterestRowItem key={i.id} interest={i} onOpen={setPreviewInterest} />
))}
</div>
</div>
) : null}
<InterestPreviewDrawer
interest={previewInterest}
portSlug={portSlug}
onClose={() => setPreviewInterest(null)}
/>
<InterestForm open={createOpen} onOpenChange={setCreateOpen} defaultClientId={clientId} />
</div>
);
}

View File

@@ -9,8 +9,6 @@ import { InlineTimezoneField } from '@/components/shared/inline-timezone-field';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list'; import { NotesList } from '@/components/shared/notes-list';
import type { CountryCode } from '@/lib/i18n/countries'; import type { CountryCode } from '@/lib/i18n/countries';
import { ClientInterestsTab } from '@/components/clients/client-interests-tab';
import { ClientPipelineSummary } from '@/components/clients/client-pipeline-summary';
import { ClientYachtsTab } from '@/components/clients/client-yachts-tab'; import { ClientYachtsTab } from '@/components/clients/client-yachts-tab';
import { ClientCompaniesTab } from '@/components/clients/client-companies-tab'; import { ClientCompaniesTab } from '@/components/clients/client-companies-tab';
import { ClientReservationsTab } from '@/components/clients/client-reservations-tab'; import { ClientReservationsTab } from '@/components/clients/client-reservations-tab';
@@ -133,82 +131,82 @@ function OverviewTab({
}; };
return ( return (
<div className="space-y-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="rounded-xl border border-border bg-card p-4 shadow-sm"> {/* Personal Info */}
<ClientPipelineSummary clientId={clientId} variant="panel" /> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Personal Information</h3>
<dl>
<EditableRow label="Full Name">
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
</EditableRow>
<EditableRow label="Nationality">
<InlineCountryField
value={client.nationalityIso ?? null}
onSave={async (iso) => {
await mutation.mutateAsync({ nationalityIso: iso });
}}
data-testid="client-nationality-inline"
/>
</EditableRow>
<EditableRow label="Preferred Language">
<InlineEditableField
value={client.preferredLanguage}
onSave={save('preferredLanguage')}
/>
</EditableRow>
<EditableRow label="Timezone">
<InlineTimezoneField
value={client.timezone}
countryHint={(client.nationalityIso as CountryCode | null) ?? null}
onSave={async (tz) => {
await mutation.mutateAsync({ timezone: tz });
}}
data-testid="client-timezone-inline"
/>
</EditableRow>
<EditableRow label="Preferred Contact">
<InlineEditableField
variant="select"
options={CONTACT_METHOD_OPTIONS}
value={client.preferredContactMethod}
onSave={save('preferredContactMethod')}
/>
</EditableRow>
</dl>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> {/* Contacts */}
{/* Personal Info */} <div className="space-y-1">
<div className="space-y-1"> <h3 className="text-sm font-medium mb-2">Contact Details</h3>
<h3 className="text-sm font-medium mb-2">Personal Information</h3> <ContactsEditor clientId={clientId} contacts={client.contacts ?? []} />
<dl> </div>
<EditableRow label="Full Name">
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
</EditableRow>
<EditableRow label="Nationality">
<InlineCountryField
value={client.nationalityIso ?? null}
onSave={async (iso) => {
await mutation.mutateAsync({ nationalityIso: iso });
}}
data-testid="client-nationality-inline"
/>
</EditableRow>
<EditableRow label="Timezone">
<InlineTimezoneField
value={client.timezone}
countryHint={(client.nationalityIso as CountryCode | null) ?? null}
onSave={async (tz) => {
await mutation.mutateAsync({ timezone: tz });
}}
data-testid="client-timezone-inline"
/>
</EditableRow>
<EditableRow label="Preferred Contact">
<InlineEditableField
variant="select"
options={CONTACT_METHOD_OPTIONS}
value={client.preferredContactMethod}
onSave={save('preferredContactMethod')}
/>
</EditableRow>
</dl>
</div>
{/* Contacts */} {/* Source */}
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Contact Details</h3> <h3 className="text-sm font-medium mb-2">Source</h3>
<ContactsEditor clientId={clientId} contacts={client.contacts ?? []} /> <dl>
</div> <EditableRow label="Source">
<InlineEditableField
variant="select"
options={SOURCE_OPTIONS}
value={client.source}
onSave={save('source')}
/>
</EditableRow>
<EditableRow label="Source Details">
<InlineEditableField value={client.sourceDetails} onSave={save('sourceDetails')} />
</EditableRow>
</dl>
</div>
{/* Source */} {/* Tags */}
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Source</h3> <h3 className="text-sm font-medium mb-2">Tags</h3>
<dl> <InlineTagEditor
<EditableRow label="Source"> endpoint={`/api/v1/clients/${clientId}/tags`}
<InlineEditableField currentTags={client.tags ?? []}
variant="select" invalidateKey={['clients', clientId]}
options={SOURCE_OPTIONS} />
value={client.source}
onSave={save('source')}
/>
</EditableRow>
<EditableRow label="Source Details">
<InlineEditableField value={client.sourceDetails} onSave={save('sourceDetails')} />
</EditableRow>
</dl>
</div>
{/* Tags */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Tags</h3>
<InlineTagEditor
endpoint={`/api/v1/clients/${clientId}/tags`}
currentTags={client.tags ?? []}
invalidateKey={['clients', clientId]}
/>
</div>
</div> </div>
</div> </div>
); );
@@ -221,11 +219,6 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
label: 'Overview', label: 'Overview',
content: <OverviewTab clientId={clientId} client={client} />, content: <OverviewTab clientId={clientId} client={client} />,
}, },
{
id: 'interests',
label: 'Interests',
content: <ClientInterestsTab clientId={clientId} />,
},
{ {
id: 'yachts', id: 'yachts',
label: 'Yachts', label: 'Yachts',
@@ -258,6 +251,15 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
/> />
), ),
}, },
{
id: 'interests',
label: 'Interests',
content: (
<div className="text-center py-12 text-muted-foreground">
<p>Interests will appear here once created.</p>
</div>
),
},
{ {
id: 'notes', id: 'notes',
label: 'Notes', label: 'Notes',

View File

@@ -155,7 +155,6 @@ function ContactRow({
onRemove: () => void; onRemove: () => void;
}) { }) {
const Icon = CHANNEL_ICONS[contact.channel] ?? MoreHorizontal; const Icon = CHANNEL_ICONS[contact.channel] ?? MoreHorizontal;
const [phoneEditing, setPhoneEditing] = useState(false);
async function togglePrimary() { async function togglePrimary() {
try { try {
@@ -175,31 +174,17 @@ function ContactRow({
} }
return ( return (
<div <div className="group flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm">
data-editing={phoneEditing ? 'true' : undefined} {/* Left: channel + value */}
className={cn( <div className="flex items-center gap-2 flex-1 min-w-0">
'group rounded-lg border text-sm transition-all duration-150',
// Active-edit dilation: lift the row out of the muted baseline with a
// soft primary ring + slightly brighter surface. Single visual signal
// replaces the need for any "now editing" label.
phoneEditing
? 'bg-card border-primary/30 ring-2 ring-primary/15 shadow-sm p-3 gap-3'
: 'bg-muted/30 p-2 gap-2',
// Stack value editor / action cluster on mobile; single row on sm+.
'flex flex-col sm:flex-row sm:items-center',
)}
>
{/* Top / left: channel + value */}
<div className="flex min-w-0 flex-1 items-center gap-2">
<ChannelPicker value={contact.channel} onChange={changeChannel}> <ChannelPicker value={contact.channel} onChange={changeChannel}>
<Icon className="h-3.5 w-3.5 text-muted-foreground" /> <Icon className="h-3.5 w-3.5 text-muted-foreground" />
</ChannelPicker> </ChannelPicker>
<div className="min-w-0 flex-1"> <div className="min-w-0">
{contact.channel === 'phone' || contact.channel === 'whatsapp' ? ( {contact.channel === 'phone' || contact.channel === 'whatsapp' ? (
<InlinePhoneField <InlinePhoneField
e164={contact.valueE164 ?? null} e164={contact.valueE164 ?? null}
country={contact.valueCountry ?? null} country={contact.valueCountry ?? null}
onEditingChange={setPhoneEditing}
onSave={async ({ e164, country }) => { onSave={async ({ e164, country }) => {
if (!e164) { if (!e164) {
toast.error('Phone number is required'); toast.error('Phone number is required');
@@ -223,46 +208,50 @@ function ContactRow({
</div> </div>
</div> </div>
{/* Bottom / right: tag + actions. Hidden while the phone editor is active {/* Right: tag + actions.
to keep focus on the form — no chips fighting for space, no noise. */} When the contact value is empty (e.g. a row created from a stale
{!phoneEditing ? ( import or an aborted edit), we hide the "Add tag" + Make-primary
<div className="flex shrink-0 items-center justify-end gap-2"> controls so the empty placeholder doesn't clutter the row. The
<div className="w-28 text-right text-xs text-muted-foreground"> trash icon is always shown so users can clean up the empty entry. */}
<InlineEditableField <div className="flex items-center gap-2 shrink-0">
value={ {contact.value ? (
contact.label && contact.label.toLowerCase() !== 'primary' ? contact.label : null <>
} <div className="w-28 text-xs text-muted-foreground text-right">
emptyText="Add tag" <InlineEditableField
placeholder="work, home…" value={
onSave={async (v) => { contact.label && contact.label.toLowerCase() !== 'primary' ? contact.label : null
await onUpdate({ label: v }); }
}} emptyText="Add tag"
/> placeholder="work, home…"
</div> onSave={async (v) => {
await onUpdate({ label: v });
}}
/>
</div>
<button <button
type="button" type="button"
onClick={togglePrimary} onClick={togglePrimary}
title={contact.isPrimary ? 'Primary' : 'Make primary'} title={contact.isPrimary ? 'Primary' : 'Make primary'}
className={cn( className={cn(
'rounded p-1 transition-colors hover:bg-background/60', 'p-1 rounded hover:bg-background/60 transition-colors',
contact.isPrimary ? 'text-primary' : 'text-muted-foreground/50', contact.isPrimary ? 'text-primary' : 'text-muted-foreground/50',
)} )}
> >
<Star className={cn('h-3.5 w-3.5', contact.isPrimary && 'fill-current')} /> <Star className={cn('h-3.5 w-3.5', contact.isPrimary && 'fill-current')} />
</button> </button>
</>
) : null}
<button <button
type="button" type="button"
onClick={onRemove} onClick={onRemove}
title="Remove" title="Remove"
// Trash is opacity-0 on desktop hover-only; on touch, always show. className="p-1 rounded text-muted-foreground/50 hover:text-destructive hover:bg-background/60 opacity-0 group-hover:opacity-100 transition-all"
className="rounded p-1 text-muted-foreground/50 transition-all hover:bg-background/60 hover:text-destructive sm:opacity-0 sm:group-hover:opacity-100" >
> <Trash2 className="h-3.5 w-3.5" />
<Trash2 className="h-3.5 w-3.5" /> </button>
</button> </div>
</div>
) : null}
</div> </div>
); );
} }
@@ -349,9 +338,7 @@ function NewContactForm({
const submitDisabled = saving || (isPhoneChannel ? !phoneValue?.e164 : !value.trim()); const submitDisabled = saving || (isPhoneChannel ? !phoneValue?.e164 : !value.trim());
return ( return (
// Single row on sm+; wraps onto multiple lines below 640px so the channel <div className="flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm">
// picker, value field, label, and buttons each get their own usable width.
<div className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/30 p-2 text-sm">
<Select <Select
value={channel} value={channel}
onValueChange={(next) => { onValueChange={(next) => {
@@ -374,7 +361,7 @@ function NewContactForm({
</Select> </Select>
{isPhoneChannel ? ( {isPhoneChannel ? (
<div className="min-w-0 flex-1 basis-full sm:basis-auto"> <div className="flex-1 min-w-0">
<PhoneInput <PhoneInput
value={phoneValue} value={phoneValue}
onChange={(v) => setPhoneValue(v)} onChange={(v) => setPhoneValue(v)}
@@ -386,7 +373,7 @@ function NewContactForm({
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
placeholder={channel === 'email' ? 'name@example.com' : 'value'} placeholder={channel === 'email' ? 'name@example.com' : 'value'}
className="h-7 min-w-0 flex-1 basis-full text-sm sm:basis-auto" className="h-7 text-sm flex-1 min-w-0"
autoFocus autoFocus
disabled={saving} disabled={saving}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -403,7 +390,7 @@ function NewContactForm({
value={label} value={label}
onChange={(e) => setLabel(e.target.value)} onChange={(e) => setLabel(e.target.value)}
placeholder="tag (optional)" placeholder="tag (optional)"
className="h-7 w-28 text-xs" className="h-7 text-xs w-28"
disabled={saving} disabled={saving}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
@@ -414,14 +401,12 @@ function NewContactForm({
}} }}
/> />
<div className="ml-auto flex gap-2"> <Button type="button" size="sm" onClick={submit} disabled={submitDisabled}>
<Button type="button" size="sm" onClick={submit} disabled={submitDisabled}> {saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'} </Button>
</Button> <Button type="button" size="sm" variant="ghost" onClick={onCancel} disabled={saving}>
<Button type="button" size="sm" variant="ghost" onClick={onCancel} disabled={saving}> Cancel
Cancel </Button>
</Button>
</div>
</div> </div>
); );
} }

View File

@@ -28,10 +28,11 @@ function formatPercent(value: number): string {
function KpiTileSkeleton() { function KpiTileSkeleton() {
return ( return (
<div className="relative overflow-hidden rounded-xl border border-border bg-card p-3 shadow-sm sm:p-5"> <div className="relative overflow-hidden rounded-xl border border-border bg-card p-5 shadow-sm">
<div className="absolute inset-x-0 top-0 h-1 bg-muted" aria-hidden /> <div className="absolute inset-x-0 top-0 h-1 bg-muted" aria-hidden />
<Skeleton className="h-3 w-20" /> <Skeleton className="h-3 w-24" />
<Skeleton className="mt-2 h-6 w-24 sm:mt-3 sm:h-7" /> <Skeleton className="mt-3 h-7 w-32" />
<Skeleton className="mt-2 h-3 w-12" />
</div> </div>
); );
} }

View File

@@ -67,11 +67,8 @@ export function MyRemindersRail() {
return `/${portSlug}/reminders`; return `/${portSlug}/reminders`;
} }
// `h-full` only at xl: where the dashboard grid pairs this rail with
// a sibling chart column. On mobile (stacked) it produced a weirdly
// tall empty card.
return ( return (
<Card className="xl:h-full"> <Card className="h-full">
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3"> <CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
<div className="space-y-0.5"> <div className="space-y-0.5">
<CardTitle className="flex items-center gap-1.5 text-base"> <CardTitle className="flex items-center gap-1.5 text-base">

View File

@@ -1,104 +0,0 @@
'use client';
import { useEffect } from 'react';
type Edge = 'top' | 'bottom' | 'left' | 'right';
interface ToolbarState {
edge: Edge;
ratio: number;
collapsed: boolean;
enabled: boolean;
defaultAction?: string;
}
interface ReactGrabAPI {
setToolbarState: (state: Partial<ToolbarState>) => void;
onToolbarStateChange: (cb: (state: ToolbarState) => void) => () => void;
}
declare global {
interface Window {
__REACT_GRAB__?: ReactGrabAPI;
}
}
const MOBILE_QUERY = '(max-width: 1023.98px)';
const DESKTOP_KEY = 'react-grab-toolbar-state-desktop';
const MOBILE_KEY = 'react-grab-toolbar-state-mobile';
const DESKTOP_DEFAULT: Partial<ToolbarState> = {
edge: 'bottom',
ratio: 0.5,
collapsed: false,
};
const MOBILE_DEFAULT: Partial<ToolbarState> = {
edge: 'right',
ratio: 0.5,
collapsed: false,
};
export function ReactGrabViewportSync() {
useEffect(() => {
if (process.env.NODE_ENV !== 'development') return;
const cleanups: Array<() => void> = [];
let pollId: number | undefined;
const wireUp = (api: ReactGrabAPI) => {
const mql = window.matchMedia(MOBILE_QUERY);
const keyFor = () => (mql.matches ? MOBILE_KEY : DESKTOP_KEY);
const defaultFor = () => (mql.matches ? MOBILE_DEFAULT : DESKTOP_DEFAULT);
let suppressNextWrite = false;
const apply = () => {
const stored = localStorage.getItem(keyFor());
suppressNextWrite = true;
api.setToolbarState(stored ? (JSON.parse(stored) as ToolbarState) : defaultFor());
};
apply();
const unsubscribe = api.onToolbarStateChange((state) => {
if (suppressNextWrite) {
suppressNextWrite = false;
return;
}
localStorage.setItem(keyFor(), JSON.stringify(state));
});
mql.addEventListener('change', apply);
cleanups.push(unsubscribe, () => mql.removeEventListener('change', apply));
};
const tryWire = () => {
const api = window.__REACT_GRAB__;
if (!api) return false;
wireUp(api);
return true;
};
if (!tryWire()) {
pollId = window.setInterval(() => {
if (tryWire() && pollId !== undefined) {
window.clearInterval(pollId);
pollId = undefined;
}
}, 100);
window.setTimeout(() => {
if (pollId !== undefined) {
window.clearInterval(pollId);
pollId = undefined;
}
}, 5000);
}
return () => {
if (pollId !== undefined) window.clearInterval(pollId);
cleanups.forEach((fn) => fn());
};
}, []);
return null;
}

View File

@@ -2,7 +2,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { Anchor, FileSignature, LayoutDashboard, Menu, Users } from 'lucide-react'; import { LayoutDashboard, Users, Ship, Anchor, Menu } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -12,27 +12,11 @@ type TabSpec = {
segment: string; // route segment after /[portSlug]/ 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[] = [ const TABS: TabSpec[] = [
{ label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' }, { label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' },
{ label: 'Berths', icon: Anchor, segment: 'berths' },
{ label: 'Clients', icon: Users, segment: 'clients' }, { label: 'Clients', icon: Users, segment: 'clients' },
{ label: 'Documents', icon: FileSignature, segment: 'documents' }, { label: 'Yachts', icon: Ship, segment: 'yachts' },
{ label: 'Berths', icon: Anchor, segment: 'berths' },
]; ];
export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) { export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) {

View File

@@ -3,17 +3,17 @@
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { import {
BarChart3,
Bell,
Bookmark,
Building2, Building2,
FileText, Bookmark,
Mail,
Receipt, Receipt,
FileText,
FolderOpen,
Mail,
Bell,
ShieldAlert,
BarChart3,
Settings, Settings,
Shield, Shield,
ShieldAlert,
Ship,
} from 'lucide-react'; } from 'lucide-react';
import { import {
@@ -30,17 +30,12 @@ type MoreItem = {
segment: string; 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.
const MORE_ITEMS: MoreItem[] = [ const MORE_ITEMS: MoreItem[] = [
{ label: 'Interests', icon: Bookmark, segment: 'interests' },
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
{ label: 'Companies', icon: Building2, segment: 'companies' }, { label: 'Companies', icon: Building2, segment: 'companies' },
{ label: 'Interests', icon: Bookmark, segment: 'interests' },
{ label: 'Invoices', icon: FileText, segment: 'invoices' }, { label: 'Invoices', icon: FileText, segment: 'invoices' },
{ label: 'Expenses', icon: Receipt, segment: 'expenses' }, { label: 'Expenses', icon: Receipt, segment: 'expenses' },
{ label: 'Documents', icon: FolderOpen, segment: 'documents' },
{ label: 'Email', icon: Mail, segment: 'email' }, { label: 'Email', icon: Mail, segment: 'email' },
{ label: 'Alerts', icon: ShieldAlert, segment: 'alerts' }, { label: 'Alerts', icon: ShieldAlert, segment: 'alerts' },
{ label: 'Reports', icon: BarChart3, segment: 'reports' }, { label: 'Reports', icon: BarChart3, segment: 'reports' },

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useRef, useState } from 'react'; import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, MapPin, Plus, Star, Trash2 } from 'lucide-react'; import { Loader2, MapPin, Plus, Star, Trash2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -225,14 +225,6 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
); );
} }
/** Regional-indicator emoji flag for an ISO alpha-2 code (e.g. 'FR' → 🇫🇷). */
function flagEmoji(code: string | null | undefined): string {
if (!code || code.length !== 2) return '';
const A = 0x1f1e6;
const a = 'A'.charCodeAt(0);
return String.fromCodePoint(A + code.charCodeAt(0) - a, A + code.charCodeAt(1) - a);
}
function CountryFieldInline({ function CountryFieldInline({
value, value,
onSave, onSave,
@@ -241,34 +233,20 @@ function CountryFieldInline({
onSave: (iso: string | null) => Promise<void>; onSave: (iso: string | null) => Promise<void>;
}) { }) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
// Tracks whether a value was picked this edit cycle so the open-change
// handler doesn't double-exit while commit is still in flight.
const pickedRef = useRef(false);
if (editing) { if (editing) {
return ( return (
<CountryCombobox <CountryCombobox
value={value ?? null} value={value ?? null}
onChange={async (iso) => { onChange={async (iso) => {
pickedRef.current = true;
setEditing(false); setEditing(false);
await onSave(iso ?? null); await onSave(iso ?? null);
}} }}
clearable clearable
className="w-full" className="w-full"
// Drop the user straight into the picker — no extra click on the
// trigger required.
defaultOpen
onOpenChange={(open) => {
// Auto-exit edit mode when the popover closes without a pick so
// the user isn't stuck staring at a "Select country…" trigger.
if (!open && !pickedRef.current) setEditing(false);
if (open) pickedRef.current = false;
}}
/> />
); );
} }
const display = value ? `${flagEmoji(value)} ${getCountryName(value, 'en')}` : null; const display = value ? getCountryName(value, 'en') : null;
return ( return (
<button <button
type="button" type="button"
@@ -290,25 +268,17 @@ function SubdivisionFieldInline({
onSave: (code: string | null) => Promise<void>; onSave: (code: string | null) => Promise<void>;
}) { }) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const pickedRef = useRef(false);
if (editing) { if (editing) {
return ( return (
<SubdivisionCombobox <SubdivisionCombobox
value={value ?? null} value={value ?? null}
country={country} country={country}
onChange={async (code) => { onChange={async (code) => {
pickedRef.current = true;
setEditing(false); setEditing(false);
await onSave(code ?? null); await onSave(code ?? null);
}} }}
clearable clearable
className="w-full" className="w-full"
defaultOpen
onOpenChange={(open) => {
if (!open && !pickedRef.current) setEditing(false);
if (open) pickedRef.current = false;
}}
/> />
); );
} }

View File

@@ -30,12 +30,6 @@ interface CountryComboboxProps {
clearable?: boolean; clearable?: boolean;
id?: string; id?: string;
'data-testid'?: string; 'data-testid'?: string;
/** Open the dropdown on first render. Used by inline-edit wrappers so the
* user lands directly in the picker after clicking the edit affordance. */
defaultOpen?: boolean;
/** Notified whenever the dropdown opens/closes. Inline-edit wrappers use
* this to auto-exit edit mode when the user dismisses without picking. */
onOpenChange?: (open: boolean) => void;
} }
/** /**
@@ -64,14 +58,8 @@ export function CountryCombobox({
clearable = true, clearable = true,
id, id,
'data-testid': testId, 'data-testid': testId,
defaultOpen = false,
onOpenChange,
}: CountryComboboxProps) { }: CountryComboboxProps) {
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(false);
const handleOpenChange = (next: boolean) => {
setOpen(next);
onOpenChange?.(next);
};
const effectiveLocale = locale ?? (typeof navigator !== 'undefined' ? navigator.language : 'en'); const effectiveLocale = locale ?? (typeof navigator !== 'undefined' ? navigator.language : 'en');
// Pre-build the options list once per locale change so the cmdk filter // Pre-build the options list once per locale change so the cmdk filter
@@ -87,7 +75,7 @@ export function CountryCombobox({
const selected = value ? options.find((o) => o.code === value) : undefined; const selected = value ? options.find((o) => o.code === value) : undefined;
return ( return (
<Popover open={open} onOpenChange={handleOpenChange}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
id={id} id={id}

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useRef, useState } from 'react'; import { useState } from 'react';
import { Loader2, Pencil } from 'lucide-react'; import { Loader2, Pencil } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -31,12 +31,8 @@ export function InlineCountryField({
}: InlineCountryFieldProps) { }: InlineCountryFieldProps) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// Set true when the user picks a value from the dropdown, so the
// popover-close handler knows commit() will exit edit mode itself.
const pickedRef = useRef(false);
async function commit(next: CountryCode | null) { async function commit(next: CountryCode | null) {
pickedRef.current = true;
if (next === (value ?? null)) { if (next === (value ?? null)) {
setEditing(false); setEditing(false);
return; return;
@@ -55,23 +51,7 @@ export function InlineCountryField({
if (editing) { if (editing) {
return ( return (
<div className={cn('flex items-center gap-1', className)}> <div className={cn('flex items-center gap-1', className)}>
<CountryCombobox <CountryCombobox value={value} onChange={(iso) => void commit(iso)} data-testid={testId} />
value={value}
onChange={(iso) => void commit(iso)}
data-testid={testId}
defaultOpen
onOpenChange={(open) => {
// When the dropdown closes without a selection, leave edit mode
// so the user isn't stuck staring at the trigger button. If a
// pick happened, commit() handles the exit (and may need to keep
// edit mode briefly to show the saving spinner).
if (!open && !pickedRef.current) {
setEditing(false);
}
// Reset for the next open cycle.
if (open) pickedRef.current = false;
}}
/>
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />} {saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
</div> </div>
); );

View File

@@ -17,12 +17,6 @@ interface InlinePhoneFieldProps {
/** Falls back to this country if `country` isn't set. */ /** Falls back to this country if `country` isn't set. */
defaultCountry?: CountryCode; defaultCountry?: CountryCode;
onSave: (next: { e164: string | null; country: CountryCode }) => Promise<void>; onSave: (next: { e164: string | null; country: CountryCode }) => Promise<void>;
/**
* Notifies the parent when the field enters/exits edit mode. Lets the row
* dim or hide noise (tag chips, action buttons) while the user is focused
* on the editor.
*/
onEditingChange?: (editing: boolean) => void;
emptyText?: string; emptyText?: string;
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
@@ -34,13 +28,12 @@ export function InlinePhoneField({
country, country,
defaultCountry, defaultCountry,
onSave, onSave,
onEditingChange,
emptyText = '—', emptyText = '—',
disabled, disabled,
className, className,
'data-testid': testId, 'data-testid': testId,
}: InlinePhoneFieldProps) { }: InlinePhoneFieldProps) {
const [editing, setEditingRaw] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<PhoneInputValue | null>(() => { const [draft, setDraft] = useState<PhoneInputValue | null>(() => {
if (!e164 && !country) return null; if (!e164 && !country) return null;
return { return {
@@ -50,11 +43,6 @@ export function InlinePhoneField({
}); });
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
function setEditing(next: boolean) {
setEditingRaw(next);
onEditingChange?.(next);
}
async function commit() { async function commit() {
const next = draft ?? { e164: null, country: defaultCountry ?? 'US' }; const next = draft ?? { e164: null, country: defaultCountry ?? 'US' };
if (next.e164 === (e164 ?? null) && next.country === (country ?? null)) { if (next.e164 === (e164 ?? null) && next.country === (country ?? null)) {
@@ -74,50 +62,39 @@ export function InlinePhoneField({
if (editing) { if (editing) {
return ( return (
// Two clean lines: country picker + number on top, action pair below. <div className={cn('flex items-center gap-1', className)}>
<div className={cn('flex w-full flex-col gap-2.5', className)}>
<PhoneInput <PhoneInput
value={draft} value={draft}
onChange={(v) => setDraft(v)} onChange={(v) => setDraft(v)}
defaultCountry={defaultCountry} defaultCountry={defaultCountry}
data-testid={testId} data-testid={testId}
/> />
<div className="flex items-center justify-end gap-1.5"> <button
<button type="button"
type="button" onClick={() => void commit()}
onClick={() => { disabled={saving}
setDraft( className="rounded px-2 py-1 text-xs font-medium hover:bg-muted disabled:opacity-50"
e164 || country >
? { {saving ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Save'}
e164: e164 ?? null, </button>
country: (country as CountryCode | null) ?? defaultCountry ?? 'US', <button
} type="button"
: null, onClick={() => {
); setDraft(
setEditing(false); e164 || country
}} ? {
disabled={saving} e164: e164 ?? null,
className={cn( country: (country as CountryCode | null) ?? defaultCountry ?? 'US',
'inline-flex h-8 items-center rounded-md px-3 text-xs font-medium', }
'text-muted-foreground transition-colors hover:bg-muted hover:text-foreground', : null,
'disabled:opacity-50', );
)} setEditing(false);
> }}
Cancel disabled={saving}
</button> className="rounded px-2 py-1 text-xs text-muted-foreground hover:bg-muted disabled:opacity-50"
<button >
type="button" Cancel
onClick={() => void commit()} </button>
disabled={saving}
className={cn(
'inline-flex h-8 min-w-[64px] items-center justify-center rounded-md px-3',
'bg-primary text-xs font-semibold text-primary-foreground shadow-sm',
'transition-colors hover:bg-primary/90 disabled:opacity-50',
)}
>
{saving ? <Loader2 className="size-3.5 animate-spin" /> : 'Save'}
</button>
</div>
</div> </div>
); );
} }

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useRef, useState } from 'react'; import { useState } from 'react';
import { Loader2, Pencil } from 'lucide-react'; import { Loader2, Pencil } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -31,12 +31,8 @@ export function InlineTimezoneField({
}: InlineTimezoneFieldProps) { }: InlineTimezoneFieldProps) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// Set true when the user picks a value from the dropdown, so the
// popover-close handler knows commit() will exit edit mode itself.
const pickedRef = useRef(false);
async function commit(next: string | null) { async function commit(next: string | null) {
pickedRef.current = true;
if (next === (value ?? null)) { if (next === (value ?? null)) {
setEditing(false); setEditing(false);
return; return;
@@ -60,16 +56,6 @@ export function InlineTimezoneField({
onChange={(tz) => void commit(tz)} onChange={(tz) => void commit(tz)}
countryHint={countryHint ?? undefined} countryHint={countryHint ?? undefined}
data-testid={testId} data-testid={testId}
defaultOpen
onOpenChange={(open) => {
// Auto-exit edit mode when the dropdown closes without a pick,
// so the user isn't stuck looking at the trigger. commit() owns
// the exit when a value was selected.
if (!open && !pickedRef.current) {
setEditing(false);
}
if (open) pickedRef.current = false;
}}
/> />
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />} {saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
</div> </div>

View File

@@ -1,16 +1,9 @@
'use client'; 'use client';
import { type ReactNode } from 'react'; import { useEffect, useRef, type ReactNode } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export interface ResponsiveTab { export interface ResponsiveTab {
id: string; id: string;
@@ -26,47 +19,56 @@ interface ResponsiveTabsProps {
} }
/** /**
* Tabs that collapse to a native <Select> on phone-sized viewports. * Tab strip that scrolls horizontally on narrow viewports. The active tab is
* Above sm: TabsList renders. At/below sm: a Select dropdown replaces the tab strip. * automatically scrolled into view so users can tell at a glance that more
* tabs exist beyond the visible edge.
*
* Previously this collapsed to a <Select> on phone widths, but that read as
* a generic dropdown and obscured the fact that multiple peer tabs exist.
*/ */
export function ResponsiveTabs({ tabs, value, onValueChange }: ResponsiveTabsProps) { export function ResponsiveTabs({ tabs, value, onValueChange }: ResponsiveTabsProps) {
const listRef = useRef<HTMLDivElement>(null);
// Keep the active trigger in view when the value changes externally
// (e.g. ?tab= in the URL or a back/forward navigation).
useEffect(() => {
const root = listRef.current;
if (!root) return;
const active = root.querySelector<HTMLButtonElement>(`[data-tab-id="${CSS.escape(value)}"]`);
if (active) {
active.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
}
}, [value]);
return ( return (
<Tabs value={value} onValueChange={onValueChange}> <Tabs value={value} onValueChange={onValueChange}>
{/* Mobile: select dropdown */} {/* Single scrollable strip for all viewport widths.
<div className="sm:hidden"> The wrapper handles horizontal overflow with momentum scroll on
<Select value={value} onValueChange={onValueChange}> touch devices; the inner TabsList stays its natural width and
<SelectTrigger> slides under the wrapper. */}
<SelectValue /> <div
</SelectTrigger> ref={listRef}
<SelectContent> className="overflow-x-auto -mx-2 px-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
{tabs.map((tab) => ( >
<SelectItem key={tab.id} value={tab.id}> <TabsList className="inline-flex w-max">
<span className="flex items-center gap-1.5"> {tabs.map((tab) => (
{tab.label} <TabsTrigger
{tab.badge !== undefined && tab.badge !== null && ( key={tab.id}
<span className="text-xs text-muted-foreground">({tab.badge})</span> value={tab.id}
)} className="gap-1.5 whitespace-nowrap"
</span> data-tab-id={tab.id}
</SelectItem> >
))} {tab.label}
</SelectContent> {tab.badge !== undefined && tab.badge !== null && (
</Select> <Badge variant="secondary" className="px-1.5 py-0 text-xs">
{tab.badge}
</Badge>
)}
</TabsTrigger>
))}
</TabsList>
</div> </div>
{/* Desktop / tablet: tab strip */}
<TabsList className="hidden sm:flex">
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id} className="gap-1.5">
{tab.label}
{tab.badge !== undefined && tab.badge !== null && (
<Badge variant="secondary" className="px-1.5 py-0 text-xs">
{tab.badge}
</Badge>
)}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => ( {tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="mt-4"> <TabsContent key={tab.id} value={tab.id} className="mt-4">
{tab.content} {tab.content}

View File

@@ -32,11 +32,6 @@ interface SubdivisionComboboxProps {
clearable?: boolean; clearable?: boolean;
id?: string; id?: string;
'data-testid'?: string; 'data-testid'?: string;
/** Open the dropdown on first render. Used by inline-edit wrappers. */
defaultOpen?: boolean;
/** Notified whenever the dropdown opens/closes. Inline-edit wrappers use
* this to auto-exit edit mode when the user dismisses without picking. */
onOpenChange?: (open: boolean) => void;
} }
export function SubdivisionCombobox({ export function SubdivisionCombobox({
@@ -49,14 +44,8 @@ export function SubdivisionCombobox({
clearable = true, clearable = true,
id, id,
'data-testid': testId, 'data-testid': testId,
defaultOpen = false,
onOpenChange,
}: SubdivisionComboboxProps) { }: SubdivisionComboboxProps) {
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(false);
const handleOpenChange = (next: boolean) => {
setOpen(next);
onOpenChange?.(next);
};
const options = useMemo(() => { const options = useMemo(() => {
if (!country) return []; if (!country) return [];
@@ -75,7 +64,7 @@ export function SubdivisionCombobox({
else triggerLabel = placeholder; else triggerLabel = placeholder;
return ( return (
<Popover open={open} onOpenChange={handleOpenChange}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
id={id} id={id}

View File

@@ -29,11 +29,6 @@ interface TimezoneComboboxProps {
clearable?: boolean; clearable?: boolean;
id?: string; id?: string;
'data-testid'?: string; 'data-testid'?: string;
/** Open the dropdown on first render. Used by inline-edit wrappers. */
defaultOpen?: boolean;
/** Notified whenever the dropdown opens/closes. Inline-edit wrappers use
* this to auto-exit edit mode when the user dismisses without picking. */
onOpenChange?: (open: boolean) => void;
} }
export function TimezoneCombobox({ export function TimezoneCombobox({
@@ -46,14 +41,8 @@ export function TimezoneCombobox({
clearable = true, clearable = true,
id, id,
'data-testid': testId, 'data-testid': testId,
defaultOpen = false,
onOpenChange,
}: TimezoneComboboxProps) { }: TimezoneComboboxProps) {
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(false);
const handleOpenChange = (next: boolean) => {
setOpen(next);
onOpenChange?.(next);
};
const allOptions = useMemo(() => { const allOptions = useMemo(() => {
return listAllTimezones().map((tz) => ({ return listAllTimezones().map((tz) => ({
@@ -77,7 +66,7 @@ export function TimezoneCombobox({
const selectedLabel = value ? formatTimezoneLabel(value) : placeholder; const selectedLabel = value ? formatTimezoneLabel(value) : placeholder;
return ( return (
<Popover open={open} onOpenChange={handleOpenChange}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
id={id} id={id}

View File

@@ -45,7 +45,7 @@ export function KPITile({
<div <div
data-testid="kpi-tile" data-testid="kpi-tile"
className={cn( className={cn(
'group relative overflow-hidden rounded-xl border border-border bg-gradient-brand-soft p-3 shadow-sm transition-all duration-base ease-smooth hover:shadow-md sm:p-5', 'group relative overflow-hidden rounded-xl border border-border bg-gradient-brand-soft p-5 shadow-sm transition-all duration-base ease-smooth hover:shadow-md',
className, className,
)} )}
{...props} {...props}
@@ -53,12 +53,10 @@ export function KPITile({
<div className={cn('absolute inset-x-0 top-0 h-1', ACCENT_STRIPES[accent])} aria-hidden /> <div className={cn('absolute inset-x-0 top-0 h-1', ACCENT_STRIPES[accent])} aria-hidden />
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs"> <div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{title} {title}
</div> </div>
<div className="mt-1 truncate text-lg font-semibold tabular-nums text-foreground sm:mt-2 sm:text-2xl"> <div className="mt-2 text-2xl font-semibold tabular-nums text-foreground">{value}</div>
{value}
</div>
{typeof delta === 'number' ? ( {typeof delta === 'number' ? (
<div className={cn('mt-1 text-xs font-medium', deltaClass)}> <div className={cn('mt-1 text-xs font-medium', deltaClass)}>
{deltaPrefix} {deltaPrefix}

View File

@@ -58,11 +58,10 @@ export function buildPipelineInputs(
'open', 'open',
'details_sent', 'details_sent',
'in_communication', 'in_communication',
'eoi_sent', 'visited',
'eoi_signed', 'signed_eoi_nda',
'deposit_10pct', 'deposit_10pct',
'contract_sent', 'contract',
'contract_signed',
'completed', 'completed',
]; ];
@@ -74,7 +73,9 @@ export function buildPipelineInputs(
}); });
// Include stages not in standard order // Include stages not in standard order
const unknownStages = Object.keys(data.stageCounts).filter((s) => !stageOrder.includes(s)); const unknownStages = Object.keys(data.stageCounts).filter(
(s) => !stageOrder.includes(s),
);
for (const stage of unknownStages) { for (const stage of unknownStages) {
summaryLines.push(`${stage}: ${data.stageCounts[stage]} interest(s)`); summaryLines.push(`${stage}: ${data.stageCounts[stage]} interest(s)`);
} }

View File

@@ -50,16 +50,18 @@ export const revenueReportTemplate: Template = {
], ],
}; };
export function buildRevenueInputs(data: RevenueData, portName?: string): Record<string, string>[] { export function buildRevenueInputs(
data: RevenueData,
portName?: string,
): Record<string, string>[] {
const stageOrder = [ const stageOrder = [
'open', 'open',
'details_sent', 'details_sent',
'in_communication', 'in_communication',
'eoi_sent', 'visited',
'eoi_signed', 'signed_eoi_nda',
'deposit_10pct', 'deposit_10pct',
'contract_sent', 'contract',
'contract_signed',
'completed', 'completed',
]; ];

View File

@@ -1,4 +1,4 @@
import { and, count, desc, eq, ilike, inArray, isNull } from 'drizzle-orm'; import { and, count, eq, ilike, inArray, isNull } from 'drizzle-orm';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { import {
@@ -11,8 +11,6 @@ import {
import { companies, companyMemberships } from '@/lib/db/schema/companies'; import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { yachts } from '@/lib/db/schema/yachts'; import { yachts } from '@/lib/db/schema/yachts';
import { berthReservations } from '@/lib/db/schema/reservations'; import { berthReservations } from '@/lib/db/schema/reservations';
import { interests } from '@/lib/db/schema/interests';
import { berths } from '@/lib/db/schema/berths';
import { tags } from '@/lib/db/schema/system'; import { tags } from '@/lib/db/schema/system';
import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { NotFoundError, ValidationError } from '@/lib/errors'; import { NotFoundError, ValidationError } from '@/lib/errors';
@@ -83,7 +81,7 @@ export async function listClients(portId: string, query: ListClientsInput) {
const ids = result.data.map((r) => r.id); const ids = result.data.map((r) => r.id);
const [yachtCounts, companyCounts, interestRows, interestCounts] = await Promise.all([ const [yachtCounts, companyCounts] = await Promise.all([
db db
.select({ ownerId: yachts.currentOwnerId, count: count() }) .select({ ownerId: yachts.currentOwnerId, count: count() })
.from(yachts) .from(yachts)
@@ -101,67 +99,18 @@ export async function listClients(portId: string, query: ListClientsInput) {
.from(companyMemberships) .from(companyMemberships)
.where(and(inArray(companyMemberships.clientId, ids), isNull(companyMemberships.endDate))) .where(and(inArray(companyMemberships.clientId, ids), isNull(companyMemberships.endDate)))
.groupBy(companyMemberships.clientId), .groupBy(companyMemberships.clientId),
db
.select({
clientId: interests.clientId,
pipelineStage: interests.pipelineStage,
updatedAt: interests.updatedAt,
mooringNumber: berths.mooringNumber,
})
.from(interests)
.leftJoin(berths, eq(berths.id, interests.berthId))
.where(
and(
eq(interests.portId, portId),
inArray(interests.clientId, ids),
isNull(interests.archivedAt),
),
)
.orderBy(desc(interests.updatedAt)),
db
.select({ clientId: interests.clientId, count: count() })
.from(interests)
.where(
and(
eq(interests.portId, portId),
inArray(interests.clientId, ids),
isNull(interests.archivedAt),
),
)
.groupBy(interests.clientId),
]); ]);
const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count])); const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count]));
const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count])); const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count]));
const interestCountMap = new Map(interestCounts.map((r) => [r.clientId, r.count]));
// interestRows is sorted desc by updatedAt; first hit per clientId is the latest.
const latestInterestMap = new Map<string, { stage: string; mooringNumber: string | null }>();
for (const row of interestRows) {
if (!latestInterestMap.has(row.clientId)) {
latestInterestMap.set(row.clientId, {
stage: row.pipelineStage,
mooringNumber: row.mooringNumber,
});
}
}
return { return {
...result, ...result,
data: result.data.map((row) => { data: result.data.map((row) => ({
const latest = latestInterestMap.get(row.id); ...row,
return { yachtCount: yachtCountMap.get(row.id) ?? 0,
...row, companyCount: companyCountMap.get(row.id) ?? 0,
yachtCount: yachtCountMap.get(row.id) ?? 0, })),
companyCount: companyCountMap.get(row.id) ?? 0,
interestCount: interestCountMap.get(row.id) ?? 0,
latestInterest: latest
? {
stage: latest.stage,
mooringNumber: latest.mooringNumber,
}
: null,
};
}),
}; };
} }

View File

@@ -635,11 +635,10 @@ export function makeCreateInterestInput(overrides?: {
| 'open' | 'open'
| 'details_sent' | 'details_sent'
| 'in_communication' | 'in_communication'
| 'eoi_sent' | 'visited'
| 'eoi_signed' | 'signed_eoi_nda'
| 'deposit_10pct' | 'deposit_10pct'
| 'contract_sent' | 'contract'
| 'contract_signed'
| 'completed'; | 'completed';
}) { }) {
return { return {

View File

@@ -181,7 +181,7 @@ describe('alert engine', () => {
await db.insert(interests).values({ await db.insert(interests).values({
portId: port.id, portId: port.id,
clientId: client.id, clientId: client.id,
pipelineStage: 'in_communication', pipelineStage: 'visited',
leadCategory: 'hot_lead', leadCategory: 'hot_lead',
dateLastContact: stale, dateLastContact: stale,
updatedAt: stale, updatedAt: stale,

View File

@@ -170,7 +170,7 @@ describe('Pipeline Transitions', () => {
await import('@/lib/services/interests.service'); await import('@/lib/services/interests.service');
const meta = makeAuditMeta({ portId }); const meta = makeAuditMeta({ portId });
await changeInterestStage(interestId, portId, { pipelineStage: 'eoi_signed' }, meta); await changeInterestStage(interestId, portId, { pipelineStage: 'signed_eoi_nda' }, meta);
const updated = await getInterestById(interestId, portId); const updated = await getInterestById(interestId, portId);
expect(updated.dateEoiSigned).not.toBeNull(); expect(updated.dateEoiSigned).not.toBeNull();
@@ -181,7 +181,7 @@ describe('Pipeline Transitions', () => {
await import('@/lib/services/interests.service'); await import('@/lib/services/interests.service');
const meta = makeAuditMeta({ portId }); const meta = makeAuditMeta({ portId });
await changeInterestStage(interestId, portId, { pipelineStage: 'contract_signed' }, meta); await changeInterestStage(interestId, portId, { pipelineStage: 'contract' }, meta);
const updated = await getInterestById(interestId, portId); const updated = await getInterestById(interestId, portId);
expect(updated.dateContractSigned).not.toBeNull(); expect(updated.dateContractSigned).not.toBeNull();

View File

@@ -142,7 +142,7 @@ describe('calculateInterestScore', () => {
portId: 'p1', portId: 'p1',
clientId: 'c1', clientId: 'c1',
createdAt: daysAgo(10), createdAt: daysAgo(10),
pipelineStage: 'contract_signed', pipelineStage: 'contract',
eoiStatus: 'signed', eoiStatus: 'signed',
contractStatus: 'signed', contractStatus: 'signed',
depositStatus: 'received', depositStatus: 'received',