feat(uat-batch): Group I — Residential parity (4 ships)

I34–I37 from the 2026-05-21 plan.

Shipped:
  I34  Residential client header layout parity. Email / Call /
       WhatsApp action buttons mirror the main ClientDetailHeader.
       WhatsApp number resolves from phoneE164 (preferred) or strips
       the free-text phone to digits. Header surfaces "Linked to
       main client" chip when the auto-link matcher (I37) finds a
       counterpart in the main CRM.
  I35  Residential interests list rebuilt for parity with the main
       InterestList. New ResidentialInterestCard +
       getResidentialInterestColumns + residentialInterestFilter-
       Definitions; the list page drives DataTable + FilterBar +
       ColumnPicker + SavedViewsDropdown + bulkActions. List
       endpoint validator widened to accept pipelineStage as a
       string OR string[] and added a source filter. Service post-
       fetches client names via a single IN-list lookup so the
       table renders fullName in column 1 without N+1.
       New /api/v1/residential/interests/bulk supports
       change_stage + archive (100-id cap). Kanban view deferred.
  I36  Residential inquiries auto-forward to partner email(s).
       New registry entry residential_partner_recipients (comma-
       separated) under section residential.partner.
       createResidentialInterest fires
       forwardResidentialInquiryToPartner after the row lands.
       Helper uses the same branded shell other transactional
       emails use. Failures log + never block create. The
       /admin/residential-stages page picks up a registry-driven
       card so admins manage recipients alongside stages.
  I37  Auto-link residential ↔ main client. Migration 0080 adds
       residential_clients.linked_client_id (nullable FK, SET NULL
       on cascade) + partial index. New findAndLinkMatchingMainClient
       service matches by email first (case-insensitive client_contacts
       lookup) then by E.164 phone. First exact match wins. Fires
       fire-and-forget from createResidentialClient. Header surfaces
       the link via a "Linked to main client" chip. Backfill script
       + reverse-direction link from main ClientDetailHeader stay
       as follow-ups.

Verified: tsc clean, vitest 1454/1454, migration applied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 22:57:19 +02:00
parent 94c24a123a
commit 989cc4d72b
13 changed files with 1083 additions and 169 deletions

View File

@@ -0,0 +1,53 @@
'use client';
import Link from 'next/link';
import { Badge } from '@/components/ui/badge';
import type { ResidentialInterestRow } from './residential-interest-columns';
import { RESIDENTIAL_STAGE_LABELS } from './residential-interest-filters';
/**
* Mobile / grid card for the residential interests list. Mirrors the
* footprint of <InterestCard> on the main list — same touch target
* conventions (entire card is clickable, generous padding, truncated
* meta below the title).
*/
export function ResidentialInterestCard({
interest,
portSlug,
}: {
interest: ResidentialInterestRow;
portSlug: string;
}) {
return (
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={`/${portSlug}/residential/interests/${interest.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">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{interest.clientName ?? '—'}</p>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<Badge variant="secondary" className="text-[10px]">
{RESIDENTIAL_STAGE_LABELS[interest.pipelineStage] ?? interest.pipelineStage}
</Badge>
{interest.source ? (
<span className="text-[11px] capitalize text-muted-foreground">
{interest.source}
</span>
) : null}
</div>
</div>
<span className="shrink-0 text-[11px] text-muted-foreground">
{new Date(interest.updatedAt).toLocaleDateString()}
</span>
</div>
{interest.preferences ? (
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{interest.preferences}</p>
) : null}
{interest.notes ? (
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground/80">{interest.notes}</p>
) : null}
</Link>
);
}