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:
163
src/components/residential/residential-interest-columns.tsx
Normal file
163
src/components/residential/residential-interest-columns.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user