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,163 @@
'use client';
import Link from 'next/link';
import { type ColumnDef } from '@tanstack/react-table';
import { MoreHorizontal, Archive, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import { RESIDENTIAL_STAGE_LABELS } from './residential-interest-filters';
export interface ResidentialInterestRow {
id: string;
residentialClientId: string;
pipelineStage: string;
source: string | null;
notes: string | null;
preferences: string | null;
assignedTo: string | null;
archivedAt: string | null;
updatedAt: string;
/** Optional client snapshot — server may join the residential client row
* so the table can show the client name in column 1 without a second
* fetch per row. */
clientName?: string | null;
}
export const RESIDENTIAL_INTEREST_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
{ id: 'clientName', label: 'Client' },
{ id: 'pipelineStage', label: 'Stage' },
{ id: 'source', label: 'Source' },
{ id: 'preferences', label: 'Preferences' },
{ id: 'notes', label: 'Notes' },
{ id: 'updatedAt', label: 'Updated' },
];
export const RESIDENTIAL_INTEREST_DEFAULT_HIDDEN: string[] = [];
interface GetColumnsOptions {
portSlug: string;
onEdit?: (interest: ResidentialInterestRow) => void;
onArchive?: (interest: ResidentialInterestRow) => void;
}
export function getResidentialInterestColumns({
portSlug,
onEdit,
onArchive,
}: GetColumnsOptions): ColumnDef<ResidentialInterestRow, unknown>[] {
return [
{
id: 'clientName',
header: 'Client',
cell: ({ row }) => {
const r = row.original;
const name = r.clientName ?? '—';
return (
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={`/${portSlug}/residential/interests/${r.id}` as any}
className="font-medium text-foreground hover:underline"
onClick={(e) => e.stopPropagation()}
>
{name}
</Link>
);
},
},
{
id: 'pipelineStage',
accessorKey: 'pipelineStage',
header: 'Stage',
cell: ({ row }) => {
const s = row.original.pipelineStage;
return (
<Badge variant="secondary" className="text-xs">
{RESIDENTIAL_STAGE_LABELS[s] ?? s}
</Badge>
);
},
},
{
id: 'source',
accessorKey: 'source',
header: 'Source',
cell: ({ row }) => (
<span className="capitalize text-muted-foreground">{row.original.source ?? '—'}</span>
),
},
{
id: 'preferences',
accessorKey: 'preferences',
header: 'Preferences',
enableSorting: false,
cell: ({ row }) => (
<span className="line-clamp-1 max-w-xs text-muted-foreground">
{row.original.preferences ?? '—'}
</span>
),
},
{
id: 'notes',
accessorKey: 'notes',
header: 'Notes',
enableSorting: false,
cell: ({ row }) => (
<span className="line-clamp-1 max-w-xs text-muted-foreground">
{row.original.notes ?? '—'}
</span>
),
},
{
id: 'updatedAt',
accessorKey: 'updatedAt',
header: 'Updated',
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">
{new Date(row.original.updatedAt).toLocaleDateString()}
</span>
),
},
{
id: 'actions',
header: '',
enableSorting: false,
size: 48,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{onEdit ? (
<DropdownMenuItem onClick={() => onEdit(row.original)}>
<Pencil className="mr-2 h-4 w-4" /> Edit
</DropdownMenuItem>
) : null}
{onArchive ? (
<DropdownMenuItem
onClick={() => onArchive(row.original)}
className="text-destructive focus:text-destructive"
>
<Archive className="mr-2 h-4 w-4" /> Archive
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
),
},
];
}