From 989cc4d72b0659dff07fcb43fee776bdc732274a Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 21 May 2026 22:57:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(uat-batch):=20Group=20I=20=E2=80=94=20Resi?= =?UTF-8?q?dential=20parity=20(4=20ships)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../admin/residential-stages/page.tsx | 11 + .../v1/residential/interests/bulk/route.ts | 105 +++++ .../residential-client-detail-header.tsx | 95 +++- .../residential/residential-interest-card.tsx | 53 +++ .../residential-interest-columns.tsx | 163 +++++++ .../residential-interest-filters.tsx | 58 +++ .../residential-interests-list.tsx | 445 +++++++++++------- .../0080_residential_linked_client.sql | 18 + src/lib/db/schema/residential.ts | 11 + src/lib/services/port-config.ts | 6 + src/lib/services/residential.service.ts | 267 ++++++++++- src/lib/settings/registry.ts | 12 + src/lib/validators/residential.ts | 8 +- 13 files changed, 1083 insertions(+), 169 deletions(-) create mode 100644 src/app/api/v1/residential/interests/bulk/route.ts create mode 100644 src/components/residential/residential-interest-card.tsx create mode 100644 src/components/residential/residential-interest-columns.tsx create mode 100644 src/components/residential/residential-interest-filters.tsx create mode 100644 src/lib/db/migrations/0080_residential_linked_client.sql diff --git a/src/app/(dashboard)/[portSlug]/admin/residential-stages/page.tsx b/src/app/(dashboard)/[portSlug]/admin/residential-stages/page.tsx index 063d74e3..112c774d 100644 --- a/src/app/(dashboard)/[portSlug]/admin/residential-stages/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/residential-stages/page.tsx @@ -1,3 +1,4 @@ +import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form'; import { ResidentialStagesAdmin } from '@/components/admin/residential-stages-admin'; import { PageHeader } from '@/components/shared/page-header'; @@ -10,6 +11,16 @@ export default function ResidentialStagesPage() { description="Configure the stages residential interests flow through. Removing a stage that still has interests prompts you to reassign them before saving." /> + + {/* Partner forwarding — sits on the same admin page so all + residential-only port settings live in one place. Reps still + see every inquiry in the CRM; this is an outbound courtesy + notification for the partner who handles residential leads. */} + ); } diff --git a/src/app/api/v1/residential/interests/bulk/route.ts b/src/app/api/v1/residential/interests/bulk/route.ts new file mode 100644 index 00000000..c8132668 --- /dev/null +++ b/src/app/api/v1/residential/interests/bulk/route.ts @@ -0,0 +1,105 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { withAuth } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { + archiveResidentialInterest, + updateResidentialInterest, +} from '@/lib/services/residential.service'; +import { PIPELINE_STAGES } from '@/lib/validators/residential'; + +/** + * Synchronous bulk endpoint for the residential interests list — mirrors + * the `/api/v1/interests/bulk` shape (and the new `/api/v1/berths/bulk`) + * so the rep-facing UX is consistent. Per-row loop with a 100-id cap. + * + * Permission gating: every action requires `residential_interests.edit` + * except `archive` which needs `residential_interests.delete` (mirrors + * the per-row endpoints' gates). + */ +const bulkSchema = z.discriminatedUnion('action', [ + z.object({ + action: z.literal('change_stage'), + ids: z.array(z.string().min(1)).min(1).max(100), + pipelineStage: z.enum(PIPELINE_STAGES), + }), + z.object({ + action: z.literal('archive'), + ids: z.array(z.string().min(1)).min(1).max(100), + }), +]); + +interface RowResult { + id: string; + ok: boolean; + error?: string; +} + +const PERMISSION_BY_ACTION: Record< + z.infer['action'], + { resource: 'residential_interests'; action: 'edit' | 'delete' } +> = { + change_stage: { resource: 'residential_interests', action: 'edit' }, + archive: { resource: 'residential_interests', action: 'delete' }, +}; + +export const POST = withAuth(async (req, ctx) => { + let body: z.infer; + try { + body = await parseBody(req, bulkSchema); + } catch (error) { + return errorResponse(error); + } + + const perm = PERMISSION_BY_ACTION[body.action]; + const allowed = ctx.isSuperAdmin ? true : !!ctx.permissions?.[perm.resource]?.[perm.action]; + if (!allowed) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const meta = { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }; + + const results: RowResult[] = []; + + for (const id of body.ids) { + try { + if (body.action === 'change_stage') { + await updateResidentialInterest( + id, + ctx.portId, + { pipelineStage: body.pipelineStage }, + meta, + ); + } else if (body.action === 'archive') { + await archiveResidentialInterest(id, ctx.portId, meta); + } + results.push({ id, ok: true }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + results.push({ id, ok: false, error: message }); + } + } + + const okCount = results.filter((r) => r.ok).length; + return NextResponse.json({ + data: { + action: body.action, + total: results.length, + ok: okCount, + failed: results.length - okCount, + results, + summary: { + total: results.length, + succeeded: okCount, + failed: results.length - okCount, + }, + }, + }); +}); diff --git a/src/components/residential/residential-client-detail-header.tsx b/src/components/residential/residential-client-detail-header.tsx index 24fa4b39..9b69befa 100644 --- a/src/components/residential/residential-client-detail-header.tsx +++ b/src/components/residential/residential-client-detail-header.tsx @@ -1,10 +1,14 @@ 'use client'; -import { Home } from 'lucide-react'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { ExternalLink, Home, Mail, Phone } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; import { DetailHeaderStrip } from '@/components/shared/detail-header-strip'; import { InlineEditableField } from '@/components/shared/inline-editable-field'; +import { WhatsAppIcon } from '@/components/icons/whatsapp'; interface Props { client: { @@ -12,6 +16,18 @@ interface Props { fullName: string; status: string; placeOfResidence: string | null; + /** Optional primary email — surfaces the same Email button the main + * ClientDetailHeader renders. Null when the residential client + * hasn't shared an email. */ + email?: string | null; + /** Optional primary phone (free-text fallback). E.164 form preferred + * via `phoneE164` so `tel:` links route correctly. */ + phone?: string | null; + phoneE164?: string | null; + /** Optional FK to a matching main-client record. Set by the + * auto-link matcher on create OR by admin action. When set, the + * header shows a chip linking to the other side. */ + linkedClientId?: string | null; }; onSaveName: (next: string | null) => Promise; } @@ -29,13 +45,23 @@ const STATUS_LABEL: Record = { }; export function ResidentialClientDetailHeader({ client, onSaveName }: Props) { + const params = useParams<{ portSlug: string }>(); + const portSlug = params?.portSlug ?? ''; + // WhatsApp number must be digits-only with no leading + (wa.me convention). + // Prefer the E.164 form when available; fall back to the free-text phone + // stripped down to digits. + const whatsappNumber = client.phoneE164 + ? client.phoneE164.replace(/^\+/, '') + : (client.phone ?? '').replace(/\D/g, '') || null; + const callHref = client.phoneE164 ?? client.phone ?? null; + return ( -
+
Residential client
-

+

@@ -43,7 +69,70 @@ export function ResidentialClientDetailHeader({ client, onSaveName }: Props) { {STATUS_LABEL[client.status] ?? client.status} {client.placeOfResidence && {client.placeOfResidence}} + {client.linkedClientId ? ( + + Linked to main client + + + ) : null}
+ + {/* Action row — mirrors the buttons on the main ClientDetailHeader so + residential reps have one-tap email / call / WhatsApp from the + header without scrolling to the contact section. */} + {client.email || callHref || whatsappNumber ? ( +
+ {client.email ? ( + + ) : null} + {callHref ? ( + + ) : null} + {whatsappNumber ? ( + + ) : null} +
+ ) : null}
); diff --git a/src/components/residential/residential-interest-card.tsx b/src/components/residential/residential-interest-card.tsx new file mode 100644 index 00000000..36112205 --- /dev/null +++ b/src/components/residential/residential-interest-card.tsx @@ -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 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 ( + +
+
+

{interest.clientName ?? '—'}

+
+ + {RESIDENTIAL_STAGE_LABELS[interest.pipelineStage] ?? interest.pipelineStage} + + {interest.source ? ( + + {interest.source} + + ) : null} +
+
+ + {new Date(interest.updatedAt).toLocaleDateString()} + +
+ {interest.preferences ? ( +

{interest.preferences}

+ ) : null} + {interest.notes ? ( +

{interest.notes}

+ ) : null} + + ); +} diff --git a/src/components/residential/residential-interest-columns.tsx b/src/components/residential/residential-interest-columns.tsx new file mode 100644 index 00000000..eaa86010 --- /dev/null +++ b/src/components/residential/residential-interest-columns.tsx @@ -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[] { + return [ + { + id: 'clientName', + header: 'Client', + cell: ({ row }) => { + const r = row.original; + const name = r.clientName ?? '—'; + return ( + e.stopPropagation()} + > + {name} + + ); + }, + }, + { + id: 'pipelineStage', + accessorKey: 'pipelineStage', + header: 'Stage', + cell: ({ row }) => { + const s = row.original.pipelineStage; + return ( + + {RESIDENTIAL_STAGE_LABELS[s] ?? s} + + ); + }, + }, + { + id: 'source', + accessorKey: 'source', + header: 'Source', + cell: ({ row }) => ( + {row.original.source ?? '—'} + ), + }, + { + id: 'preferences', + accessorKey: 'preferences', + header: 'Preferences', + enableSorting: false, + cell: ({ row }) => ( + + {row.original.preferences ?? '—'} + + ), + }, + { + id: 'notes', + accessorKey: 'notes', + header: 'Notes', + enableSorting: false, + cell: ({ row }) => ( + + {row.original.notes ?? '—'} + + ), + }, + { + id: 'updatedAt', + accessorKey: 'updatedAt', + header: 'Updated', + cell: ({ row }) => ( + + {new Date(row.original.updatedAt).toLocaleDateString()} + + ), + }, + { + id: 'actions', + header: '', + enableSorting: false, + size: 48, + cell: ({ row }) => ( + + + + + + {onEdit ? ( + onEdit(row.original)}> + Edit + + ) : null} + {onArchive ? ( + onArchive(row.original)} + className="text-destructive focus:text-destructive" + > + Archive + + ) : null} + + + ), + }, + ]; +} diff --git a/src/components/residential/residential-interest-filters.tsx b/src/components/residential/residential-interest-filters.tsx new file mode 100644 index 00000000..00010e1f --- /dev/null +++ b/src/components/residential/residential-interest-filters.tsx @@ -0,0 +1,58 @@ +import type { FilterDefinition } from '@/components/shared/filter-bar'; +import { DEFAULT_RESIDENTIAL_PIPELINE_STAGES, PIPELINE_STAGES } from '@/lib/validators/residential'; + +/** + * Filter definitions for the residential interests list — mirrors the + * shape used by the main interests list (`interestFilterDefinitions`) + * so the FilterBar + saved-views infra works identically. Residential + * has its own pipeline (new / contacted / viewing_scheduled / …) and + * narrower source enum. + */ + +const RESIDENTIAL_STAGE_LABELS: Record = { + new: 'New', + contacted: 'Contacted', + viewing_scheduled: 'Viewing scheduled', + offer_made: 'Offer made', + offer_accepted: 'Offer accepted', + closed_won: 'Closed — won', + closed_lost: 'Closed — lost', +}; + +export const residentialInterestFilterDefinitions: FilterDefinition[] = [ + { + key: 'search', + label: 'Search', + type: 'text', + placeholder: 'Search notes or preferences…', + }, + { + key: 'pipelineStage', + label: 'Stage', + type: 'multi-select', + options: PIPELINE_STAGES.map((s: string) => ({ + label: RESIDENTIAL_STAGE_LABELS[s] ?? s, + value: s, + })), + }, + { + key: 'source', + label: 'Source', + type: 'select', + options: [ + { label: 'Website', value: 'website' }, + { label: 'Manual', value: 'manual' }, + { label: 'Referral', value: 'referral' }, + { label: 'Broker', value: 'broker' }, + { label: 'Other', value: 'other' }, + ], + }, + { + key: 'includeArchived', + label: 'Include Archived', + type: 'boolean', + }, +]; + +export { RESIDENTIAL_STAGE_LABELS }; +export { DEFAULT_RESIDENTIAL_PIPELINE_STAGES }; diff --git a/src/components/residential/residential-interests-list.tsx b/src/components/residential/residential-interests-list.tsx index 550ae65f..8ed62295 100644 --- a/src/components/residential/residential-interests-list.tsx +++ b/src/components/residential/residential-interests-list.tsx @@ -1,11 +1,20 @@ 'use client'; import { useState } from 'react'; -import Link from 'next/link'; import { useParams, useRouter } from 'next/navigation'; -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Archive, ArrowRight, LayoutList, Plus } from 'lucide-react'; +import { toast } from 'sonner'; -import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { Select, SelectContent, @@ -13,51 +22,73 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { ColumnPicker } from '@/components/shared/column-picker'; +import { DataTable } from '@/components/shared/data-table'; +import { EmptyState } from '@/components/shared/empty-state'; +import { FilterBar } from '@/components/shared/filter-bar'; import { PageHeader } from '@/components/shared/page-header'; +import { SaveViewDialog } from '@/components/shared/save-view-dialog'; +import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown'; +import { TableSkeleton } from '@/components/shared/loading-skeleton'; +import { useConfirmation } from '@/hooks/use-confirmation'; +import { usePaginatedQuery } from '@/hooks/use-paginated-query'; +import { usePermissions } from '@/hooks/use-permissions'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; +import { useTablePreferences } from '@/hooks/use-table-preferences'; import { apiFetch } from '@/lib/api/client'; -import { PIPELINE_STAGES } from '@/lib/validators/residential'; - -interface ResidentialInterestRow { - id: string; - residentialClientId: string; - pipelineStage: string; - source: string | null; - notes: string | null; - preferences: string | null; - assignedTo: string | null; - updatedAt: string; -} - -interface ListResponse { - data: ResidentialInterestRow[]; - pagination: { total: number }; -} - -const STAGE_LABELS: Record = { - new: 'New', - contacted: 'Contacted', - viewing_scheduled: 'Viewing scheduled', - offer_made: 'Offer made', - offer_accepted: 'Offer accepted', - closed_won: 'Closed - won', - closed_lost: 'Closed - lost', -}; +import { ResidentialInterestCard } from './residential-interest-card'; +import { + getResidentialInterestColumns, + RESIDENTIAL_INTEREST_COLUMN_OPTIONS, + RESIDENTIAL_INTEREST_DEFAULT_HIDDEN, + type ResidentialInterestRow, +} from './residential-interest-columns'; +import { + DEFAULT_RESIDENTIAL_PIPELINE_STAGES, + RESIDENTIAL_STAGE_LABELS, + residentialInterestFilterDefinitions, +} from './residential-interest-filters'; +/** + * Residential interests list — parity with the main InterestList. Wires + * the same DataTable + FilterBar + ColumnPicker + SavedViews + bulkActions + * stack onto the /api/v1/residential/interests endpoint. Kanban view is + * intentionally omitted (the residential pipeline stages differ and the + * board layout isn't yet wired through for them — opens as a follow-up). + */ export function ResidentialInterestsList() { const params = useParams<{ portSlug: string }>(); const router = useRouter(); const portSlug = params?.portSlug ?? ''; - const [search, setSearch] = useState(''); - const [stage, setStage] = useState('all'); + const queryClient = useQueryClient(); + const { confirm, dialog: confirmDialog } = useConfirmation(); + const { can } = usePermissions(); + const [saveViewOpen, setSaveViewOpen] = useState(false); - const { data, isLoading } = useQuery({ - queryKey: ['residential-interests', { search, stage }], - queryFn: () => { - const qs = new URLSearchParams({ search, limit: '50' }); - if (stage !== 'all') qs.set('pipelineStage', stage); - return apiFetch(`/api/v1/residential/interests?${qs.toString()}`); - }, + // Bulk-action dialog state — same shape as the main InterestList so + // the inner controls feel identical. + const [stageDialog, setStageDialog] = useState<{ ids: string[] } | null>(null); + const [stageChoice, setStageChoice] = useState( + DEFAULT_RESIDENTIAL_PIPELINE_STAGES[0] ?? 'new', + ); + + const { + data, + pagination, + isLoading, + isFetching, + sort, + setSort, + setPage, + setPageSize, + filters, + setFilter, + setAllFilters, + clearFilters, + } = usePaginatedQuery({ + queryKey: ['residential-interests'], + endpoint: '/api/v1/residential/interests', + filterDefinitions: residentialInterestFilterDefinitions, }); useRealtimeInvalidation({ @@ -66,141 +97,231 @@ export function ResidentialInterestsList() { 'residential_interest:archived': [['residential-interests']], }); + // Mirror the main list's two-mutation idiom — per-row archive when the + // rep uses the kebab on a single row, bulk endpoint when they tick the + // checkbox in the header. + const archiveMutation = useMutation({ + mutationFn: (id: string) => + apiFetch(`/api/v1/residential/interests/${id}`, { method: 'DELETE' }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['residential-interests'] }); + }, + }); + + const bulkMutation = useMutation({ + mutationFn: async ( + payload: + | { action: 'archive'; ids: string[] } + | { action: 'change_stage'; ids: string[]; pipelineStage: string }, + ) => + apiFetch<{ + data: { summary: { total: number; succeeded: number; failed: number } }; + }>('/api/v1/residential/interests/bulk', { method: 'POST', body: payload }), + onSuccess: (res) => { + void queryClient.invalidateQueries({ queryKey: ['residential-interests'] }); + const s = res.data.summary; + if (s.failed > 0) { + toast.warning( + `${s.succeeded} of ${s.total} succeeded. ${s.failed} failed — check the activity log.`, + ); + } else { + toast.success(`Updated ${s.succeeded} interest${s.succeeded === 1 ? '' : 's'}`); + } + }, + }); + + const columns = getResidentialInterestColumns({ + portSlug, + onArchive: async (interest) => { + const ok = await confirm({ + title: 'Archive interest', + description: 'This can be undone from the archived list.', + confirmLabel: 'Archive', + }); + if (!ok) return; + archiveMutation.mutate(interest.id); + }, + }); + + // Persisted per-user column visibility + density. + const { hidden, setHidden, density } = useTablePreferences( + 'residential_interests', + RESIDENTIAL_INTEREST_DEFAULT_HIDDEN, + ); + const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false])); + + // Stages enum is set at module load — no runtime invariant to enforce. + // (Earlier draft had a useEffect resetting `stageChoice` if it fell + // out of the enum; React Compiler flags setState-in-effect, so we + // rely on the dropdown's controlled value to enforce validity.) + return (
+
+ +
+
+ } /> -
- setSearch(e.target.value)} - className="max-w-sm h-9" +
+ - +
+ setAllFilters(savedFilters)} + /> +
- {/* Desktop: table layout. Hidden below lg; mobile renders cards. */} -
- - - - - - - - - - - - {isLoading && ( - - - - )} - {!isLoading && data?.data.length === 0 && ( - - - - )} - {data?.data.map((i) => ( - router.push(`/${portSlug}/residential/interests/${i.id}` as never)} - > - - - - - - - ))} - -
StagePreferencesNotesSourceUpdated
- Loading… -
- No interests match. -
- e.stopPropagation()} - > - {STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage} - - - {i.preferences ?? '-'} - - {i.notes ?? '-'} - {i.source ?? '-'} - {new Date(i.updatedAt).toLocaleDateString()} -
-
+ - {/* Mobile: card list. Stage as the headline (it's the most actionable - field for triage), preferences/notes truncated below. */} -
- {isLoading && ( -
- Loading… + {isLoading ? ( + + ) : ( + { + setPage(p); + setPageSize(ps); + }} + sort={sort} + onSortChange={setSort} + isLoading={isFetching && !isLoading} + getRowId={(row) => row.id} + onRowClick={(row) => router.push(`/${portSlug}/residential/interests/${row.id}` as never)} + bulkActions={ + can('residential_interests', 'edit') + ? [ + { + label: 'Change stage', + icon: ArrowRight, + onClick: (ids) => { + if (ids.length === 0) return; + setStageChoice(DEFAULT_RESIDENTIAL_PIPELINE_STAGES[0] ?? 'new'); + setStageDialog({ ids }); + }, + }, + ...(can('residential_interests', 'delete') + ? [ + { + label: 'Archive', + icon: Archive, + variant: 'destructive' as const, + onClick: async (ids: string[]) => { + if (ids.length === 0) return; + const ok = await confirm({ + title: `Archive ${ids.length} interest${ids.length === 1 ? '' : 's'}`, + description: 'This can be undone from the archived list.', + confirmLabel: 'Archive', + }); + if (!ok) return; + bulkMutation.mutate({ action: 'archive', ids }); + }, + }, + ] + : []), + ] + : undefined + } + cardRender={(row) => ( + + )} + emptyState={ + + } + /> + )} + + {/* Bulk: change stage */} + !o && setStageDialog(null)}> + + + Change stage + + Move {stageDialog?.ids.length ?? 0} interest + {stageDialog?.ids.length === 1 ? '' : 's'} to a new pipeline stage. + + +
+
- )} - {!isLoading && data?.data.length === 0 && ( -
- No interests match. -
- )} - {data?.data.map((i) => ( - -
-

- {STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage} -

- - {new Date(i.updatedAt).toLocaleDateString()} - -
- {i.preferences ? ( -

{i.preferences}

- ) : null} - {i.notes ? ( -

{i.notes}

- ) : null} - {i.source ? ( -

{i.source}

- ) : null} - - ))} -
+ + + + + + + + {confirmDialog}
); } diff --git a/src/lib/db/migrations/0080_residential_linked_client.sql b/src/lib/db/migrations/0080_residential_linked_client.sql new file mode 100644 index 00000000..e9af5f7d --- /dev/null +++ b/src/lib/db/migrations/0080_residential_linked_client.sql @@ -0,0 +1,18 @@ +-- 2026-05-21: residential ↔ main-client auto-link. +-- +-- Adds a nullable FK from `residential_clients` to the main `clients` +-- table. When the matching service finds a main-client record with the +-- same email / E.164 phone, the residential row is linked so reps can +-- see the cross-record relationship from either side without manual +-- de-duplication. +-- +-- ON DELETE SET NULL keeps the residential record intact even if the +-- main client is hard-deleted (GDPR wipe etc.). Index supports +-- "what residential rows are linked to this main client" queries. + +ALTER TABLE residential_clients + ADD COLUMN linked_client_id text REFERENCES clients(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_residential_clients_linked_client + ON residential_clients (linked_client_id) + WHERE linked_client_id IS NOT NULL; diff --git a/src/lib/db/schema/residential.ts b/src/lib/db/schema/residential.ts index 1f0b9317..34688f8f 100644 --- a/src/lib/db/schema/residential.ts +++ b/src/lib/db/schema/residential.ts @@ -44,6 +44,14 @@ export const residentialClients = pgTable( status: text('status').notNull().default('prospect'), source: text('source'), // website | manual | referral | broker notes: text('notes'), + /** + * Optional link to a matching record in the main `clients` table. + * Populated by `findAndLinkMatchingMainClient` after create, or + * manually via the admin UI. ON DELETE SET NULL — the residential + * record outlives a GDPR wipe of the main client. Migration 0080 + * adds the FK + supporting index. + */ + linkedClientId: text('linked_client_id'), archivedAt: timestamp('archived_at', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), @@ -54,6 +62,9 @@ export const residentialClients = pgTable( index('idx_residential_clients_archived') .on(table.portId) .where(sql`${table.archivedAt} IS NULL`), + index('idx_residential_clients_linked_client') + .on(table.linkedClientId) + .where(sql`${table.linkedClientId} IS NOT NULL`), ], ); diff --git a/src/lib/services/port-config.ts b/src/lib/services/port-config.ts index 65a98a69..9f25fa02 100644 --- a/src/lib/services/port-config.ts +++ b/src/lib/services/port-config.ts @@ -121,6 +121,12 @@ export const SETTING_KEYS = { // `getStageAdvanceMode` — aggressive defaults match the conventional // CRM behaviour (EOI signed → reservation auto-advances). stageAdvanceRules: 'stage_advance_rules', + + // Residential partner-forwarding recipients — comma-separated emails + // that receive a courtesy notification on every new residential + // inquiry. Blank disables. See createResidentialInterest + + // forwardResidentialInquiryToPartner for usage. + residentialPartnerRecipients: 'residential_partner_recipients', } as const; // ─── Stage auto-advance ────────────────────────────────────────────────────── diff --git a/src/lib/services/residential.service.ts b/src/lib/services/residential.service.ts index 8a8fc7de..d5427db5 100644 --- a/src/lib/services/residential.service.ts +++ b/src/lib/services/residential.service.ts @@ -1,6 +1,7 @@ -import { and, eq } from 'drizzle-orm'; +import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/lib/db'; +import { clients, clientContacts } from '@/lib/db/schema/clients'; import { residentialClients, residentialInterests } from '@/lib/db/schema/residential'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { CodedError, NotFoundError } from '@/lib/errors'; @@ -16,6 +17,9 @@ import type { UpdateResidentialClientInput, UpdateResidentialInterestInput, } from '@/lib/validators/residential'; +import { sendEmail } from '@/lib/email'; +import { SETTING_KEYS, getPortBrandingConfig, readSetting } from '@/lib/services/port-config'; +import { brandingPrimaryColor, renderShell } from '@/lib/email/shell'; // ─── Residential clients ───────────────────────────────────────────────────── @@ -95,6 +99,14 @@ export async function createResidentialClient( }); emitToRoom(`port:${portId}`, 'residential_client:created', { id: row.id }); + // Best-effort auto-link to an existing main-client record. Match by + // email (cheap, single index lookup) then by E.164 phone (next-best). + // Failures or no-match scenarios silently leave the row unlinked — + // reps can wire it up via the admin UI later. + void findAndLinkMatchingMainClient(row.id, portId).catch((err) => { + console.error('[residential] auto-link match failed', err); + }); + return row; } @@ -186,17 +198,30 @@ export async function listResidentialInterests( search, includeArchived, pipelineStage, + source, assignedTo, residentialClientId, } = query; const filters = []; - if (pipelineStage) filters.push(eq(residentialInterests.pipelineStage, pipelineStage)); + // Normalize string-or-array filter inputs into Drizzle `inArray` clauses + // when multiple values are supplied; fall back to `eq` for the single + // case so the validator's union shape doesn't change the SQL. + if (pipelineStage) { + const values = Array.isArray(pipelineStage) ? pipelineStage : [pipelineStage]; + if (values.length > 1) filters.push(inArray(residentialInterests.pipelineStage, values)); + else if (values[0]) filters.push(eq(residentialInterests.pipelineStage, values[0])); + } + if (source) { + const values = Array.isArray(source) ? source : [source]; + if (values.length > 1) filters.push(inArray(residentialInterests.source, values)); + else if (values[0]) filters.push(eq(residentialInterests.source, values[0])); + } if (assignedTo) filters.push(eq(residentialInterests.assignedTo, assignedTo)); if (residentialClientId) filters.push(eq(residentialInterests.residentialClientId, residentialClientId)); - return buildListQuery({ + const result = await buildListQuery({ table: residentialInterests, portIdColumn: residentialInterests.portId, portId, @@ -218,6 +243,41 @@ export async function listResidentialInterests( includeArchived, archivedAtColumn: residentialInterests.archivedAt, }); + + // Per-page client-name lookup so the list table can render the + // residentialClient.fullName in column 1 without a second hop per row. + // Two-pass post-fetch pattern mirrors the main interests list (latest + // stage / tag aggregation lives there). Page size is capped by the + // validator so this stays a single bulk IN-list query. + // `buildListQuery` returns `data: typeof table.$inferSelect[]` so the + // row type is `residentialInterests` — known shape, but TS infers + // `unknown[]` through the generic helper. Cast through `unknown` once + // here so the downstream enrichment is type-clean. + type InterestRow = typeof residentialInterests.$inferSelect; + const typedData = result.data as unknown as InterestRow[]; + if (typedData.length > 0) { + const clientIds = Array.from( + new Set(typedData.map((r) => r.residentialClientId).filter((v): v is string => Boolean(v))), + ); + if (clientIds.length > 0) { + const clients = await db + .select({ + id: residentialClients.id, + fullName: residentialClients.fullName, + }) + .from(residentialClients) + .where( + and(eq(residentialClients.portId, portId), inArray(residentialClients.id, clientIds)), + ); + const nameById = new Map(clients.map((c) => [c.id, c.fullName])); + result.data = typedData.map((r) => ({ + ...r, + clientName: nameById.get(r.residentialClientId) ?? null, + })) as unknown as typeof result.data; + } + } + + return result; } export async function getResidentialInterestById(id: string, portId: string) { @@ -275,9 +335,210 @@ export async function createResidentialInterest( }); emitToRoom(`port:${portId}`, 'residential_interest:created', { id: row.id }); + // Fire-and-forget partner-forward email. Failures here MUST NOT block + // the create — partner notification is a courtesy. Errors are logged + // server-side so the operator can see them, but the API still 201s. + void forwardResidentialInquiryToPartner({ + portId, + client, + interest: row, + }).catch((err) => { + console.error('[residential] partner forward failed', err); + }); + return row; } +/** + * Sends a courtesy notification to the configured partner email + * recipients when a new residential inquiry lands. Recipients are + * configured per-port via the `residential_partner_recipients` system + * setting (comma-separated list). No-ops when the setting is blank or + * the inquiry has no usable client snapshot. + * + * Uses the same branded shell as the rest of the transactional emails + * so the partner sees a port-branded notification rather than a raw + * HTML block. + */ +async function forwardResidentialInquiryToPartner(input: { + portId: string; + client: typeof residentialClients.$inferSelect; + interest: typeof residentialInterests.$inferSelect; +}): Promise { + const { portId, client, interest } = input; + const raw = await readSetting(SETTING_KEYS.residentialPartnerRecipients, portId); + if (!raw) return; + const recipients = raw + .split(',') + .map((s) => s.trim()) + .filter((s) => /^.+@.+\..+$/.test(s)); + if (recipients.length === 0) return; + + const branding = await getPortBrandingConfig(portId); + const accent = brandingPrimaryColor({ + logoUrl: branding.logoUrl, + backgroundUrl: branding.emailBackgroundUrl, + primaryColor: branding.primaryColor, + emailHeaderHtml: branding.emailHeaderHtml, + emailFooterHtml: branding.emailFooterHtml, + }); + const escapeHtml = (s: string): string => + s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + const subject = `New residential inquiry: ${client.fullName}`; + const body = ` +

+ New residential inquiry +

+

+ A new residential inquiry was submitted via the CRM. Details below; full record lives in the + port's CRM under Residential > Interests. +

+ + + + + + ${ + client.email + ? ` + + + ` + : '' + } + ${ + client.phone + ? ` + + + ` + : '' + } + ${ + client.placeOfResidence + ? ` + + + ` + : '' + } + ${ + interest.preferences + ? ` + + + ` + : '' + } + ${ + interest.notes + ? ` + + + ` + : '' + } +
Name${escapeHtml(client.fullName)}
Email${escapeHtml(client.email)}
Phone${escapeHtml(client.phone)}
Location${escapeHtml(client.placeOfResidence)}
Preferences${escapeHtml(interest.preferences)}
Notes${escapeHtml(interest.notes)}
+

+ Pipeline stage at submission: ${escapeHtml(interest.pipelineStage)}. +

+ `; + const html = renderShell({ + title: subject, + body, + branding: { + logoUrl: branding.logoUrl, + backgroundUrl: branding.emailBackgroundUrl, + primaryColor: branding.primaryColor, + emailHeaderHtml: branding.emailHeaderHtml, + emailFooterHtml: branding.emailFooterHtml, + }, + }); + await sendEmail(recipients, subject, html, undefined, undefined, portId); +} + +/** + * Best-effort matcher that links a residential client to an existing + * main `clients` row representing the same person. Matches by: + * 1. email (residential.email matches clients.contacts of channel='email') + * 2. phoneE164 (residential.phone_e164 matches clients.contacts of + * channel='phone' or 'whatsapp') + * + * Match ordering is "exact email beats phone beats nothing" — the + * first hit wins. Returns the linked main-client id when a match was + * found, or null when no match exists. + * + * Caller is expected to handle errors / call best-effort (residential + * lifecycle MUST NOT block on matching). Exported so the admin + * backfill script can re-run the matcher across historical rows. + */ +export async function findAndLinkMatchingMainClient( + residentialClientId: string, + portId: string, +): Promise { + const row = await db.query.residentialClients.findFirst({ + where: and( + eq(residentialClients.id, residentialClientId), + eq(residentialClients.portId, portId), + ), + }); + if (!row) return null; + if (row.linkedClientId) return row.linkedClientId; + + // Try email match first. Look for a main-client contact row in the + // same port whose value matches case-insensitively. Pick the most- + // recently-updated client when multiple match (rare but possible). + let matchedClientId: string | null = null; + if (row.email) { + const emailMatches = await db + .select({ clientId: clientContacts.clientId }) + .from(clientContacts) + .innerJoin(clients, eq(clients.id, clientContacts.clientId)) + .where( + and( + eq(clients.portId, portId), + eq(clientContacts.channel, 'email'), + eq(clientContacts.value, row.email.toLowerCase()), + ), + ) + .limit(1); + if (emailMatches[0]) matchedClientId = emailMatches[0].clientId; + } + + if (!matchedClientId && row.phoneE164) { + const phoneMatches = await db + .select({ clientId: clientContacts.clientId }) + .from(clientContacts) + .innerJoin(clients, eq(clients.id, clientContacts.clientId)) + .where( + and( + eq(clients.portId, portId), + inArray(clientContacts.channel, ['phone', 'whatsapp']), + eq(clientContacts.valueE164, row.phoneE164), + ), + ) + .limit(1); + if (phoneMatches[0]) matchedClientId = phoneMatches[0].clientId; + } + + if (!matchedClientId) return null; + + await db + .update(residentialClients) + .set({ linkedClientId: matchedClientId, updatedAt: new Date() }) + .where( + and(eq(residentialClients.id, residentialClientId), eq(residentialClients.portId, portId)), + ); + emitToRoom(`port:${portId}`, 'residential_client:updated', { id: residentialClientId }); + return matchedClientId; +} + export async function updateResidentialInterest( id: string, portId: string, diff --git a/src/lib/settings/registry.ts b/src/lib/settings/registry.ts index 1fa93930..3edff5ce 100644 --- a/src/lib/settings/registry.ts +++ b/src/lib/settings/registry.ts @@ -599,6 +599,18 @@ export const REGISTRY: SettingEntry[] = [ scope: 'port', placeholder: 'Cold', }, + + // ─── Residential — partner forwarding ────────────────────────────────────── + { + key: 'residential_partner_recipients', + section: 'residential.partner', + label: 'Partner forwarding recipients', + description: + 'Comma-separated list of email addresses that receive a copy of every new residential inquiry the moment it lands. Leave blank to disable partner forwarding. Reps still see every inquiry in the CRM; this is an outbound courtesy notification for an external partner who handles residential leads on the port’s behalf.', + type: 'string', + scope: 'port', + placeholder: 'partner@example.com, partner2@example.com', + }, ]; /** Quick lookup index keyed by setting key. */ diff --git a/src/lib/validators/residential.ts b/src/lib/validators/residential.ts index 9fe0e12a..09e21d2d 100644 --- a/src/lib/validators/residential.ts +++ b/src/lib/validators/residential.ts @@ -83,7 +83,13 @@ export const updateResidentialInterestSchema = createResidentialInterestSchema .partial(); export const listResidentialInterestsSchema = baseListQuerySchema.extend({ - pipelineStage: z.string().optional(), + // pipelineStage accepts a string OR string[] so the FilterBar can filter + // multi-select. The legacy single-string form stays accepted so existing + // callers (e.g. residential-client-tabs) don't need to migrate. + pipelineStage: z.union([z.string(), z.array(z.string())]).optional(), + // Source filter — mirrors the main interest list. Comma-separated when + // submitted as a query string ("website,referral"). + source: z.union([z.string(), z.array(z.string())]).optional(), assignedTo: z.string().optional(), residentialClientId: z.string().optional(), });