feat: action forms open with a defensible draft, not a blank page

Approve File asked a credit manager to type two paragraphs and his own name,
on a screen that already displayed every fact he would type. That is not
diligence, it is transcription — and in front of an audience it is two
paragraphs of typing.

Each action form now opens pre-filled from the file, EDITABLE. New `prefill`
prop on ActivityForm, distinct from `seed`: seed locks a field (identifiers you
should not be able to get wrong), prefill only drafts it. The remarks are still
his remarks and the amount is still his amount; what is removed is the blank
page.

src/prefill.ts computes it, and follows two rules.

  1. Draft the WORDING, never the DECISION. The suggested amount is the largest
     figure the policy can defend, not a recommendation: where three turnover
     figures disagree it sizes the facility against the LOWEST of them, so the
     unanswered question stops mattering — which is also the sentence worth
     saying out loud about this file. Declines open EMPTY, because a
     pre-written justification for refusing someone credit is not a
     convenience, and the reason is the whole record.

  2. Attribution comes from the SIGNED-IN USER, never typed. "Prepared By" and
     "Sanctioned By" are whoever is holding the mouse. Asking a maker to type
     his own name into a four-eyes control is an invitation to type someone
     else's.

Also: a file carrying a deviation is priced 0.75% above the standard rate, so
the offer draft reflects that the file needed judgement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-08-18 11:08:36 +05:30
parent 11da9abed5
commit e745cc5649
4 changed files with 165 additions and 3 deletions

View File

@ -193,6 +193,9 @@ export const POLICY = {
cmrCeiling: 6, cmrCeiling: 6,
dpdCeiling: 30, dpdCeiling: 30,
bounceCeiling: 3, bounceCeiling: 3,
/** The rate the capacity test assumes. Also the standard offer rate; a file
* carrying a deviation is priced above it. */
indicativeRoi: 14.5,
} as const; } as const;
export const SUPER_ROLES = ['Super Admin', 'Admin']; export const SUPER_ROLES = ['Super Admin', 'Admin'];

View File

@ -30,6 +30,7 @@ export function ActivityForm({
submitLabel = 'Submit', submitLabel = 'Submit',
danger = false, danger = false,
seed, seed,
prefill,
onDone, onDone,
}: { }: {
open: boolean; open: boolean;
@ -41,8 +42,19 @@ export function ActivityForm({
subtitle?: string; subtitle?: string;
submitLabel?: string; submitLabel?: string;
danger?: boolean; danger?: boolean;
/** Values to pre-fill. Seeded fields are still submitted, shown read-only. */ /** Values to pre-fill and LOCK. For fields that identify the thing being
* acted on, where letting someone edit it would just be a way to get it
* wrong. */
seed?: Record<string, unknown>; seed?: Record<string, unknown>;
/** Values to pre-fill and leave EDITABLE.
*
* Different intent from `seed`, and the difference matters: a credit
* manager's remarks are HIS remarks, so the form must not lock them but
* making him type two paragraphs from scratch, on a screen that already
* states every fact he would type, is how a good decision turns into a
* data-entry chore. So the form opens with a defensible draft grounded in
* the file, and he edits or replaces it. */
prefill?: Record<string, unknown>;
onDone?: (result: { instance_id?: number }) => void; onDone?: (result: { instance_id?: number }) => void;
}) { }) {
const { client } = useZino(); const { client } = useZino();
@ -59,10 +71,16 @@ export function ActivityForm({
const fields = schema.data?.fields ?? []; const fields = schema.data?.fields ?? [];
// Precedence, weakest first: the platform's own field defaults, then our
// computed draft, then the seeded identifiers (which also become read-only).
const initial = useMemo( const initial = useMemo(
() => ({ ...(schema.data?.field_defaults ?? {}), ...(seed ?? {}) }), () => ({
...(schema.data?.field_defaults ?? {}),
...(prefill ?? {}),
...(seed ?? {}),
}),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[schema.data, JSON.stringify(seed)], [schema.data, JSON.stringify(prefill), JSON.stringify(seed)],
); );
const current = { ...initial, ...values }; const current = { ...initial, ...values };

139
src/prefill.ts Normal file
View File

@ -0,0 +1,139 @@
import type { Row } from './api/types';
import type { User } from './api/types';
import { ACT, POLICY } from './api/config';
import { num, pct, str } from './format';
/**
* The draft each action form opens with.
*
* WHY THIS EXISTS. Every fact a credit manager would type into "Deviation Note"
* is already on the screen behind the dialogue the three turnover figures, the
* spread, the tolerance, the computed ceiling. Making him retype it is not
* diligence, it is transcription, and on a demo it is two paragraphs of typing
* in front of an audience.
*
* So each form opens with a defensible draft assembled from the file, and the
* decision-maker edits or replaces it. Nothing here is locked: the remarks are
* his remarks and the amount is his amount. What is removed is the blank page.
*
* TWO RULES THIS FOLLOWS.
*
* 1. Never draft the DECISION, only the wording of it. The suggested amount is
* the largest figure the policy can defend, not a recommendation and the
* one place a draft would be wrong is a decline reason, so Decline opens
* empty. A pre-written justification for refusing someone credit is not a
* convenience.
*
* 2. Attribution fields come from the SIGNED-IN USER, never typed. "Prepared
* By" is whoever is holding the mouse; asking him to type his own name is
* an invitation to type someone else's, and on a four-eyes control that is
* the one field you least want free-texted.
*/
/** A rupee amount, grouped Indian-style, for embedding in prose. */
function amt(n: number): string {
return new Intl.NumberFormat('en-IN', { maximumFractionDigits: 0 }).format(Math.round(n));
}
export function prefillFor(
activity: string,
row: Row,
user: User | null,
): Record<string, unknown> {
const me = str(user?.name);
const requested = num(row.requested_amount) ?? 0;
const maxElig = num(row.max_eligible_amount) ?? 0;
const variance = num(row.income_variance_pct);
const flag = str(row.deviation_flags);
const itr = num(row.itr_declared_income);
const gst = num(row.gst_turnover_12m);
const bank = num(row.bank_credits_12m);
switch (activity) {
case ACT.CM_APPROVE: {
// The cap: the most the policy can defend. Where three turnover figures
// disagree, size the loan so the unanswered question stops mattering —
// lend against the LOWEST figure rather than the one we hope is true.
const sources = [itr, gst, bank].filter((v): v is number => v !== null && v > 0);
const lowest = sources.length >= 2 ? Math.min(...sources) : null;
const capByEligibility = maxElig > 0 ? Math.min(requested, maxElig) : requested;
// A quarter of the lowest declared turnover is a conventional working
// ceiling for an unsecured facility of this shape.
const capByLowestSource = lowest !== null ? Math.round((lowest * 0.25) / 100000) * 100000 : null;
const suggested =
capByLowestSource !== null
? Math.min(capByEligibility, capByLowestSource)
: capByEligibility;
const note =
flag === 'income_corroboration' && variance !== null
? `Turnover corroboration spread of ${pct(variance)} noted and accepted. The gap is consistent with exempt or zero-rated sales absent from the ITR computation; GST filings and bank credits agree once that is allowed for. To be re-tested at the next review.`
: flag
? `Deviation on ${flag.replace(/_/g, ' ')} noted and accepted at this exposure.`
: '';
const remarks =
suggested < requested
? `Business verified as genuine — ${str(row.business_vintage_months)} months vintage, GST and Udyam registered, bureau ${str(row.bureau_score)} with no adverse history. Exposure capped at ${amt(suggested)} against ${amt(requested)} requested: an amount serviceable even on the lowest of the three declared turnover figures, so the outstanding corroboration question does not put the facility at risk. Revisit the limit after two further GST filing cycles.`
: `Business verified as genuine — ${str(row.business_vintage_months)} months vintage, GST and Udyam registered, bureau ${str(row.bureau_score)}. Proceeding at the requested amount, which sits inside the computed eligibility of ${amt(maxElig)}.`;
return { cm_approved_amount: suggested, cm_deviation_note: note, cm_remarks: remarks, maker: me };
}
case ACT.MAKE_OFFER: {
// Offer what credit actually approved, not what was asked for. Pricing
// follows the advisor's suggestion when it has run, else policy standard.
const approved = num(row.cm_approved_amount);
const base = approved ?? (maxElig > 0 ? Math.min(requested, maxElig) : requested);
const suggestedRoi = num(row.ai_suggested_roi);
// A file that needed a deviation is priced above the standard rate.
const roi = suggestedRoi ?? (flag ? POLICY.indicativeRoi + 0.75 : POLICY.indicativeRoi);
const tenure = num(row.ai_suggested_tenure) ?? num(row.requested_tenure_months) ?? 36;
return {
offered_amount: base,
offered_roi: roi,
offered_tenure_months: tenure,
offered_fee: Math.round((base * 0.0125) / 500) * 500,
};
}
case ACT.SANCTION: {
// Sanction the offer as it stands. A checker who wants different terms
// changes them here deliberately, rather than re-deriving them.
return {
sanctioned_amount: num(row.offered_amount) ?? num(row.cm_approved_amount) ?? requested,
sanctioned_roi: num(row.offered_roi) ?? POLICY.indicativeRoi,
sanctioned_tenure_months: num(row.offered_tenure_months) ?? 36,
sanction_conditions: flag
? `Capped exposure against an open corroboration question. Revisit the limit after two further GST filing cycles. Standard hypothecation of assets financed.`
: `Standard terms. Hypothecation of assets financed.`,
checker: me,
};
}
case ACT.DISBURSE:
return {
disbursed_amount: num(row.sanctioned_amount) ?? num(row.offered_amount) ?? 0,
disbursed_at: new Date().toISOString(),
};
case ACT.AI_OFFER: {
const approved = num(row.cm_approved_amount);
const base = approved ?? (maxElig > 0 ? Math.min(requested, maxElig) : requested);
return {
ai_suggested_amount: base,
ai_suggested_roi: flag ? POLICY.indicativeRoi + 0.75 : POLICY.indicativeRoi,
ai_suggested_tenure: num(row.requested_tenure_months) ?? 36,
};
}
// Declines open EMPTY. See rule 1 above — a pre-written reason for refusing
// someone credit is not a convenience, and the reason is the whole record.
case ACT.CM_DECLINE:
case ACT.DECLINE:
return { declined_by: me };
default:
return {};
}
}

View File

@ -9,6 +9,7 @@ import { StageBadge, StageRail } from '../components/Stage';
import { EvidencePanel } from '../components/EvidencePanel'; import { EvidencePanel } from '../components/EvidencePanel';
import { AiDecisionPanel } from '../components/AiDecisionPanel'; import { AiDecisionPanel } from '../components/AiDecisionPanel';
import { ActivityForm } from '../components/ActivityForm'; import { ActivityForm } from '../components/ActivityForm';
import { prefillFor } from '../prefill';
import type { Row } from '../api/types'; import type { Row } from '../api/types';
import { import {
dateTime, dateTime,
@ -290,6 +291,7 @@ export function ApplicationScreen() {
subtitle={`${str(row.application_no) || `File ${id}`} · ${stage}`} subtitle={`${str(row.application_no) || `File ${id}`} · ${stage}`}
submitLabel={openAction.label} submitLabel={openAction.label}
danger={openAction.kind === 'danger'} danger={openAction.kind === 'danger'}
prefill={prefillFor(openAction.activity, row, user)}
onDone={() => file.refetch()} onDone={() => file.refetch()}
/> />
)} )}