Files
pn-new-crm/src/components/interests/interest-detail-header.tsx
Matt Ciaccio 76a7387dcc fix(ux): batch UX audit fixes across spine pages
Comprehensive audit findings rolled up into one pass.

Bugs:

  - dialog.tsx — sm-breakpoint centering classes (sm:left-[50%] /
    sm:top-[50%]) were being silently stripped by tailwind-merge because
    the base inset-0 + sm:inset-auto pair counted as a conflict. Replaced
    with explicit per-side utilities (top-0 right-0 bottom-0 left-0 +
    sm:right-auto sm:bottom-auto). Every Dialog instance now centers
    correctly on desktop. (Affected 16 dialog consumers.)

  - interest-documents-tab.tsx — useQuery shared the queryKey
    ['interests', interestId] with the parent InterestDetail's query but
    returned a different shape ({ data: ... } envelope vs unwrapped).
    They clobbered each other's cache on tab mount, degenerating the
    parent header to "Unknown Client" / "Open" briefly. Unified the
    queryFn shape so the cache stays consistent.

  - interest-tabs.tsx — milestone steps now derive done-state from
    PIPELINE_STAGES.indexOf(currentStage) >= step.advanceStage_idx as
    well as from the date stamp. Stage truth > date truth. Seeded /
    imported interests that arrived past `open` without per-step dates
    now correctly show their milestone steps as checked.

  - interest-detail.tsx — wires useMobileChrome so the mobile topbar
    shows the client name instead of the interest UUID.

  - interest-documents-tab.tsx — empty state restructured to a centered
    "No documents yet — Generate EOI" CTA card instead of a small
    primary button floating in the corner.

  - timeline/route.ts — synthesizes a "Created at <stage>" event when
    no audit-log rows exist for the interest, so the Activity tab
    isn't empty for seeded interests.

  - lead-source-chart.tsx — pie radii switched from fixed 90px/50px
    to "70%"/"40%" so the pie scales with the container instead of
    being clipped at narrow widths; reserved 40px for the legend.

Visual / clarity:

  - interest-detail-header.tsx — Won/Lost rendered as branded text
    buttons on desktop ("Mark won", "Close as lost") and icon-only on
    mobile via `hidden sm:inline`. Edit/Archive stay icon-only. Reopen
    promoted to a labeled button when the interest is closed. Added
    "Last contact Xd ago" to the meta row.

  - detail-header-strip.tsx — py-4 → py-3 (tighter strip).

  - interest-tabs.tsx — milestone cards: the next pending milestone
    gets a brand-blue ring + "NEXT" pill so the user can see at a
    glance which lifecycle to act on. Its primary action gets the
    filled button variant.

  - interest-tabs.tsx — Deposit milestone: invoice flow promoted to
    primary CTA ("Create deposit invoice"), manual stage advance
    demoted to a small text link ("Mark received manually"). Reflects
    the actual recommended path now that recordPayment auto-advances
    on payment.

  - inline-editable-field.tsx — pencil affordance shown faintly
    (opacity-20) at rest so users discover that fields are editable
    without having to hover-test every label. Lifts to opacity-60 on
    hover.

  - constants.ts — STAGE_SHORT_LABELS map for cramped contexts;
    pipeline-chart.tsx + pipeline-funnel-chart.tsx use them on mobile
    via useIsMobile, so the rotated 9-stage axis isn't a wall of
    overlap on a 393px screen.

  - client-pipeline-summary.tsx — StageStepper rebuilt as a single
    segmented progress bar instead of 9 micro-dots + connectors that
    rendered inconsistently at tight widths. Each stage is an equal
    slice that lights up as the interest reaches it; tooltips on hover
    give the full stage name. Also dropped a pre-existing dead `br`
    variable.

  - dashboard empty states — Lead Source, Revenue Breakdown, Pipeline
    Funnel, and Recent Activity now have helpful descriptions explaining
    what populates them, instead of bare "No interests in range".

  - use-paginated-query.ts — reuses `&` when the endpoint already has
    `?`, so callers like the documents hub don't generate
    `…?tab=eoi_queue&signatureOnly=true?page=1&limit=25` (which the API
    rejected as 400). Caught while testing the now-removed EOI route
    but applies broadly.

tsc clean. vitest 832/832 pass. eslint 0 errors (down from 1
pre-existing) on every file touched.

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

321 lines
12 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Pencil, Archive, RotateCcw, Trophy, XCircle, RefreshCcw } from 'lucide-react';
import Link from 'next/link';
import { Badge } from '@/components/ui/badge';
import { TagBadge } from '@/components/shared/tag-badge';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { PermissionGate } from '@/components/shared/permission-gate';
import { InterestForm } from '@/components/interests/interest-form';
import { InlineStagePicker } from '@/components/interests/inline-stage-picker';
import { InterestOutcomeDialog } from '@/components/interests/interest-outcome-dialog';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
const OUTCOME_BADGE: Record<string, { label: string; className: string }> = {
won: { label: 'Won', className: 'bg-emerald-100 text-emerald-700' },
lost_other_marina: { label: 'Lost — other marina', className: 'bg-rose-100 text-rose-700' },
lost_unqualified: { label: 'Lost — unqualified', className: 'bg-rose-100 text-rose-700' },
lost_no_response: { label: 'Lost — no response', className: 'bg-rose-100 text-rose-700' },
cancelled: { label: 'Cancelled', className: 'bg-slate-200 text-slate-700' },
};
const CATEGORY_LABELS: Record<string, string> = {
general_interest: 'General',
specific_qualified: 'Specific Qualified',
hot_lead: 'Hot Lead',
};
interface InterestDetailHeaderProps {
portSlug: string;
interest: {
id: string;
clientId: string;
clientName: string | null;
berthId: string | null;
berthMooringNumber: string | null;
pipelineStage: string;
leadCategory: string | null;
source: string | null;
notes: string | null;
reminderEnabled: boolean;
reminderDays: number | null;
archivedAt: string | null;
outcome?: string | null;
outcomeReason?: string | null;
dateLastContact?: string | null;
tags?: Array<{ id: string; name: string; color: string }>;
};
}
function formatLastContactAge(iso: string): string {
const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
if (days <= 0) return 'today';
if (days === 1) return 'yesterday';
if (days < 30) return `${days}d ago`;
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
return `${Math.floor(days / 365)}y ago`;
}
export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeaderProps) {
const queryClient = useQueryClient();
const [editOpen, setEditOpen] = useState(false);
const [archiveOpen, setArchiveOpen] = useState(false);
const [outcomeDialog, setOutcomeDialog] = useState<null | 'won' | 'lost'>(null);
const isArchived = !!interest.archivedAt;
const outcomeBadge = interest.outcome ? OUTCOME_BADGE[interest.outcome] : null;
const isClosed = !!interest.outcome;
const reopenMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
},
});
const archiveMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/interests/${interest.id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
setArchiveOpen(false);
},
});
const restoreMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/interests/${interest.id}/restore`, { method: 'POST' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
setArchiveOpen(false);
},
});
const meta: Array<{ key: string; node: React.ReactNode }> = [];
if (interest.berthMooringNumber) {
meta.push({
key: 'berth',
node: (
<Link
href={`/${portSlug}/berths/${interest.berthId}`}
className="text-foreground hover:underline"
>
Berth {interest.berthMooringNumber}
</Link>
),
});
}
if (interest.leadCategory) {
meta.push({
key: 'cat',
node: <span>{CATEGORY_LABELS[interest.leadCategory] ?? interest.leadCategory}</span>,
});
}
if (interest.source) {
meta.push({
key: 'src',
node: <span className="capitalize">{interest.source}</span>,
});
}
if (interest.dateLastContact) {
meta.push({
key: 'last',
node: (
<span className="text-foreground/70">
Last contact {formatLastContactAge(interest.dateLastContact)}
</span>
),
});
}
return (
<>
<DetailHeaderStrip>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="truncate text-lg font-bold text-foreground sm:text-xl">
{interest.clientName ?? 'Unknown Client'}
</h1>
{isArchived && (
<Badge variant="secondary" className="text-xs">
Archived
</Badge>
)}
{outcomeBadge ? (
<span
className={cn(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
outcomeBadge.className,
)}
title={interest.outcomeReason ?? undefined}
>
{outcomeBadge.label}
</span>
) : (
<PermissionGate
resource="interests"
action="change_stage"
fallback={
<span className="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-700">
{interest.pipelineStage}
</span>
}
>
<InlineStagePicker
interestId={interest.id}
currentStage={interest.pipelineStage}
className="-ml-2.5"
/>
</PermissionGate>
)}
</div>
{meta.length > 0 ? (
<p className="text-xs text-muted-foreground sm:text-sm">
{meta.map((m, i) => (
<span key={m.key}>
{i > 0 ? (
<span aria-hidden className="mx-1.5">
·
</span>
) : null}
{m.node}
</span>
))}
</p>
) : null}
{interest.tags && interest.tags.length > 0 && (
<div className="flex flex-wrap gap-1 pt-0.5">
{interest.tags.map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
))}
</div>
)}
</div>
{/* Top-right actions. Won/Lost are sales-critical and read as text
buttons on desktop; Edit/Archive stay icon-only. On mobile,
Won/Lost shrink to icon buttons to keep the cluster from
wrapping. */}
<div className="flex shrink-0 items-center gap-1">
<PermissionGate resource="interests" action="change_stage">
{isClosed ? (
<button
type="button"
onClick={() => reopenMutation.mutate()}
disabled={reopenMutation.isPending}
className={cn(
'inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs font-medium text-foreground transition-colors',
'hover:bg-foreground/5 disabled:opacity-50',
)}
>
<RefreshCcw className="size-3.5" />
Reopen
</button>
) : (
<>
<button
type="button"
onClick={() => setOutcomeDialog('won')}
aria-label="Mark as won"
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
'border border-emerald-200 bg-emerald-50 text-emerald-700',
'hover:bg-emerald-100',
)}
>
<Trophy className="size-3.5" />
<span className="hidden sm:inline">Mark won</span>
</button>
<button
type="button"
onClick={() => setOutcomeDialog('lost')}
aria-label="Close as lost"
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
'border border-rose-200 text-rose-700',
'hover:bg-rose-50',
)}
>
<XCircle className="size-3.5" />
<span className="hidden sm:inline">Close as lost</span>
</button>
</>
)}
</PermissionGate>
<PermissionGate resource="interests" action="edit">
<button
type="button"
onClick={() => setEditOpen(true)}
aria-label="Edit interest"
title="Edit interest"
className={cn(
'rounded-md p-1.5 text-muted-foreground/70 transition-colors',
'hover:bg-foreground/5 hover:text-foreground',
)}
>
<Pencil className="size-4" />
</button>
</PermissionGate>
<PermissionGate resource="interests" action="delete">
<button
type="button"
onClick={() => setArchiveOpen(true)}
aria-label={isArchived ? 'Restore interest' : 'Archive interest'}
title={isArchived ? 'Restore interest' : 'Archive interest'}
className={cn(
'rounded-md p-1.5 text-muted-foreground/70 transition-colors',
'hover:bg-foreground/5',
isArchived ? 'hover:text-foreground' : 'hover:text-destructive',
)}
>
{isArchived ? <RotateCcw className="size-4" /> : <Archive className="size-4" />}
</button>
</PermissionGate>
</div>
</div>
</DetailHeaderStrip>
{outcomeDialog && (
<InterestOutcomeDialog
interestId={interest.id}
mode={outcomeDialog}
open={outcomeDialog !== null}
onOpenChange={(open) => !open && setOutcomeDialog(null)}
/>
)}
<InterestForm
open={editOpen}
onOpenChange={setEditOpen}
interest={interest as unknown as Parameters<typeof InterestForm>[0]['interest']}
/>
<ArchiveConfirmDialog
open={archiveOpen}
onOpenChange={setArchiveOpen}
entityName={interest.clientName ?? 'Interest'}
entityType="Interest"
isArchived={isArchived}
onConfirm={() => {
if (isArchived) {
restoreMutation.mutate();
} else {
archiveMutation.mutate();
}
}}
isLoading={archiveMutation.isPending || restoreMutation.isPending}
/>
</>
);
}