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:
parent
1a622b1267
commit
3b95cf8f81
3
.gitignore
vendored
3
.gitignore
vendored
@ -19,3 +19,6 @@ yarn-error.log*
|
|||||||
.env
|
.env
|
||||||
|
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Local MCP config (contains credentials)
|
||||||
|
.mcp.json
|
||||||
@ -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 ?? {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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>}
|
||||||
|
|||||||
@ -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 plan’s 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 won’t 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'}
|
||||||
|
|||||||
@ -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
85
src/lib/recommend.ts
Normal 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 plan’s entry-age band');
|
||||||
|
if (!c.okBounds) out.push('sum assured outside the plan’s min/max');
|
||||||
|
if (!c.okMult) out.push('sum assured outside the allowed income multiple');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user