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:
@@ -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."
|
||||
/>
|
||||
<ResidentialStagesAdmin />
|
||||
|
||||
{/* 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. */}
|
||||
<RegistryDrivenForm
|
||||
sections={['residential.partner']}
|
||||
title="Partner forwarding"
|
||||
description="Email address(es) that receive a copy of every new residential inquiry the moment it lands. Comma-separated. Leave blank to disable."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
105
src/app/api/v1/residential/interests/bulk/route.ts
Normal file
105
src/app/api/v1/residential/interests/bulk/route.ts
Normal file
@@ -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<typeof bulkSchema>['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<typeof bulkSchema>;
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user