HDFC-Loan-Desk/src/format.ts
Yashas 2e8cc580ac feat: HDFC Loan Desk operator console
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>
2026-08-17 15:42:55 +05:30

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);
}