fix: port designer field rules into bespoke activity forms

Custom frontend skipped designer custom_js / field_rules, so workflow
gates relying on computed fields never passed.

- Recommend: compute & send sa_valid (+ real rate-card premium) so the
  Meeting Scheduled -> Product Recommended stage gate passes; add inline
  validity hint; success copy now reflects whether the gate advanced
- Assign: thread form_data through LookupField so owner_agent options are
  region-filtered (field_rules filter_options cascade); clear agent on
  region change
- add src/lib/recommend.ts (faithful port of the Recommend custom_js)

Verified via live UI E2E: lead reaches Product Recommended (sa_valid=true,
premium=21000) and owner agent matches the chosen region.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bhanu Prakash Sai Potteri 2026-06-26 12:16:59 +05:30
parent 1a622b1267
commit 3b95cf8f81
7 changed files with 138 additions and 24 deletions

3
.gitignore vendored
View File

@ -19,3 +19,6 @@ yarn-error.log*
.env .env
*.tsbuildinfo *.tsbuildinfo
# Local MCP config (contains credentials)
.mcp.json

View File

@ -206,10 +206,20 @@ export class ZinoClient {
// --- RDBMS lookup records (owner-agent picker etc.) --- // --- RDBMS lookup records (owner-agent picker etc.) ---
/** Search an RDBMS lookup template. Returns the matching rows. */ /** Search an RDBMS lookup template. Returns the matching rows. `formData`
* carries the current form values so the server can apply the activity's
* `field_rules` filter_options (dependent/cascading lookups, e.g. owner
* agents scoped to the chosen region). */
lookupRecords( lookupRecords(
templateUid: string, templateUid: string,
opts: { activityId: string; fieldId: string; search?: string; instanceId?: number | string; limit?: number }, opts: {
activityId: string;
fieldId: string;
search?: string;
instanceId?: number | string;
limit?: number;
formData?: Record<string, unknown>;
},
): Promise<{ records: Array<Record<string, unknown>> }> { ): Promise<{ records: Array<Record<string, unknown>> }> {
return this.request('POST', `/app/${APP_ID}/rdbms-templates/${templateUid}/records`, { return this.request('POST', `/app/${APP_ID}/rdbms-templates/${templateUid}/records`, {
workflow_uuid: WORKFLOW_UUID, workflow_uuid: WORKFLOW_UUID,
@ -219,7 +229,7 @@ export class ZinoClient {
search: opts.search ?? '', search: opts.search ?? '',
limit: opts.limit ?? 50, limit: opts.limit ?? 50,
offset: 0, offset: 0,
form_data: {}, form_data: opts.formData ?? {},
}); });
} }

View File

@ -32,6 +32,7 @@ export const ACTIVITIES = {
premiumFrequency: 'premium_frequency_input', // select premiumFrequency: 'premium_frequency_input', // select
riders: 'riders_input', // multiselect riders: 'riders_input', // multiselect
notes: 'recommendation_notes', // longtext notes: 'recommendation_notes', // longtext
saValid: 'sa_valid_input', // boolean — computed (see lib/recommend.ts); gates Product Recommended
}, },
}, },
COLLECT_DOCUMENTS: { COLLECT_DOCUMENTS: {

View File

@ -79,7 +79,10 @@ export function AssignBody({ instanceId, record, onSuccess }: ActivityBodyProps)
fieldId={REGION.id} fieldId={REGION.id}
instanceId={instanceId} instanceId={instanceId}
value={region} value={region}
onChange={setRegion} onChange={(v) => {
setRegion(v);
setAgent(null); // region drives the agent list — drop a stale pick
}}
/> />
)} )}
{OWNER && ( {OWNER && (
@ -93,6 +96,7 @@ export function AssignBody({ instanceId, record, onSuccess }: ActivityBodyProps)
instanceId={instanceId} instanceId={instanceId}
value={agent} value={agent}
onChange={setAgent} onChange={setAgent}
formData={{ assigned_region_input: region }}
/> />
)} )}
{schemaQ.loading && <div className="col-span-full text-sm text-faint">Loading form</div>} {schemaQ.loading && <div className="col-span-full text-sm text-faint">Loading form</div>}

View File

@ -16,25 +16,12 @@ import { useQuery, useZino } from '../../api/provider';
import { decisionToCard } from '../../api/adapters'; import { decisionToCard } from '../../api/adapters';
import { fieldFromSchema } from '../../api/schema'; import { fieldFromSchema } from '../../api/schema';
import { ACTIVITIES } from '../../api/config'; import { ACTIVITIES } from '../../api/config';
import { recommendCalc, saValidIssues } from '../../lib/recommend';
import type { ActivityBodyProps } from './types'; import type { ActivityBodyProps } from './types';
import type { LeadRecord } from '../../api/types'; import type { LeadRecord } from '../../api/types';
const F = ACTIVITIES.RECOMMEND_PRODUCT.fields; const F = ACTIVITIES.RECOMMEND_PRODUCT.fields;
function indicativePremium(sum: number, freq: string): number {
const base = Math.round(sum * 0.00142);
switch (freq) {
case 'monthly':
return Math.round((base / 12) * 1.04);
case 'half_yearly':
return Math.round((base / 2) * 1.02);
case 'quarterly':
return Math.round((base / 4) * 1.03);
default:
return base;
}
}
function toRiderValues(raw: LeadRecord['riders']): string[] { function toRiderValues(raw: LeadRecord['riders']): string[] {
if (!raw) return []; if (!raw) return [];
if (Array.isArray(raw)) return raw as string[]; if (Array.isArray(raw)) return raw as string[];
@ -74,8 +61,11 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro
} }
}, [record]); }, [record]);
const computed = indicativePremium(sum, freq); // Mirror the designer custom_js: rate-card premium + sa_valid (the stage-gate
const effectivePremium = premiumTouched ? premium : computed; // field). Without sa_valid the lead can't advance past Meeting Scheduled.
const calc = recommendCalc(product, record?.date_of_birth, sum, record?.annual_income);
const effectivePremium = premiumTouched ? premium : calc.premium;
const issues = saValidIssues(calc);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null); const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
@ -97,8 +87,14 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro
[F.premiumFrequency]: freq, [F.premiumFrequency]: freq,
[F.riders]: riders, [F.riders]: riders,
[F.notes]: notes, [F.notes]: notes,
[F.saValid]: calc.saValid,
});
setResult({
ok: true,
msg: calc.saValid
? 'Recommendation submitted — lead advanced to Product Recommended.'
: 'Recommendation saved, but the sum assured is outside the plans eligible range — the lead stays at Meeting Scheduled until corrected.',
}); });
setResult({ ok: true, msg: 'Recommendation submitted — lead advanced to Product Recommended.' });
onSuccess?.(res); onSuccess?.(res);
} catch (e) { } catch (e) {
setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') }); setResult({ ok: false, msg: e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Submit failed') });
@ -157,7 +153,7 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro
prefix="₹" prefix="₹"
type="number" type="number"
value={effectivePremium} value={effectivePremium}
hint={premiumTouched ? undefined : `Indicative — ₹${formatINR(computed)}`} hint={premiumTouched ? undefined : calc.evaluated ? `Rate-card — ₹${formatINR(calc.premium)}` : undefined}
onChange={(e) => { onChange={(e) => {
setPremium(Number(e.target.value) || 0); setPremium(Number(e.target.value) || 0);
setPremiumTouched(true); setPremiumTouched(true);
@ -184,6 +180,15 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro
</label> </label>
</div> </div>
</div> </div>
{issues.length > 0 && (
<div className="mt-4 flex items-start gap-2.5 rounded-md border border-amber-300 bg-escalated-soft px-4 py-3 text-sm text-amber-800">
<CheckCircle2 size={16} className="mt-0.5 shrink-0 rotate-45 text-amber-600" />
<div>
<div className="font-semibold">Sum assured not yet eligible lead wont advance</div>
<div className="mt-0.5 text-xs text-amber-700">{issues.join('; ')}.</div>
</div>
</div>
)}
<div className="flex gap-2.5 mt-5"> <div className="flex gap-2.5 mt-5">
<Button variant="primary" disabled={submitting} onClick={submit}> <Button variant="primary" disabled={submitting} onClick={submit}>
{submitting ? 'Submitting…' : 'Send recommendation'} {submitting ? 'Submitting…' : 'Send recommendation'}

View File

@ -19,6 +19,9 @@ export interface LookupFieldProps {
instanceId?: number | string; instanceId?: number | string;
value: LookupValue | null; value: LookupValue | null;
onChange: (v: LookupValue | null) => void; onChange: (v: LookupValue | null) => void;
/** Current form values, so the server can apply field_rules filter_options
* (cascading lookups e.g. owner agents scoped to the picked region). */
formData?: Record<string, unknown>;
} }
type Row = Record<string, unknown>; type Row = Record<string, unknown>;
@ -37,8 +40,11 @@ export function LookupField({
instanceId, instanceId,
value, value,
onChange, onChange,
formData,
}: LookupFieldProps) { }: LookupFieldProps) {
const { client } = useZino(); const { client } = useZino();
// Serialized so the effect refetches when a dependency value changes.
const formKey = JSON.stringify(formData ?? {});
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [rows, setRows] = useState<Row[]>([]); const [rows, setRows] = useState<Row[]>([]);
@ -73,7 +79,7 @@ export function LookupField({
setLoading(true); setLoading(true);
const t = setTimeout(() => { const t = setTimeout(() => {
client client
.lookupRecords(templateUid, { activityId, fieldId, instanceId, search, limit: 50 }) .lookupRecords(templateUid, { activityId, fieldId, instanceId, search, limit: 50, formData })
.then((r) => { .then((r) => {
if (live) setRows(dedupe(r.records ?? [])); if (live) setRows(dedupe(r.records ?? []));
}) })
@ -89,7 +95,7 @@ export function LookupField({
clearTimeout(t); clearTimeout(t);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, search, templateUid, activityId, fieldId, instanceId, client]); }, [open, search, templateUid, activityId, fieldId, instanceId, client, formKey]);
function pick(row: Row) { function pick(row: Row) {
const stored: LookupValue = {}; const stored: LookupValue = {};

85
src/lib/recommend.ts Normal file
View File

@ -0,0 +1,85 @@
// Faithful port of the Recommend Product form's designer `custom_js`
// (activity_data_fields → recommended_product_input / sum_assured_input).
// The standard renderer runs that JS on field-change and writes
// `sa_valid_input` + the rate-card `premium_amount_input`. The bespoke
// RecommendBody must replicate it, or the Meeting Scheduled → Product
// Recommended stage gate (rule d44a9000: `sa_valid is_true`) never passes and
// the lead is silently stuck. Verified against workflow e29c3c33 / app 385.
import type { ProductLookup } from '../api/types';
export interface RecommendCalc {
/** Rate-card (or income-pct) premium — what the designer writes to premium_amount_input. */
premium: number;
/** Drives the stage gate. True only when age, sum-assured bounds and income multiple all pass. */
saValid: boolean;
age: number | null;
okAge: boolean;
okBounds: boolean;
okMult: boolean;
/** True once a plan + sum assured are present (so callers can show validity). */
evaluated: boolean;
}
const num = (v: unknown): number => Number(v) || 0;
function ageFromDob(dob?: string | null): number | null {
if (!dob) return null;
const b = new Date(dob);
if (Number.isNaN(b.getTime())) return null;
const n = new Date();
let age = n.getFullYear() - b.getFullYear();
if (n < new Date(n.getFullYear(), b.getMonth(), b.getDate())) age--;
return age;
}
export function recommendCalc(
plan: ProductLookup | null | undefined,
dob: string | null | undefined,
sumAssured: number,
annualIncome: number | null | undefined,
): RecommendCalc {
const age = ageFromDob(dob);
const sa = num(sumAssured);
const inc = num(annualIncome);
const blank: RecommendCalc = { premium: 0, saValid: false, age, okAge: false, okBounds: false, okMult: false, evaluated: false };
if (!plan || typeof plan !== 'object' || age == null || !sa) return blank;
const p = plan as Record<string, unknown>;
let premium = 0;
if (p.premium_basis === 'rate_card') {
let bands: Record<string, number> = {};
try {
bands =
typeof p.term_rate_bands === 'string'
? (JSON.parse(p.term_rate_bands) as Record<string, number>)
: ((p.term_rate_bands as Record<string, number>) ?? {});
} catch {
bands = {};
}
const band = age <= 30 ? '18-30' : age <= 40 ? '31-40' : age <= 50 ? '41-50' : '51-65';
premium = Math.round((sa / 1000) * num(bands[band]));
} else {
premium = Math.round(inc * num(p.premium_income_pct));
}
const okAge =
(p.entry_age_min == null || age >= num(p.entry_age_min)) &&
(p.entry_age_max == null || age <= num(p.entry_age_max));
const okBounds = sa >= num(p.min_sum_assured) && (!p.max_sum_assured || sa <= num(p.max_sum_assured));
const okMult =
!inc || (sa >= inc * num(p.sa_income_multiple_min) && sa <= inc * num(p.sa_income_multiple_max));
return { premium, saValid: okAge && okBounds && okMult, age, okAge, okBounds, okMult, evaluated: true };
}
/** Human reasons a recommendation fails validation (for an inline hint). */
export function saValidIssues(c: RecommendCalc): string[] {
if (!c.evaluated || c.saValid) return [];
const out: string[] = [];
if (!c.okAge) out.push('applicant age outside the plans entry-age band');
if (!c.okBounds) out.push('sum assured outside the plans min/max');
if (!c.okMult) out.push('sum assured outside the allowed income multiple');
return out;
}