From 6009ccb7de3e2c9f5be3d1d0234ee25cba415a57 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Fri, 1 May 2026 15:27:53 +0200 Subject: [PATCH] feat(mobile): mobile card view for clients + interests lists Adds optional cardRender prop to 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: - rounded card with optional left status accent bar, whole-card link to detail page, top-right actions slot, and tactile hover/active states. - 40px brand-tinted circle (initials or domain icon). - 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) --- src/components/clients/client-card.tsx | 125 ++++++++++++++ src/components/clients/client-list.tsx | 9 + src/components/interests/interest-card.tsx | 188 +++++++++++++++++++++ src/components/interests/interest-list.tsx | 9 + src/components/shared/data-table.tsx | 30 +++- src/components/shared/list-card.tsx | 132 +++++++++++++++ 6 files changed, 492 insertions(+), 1 deletion(-) create mode 100644 src/components/clients/client-card.tsx create mode 100644 src/components/interests/interest-card.tsx create mode 100644 src/components/shared/list-card.tsx diff --git a/src/components/clients/client-card.tsx b/src/components/clients/client-card.tsx new file mode 100644 index 0000000..6a07717 --- /dev/null +++ b/src/components/clients/client-card.tsx @@ -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 = { + 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 ( + + + + + + onEdit(client)}> + + Edit + + onArchive(client)}> + + Archive + + + + } + > +
+ +
+
+

+ {client.fullName} +

+ +
+ + {primary ? ( +

{primary.value}

+ ) : null} + + {meta.length > 0 ? ( +
+ {meta.map((m, i) => ( + + {i > 0 ? · : null} + {m} + + ))} +
+ ) : null} + + {counts.length > 0 ? ( +

{counts.join(' · ')}

+ ) : null} + + {tags.length > 0 ? ( +
+ {tags.slice(0, 2).map((tag) => ( + + ))} + {tags.length > 2 ? ( + + +{tags.length - 2} + + ) : null} +
+ ) : null} +
+
+
+ ); +} diff --git a/src/components/clients/client-list.tsx b/src/components/clients/client-list.tsx index 38efdbc..3a084bb 100644 --- a/src/components/clients/client-list.tsx +++ b/src/components/clients/client-list.tsx @@ -16,6 +16,7 @@ import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog import { PermissionGate } from '@/components/shared/permission-gate'; import { ClientForm } from '@/components/clients/client-form'; import { clientFilterDefinitions } from '@/components/clients/client-filters'; +import { ClientCard } from '@/components/clients/client-card'; import { getClientColumns, type ClientRow } from '@/components/clients/client-columns'; import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; @@ -118,6 +119,14 @@ export function ClientList() { onSortChange={setSort} isLoading={isFetching && !isLoading} getRowId={(row) => row.id} + cardRender={(row) => ( + + )} emptyState={ = { + open: 'Open', + details_sent: 'Details Sent', + in_communication: 'In Communication', + visited: 'Visited', + signed_eoi_nda: 'Signed EOI/NDA', + deposit_10pct: 'Deposit 10%', + contract: 'Contract', + completed: 'Completed', +}; + +/** Pill colors (used for the stage badge in the meta row). */ +const STAGE_PILL: Record = { + open: 'bg-slate-100 text-slate-700', + details_sent: 'bg-blue-100 text-blue-700', + in_communication: 'bg-sky-100 text-sky-700', + visited: 'bg-violet-100 text-violet-700', + signed_eoi_nda: 'bg-amber-100 text-amber-700', + deposit_10pct: 'bg-orange-100 text-orange-700', + contract: 'bg-green-100 text-green-700', + completed: 'bg-emerald-100 text-emerald-700', +}; + +/** Accent-bar colors — saturate progressively so the pipeline depth reads at a glance. */ +const STAGE_ACCENT: Record = { + open: 'bg-slate-300', + details_sent: 'bg-blue-400', + in_communication: 'bg-sky-400', + visited: 'bg-violet-400', + signed_eoi_nda: 'bg-amber-400', + deposit_10pct: 'bg-orange-400', + contract: 'bg-green-500', + completed: 'bg-emerald-500', +}; + +const CATEGORY_LABELS: Record = { + general_interest: 'General', + specific_qualified: 'Qualified', + hot_lead: 'Hot lead', +}; + +const SOURCE_LABELS: Record = { + website: 'Website', + manual: 'Manual', + referral: 'Referral', + broker: 'Broker', +}; + +interface InterestCardProps { + interest: InterestRow; + portSlug: string; + onEdit: (interest: InterestRow) => void; + onArchive: (interest: InterestRow) => void; +} + +export function InterestCard({ interest, portSlug, onEdit, onArchive }: InterestCardProps) { + const stageLabel = STAGE_LABELS[interest.pipelineStage] ?? interest.pipelineStage; + const stagePill = STAGE_PILL[interest.pipelineStage] ?? 'bg-gray-100 text-gray-700'; + const accentClass = STAGE_ACCENT[interest.pipelineStage] ?? 'bg-slate-300'; + const isHotLead = interest.leadCategory === 'hot_lead'; + const categoryLabel = interest.leadCategory ? CATEGORY_LABELS[interest.leadCategory] : null; + const sourceLabel = interest.source ? (SOURCE_LABELS[interest.source] ?? interest.source) : null; + const tags = interest.tags ?? []; + + const clientName = interest.clientName ?? 'Unknown client'; + const berthLabel = interest.berthMooringNumber; + + return ( + + + + + + onEdit(interest)}> + + Edit + + onArchive(interest)}> + + Archive + + + + } + > +
+ +
+ {/* Title row: name + spacer for the absolutely-positioned actions menu */} +
+

+ {clientName} +

+ +
+ + {/* Berth or general-interest line */} +

+ {berthLabel ? ( + <> + + {berthLabel} + + ) : ( + <> + + General interest + + )} +

+ + {/* Stage pill + category + source */} +
+ + {stageLabel} + + + {isHotLead ? ( + + Hot + + ) : null} + + {(categoryLabel && !isHotLead) || sourceLabel ? ( +
+ {categoryLabel && !isHotLead ? {categoryLabel} : null} + {categoryLabel && !isHotLead && sourceLabel ? · : null} + {sourceLabel ? {sourceLabel} : null} +
+ ) : null} +
+ + {tags.length > 0 ? ( +
+ {tags.slice(0, 2).map((tag) => ( + + ))} + {tags.length > 2 ? ( + + +{tags.length - 2} + + ) : null} +
+ ) : null} +
+
+
+ ); +} diff --git a/src/components/interests/interest-list.tsx b/src/components/interests/interest-list.tsx index 582f7fb..f989439 100644 --- a/src/components/interests/interest-list.tsx +++ b/src/components/interests/interest-list.tsx @@ -18,6 +18,7 @@ import { InterestForm } from '@/components/interests/interest-form'; import { PipelineBoard } from '@/components/interests/pipeline-board'; import { interestFilterDefinitions } from '@/components/interests/interest-filters'; import { getInterestColumns, type InterestRow } from '@/components/interests/interest-columns'; +import { InterestCard } from '@/components/interests/interest-card'; import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { apiFetch } from '@/lib/api/client'; @@ -145,6 +146,14 @@ export function InterestList() { onSortChange={setSort} isLoading={isFetching && !isLoading} getRowId={(row) => row.id} + cardRender={(row) => ( + + )} emptyState={ { isLoading?: boolean; getRowId?: (row: TData) => string; onRowClick?: (row: TData) => void; + /** + * Mobile card renderer. When provided, the table is hidden below `lg:` + * and replaced with a vertical list of cards built from this callback. + * The same TanStack `table` instance powers both views, so pagination, + * sort, and selection stay in sync across the breakpoint. + */ + cardRender?: (row: Row) => React.ReactNode; } export function DataTable({ @@ -66,6 +74,7 @@ export function DataTable({ isLoading, getRowId, onRowClick, + cardRender, }: DataTableProps) { const [internalSelection, setInternalSelection] = useState({}); const rowSelectionState = externalSelection ?? internalSelection; @@ -142,9 +151,11 @@ export function DataTable({ ); } + const rows = table.getRowModel().rows; + return (
-
+
{table.getHeaderGroups().map((headerGroup) => ( @@ -219,6 +230,23 @@ export function DataTable({
+ {/* Mobile card list */} + {cardRender && ( +
    + {isLoading ? ( +
  • + +
  • + ) : rows.length === 0 ? ( +
  • + {emptyState ?? 'No results.'} +
  • + ) : ( + rows.map((row) =>
  • {cardRender(row)}
  • ) + )} +
+ )} + {/* Pagination */} {pagination && pagination.totalPages > 1 && (
diff --git a/src/components/shared/list-card.tsx b/src/components/shared/list-card.tsx new file mode 100644 index 0000000..797d098 --- /dev/null +++ b/src/components/shared/list-card.tsx @@ -0,0 +1,132 @@ +'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 `` 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 ( +
+ {accentClassName ? ( + + ) : null} + + {children} + + {actions ?
{actions}
: null} +
+ ); +} + +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 ( +
+ {icon ?? initials ?? '?'} +
+ ); +} + +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 `
` to build the meta line. + */ +export function ListCardMeta({ icon, children, className }: ListCardMetaProps) { + return ( + + {icon ? {icon} : null} + {children} + + ); +} + +/** + * Derive 1–2 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(); +}