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
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
# Local MCP config (contains credentials)
|
||||
.mcp.json
|
||||
@ -206,10 +206,20 @@ export class ZinoClient {
|
||||
|
||||
// --- 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(
|
||||
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>> }> {
|
||||
return this.request('POST', `/app/${APP_ID}/rdbms-templates/${templateUid}/records`, {
|
||||
workflow_uuid: WORKFLOW_UUID,
|
||||
@ -219,7 +229,7 @@ export class ZinoClient {
|
||||
search: opts.search ?? '',
|
||||
limit: opts.limit ?? 50,
|
||||
offset: 0,
|
||||
form_data: {},
|
||||
form_data: opts.formData ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -32,6 +32,7 @@ export const ACTIVITIES = {
|
||||
premiumFrequency: 'premium_frequency_input', // select
|
||||
riders: 'riders_input', // multiselect
|
||||
notes: 'recommendation_notes', // longtext
|
||||
saValid: 'sa_valid_input', // boolean — computed (see lib/recommend.ts); gates Product Recommended
|
||||
},
|
||||
},
|
||||
COLLECT_DOCUMENTS: {
|
||||
|
||||
@ -79,7 +79,10 @@ export function AssignBody({ instanceId, record, onSuccess }: ActivityBodyProps)
|
||||
fieldId={REGION.id}
|
||||
instanceId={instanceId}
|
||||
value={region}
|
||||
onChange={setRegion}
|
||||
onChange={(v) => {
|
||||
setRegion(v);
|
||||
setAgent(null); // region drives the agent list — drop a stale pick
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{OWNER && (
|
||||
@ -93,6 +96,7 @@ export function AssignBody({ instanceId, record, onSuccess }: ActivityBodyProps)
|
||||
instanceId={instanceId}
|
||||
value={agent}
|
||||
onChange={setAgent}
|
||||
formData={{ assigned_region_input: region }}
|
||||
/>
|
||||
)}
|
||||
{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 { fieldFromSchema } from '../../api/schema';
|
||||
import { ACTIVITIES } from '../../api/config';
|
||||
import { recommendCalc, saValidIssues } from '../../lib/recommend';
|
||||
import type { ActivityBodyProps } from './types';
|
||||
import type { LeadRecord } from '../../api/types';
|
||||
|
||||
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[] {
|
||||
if (!raw) return [];
|
||||
if (Array.isArray(raw)) return raw as string[];
|
||||
@ -74,8 +61,11 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro
|
||||
}
|
||||
}, [record]);
|
||||
|
||||
const computed = indicativePremium(sum, freq);
|
||||
const effectivePremium = premiumTouched ? premium : computed;
|
||||
// Mirror the designer custom_js: rate-card premium + sa_valid (the stage-gate
|
||||
// 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 [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
@ -97,8 +87,14 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro
|
||||
[F.premiumFrequency]: freq,
|
||||
[F.riders]: riders,
|
||||
[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);
|
||||
} catch (e) {
|
||||
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="₹"
|
||||
type="number"
|
||||
value={effectivePremium}
|
||||
hint={premiumTouched ? undefined : `Indicative — ₹${formatINR(computed)}`}
|
||||
hint={premiumTouched ? undefined : calc.evaluated ? `Rate-card — ₹${formatINR(calc.premium)}` : undefined}
|
||||
onChange={(e) => {
|
||||
setPremium(Number(e.target.value) || 0);
|
||||
setPremiumTouched(true);
|
||||
@ -184,6 +180,15 @@ export function RecommendBody({ instanceId, record, onSuccess }: ActivityBodyPro
|
||||
</label>
|
||||
</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">
|
||||
<Button variant="primary" disabled={submitting} onClick={submit}>
|
||||
{submitting ? 'Submitting…' : 'Send recommendation'}
|
||||
|
||||
@ -19,6 +19,9 @@ export interface LookupFieldProps {
|
||||
instanceId?: number | string;
|
||||
value: LookupValue | null;
|
||||
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>;
|
||||
@ -37,8 +40,11 @@ export function LookupField({
|
||||
instanceId,
|
||||
value,
|
||||
onChange,
|
||||
formData,
|
||||
}: LookupFieldProps) {
|
||||
const { client } = useZino();
|
||||
// Serialized so the effect refetches when a dependency value changes.
|
||||
const formKey = JSON.stringify(formData ?? {});
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
@ -73,7 +79,7 @@ export function LookupField({
|
||||
setLoading(true);
|
||||
const t = setTimeout(() => {
|
||||
client
|
||||
.lookupRecords(templateUid, { activityId, fieldId, instanceId, search, limit: 50 })
|
||||
.lookupRecords(templateUid, { activityId, fieldId, instanceId, search, limit: 50, formData })
|
||||
.then((r) => {
|
||||
if (live) setRows(dedupe(r.records ?? []));
|
||||
})
|
||||
@ -89,7 +95,7 @@ export function LookupField({
|
||||
clearTimeout(t);
|
||||
};
|
||||
// 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) {
|
||||
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