Custom frontend for the HDFC loan-origination demo (dev, org 83 / app 524,
workflow hdfc_wf_loan). MSME and personal loan files: agents assemble the
evidence, rules compute capacity, credit decides.
Stack and platform contract follow the Flight-Disruption-Management console,
which runs against this same cluster:
* API client carries its predecessor's hard-won notes — instance_id goes
over the wire as a NUMBER (a quoted id fails the int64 decode), org_id as
a STRING, record views answer under `data` OR `records`, and audit rows
need the TRIGGER_ prefix filtered or every activity appears three times.
* VITE_ZINO_API_URL is read at RUNTIME from the config.js the server writes
at placement, never compiled in, so one artifact is promoted between
environments unchanged. Missing config fails loudly.
* base: './' plus a router basename from <base href>, so one build serves
any mount path.
* Forms are read from the LIVE activity schema — no field definitions in
this repo. Add a field in Studio, redeploy, it appears.
Written for this app:
* EvidencePanel, the centrepiece. Three independent income sources side by
side with the widest pair marked; every computed ratio shown against the
threshold it was tested on; and a visible line between what a rule
computed and what a model wrote (rules are never violet).
* Queues are the one record view filtered server-side on
current_state_name — the sidebar is the pipeline. Income variance is
surfaced in the list, not only on the file.
* Application 360 with the evidence panel above the offer and the sanction,
so the screen reads in the order the decision was made.
* HDFC palette where red is never decoration: the logo block and declines
only. The referred queue's amber is the only amber in the pipeline.
Known gaps, documented in README rather than hidden: OCR uploads render as a
visible pending row instead of a control that pretends to work, and Credit
Assessment is still performed by a human pending the agent wiring.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
106 lines
3.1 KiB
TypeScript
106 lines
3.1 KiB
TypeScript
/**
|
|
* Formatting. Indian conventions throughout, because the numbers in this app
|
|
* are rupee amounts read by Indian bankers.
|
|
*
|
|
* `en-IN` grouping is not cosmetic: ₹18,00,000 is how eighteen lakh is written,
|
|
* and ₹1,800,000 reads as a foreign system's idea of the same number. The
|
|
* lakh/crore short forms exist because a queue column has no room for eight
|
|
* digits and "₹18.0 L" is what a person would say out loud.
|
|
*/
|
|
|
|
const INR = new Intl.NumberFormat('en-IN', { maximumFractionDigits: 0 });
|
|
|
|
export function num(v: unknown): number | null {
|
|
if (v === null || v === undefined || v === '') return null;
|
|
const n = Number(v);
|
|
return Number.isFinite(n) ? n : null;
|
|
}
|
|
|
|
export function str(v: unknown): string {
|
|
if (v === null || v === undefined) return '';
|
|
return String(v);
|
|
}
|
|
|
|
/** ₹18,00,000 */
|
|
export function money(v: unknown): string {
|
|
const n = num(v);
|
|
return n === null ? '—' : `₹${INR.format(n)}`;
|
|
}
|
|
|
|
/** ₹18.0 L / ₹1.25 Cr — for columns and tiles. */
|
|
export function moneyShort(v: unknown): string {
|
|
const n = num(v);
|
|
if (n === null) return '—';
|
|
if (Math.abs(n) >= 1e7) return `₹${(n / 1e7).toFixed(2)} Cr`;
|
|
if (Math.abs(n) >= 1e5) return `₹${(n / 1e5).toFixed(1)} L`;
|
|
return `₹${INR.format(n)}`;
|
|
}
|
|
|
|
/** 44.3% */
|
|
export function pct(v: unknown, digits = 1): string {
|
|
const n = num(v);
|
|
return n === null ? '—' : `${n.toFixed(digits)}%`;
|
|
}
|
|
|
|
/** A stored ratio (0.7445) shown as a percentage. */
|
|
export function ratioPct(v: unknown, digits = 1): string {
|
|
const n = num(v);
|
|
return n === null ? '—' : `${(n * 100).toFixed(digits)}%`;
|
|
}
|
|
|
|
/** 1.34 */
|
|
export function ratio(v: unknown, digits = 2): string {
|
|
const n = num(v);
|
|
return n === null ? '—' : n.toFixed(digits);
|
|
}
|
|
|
|
export function dateTime(v: unknown): string {
|
|
const s = str(v);
|
|
if (!s) return '—';
|
|
const d = new Date(s);
|
|
if (Number.isNaN(d.getTime())) return s;
|
|
return d.toLocaleString('en-IN', {
|
|
day: '2-digit',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
export function dateOnly(v: unknown): string {
|
|
const s = str(v);
|
|
if (!s) return '—';
|
|
const d = new Date(s);
|
|
if (Number.isNaN(d.getTime())) return s;
|
|
return d.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
|
}
|
|
|
|
/** "msme" → "MSME / Business". Product codes are stored, not displayed. */
|
|
export function productLabel(v: unknown): string {
|
|
const s = str(v);
|
|
if (s === 'msme') return 'MSME / Business';
|
|
if (s === 'personal') return 'Personal';
|
|
return s || '—';
|
|
}
|
|
|
|
export function relationshipLabel(v: unknown): string {
|
|
const s = str(v);
|
|
if (s === 'new_to_bank') return 'New to Bank';
|
|
if (s === 'existing') return 'Existing Customer';
|
|
return s || '—';
|
|
}
|
|
|
|
/**
|
|
* The assessment's reason codes arrive as one pipe-delimited string, because
|
|
* that is the shape a customJS node can return without inventing a schema.
|
|
* Split for display — a wall of pipes is unreadable, and each code is a
|
|
* separate finding a credit manager weighs separately.
|
|
*/
|
|
export function reasonCodes(v: unknown): string[] {
|
|
return str(v)
|
|
.split('|')
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
}
|