Files
pn-new-crm/src/components/shared/list-card.tsx
Matt Ciaccio 5fc68a5f34 fix(audit): frontend HIGHs — surface fetch errors, kill href=#, invalidate queries, toast over alert
R2-H10: webhook-delivery-log and audit-log-list both swallowed fetch
errors silently — failed loads showed spinner forever or stale data.
Both now set a loadError state, show an inline retry banner, and fire
a toast.error. Same applies to audit-log loadMore.

R2-H11: audit-log-card rendered as `<a href="#">` — tapping on mobile
inserted `#` in the URL and scrolled to top (back-button trap).
ListCard now treats `href` as optional and renders a non-link `<div>`
when omitted; audit-log-card no longer passes href.

R2-H12: smart-archive-dialog only invalidated ['clients'] / ['berths']
/ ['interests']. Detail header kept showing Archived=false until hard
reload. Now also invalidates ['clients', clientId] and removes the
['client-archive-dossier', clientId] cache so re-open re-fetches.

R2-H13: client-list bulk mutation used native alert() on partial
failure (blocking the page) and had no onError handler. Replaced with
toast.warning / toast.success / toast.error.

1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:18:14 +02:00

139 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import Link from 'next/link';
import type { Route } from 'next';
import { type ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface ListCardProps {
/** Detail-page URL the card navigates to when tapped. Omit to render a
* non-navigating card (audit log entries, read-only rows). */
href?: string;
/**
* Optional Tailwind background class painted on a 3px vertical strip on the
* left edge - used to encode pipeline stage / status / category at a glance.
* Pass `undefined` for entities with no status to surface (clients, etc.).
*/
accentClassName?: string;
/**
* Top-right action slot - typically a `<DropdownMenu>` for edit/archive.
* Rendered absolutely-positioned outside the navigation Link so its clicks
* don't trigger detail navigation.
*/
actions?: ReactNode;
ariaLabel: string;
className?: string;
children: ReactNode;
}
/**
* Shared shell for every mobile list card. Wraps the body in a Link to the
* detail page, paints an optional status accent bar on the left edge, and
* exposes a top-right slot for an actions menu. Touch/hover feedback comes
* from a soft `hover:bg-muted/30` + `active:bg-muted/50` tint, no shadow
* shifts (which feel jittery on mobile).
*/
export function ListCard({
href,
accentClassName,
actions,
ariaLabel,
className,
children,
}: ListCardProps) {
const innerClassName = cn(
'block p-3',
accentClassName && 'pl-4',
'rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
);
return (
<article
className={cn(
'group relative overflow-hidden rounded-lg border bg-card shadow-xs',
'transition-colors hover:bg-muted/30 active:bg-muted/50',
className,
)}
>
{accentClassName ? (
<span aria-hidden className={cn('absolute inset-y-0 left-0 w-1', accentClassName)} />
) : null}
{href ? (
<Link href={href as Route} aria-label={ariaLabel} className={innerClassName}>
{children}
</Link>
) : (
<div aria-label={ariaLabel} className={innerClassName}>
{children}
</div>
)}
{actions ? <div className="absolute right-1.5 top-1.5">{actions}</div> : null}
</article>
);
}
interface ListCardAvatarProps {
/** Two-letter initials (or one for single-word names). Caller derives. */
initials?: string;
/** Domain icon (Lucide). Used when the entity isn't a person - yacht, berth, company. */
icon?: ReactNode;
className?: string;
}
/**
* 40px lead-slot avatar. Pass `initials` for people-shaped entities, or
* `icon` for non-person entities (yachts, berths, companies, expenses).
* Uses the brand-soft background so it reads as part of the marina aesthetic
* rather than a generic Material avatar.
*/
export function ListCardAvatar({ initials, icon, className }: ListCardAvatarProps) {
return (
<div
className={cn(
'flex h-10 w-10 shrink-0 items-center justify-center rounded-full',
'bg-brand-50 text-sm font-semibold tracking-tight text-brand',
className,
)}
>
{icon ?? initials ?? '?'}
</div>
);
}
interface ListCardMetaProps {
/** Optional Lucide icon, rendered at 12px next to the text. */
icon?: ReactNode;
children: ReactNode;
className?: string;
}
/**
* Single inline meta segment: tiny icon (optional) + muted text. Compose
* multiple segments inside a `<div className="flex flex-wrap items-center
* gap-x-2 gap-y-0.5 text-xs text-muted-foreground">` to build the meta line.
*/
export function ListCardMeta({ icon, children, className }: ListCardMetaProps) {
return (
<span className={cn('inline-flex items-center gap-1', className)}>
{icon ? <span className="shrink-0 text-muted-foreground/80">{icon}</span> : null}
<span className="truncate">{children}</span>
</span>
);
}
/**
* Derive 12 letter initials from a name, ignoring purely-numeric tokens
* (so "Recovery Test 1777" → "RT", not "R1"). Returns "?" only for empty
* input. Centralised here so every list card uses the same logic.
*/
export function deriveInitials(name: string): string {
const alphaParts = name
.trim()
.split(/\s+/)
.filter((p) => /^[A-Za-z]/.test(p));
if (alphaParts.length === 0) return name.trim().slice(0, 1).toUpperCase() || '?';
if (alphaParts.length === 1) return (alphaParts[0]?.[0] ?? '?').toUpperCase();
return ((alphaParts[0]?.[0] ?? '') + (alphaParts[1]?.[0] ?? '')).toUpperCase();
}