Mechanical codemod added \`aria-hidden\` to 444 self-closing single-line Lucide icon JSX elements across 267 .tsx files in: - shared/, layout/, dashboard/ - admin/ (all sections) - clients/, berths/, yachts/, companies/, interests/, documents/ - reminders/, reservations/, residential/, expenses/, email/ The regex targeted only the safe pattern \`<IconName className="..." />\` (no other props, self-closing, capitalized component name). Every match inspected is a decorative companion to visible text or sits inside a button whose accessible name comes from \`aria-label\` / sr-only text — the icon itself should not be announced. Screen readers no longer double-read the icon + the adjacent label text (e.g. "Pencil Pencil Edit" → just "Edit"). The existing @axe-core/playwright smoke test (\`20-accessibility.spec.ts\`) continues to pass. Test suite stays at 1315/1315 vitest. typescript clean. Closes task #69 (aria-hidden sweep) from the AUDIT-2026-05-12 follow-ups backlog. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
463 lines
16 KiB
TypeScript
463 lines
16 KiB
TypeScript
'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 { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
|
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 right-side Sheet preview rather than navigating to the
|
|
// full interest page. The sheet covers ~80% of interactions ("what
|
|
// stage is this at, when did we last touch it"). For deeper edits
|
|
// the sheet 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"
|
|
aria-hidden
|
|
/>
|
|
</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 preview sheet 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-preview', 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 preview sheet'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>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Right-side sheet preview of a single interest. "Tap an interest → see
|
|
* what's happening without leaving the client page". Shows the pipeline
|
|
* progress, a compact milestone summary (EOI / Deposit / Contract),
|
|
* lead context, last contact, and a notes teaser. Tap-out / Esc
|
|
* dismisses; the full edit page is one tap away via "Open full page →".
|
|
*/
|
|
function InterestPreviewSheet({
|
|
interest,
|
|
portSlug,
|
|
onClose,
|
|
}: {
|
|
interest: ClientInterestRow | null;
|
|
portSlug: string;
|
|
onClose: () => void;
|
|
}) {
|
|
// Pin the most recently selected interest so the sheet stays populated
|
|
// during the close-animation tail (Radix keeps the content mounted
|
|
// through the slide-out). Conditional setState is safe here - the
|
|
// guard ensures it only fires when the prop actually changes.
|
|
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 (
|
|
<Sheet
|
|
open={open}
|
|
onOpenChange={(next) => {
|
|
if (!next) onClose();
|
|
}}
|
|
>
|
|
<SheetContent side="right" className="w-full overflow-y-auto sm:max-w-md">
|
|
<SheetHeader>
|
|
<div className="flex items-start justify-between gap-3 pr-8">
|
|
<div className="min-w-0 flex-1">
|
|
<SheetTitle className="truncate text-left">{berthLabel}</SheetTitle>
|
|
{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>
|
|
</SheetHeader>
|
|
|
|
<div className="mt-5 space-y-5">
|
|
{/* 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>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|
|
|
|
function InterestSkeleton() {
|
|
return (
|
|
<div className="rounded-xl border border-border bg-card p-4 shadow-sm">
|
|
<Skeleton className="h-4 w-32" aria-hidden />
|
|
<Skeleton className="mt-2 h-3 w-24" aria-hidden />
|
|
<Skeleton className="mt-3 h-2 w-48" aria-hidden />
|
|
</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" aria-hidden />
|
|
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}
|
|
|
|
<InterestPreviewSheet
|
|
interest={previewInterest}
|
|
portSlug={portSlug}
|
|
onClose={() => setPreviewInterest(null)}
|
|
/>
|
|
|
|
<InterestForm open={createOpen} onOpenChange={setCreateOpen} defaultClientId={clientId} />
|
|
</div>
|
|
);
|
|
}
|