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>
This commit is contained in:
125
src/components/clients/client-card.tsx
Normal file
125
src/components/clients/client-card.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { Archive, MoreHorizontal, Pencil } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { TagBadge } from '@/components/shared/tag-badge';
|
||||
import {
|
||||
ListCard,
|
||||
ListCardAvatar,
|
||||
ListCardMeta,
|
||||
deriveInitials,
|
||||
} from '@/components/shared/list-card';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
import type { ClientRow } from './client-columns';
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
website: 'Website',
|
||||
manual: 'Manual',
|
||||
referral: 'Referral',
|
||||
broker: 'Broker',
|
||||
};
|
||||
|
||||
interface ClientCardProps {
|
||||
client: ClientRow;
|
||||
portSlug: string;
|
||||
onEdit: (client: ClientRow) => void;
|
||||
onArchive: (client: ClientRow) => void;
|
||||
}
|
||||
|
||||
export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardProps) {
|
||||
const primary = client.contacts?.find((c) => c.isPrimary);
|
||||
const nationality = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
|
||||
const sourceLabel = client.source ? (SOURCE_LABELS[client.source] ?? client.source) : null;
|
||||
const yachtCount = client.yachtCount ?? 0;
|
||||
const companyCount = client.companyCount ?? 0;
|
||||
const tags = client.tags ?? [];
|
||||
|
||||
const meta = [nationality, sourceLabel].filter(Boolean) as string[];
|
||||
const counts: string[] = [];
|
||||
if (yachtCount > 0) counts.push(`${yachtCount} ${yachtCount === 1 ? 'yacht' : 'yachts'}`);
|
||||
if (companyCount > 0)
|
||||
counts.push(`${companyCount} ${companyCount === 1 ? 'company' : 'companies'}`);
|
||||
|
||||
return (
|
||||
<ListCard
|
||||
href={`/${portSlug}/clients/${client.id}`}
|
||||
ariaLabel={`Client ${client.fullName}`}
|
||||
actions={
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Actions for ${client.fullName}`}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(client)}>
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(client)}>
|
||||
<Archive className="mr-2 h-3.5 w-3.5" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<ListCardAvatar initials={deriveInitials(client.fullName)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
|
||||
{client.fullName}
|
||||
</h3>
|
||||
<span aria-hidden className="block h-9 w-9 shrink-0" />
|
||||
</div>
|
||||
|
||||
{primary ? (
|
||||
<p className="truncate text-sm text-muted-foreground">{primary.value}</p>
|
||||
) : null}
|
||||
|
||||
{meta.length > 0 ? (
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
|
||||
{meta.map((m, i) => (
|
||||
<span key={m} className="inline-flex items-center gap-1">
|
||||
{i > 0 ? <span aria-hidden>·</span> : null}
|
||||
<ListCardMeta>{m}</ListCardMeta>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{counts.length > 0 ? (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{counts.join(' · ')}</p>
|
||||
) : null}
|
||||
|
||||
{tags.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{tags.slice(0, 2).map((tag) => (
|
||||
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||
))}
|
||||
{tags.length > 2 ? (
|
||||
<span className="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
|
||||
+{tags.length - 2}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</ListCard>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user