Files
pn-new-crm/src/components/shared/inline-editable-field.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

275 lines
6.9 KiB
TypeScript

'use client';
import { useEffect, useRef, useState } from 'react';
import { Loader2, Pencil } from 'lucide-react';
import { toast } from 'sonner';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
interface SelectOption {
value: string;
label: string;
}
interface BaseProps {
value: string | null | undefined;
onSave: (next: string | null) => Promise<void>;
placeholder?: string;
emptyText?: string;
className?: string;
disabled?: boolean;
}
interface TextProps extends BaseProps {
variant?: 'text';
}
interface SelectFieldProps extends BaseProps {
variant: 'select';
options: SelectOption[];
}
interface TextareaProps extends BaseProps {
variant: 'textarea';
rows?: number;
}
export type InlineEditableFieldProps = TextProps | SelectFieldProps | TextareaProps;
/**
* Click-to-edit field used in detail panels. Shows the value as plain text
* with a pencil affordance on hover; clicking swaps to an input that saves on
* Enter/blur and cancels on Escape.
*/
export function InlineEditableField(props: InlineEditableFieldProps) {
const { value, onSave, placeholder, emptyText = '—', className, disabled } = props;
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value ?? '');
const [saving, setSaving] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
setDraft(value ?? '');
}, [value]);
useEffect(() => {
if (editing) {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
} else if (textareaRef.current) {
textareaRef.current.focus();
textareaRef.current.select();
}
}
}, [editing]);
async function commit(nextRaw: string) {
const trimmed = nextRaw.trim();
if (trimmed === (value ?? '')) {
setEditing(false);
return;
}
setSaving(true);
try {
await onSave(trimmed === '' ? null : trimmed);
setEditing(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to save');
setDraft(value ?? '');
} finally {
setSaving(false);
}
}
function cancel() {
setDraft(value ?? '');
setEditing(false);
}
if (props.variant === 'select') {
const labelFor = (v: string | null | undefined) =>
v ? (props.options.find((o) => o.value === v)?.label ?? v) : null;
if (!editing) {
return (
<ReadButton
value={labelFor(value)}
emptyText={emptyText}
disabled={disabled}
onClick={() => setEditing(true)}
className={className}
/>
);
}
return (
<div className={cn('flex items-center gap-1', className)}>
<Select
value={draft}
onValueChange={(v) => void commit(v)}
open
onOpenChange={(open) => {
if (!open && !saving) setEditing(false);
}}
>
<SelectTrigger className="h-7 text-sm w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{props.options.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
</div>
);
}
if (props.variant === 'textarea') {
if (!editing) {
return (
<ReadButton
value={value || null}
emptyText={emptyText}
disabled={disabled}
onClick={() => setEditing(true)}
multiline
className={className}
/>
);
}
return (
<div className={cn('flex flex-col gap-1', className)}>
<Textarea
ref={textareaRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
cancel();
}
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
void commit(draft);
}
}}
onBlur={() => {
if (!saving) void commit(draft);
}}
placeholder={placeholder}
disabled={saving}
rows={props.rows ?? 4}
className="text-sm"
/>
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
</div>
);
}
if (!editing) {
return (
<ReadButton
value={value || null}
emptyText={emptyText}
disabled={disabled}
onClick={() => setEditing(true)}
className={className}
/>
);
}
return (
<div className={cn('flex items-center gap-1', className)}>
<Input
ref={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void commit(draft);
}
if (e.key === 'Escape') {
e.preventDefault();
cancel();
}
}}
onBlur={() => {
if (!saving) void commit(draft);
}}
placeholder={placeholder}
disabled={saving}
className="h-7 text-sm"
/>
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
</div>
);
}
function ReadButton({
value,
emptyText,
disabled,
onClick,
multiline,
className,
}: {
value: string | null;
emptyText: string;
disabled?: boolean;
onClick: () => void;
multiline?: boolean;
className?: string;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(
'group rounded px-1 -mx-1 py-0.5 text-left text-sm',
multiline ? 'flex w-full items-start gap-1.5' : 'inline-flex items-center gap-1.5',
'hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
disabled && 'cursor-not-allowed opacity-60 hover:bg-transparent',
className,
)}
>
<span
className={cn(
'flex-1',
multiline && 'whitespace-pre-wrap',
!value && 'text-muted-foreground',
)}
>
{value ?? emptyText}
</span>
{!disabled && (
<Pencil
className={cn(
// Show the pencil faintly at rest so users discover the field is
// editable without having to hover-and-test every label.
'h-3 w-3 opacity-20 transition-opacity group-hover:opacity-60',
multiline && 'mt-1 shrink-0',
)}
/>
)}
</button>
);
}