Files
pn-new-crm/src/components/shared/list-card.tsx
Matt Ciaccio 6009ccb7de feat(mobile): mobile card view for clients + interests lists
Adds optional cardRender prop to <DataTable> that switches the layout
to a vertical card list below lg: while keeping the same TanStack
table instance powering both views (pagination, sort, selection).

New shared shell:
  - <ListCard>          rounded card with optional left status accent bar,
                        whole-card link to detail page, top-right actions
                        slot, and tactile hover/active states.
  - <ListCardAvatar>    40px brand-tinted circle (initials or domain icon).
  - <ListCardMeta>      inline icon + muted text segment.
  - deriveInitials()    shared helper that ignores numeric tokens (so
                        "Recovery Test 1777" -> "RT", not "R1").

Clients and interests pages now render mobile cards via cardRender
using this shell; desktop view (lg+) is unchanged. Interests cards
encode pipeline stage as a left-edge accent strip whose saturation
deepens with pipeline progression (open -> completed). Berths display
with an Anchor icon; null-berth interests fall back to a Compass +
"General interest" italic label. Hot leads get a discreet "Hot" pill.

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

133 lines
4.2 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 ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface ListCardProps {
/** Detail-page URL the card navigates to when tapped. */
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) {
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}
<Link
href={href}
aria-label={ariaLabel}
className={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',
)}
>
{children}
</Link>
{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();
}