fix(residential): mobile card list alongside the desktop table
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m12s
Build & Push Docker Images / build-and-push (push) Failing after 5m42s

Both the residential-clients and residential-interests pages rendered
plain HTML <table>s with 5–6 columns directly. At 390px viewport the
header columns clipped at the right edge — "Sour..." for the clients
page, no header for the interests page either.

Adds a parallel mobile card list:
  - <table> stays inside `hidden lg:block` (unchanged at lg+)
  - new card list inside `lg:hidden` mirrors the row data:
    - Clients: name + status pill on top, then email · phone ·
      residence · source as a wrap-friendly meta row.
    - Interests: stage label as headline, updated-at on the right,
      preferences (line-clamp-2) and notes (line-clamp-1) below,
      source small at the bottom.
  - Each card is a Link to the detail page (matching the row click
    target on desktop).
  - Empty + loading states render as a centered card on mobile.

This is the same `hidden lg:block` / `lg:hidden` pattern used for the
main /clients and /interests pages. Doesn't refactor to the full
DataView primitive (would mean rebuilding the residential data layer
on TanStack Table) — keeps the change tightly scoped to the visible
output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-03 17:24:58 +02:00
parent 8e4d2fc5b4
commit 9ad1df85d2
2 changed files with 92 additions and 2 deletions

View File

@@ -18,6 +18,7 @@ import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input'; import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
import type { CountryCode } from '@/lib/i18n/countries'; import type { CountryCode } from '@/lib/i18n/countries';
interface ResidentialClientRow { interface ResidentialClientRow {
@@ -85,7 +86,9 @@ export function ResidentialClientsList() {
/> />
</div> </div>
<div className="rounded-lg border bg-card overflow-hidden"> {/* Desktop: table layout. Hidden below lg because the 6 columns clip
off the viewport at phone widths. */}
<div className="hidden lg:block rounded-lg border bg-card overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-muted/40 text-xs text-muted-foreground"> <thead className="bg-muted/40 text-xs text-muted-foreground">
<tr> <tr>
@@ -137,6 +140,51 @@ export function ResidentialClientsList() {
</table> </table>
</div> </div>
{/* Mobile: card list. Each card mirrors the table row data with
name + status pill on top, then meta line(s) below. */}
<div className="lg:hidden space-y-2">
{isLoading && (
<div className="rounded-lg border bg-card px-3 py-8 text-center text-sm text-muted-foreground">
Loading
</div>
)}
{!isLoading && data?.data.length === 0 && (
<div className="rounded-lg border bg-card px-3 py-8 text-center text-sm text-muted-foreground">
No residential clients yet.
</div>
)}
{data?.data.map((c) => (
<Link
key={c.id}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/residential/clients/${c.id}` as any}
className="block rounded-lg border bg-card p-3 transition-colors hover:bg-muted/30"
>
<div className="flex items-start justify-between gap-2">
<p className="font-medium text-sm truncate">{c.fullName}</p>
<span
className={cn(
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide',
c.status === 'active'
? 'bg-emerald-100 text-emerald-800'
: c.status === 'inactive'
? 'bg-muted text-muted-foreground'
: 'bg-blue-100 text-blue-800',
)}
>
{STATUS_LABELS[c.status] ?? c.status}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
{c.email ? <span className="truncate">{c.email}</span> : null}
{c.phone ? <span>{c.phone}</span> : null}
{c.placeOfResidence ? <span>{c.placeOfResidence}</span> : null}
{c.source ? <span className="capitalize">· {c.source}</span> : null}
</div>
</Link>
))}
</div>
<NewResidentialClientSheet open={createOpen} onOpenChange={setCreateOpen} /> <NewResidentialClientSheet open={createOpen} onOpenChange={setCreateOpen} />
</div> </div>
); );

View File

@@ -94,7 +94,8 @@ export function ResidentialInterestsList() {
</Select> </Select>
</div> </div>
<div className="rounded-lg border bg-card overflow-hidden"> {/* Desktop: table layout. Hidden below lg; mobile renders cards. */}
<div className="hidden lg:block rounded-lg border bg-card overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-muted/40 text-xs text-muted-foreground"> <thead className="bg-muted/40 text-xs text-muted-foreground">
<tr> <tr>
@@ -149,6 +150,47 @@ export function ResidentialInterestsList() {
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Mobile: card list. Stage as the headline (it's the most actionable
field for triage), preferences/notes truncated below. */}
<div className="lg:hidden space-y-2">
{isLoading && (
<div className="rounded-lg border bg-card px-3 py-8 text-center text-sm text-muted-foreground">
Loading
</div>
)}
{!isLoading && data?.data.length === 0 && (
<div className="rounded-lg border bg-card px-3 py-8 text-center text-sm text-muted-foreground">
No interests match.
</div>
)}
{data?.data.map((i) => (
<Link
key={i.id}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/residential/interests/${i.id}` as any}
className="block rounded-lg border bg-card p-3 transition-colors hover:bg-muted/30"
>
<div className="flex items-start justify-between gap-2">
<p className="font-medium text-sm">
{STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage}
</p>
<span className="shrink-0 text-[11px] text-muted-foreground">
{new Date(i.updatedAt).toLocaleDateString()}
</span>
</div>
{i.preferences ? (
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{i.preferences}</p>
) : null}
{i.notes ? (
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground/80">{i.notes}</p>
) : null}
{i.source ? (
<p className="mt-1 text-[11px] capitalize text-muted-foreground">{i.source}</p>
) : null}
</Link>
))}
</div>
</div> </div>
); );
} }