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>
This commit is contained in:
Yashas 2026-08-17 15:42:55 +05:30
commit 2e8cc580ac
32 changed files with 5681 additions and 0 deletions

5
.env.example Normal file
View File

@ -0,0 +1,5 @@
# Local development only (`npm run dev`). Nothing here is used by a deployed
# build — those values come from config.js, which the server writes when it
# places the artifact. Do not commit environment-specific values: one build is
# promoted across environments unchanged.
VITE_ZINO_API_URL=https://dev.getzino.in

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
node_modules
dist
dist-ssr
*.local
.DS_Store
.vite
.vscode/*
!.vscode/extensions.json
.idea
npm-debug.log*
.env
*.tsbuildinfo

122
README.md Normal file
View File

@ -0,0 +1,122 @@
# HDFC Bank — Loan Desk
Custom operator console for the HDFC loan-origination demo. MSME and personal
loan files: **agents assemble the evidence, rules compute capacity, credit
decides.**
The workflow behind it is seeded from
`sm2/custom-apps/hdfc-loan-desk/` (org **83**, app **524**, workflow
`hdfc_wf_loan`). Design doc: `sm2/app-designs/loan-origination/design.html`.
## Run it
```bash
npm install
npm run dev # http://localhost:5174
npm run build # tsc -b && vite build → dist/
npm run typecheck
```
`.env` carries the API host **for local dev only**:
```
VITE_ZINO_API_URL=https://dev.getzino.in
```
Logins (all password `HdfcDemo@2026`, org `83`):
| Email | Role | Can do |
|---|---|---|
| `priya.rm@hdfc.example` | RM, Business Banking | Capture, Upload Documents, **Make Offer**, Decline |
| `rahul.credit@hdfc.example` | Credit Manager (maker) | Credit Assessment, **Approve / Decline Referred** |
| `anita.approver@hdfc.example` | Credit Approver (checker) | **Sanction** — and nothing else |
| `vikram.ops@hdfc.example` | Disbursal Ops | **Release Disbursal** — and nothing else |
Sign in as Rahul to land in the Credit Queue, which is where the demo happens.
## How it talks to the platform
Everything goes through `src/api/client.ts`. Three routes matter:
- `POST /usr/login` — auth. **`org_id` must be a string**; a number returns
`cannot unmarshal number into Go struct field LoginRequest.org_id`.
- `POST /app/524/view/recordview` — every queue is this one record view
(`hdfc-rv-applications`) filtered server-side on `current_state_name`.
- `POST /app/524/view/form-screens` then `POST /app/524/activity` — forms are
read from the **live** activity schema and submitted straight back.
### Forms are not defined in this repo
`ActivityForm` renders whatever `/view/form-screens` returns — labels, types,
select options, which fields are mandatory. Add a field to an activity in
Studio, redeploy, and it appears here with no frontend change. That is
deliberate: the workflow is the source of truth, and a hardcoded form would
quietly diverge from it.
### The runtime-config contract
`VITE_ZINO_API_URL` is read at **runtime** from a `config.js` the server writes
when it places the build — never compiled in. One artifact is promoted between
environments unchanged, so a build-time URL would point every environment at
whichever backend happened to build it. `requireConfigValue` throws if it is
missing, so a production build with no `config.js` fails loudly rather than
calling the wrong backend.
`vite.config.ts` uses `base: './'` and the router takes its basename from the
`<base href>` the server writes, so **one build serves any mount path**. Do not
reintroduce a build-time base.
## What the screens are for
**Queues** (`src/screens/PipelineScreen.tsx`) — the sidebar *is* the pipeline,
in the order a file moves. The variance figure is surfaced in the list, not just
on the file, so a credit manager scanning the queue can see which referrals are
corroboration questions before opening any of them.
**The file** (`src/screens/ApplicationScreen.tsx`) — evidence panel first, above
the offer and the sanction, because whoever reads the screen top to bottom
should read the decision in the order it was made.
**The evidence panel** (`src/components/EvidencePanel.tsx`) is the point of the
whole app. It is built around three claims:
1. **Three independent income sources, side by side**, with the widest pair
marked. A single figure labelled "variance 44.3%" is a number nobody can
check; three figures with the spread drawn is an argument a person can accept
or reject on sight.
2. **Every ratio shown against the threshold it was tested on.** "1.34" means
nothing; "1.34 against a floor of 1.25" is a finding.
3. **A visible line between what a rule computed and what a model wrote.**
Rules are never violet; the agent's narrative always is. Nobody should have
to be told which is which.
## Theming
One attribute — `data-theme` on `<html>` — swaps the whole console. Every colour
is a CSS custom property redefined under `:root[data-theme='dark']` in
`src/styles.css`. There is no second stylesheet and no `dark:` prefix anywhere.
**Never put a literal colour in a component.** A hex or a raw Tailwind shade is
invisible to the toggle and will be wrong in one of the two themes.
The palette is the bank's two colours, and **red is never decoration** — it
marks the logo block and a decline, nothing else. So when red appears in a
queue it means something. The referred queue's amber is the only amber in the
pipeline, for the same reason: it should read as "needs you" without reading as
an error. A referred file is not a bad file.
## Known gaps
- **OCR document uploads are not wired.** The seven `ocr` fields on Upload
Documents render as a visible pending row rather than a control that pretends
to work; the extracted figures are entered directly. The evidence factory that
generates the documents is the next piece of work.
- **No AI employees yet.** Credit Assessment is currently performed by a human
(the Credit Manager holds the role alongside the agent). Wiring the agent
changes *who submits the activity*, not the workflow — the state machine, the
gate and this console are unchanged by it.
- **The advisory offer** only renders once the advisor has run; the
`ai_suggested_*` columns are deliberately separate from `offered_*` so a
suggestion can never be mistaken for a commitment.
- **Four-eyes is role separation**, not same-user detection: it holds because no
user is granted both `credit_manager` and `credit_approver`.

51
index.html Normal file
View File

@ -0,0 +1,51 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!--BASE_HREF-->
<title>HDFC Bank | Loan Desk</title>
<!-- The logo block as a data-URI favicon: an inline SVG needs no asset,
which matters because the build is promoted between environments
unchanged and a /favicon.ico would resolve against whatever mount path
the server chose. -->
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23004C8F'/%3E%3Crect x='6' y='6' width='20' height='20' fill='%23fff'/%3E%3Crect x='9' y='9' width='14' height='14' fill='%23ED232A'/%3E%3C/svg%3E"
/>
<!-- Navy chrome on mobile browsers, matching the app header. Kept in step
with the active theme by applyTheme() in src/theme.ts. -->
<meta name="theme-color" content="#004c8f" />
<!-- Theme, resolved BEFORE first paint.
This duplicates a few lines of src/theme.ts on purpose. The bundle is a
module and therefore deferred, so resolving the theme in React means
the browser paints the light default first and a dark-mode user gets a
white flash on every navigation. Blocking and inline is the only place
this can run early enough. Keep the localStorage key in step with
THEME_KEY. -->
<script>
(function () {
try {
var t = localStorage.getItem('hdfc-loan-desk-theme');
if (t !== 'light' && t !== 'dark') {
t = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
document.documentElement.setAttribute('data-theme', t);
if (t === 'dark') {
var m = document.querySelector('meta[name="theme-color"]');
if (m) m.setAttribute('content', '#00152b');
}
} catch (e) {
document.documentElement.setAttribute('data-theme', 'light');
}
})();
</script>
<script src="./config.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2409
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "hdfc-loan-desk",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "HDFC Bank — Loan Desk. MSME and personal loan origination: agents assemble the file, rules compute capacity, credit decides.",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"tailwindcss": "^4.0.0",
"typescript": "^5.5.4",
"vite": "^5.4.6"
}
}

36
src/App.tsx Normal file
View File

@ -0,0 +1,36 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { useZino } from './api/provider';
import { Shell } from './layout/Shell';
import { Login } from './pages/Login';
import { PipelineScreen } from './screens/PipelineScreen';
import { ApplicationScreen } from './screens/ApplicationScreen';
import { NewApplicationScreen } from './screens/NewApplicationScreen';
/**
* Routes.
*
* The signed-out case swaps the WHOLE tree for the login page rather than
* redirecting, so there is no window in which a protected screen mounts,
* fires a request and gets a 401 back. The client's auth-error handler clears
* the user, which lands here and re-renders as Login one path in, one path
* out.
*/
export function App() {
const { user } = useZino();
if (!user) return <Login />;
return (
<Routes>
<Route element={<Shell />}>
<Route path="/q/:q" element={<PipelineScreen />} />
<Route path="/f/:id" element={<ApplicationScreen />} />
<Route path="/new" element={<NewApplicationScreen />} />
{/* The Credit Queue is the landing page, not "all files". It is the
queue with work in it, and the one the app exists to serve. */}
<Route path="/" element={<Navigate to="/q/credit" replace />} />
<Route path="*" element={<Navigate to="/q/credit" replace />} />
</Route>
</Routes>
);
}

244
src/api/client.ts Normal file
View File

@ -0,0 +1,244 @@
import type {
ActivityResult,
AiDecision,
ApiError,
AuditEntry,
DetailViewResponse,
FormScreenResponse,
LoginResponse,
RecordViewParams,
RecordViewResponse,
Row,
User,
} from './types';
import { APP_ID } from './config';
const TOKEN_KEY = 'hdfc_loan_desk_token';
/**
* HTTP client for the Zino gateway.
*
* Most routes are app-scoped (`/app/524/...`); login and the AI-employee
* monitor are not. The JWT persists in localStorage so a refresh does not
* bounce the operator back to the login screen mid-disruption.
*/
export class ZinoClient {
readonly baseUrl: string;
private token: string | null = null;
private onAuthError?: () => void;
constructor(baseUrl: string, onAuthError?: () => void) {
this.baseUrl = baseUrl.replace(/\/+$/, '');
this.onAuthError = onAuthError;
if (typeof window !== 'undefined') this.token = localStorage.getItem(TOKEN_KEY);
}
setAuthErrorHandler(fn: () => void): void {
this.onAuthError = fn;
}
setToken(token: string | null): void {
this.token = token;
if (typeof window === 'undefined') return;
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
}
getToken(): string | null {
return this.token;
}
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
const res = await fetch(`${this.baseUrl}${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (res.status === 401) {
this.setToken(null);
this.onAuthError?.();
throw { status: 401, message: 'Session expired — sign in again' } as ApiError;
}
if (!res.ok) {
let message = res.statusText;
try {
const j = (await res.json()) as { error?: string; message?: string };
message = j.error || j.message || message;
} catch {
/* non-JSON body */
}
throw { status: res.status, message } as ApiError;
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
// --- Auth ---------------------------------------------------------------
/** org_id must be a STRING. The gateway rejects a number with
* "cannot unmarshal number into Go struct field LoginRequest.org_id". */
async login(email: string, password: string, orgId: string): Promise<LoginResponse> {
const res = await this.request<LoginResponse>('POST', '/usr/login', {
email,
password,
org_id: String(orgId),
});
this.setToken(res.token);
return res;
}
logout(): void {
this.setToken(null);
}
/** Decode the persisted JWT into a User no network round trip, so a hard
* refresh restores the session without flashing the login screen. */
currentUser(): User | null {
if (!this.token) return null;
try {
const p = JSON.parse(atob(this.token.split('.')[1]));
return {
id: String(p.user_id ?? p.sub ?? ''),
org_id: String(p.org_id ?? ''),
name: p.name ?? '',
email: p.email ?? '',
roles: p.roles ?? [],
groups: p.groups ?? [],
};
} catch {
return null;
}
}
// --- Views --------------------------------------------------------------
async recordView(rvUid: string, params: RecordViewParams = {}): Promise<RecordViewResponse> {
return this.request<RecordViewResponse>('POST', `/app/${APP_ID}/view/recordview`, {
rv_template_uid: rvUid,
search_query: {
page: params.page ?? 1,
limit: params.limit ?? 50,
sort_by: params.sortBy ?? '',
sort_dir: params.sortDir ?? 'desc',
search: params.search ?? '',
filters: (params.filters ?? []).map((f) => ({
field_key: f.field_key,
value: f.value,
value2: '',
data_type: f.data_type ?? 'string',
})),
},
});
}
/** Rows only. The response key differs by source type workflow views
* return `data`, rdbms views have been seen returning `records`. */
async rows(rvUid: string, params: RecordViewParams = {}): Promise<Row[]> {
const r = await this.recordView(rvUid, params);
return r.data ?? r.records ?? [];
}
detailView(dvUid: string, instanceId: number | string): Promise<DetailViewResponse> {
return this.request<DetailViewResponse>(
'GET',
`/app/${APP_ID}/view/detailview/${dvUid}?instance_id=${encodeURIComponent(String(instanceId))}`,
);
}
async audit(instanceId: number | string): Promise<AuditEntry[]> {
const rows = await this.request<AuditEntry[]>(
'GET',
`/app/${APP_ID}/view/audit?instance_id=${encodeURIComponent(String(instanceId))}`,
);
// One SUBMISSION writes several audit rows: a dispatch record, a
// trigger-pipeline record, and finally the commit (whose
// execution_state is the state id, and which carries the badge). The
// history screen was rendering all of them, so every activity appeared
// three times with identical text. Only the commit row is the event a
// person means by "what happened"; the rest are the engine's own
// bookkeeping, kept in the DB for reconciliation and deliberately not
// shown. Filtered by prefix because the set of plumbing constants has
// grown before (TRIGGER_DISPATCHED_POST_COMMIT is historical-only).
return (rows ?? []).filter((r) => !String(r.execution_state ?? '').startsWith('TRIGGER_'));
}
/** AI-employee decisions for an instance. Public monitor route. */
aiDecisions(instanceId: number | string): Promise<AiDecision[]> {
return this.request<{ decisions: AiDecision[] }>(
'GET',
`/monitor/decisions?instance_id=${encodeURIComponent(String(instanceId))}`,
).then((r) => r.decisions ?? []);
}
// --- Forms --------------------------------------------------------------
/** The live definition of an activity's form: fields, types, select options
* and which are mandatory. Read at open time rather than hardcoded, so a
* field added in Studio appears here without a frontend change. */
formSchema(activityUid: string, instanceId?: number | string): Promise<FormScreenResponse> {
return this.request<FormScreenResponse>('POST', `/app/${APP_ID}/view/form-screens`, {
activity_id: activityUid,
device_type: 'desktop',
...(instanceId != null ? { instance_id: numericId(instanceId) } : {}),
});
}
// --- Workflow execution -------------------------------------------------
/** Start a new instance (an INIT activity such as Register Flight). */
startInstance(
workflowUuid: string,
activityUid: string,
data: Record<string, unknown>,
): Promise<ActivityResult> {
return this.request<ActivityResult>('POST', `/app/${APP_ID}/start`, {
workflow_uuid: workflowUuid,
activity_id: activityUid,
data,
});
}
/** Perform an activity on an existing instance. The workflow refuses
* anything the current state does not allow that refusal is a feature,
* not an error to paper over. */
performActivity(
workflowUuid: string,
instanceId: number | string,
activityUid: string,
data: Record<string, unknown>,
): Promise<ActivityResult> {
return this.request<ActivityResult>('POST', `/app/${APP_ID}/activity`, {
workflow_uuid: workflowUuid,
instance_id: numericId(instanceId),
activity_id: activityUid,
data,
});
}
}
/**
* Instance ids must go over the wire as JSON NUMBERS.
*
* The route params they come from are strings, and view-service decodes
* `instance_id` into an int64 so a quoted "9765" fails the decode and the
* whole request comes back "Invalid request body" with no clue which field
* was at fault. That is what broke Retry call: the form schema never loaded,
* because the id had been carried straight through from useParams.
*
* performActivity had its own inline coercion and formSchema did not, which
* is exactly the kind of divergence that survives review. One helper, used by
* both.
*
* A non-numeric id is passed through untouched rather than becoming NaN if
* an id ever stops being an integer, the server should say so plainly instead
* of receiving null.
*/
function numericId(id: number | string): number | string {
if (typeof id === 'number') return id;
const n = Number(id);
return Number.isFinite(n) && id.trim() !== '' ? n : id;
}

190
src/api/config.ts Normal file
View File

@ -0,0 +1,190 @@
// HDFC Bank — Loan Desk. dev, org 83 / app 524.
//
// Every id here is a readable uid rather than a UUID. That is not a
// simplification: this app's config was seeded directly into the builder
// tables (sm2/custom-apps/hdfc-loan-desk/), so the uids ARE the canonical
// handles the gateway resolves. `hdfc-rv-applications` is what the API expects.
export const ORG_ID = '83';
export const APP_ID = '524';
/** One workflow. One instance per loan application. */
export const WORKFLOW = 'hdfc_wf_loan';
export const RECORD_VIEW = 'hdfc-rv-applications';
export const DETAIL_VIEW = 'hdfc-dv-application';
// ---------------------------------------------------------------------------
// Activities
//
// The uid is the contract with the workflow. Forms are NOT hardcoded here:
// each activity's fields are read from view-service at open time, so a field
// added in Studio appears without a frontend change.
// ---------------------------------------------------------------------------
export const ACT = {
CAPTURE: 'hdfc-act-capture', // INIT
UPLOAD_DOCS: 'hdfc-act-upload-docs',
ASSESS: 'hdfc-act-assess',
CM_APPROVE: 'hdfc-act-cm-approve',
CM_DECLINE: 'hdfc-act-cm-decline',
AI_OFFER: 'hdfc-act-ai-offer',
MAKE_OFFER: 'hdfc-act-make-offer',
SANCTION: 'hdfc-act-sanction',
DISBURSE: 'hdfc-act-disburse',
DECLINE: 'hdfc-act-decline',
} as const;
// ---------------------------------------------------------------------------
// The pipeline
//
// Stage order drives the progress rail and the queue navigation. This mirrors
// workflow.tbl_wf_states — keep them in step.
// ---------------------------------------------------------------------------
export const STAGES = [
'New Application',
'Documents Collected',
'Referred to Credit',
'Credit Approved',
'Offer Made',
'Sanctioned',
'Disbursed',
] as const;
export type Stage = (typeof STAGES)[number];
/** Off the happy path. Terminal. */
export const TERMINAL_BRANCH = 'Declined';
export function stageStep(stateName?: string): number {
if (!stateName) return -1;
return STAGES.indexOf(stateName as Stage);
}
export type Tone = 'slate' | 'amber' | 'sky' | 'emerald' | 'rose' | 'violet';
export const STAGE_TONE: Record<string, Tone> = {
'New Application': 'slate',
'Documents Collected': 'sky',
// Amber, and deliberately the only amber in the pipeline: this is the queue
// a human owns, and it should read as "needs you" at a glance.
'Referred to Credit': 'amber',
'Credit Approved': 'violet',
'Offer Made': 'sky',
Sanctioned: 'emerald',
Disbursed: 'emerald',
Declined: 'rose',
};
// ---------------------------------------------------------------------------
// Queues
//
// The navigation IS the pipeline: each queue is the record view filtered on
// current_state_name. `stage: null` means no filter — every file.
// ---------------------------------------------------------------------------
export interface Queue {
path: string;
label: string;
stage: string | null;
/** Shown in the sidebar as the reason this queue exists. */
hint: string;
}
export const QUEUES: Queue[] = [
{ path: 'all', label: 'All Files', stage: null, hint: 'Every application' },
{ path: 'inbox', label: 'Inbox', stage: 'New Application', hint: 'Awaiting documents' },
{ path: 'documents', label: 'Document Desk', stage: 'Documents Collected', hint: 'Ready for assessment' },
{ path: 'credit', label: 'Credit Queue', stage: 'Referred to Credit', hint: 'Referred — needs a decision' },
{ path: 'approved', label: 'Approved', stage: 'Credit Approved', hint: 'Awaiting offer and KFS' },
{ path: 'offers', label: 'Offers', stage: 'Offer Made', hint: 'Awaiting sanction' },
{ path: 'sanctioned', label: 'Sanction Desk', stage: 'Sanctioned', hint: 'Awaiting disbursal' },
{ path: 'disbursed', label: 'Book', stage: 'Disbursed', hint: 'Disbursed' },
{ path: 'declined', label: 'Declined', stage: 'Declined', hint: 'Closed, not taken' },
];
// ---------------------------------------------------------------------------
// Actions available per stage
//
// Mirrors workflow.tbl_wf_state_allowed_activities. This does NOT enforce
// anything — the workflow re-checks RBAC on every submission, and an
// unauthorised action is refused by the server whatever this file says. It
// exists so a Credit Manager is not shown a Disburse button she cannot use.
//
// `roles` is the same list seeded into tbl_wf_perm_allowed_roles. A file that
// disagrees with the seed produces a button that returns "not permitted",
// which is confusing but not dangerous.
// ---------------------------------------------------------------------------
export interface Action {
activity: string;
label: string;
roles: string[];
/** Visual weight. `primary` is the expected next step for this stage. */
kind: 'primary' | 'secondary' | 'danger';
}
export const STAGE_ACTIONS: Record<string, Action[]> = {
'New Application': [
{ activity: ACT.UPLOAD_DOCS, label: 'Upload Documents', roles: ['rm_business_banking'], kind: 'primary' },
{ activity: ACT.DECLINE, label: 'Decline', roles: ['rm_business_banking', 'credit_manager'], kind: 'danger' },
],
'Documents Collected': [
{ activity: ACT.ASSESS, label: 'Run Credit Assessment', roles: ['credit_manager', 'ai_credit_assessor'], kind: 'primary' },
{ activity: ACT.DECLINE, label: 'Decline', roles: ['rm_business_banking', 'credit_manager'], kind: 'danger' },
],
'Referred to Credit': [
{ activity: ACT.CM_APPROVE, label: 'Approve File', roles: ['credit_manager'], kind: 'primary' },
{ activity: ACT.CM_DECLINE, label: 'Decline File', roles: ['credit_manager'], kind: 'danger' },
],
'Credit Approved': [
{ activity: ACT.MAKE_OFFER, label: 'Make Offer & Issue KFS', roles: ['rm_business_banking'], kind: 'primary' },
{ activity: ACT.AI_OFFER, label: 'AI Offer Recommendation', roles: ['ai_loan_advisor'], kind: 'secondary' },
{ activity: ACT.DECLINE, label: 'Decline', roles: ['rm_business_banking', 'credit_manager'], kind: 'danger' },
],
'Offer Made': [
{ activity: ACT.SANCTION, label: 'Sanction Loan', roles: ['credit_approver'], kind: 'primary' },
{ activity: ACT.DECLINE, label: 'Decline', roles: ['rm_business_banking', 'credit_manager'], kind: 'danger' },
],
Sanctioned: [
{ activity: ACT.DISBURSE, label: 'Release Disbursal', roles: ['disbursal_ops'], kind: 'primary' },
],
};
export function actionsFor(stage: string | undefined, roles: string[] | undefined): Action[] {
const all = STAGE_ACTIONS[stage ?? ''] ?? [];
const mine = roles ?? [];
if (mine.some((r) => SUPER_ROLES.includes(r))) return all;
return all.filter((a) => a.roles.some((r) => mine.includes(r)));
}
// ---------------------------------------------------------------------------
// Credit policy, for display only
//
// The AUTHORITATIVE copy of every one of these lives in the POLICY block of
// the Credit Assessment trigger's customJS (03_triggers.sql), evaluated
// server-side. These are here so the evidence panel can show a computed value
// against the threshold it was tested on — "1.34 against a floor of 1.25" says
// far more than "1.34".
//
// They must be kept in step with the trigger by hand. If a number here ever
// disagrees with the server, the server is right.
// ---------------------------------------------------------------------------
export const POLICY = {
version: 'HDFC-CP-2026.08',
dscrFloor: 1.25,
foirCeiling: 0.55,
varianceTolerancePct: 25,
bureauFloor: 680,
cmrCeiling: 6,
dpdCeiling: 30,
bounceCeiling: 3,
} as const;
export const SUPER_ROLES = ['Super Admin', 'Admin'];
/** Roles read better with a space than an underscore. */
export function prettyRole(role: string): string {
return role
.replace(/^ai_/, 'AI ')
.replace(/_/g, ' ')
.replace(/\brm\b/i, 'RM')
.replace(/\b\w/g, (c) => c.toUpperCase());
}

123
src/api/provider.tsx Normal file
View File

@ -0,0 +1,123 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { ReactNode } from 'react';
import { ZinoClient } from './client';
import type { User } from './types';
interface ZinoContextValue {
client: ZinoClient;
user: User | null;
login: (email: string, password: string, orgId: string) => Promise<void>;
logout: () => void;
}
const ZinoContext = createContext<ZinoContextValue | null>(null);
export function ZinoProvider({ baseUrl, children }: { baseUrl: string; children: ReactNode }) {
const clientRef = useRef<ZinoClient>();
if (!clientRef.current) clientRef.current = new ZinoClient(baseUrl);
const client = clientRef.current;
// Restored synchronously from the persisted JWT so a hard navigation to a
// protected route does not flash through /login.
const [user, setUser] = useState<User | null>(() => client.currentUser());
useEffect(() => {
client.setAuthErrorHandler(() => setUser(null));
}, [client]);
const value = useMemo<ZinoContextValue>(
() => ({
client,
user,
login: async (email, password, orgId) => {
const res = await client.login(email, password, orgId);
setUser(res.user ?? client.currentUser());
},
logout: () => {
client.logout();
setUser(null);
},
}),
[client, user],
);
return <ZinoContext.Provider value={value}>{children}</ZinoContext.Provider>;
}
export function useZino(): ZinoContextValue {
const ctx = useContext(ZinoContext);
if (!ctx) throw new Error('useZino must be used within <ZinoProvider>');
return ctx;
}
export interface QueryState<T> {
data: T | null;
loading: boolean;
error: string | null;
refetch: () => void;
}
/**
* Fire `fn` when a dependency changes, tracking loading/error and discarding
* results from a superseded call.
*
* `refetchMs` polls. That matters here more than in most consoles: a passenger
* case moves Pending Contact In Outreach Contacted Rebooked on its own,
* driven by a worker and an AI employee, with no user action to hang a refresh
* off. A stale screen would look like a stuck system.
*/
export function useQuery<T>(
fn: () => Promise<T>,
deps: unknown[],
enabled = true,
refetchMs?: number,
): QueryState<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(enabled);
const [error, setError] = useState<string | null>(null);
const [tick, setTick] = useState(0);
const refetch = useCallback(() => setTick((t) => t + 1), []);
useEffect(() => {
if (!enabled) {
setLoading(false);
return;
}
let live = true;
setLoading(true);
setError(null);
fn()
.then((d) => live && setData(d))
.catch((e: unknown) => {
if (live)
setError(
e instanceof Error ? e.message : ((e as { message?: string })?.message ?? 'Request failed'),
);
})
.finally(() => live && setLoading(false));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, tick, enabled]);
// Polling is kept in its own effect so a manual refetch does not restart the
// interval, and the interval does not cancel an in-flight manual refresh.
useEffect(() => {
if (!enabled || !refetchMs || refetchMs <= 0) return;
const id = setInterval(() => setTick((t) => t + 1), refetchMs);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, enabled, refetchMs]);
return { data, loading, error, refetch };
}

124
src/api/types.ts Normal file
View File

@ -0,0 +1,124 @@
// Shapes returned by the Zino gateway. Only the parts this console reads are
// modelled; unknown extras survive as index signatures rather than being
// dropped, because record views return every configured column and the set
// changes whenever the app is redeployed.
export interface ApiError {
status: number;
message: string;
}
export interface User {
id: string;
org_id: string;
name: string;
email: string;
roles: string[];
groups: string[];
}
export interface LoginResponse {
token: string;
user?: User;
}
export interface RecordViewField {
type: string;
field_key: string;
field_uid: string;
output_label: string;
activity_id: string;
data_type: string;
is_filter: boolean;
is_search: boolean;
}
export interface RecordViewResponse {
config: { fields: RecordViewField[] };
data?: Row[];
records?: Row[];
total?: number;
page?: number;
limit?: number;
}
/** A record-view row. Values are whatever the column's data_type implies. */
export type Row = Record<string, unknown>;
export interface RecordViewParams {
page?: number;
limit?: number;
sortBy?: string;
sortDir?: 'asc' | 'desc';
search?: string;
filters?: Array<{ field_key: string; value: string; data_type?: string }>;
}
export interface DetailViewResponse {
config: { fields: RecordViewField[] };
data: Row;
}
/** One entry in an instance's activity log. */
export interface AuditEntry {
id: number;
user_id: string;
user_name: string;
user_email: string;
user_roles: string[] | null;
activity_id: string;
activity_name?: string;
data: Record<string, unknown>;
execution_state: string;
state_name?: string;
created_at?: string;
performed_at?: string;
ai_reasoning?: string | null;
ai_confidence?: number | null;
ai_model?: string | null;
}
/** An AI-employee decision, from the public monitor endpoint. */
export interface AiDecision {
id: number;
instance_id: number | string;
action?: string;
status?: string;
reasoning?: string;
confidence?: number;
cost?: number;
llm_calls?: number;
/** Which LLM actually produced this decision. Displayed deliberately:
* a whole day was lost to a silent model substitution nobody could see. */
model?: string;
created_at?: string;
}
/** A field on an activity form, as the view-service describes it. */
export interface FormField {
id: string;
uid: string;
name: string;
type: string;
data_type: string;
mandatory?: boolean;
properties?: { options?: Array<{ label: string; value: string }> } & Record<string, unknown>;
mapped_workflow_field?: string;
}
export interface FormScreenResponse {
activity_uid: string;
activity_name: string;
workflow_uuid: string;
fields: FormField[];
grid_config?: Array<Record<string, unknown>>;
field_defaults?: Record<string, unknown>;
}
export interface ActivityResult {
success?: boolean;
message?: string;
instance_id?: number;
status_code?: number;
data?: Record<string, unknown>;
}

View File

@ -0,0 +1,255 @@
import { useMemo, useState } from 'react';
import { useQuery, useZino } from '../api/provider';
import { Button, ErrorNote, Input, Label, Modal, Select, Spinner, Textarea } from './core';
import type { FormField } from '../api/types';
import { WORKFLOW } from '../api/config';
/**
* Renders an activity's form from the LIVE schema.
*
* Nothing about the fields is hardcoded: labels, types, select options and
* which are mandatory all come from `/view/form-screens`. Add a field to the
* activity, redeploy, and it appears here which matters because this
* console's whole point is that the workflow is the source of truth, not the
* frontend's idea of it.
*
* The submit is deliberately thin. When the workflow refuses an activity
* most often "activity not allowed in state …", or an RBAC refusal the
* operator sees that sentence verbatim. That is the state machine explaining
* itself, and burying it under "Something went wrong" would throw away the
* only clue.
*/
export function ActivityForm({
open,
onClose,
activityUid,
instanceId,
title,
subtitle,
submitLabel = 'Submit',
danger = false,
seed,
onDone,
}: {
open: boolean;
onClose: () => void;
activityUid: string;
/** Omit to START a new instance (the INIT activity). */
instanceId?: number | string;
title: string;
subtitle?: string;
submitLabel?: string;
danger?: boolean;
/** Values to pre-fill. Seeded fields are still submitted, shown read-only. */
seed?: Record<string, unknown>;
onDone?: (result: { instance_id?: number }) => void;
}) {
const { client } = useZino();
const schema = useQuery(
() => client.formSchema(activityUid, instanceId),
[activityUid, instanceId],
open,
);
const [values, setValues] = useState<Record<string, unknown>>({});
const [touched, setTouched] = useState(false);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const all = schema.data?.fields ?? [];
// OCR fields carry a document upload plus an extracted payload. The
// extraction pipeline is not wired in this build, so rather than render a
// control that pretends to work — or silently drop the field, which is how
// submissions quietly lose data — they render as a visible pending row.
const fields = all.filter((f) => f.data_type !== 'ocr');
const pendingDocs = all.filter((f) => f.data_type === 'ocr');
const initial = useMemo(
() => ({ ...(schema.data?.field_defaults ?? {}), ...(seed ?? {}) }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[schema.data, JSON.stringify(seed)],
);
const current = { ...initial, ...values };
const missing = fields
.filter((f) => f.mandatory)
.filter((f) => {
const v = current[f.id];
return v === undefined || v === null || v === '';
})
.map((f) => f.id);
function reset() {
setValues({});
setTouched(false);
setErr(null);
}
async function submit() {
setTouched(true);
if (missing.length) return;
setBusy(true);
setErr(null);
try {
const res = instanceId
? await client.performActivity(WORKFLOW, instanceId, activityUid, current)
: await client.startInstance(WORKFLOW, activityUid, current);
reset();
onDone?.({ instance_id: res.instance_id });
onClose();
} catch (e) {
setErr((e as { message?: string })?.message ?? 'Could not submit');
} finally {
setBusy(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
title={title}
subtitle={subtitle}
wide={fields.length > 8}
>
{schema.loading && <Spinner label="Loading form…" />}
{schema.error && <ErrorNote>{schema.error}</ErrorNote>}
{!schema.loading && !schema.error && (
<div className="space-y-4">
{err && <ErrorNote>{err}</ErrorNote>}
{fields.length === 0 && pendingDocs.length === 0 && (
<p className="text-sm text-muted">
This activity takes no input submitting records it as performed.
</p>
)}
<div className={fields.length > 8 ? 'grid grid-cols-1 gap-3 sm:grid-cols-2' : 'space-y-3'}>
{fields.map((f) => (
<FieldControl
key={f.id}
field={f}
value={current[f.id]}
readOnly={seed ? Object.prototype.hasOwnProperty.call(seed, f.id) : false}
error={touched && missing.includes(f.id) ? 'Required' : null}
onChange={(v) => setValues((s) => ({ ...s, [f.id]: v }))}
/>
))}
</div>
{pendingDocs.length > 0 && (
<div className="rounded-md border border-dashed border-line bg-panel2 px-3 py-2.5">
<p className="text-xs font-semibold tracking-wide text-faint uppercase">
Document uploads
</p>
<p className="mt-1 text-xs text-muted">
{pendingDocs.map((f) => f.name).join(' · ')}
</p>
<p className="mt-1.5 text-xs text-faint">
Extraction is not wired in this build enter the extracted figures above.
</p>
</div>
)}
<div className="flex items-center justify-end gap-2 border-t border-line pt-4">
<Button kind="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button kind={danger ? 'danger' : 'primary'} onClick={submit} busy={busy}>
{submitLabel}
</Button>
</div>
</div>
)}
</Modal>
);
}
function FieldControl({
field,
value,
onChange,
readOnly,
error,
}: {
field: FormField;
value: unknown;
onChange: (v: unknown) => void;
readOnly?: boolean;
error?: string | null;
}) {
const opts = field.properties?.options ?? [];
// Long prose gets a textarea whatever the schema says, because a
// single-line input for a credit narrative is unusable — and the narrative
// is the field a credit manager actually reads.
const isProse = field.data_type === 'longtext';
const control = (() => {
switch (field.data_type) {
case 'select':
return (
<Select value={String(value ?? '')} options={opts} onChange={onChange} />
);
case 'boolean':
return (
<label className="flex items-center gap-2.5 py-1">
<input
type="checkbox"
disabled={readOnly}
checked={value === true || value === 'true'}
onChange={(e) => onChange(e.target.checked)}
className="size-4 rounded border-line bg-panel2 accent-brand"
/>
<span className="text-sm text-ink">Yes</span>
</label>
);
case 'number':
return (
<Input
type="number"
disabled={readOnly}
value={value === undefined || value === null ? '' : String(value)}
onChange={(v) => onChange(v === '' ? '' : Number(v))}
/>
);
case 'date':
return (
<Input
type="date"
disabled={readOnly}
value={String(value ?? '').slice(0, 10)}
onChange={onChange}
/>
);
case 'datetime':
return (
<Input
type="datetime-local"
disabled={readOnly}
value={String(value ?? '').slice(0, 16)}
onChange={onChange}
/>
);
case 'longtext':
return <Textarea value={String(value ?? '')} rows={6} onChange={onChange} />;
// Anything unrecognised falls back to text rather than disappearing: a
// field the operator cannot see is a field the submission silently omits.
default:
return (
<Input disabled={readOnly} value={String(value ?? '')} onChange={onChange} />
);
}
})();
return (
<div className={isProse ? 'sm:col-span-full' : undefined}>
<Label required={field.mandatory}>{field.name}</Label>
{control}
{error && <p className="mt-1 text-xs font-medium text-bad">{error}</p>}
</div>
);
}

View File

@ -0,0 +1,329 @@
import { AlertTriangle, Check, Cpu, Scale, X } from 'lucide-react';
import { Badge, Card, Facts } from './core';
import type { Row } from '../api/types';
import { POLICY } from '../api/config';
import { money, moneyShort, num, pct, ratio, ratioPct, reasonCodes, str } from '../format';
/**
* The evidence panel why the machine decided what it decided.
*
* This is the screen the whole app exists for. A credit manager opening a
* referred file needs to answer one question in about ten seconds: *what did
* this thing see, and do I agree?* So the panel is built around three claims,
* in this order:
*
* 1. Three independent income sources, side by side. A single figure with a
* "variance 44.3%" label beside it is a number nobody can check. Three
* figures with the widest pair marked is an argument a person can accept
* or reject on sight.
* 2. Every computed ratio shown AGAINST THE THRESHOLD IT WAS TESTED ON.
* "1.34" means nothing; "1.34 against a floor of 1.25" is a finding.
* 3. A visible line between what a RULE computed and what a MODEL wrote.
* Rules are never violet; the agent's narrative always is. That is the
* governance claim of the whole demo, and it has to be legible without
* anyone explaining it.
*/
// ---------------------------------------------------------------------------
// A single test: value, threshold, verdict.
// ---------------------------------------------------------------------------
function Test({
label,
value,
threshold,
pass,
note,
}: {
label: string;
value: string;
threshold: string;
pass: boolean | null;
note?: string;
}) {
return (
<div className="flex items-start justify-between gap-3 border-b border-line py-2.5 last:border-0">
<div className="min-w-0">
<div className="text-sm font-medium text-ink">{label}</div>
<div className="mt-0.5 text-xs text-faint">{threshold}</div>
{note && <div className="mt-1 text-xs text-muted">{note}</div>}
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="tnum text-sm font-semibold text-ink">{value}</span>
{pass === null ? (
<span className="text-xs text-faint"></span>
) : pass ? (
<span className="rounded-full bg-good-soft p-1 text-good ring-1 ring-inset ring-good-edge">
<Check className="h-3 w-3" strokeWidth={3} />
</span>
) : (
<span className="rounded-full bg-warn-soft p-1 text-warn ring-1 ring-inset ring-warn-edge">
<X className="h-3 w-3" strokeWidth={3} />
</span>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// The three income sources
// ---------------------------------------------------------------------------
function IncomeSource({
label,
source,
value,
mark,
}: {
label: string;
source: string;
value: number | null;
mark: 'high' | 'low' | null;
}) {
const ring =
mark === 'high'
? 'ring-warn-edge bg-warn-soft'
: mark === 'low'
? 'ring-warn-edge bg-warn-soft'
: 'ring-line bg-panel2';
return (
<div className={`rounded-md px-3 py-2.5 ring-1 ring-inset ${ring}`}>
<div className="text-[11px] font-semibold tracking-wide text-faint uppercase">{label}</div>
<div className="tnum mt-1 text-lg font-bold text-ink">{moneyShort(value)}</div>
<div className="mt-0.5 text-[11px] text-muted">{source}</div>
{mark && (
<div className="mt-1.5 text-[10px] font-bold tracking-wide text-warn uppercase">
{mark === 'high' ? 'highest' : 'lowest'}
</div>
)}
</div>
);
}
export function EvidencePanel({ row }: { row: Row }) {
const itr = num(row.itr_declared_income);
const gst = num(row.gst_turnover_12m);
const bank = num(row.bank_credits_12m);
const present = [itr, gst, bank].filter((v): v is number => v !== null);
const hi = present.length ? Math.max(...present) : null;
const lo = present.length >= 2 ? Math.min(...present) : null;
const mark = (v: number | null): 'high' | 'low' | null => {
if (v === null || present.length < 2) return null;
if (v === hi) return 'high';
if (v === lo) return 'low';
return null;
};
const variance = num(row.income_variance_pct);
const varianceBreach = variance !== null && variance > POLICY.varianceTolerancePct;
const dscr = num(row.dscr);
const maxElig = num(row.max_eligible_amount);
const requested = num(row.requested_amount);
const bureau = num(row.bureau_score);
const cmr = num(row.cmr_rank);
const dpd = num(row.live_dpd_max);
const bounces = num(row.bounce_count);
const outcome = str(row.assessment_outcome);
const codes = reasonCodes(row.assessment_reason_codes);
const analysis = str(row.ai_analysis);
const citations = str(row.assessment_citations);
const outcomeTone = outcome === 'approve' ? 'emerald' : outcome === 'reject' ? 'rose' : 'amber';
const outcomeLabel =
outcome === 'approve' ? 'Approve' : outcome === 'reject' ? 'Reject' : outcome ? 'Refer' : '—';
return (
<div className="space-y-4">
{/* ---------------- Income corroboration ---------------- */}
<Card
title="Income corroboration"
subtitle="Three independent sources for the same underlying reality"
actions={
variance !== null ? (
<Badge tone={varianceBreach ? 'amber' : 'emerald'}>
spread {pct(variance)}
</Badge>
) : undefined
}
>
<div className="px-4 py-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<IncomeSource
label="Declared to tax"
source="ITR-V"
value={itr}
mark={mark(itr)}
/>
<IncomeSource
label="Declared to GST"
source="GSTR-3B, 12 months"
value={gst}
mark={mark(gst)}
/>
<IncomeSource
label="Observed in banking"
source="Statement credits, 12 months"
value={bank}
mark={mark(bank)}
/>
</div>
{varianceBreach && (
<div className="mt-3 flex items-start gap-2.5 rounded-md border border-warn-edge bg-warn-soft px-3 py-2.5">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warn" />
<p className="text-sm text-warn">
<span className="font-semibold">
The widest pair differs by {pct(variance)}
</span>{' '}
against a tolerance of {POLICY.varianceTolerancePct}%. Each document is internally
consistent only the comparison exposes the gap. This never auto-declines: an
unexplained gap is a question for a person, not a refusal.
</p>
</div>
)}
</div>
</Card>
{/* ---------------- Computed by rule ---------------- */}
<Card
title={
<span className="inline-flex items-center gap-2">
<Scale className="h-4 w-4 text-muted" />
Computed by rule
</span>
}
subtitle={`Server-side, reproducible from stored evidence · policy ${POLICY.version}`}
>
<div className="px-4 py-1">
<Test
label="DSCR — debt service coverage"
value={ratio(dscr)}
threshold={`floor ${POLICY.dscrFloor}`}
pass={dscr === null ? null : dscr >= POLICY.dscrFloor}
/>
<Test
label="Debt service ratio"
value={ratioPct(row.foir)}
threshold="share of monthly cash flow committed to debt"
pass={null}
/>
<Test
label="Maximum eligible amount"
value={moneyShort(maxElig)}
threshold={`against ${moneyShort(requested)} requested`}
pass={maxElig === null || requested === null ? null : requested <= maxElig}
/>
<Test
label="Bureau score"
value={bureau === null ? '—' : String(bureau)}
threshold={`floor ${POLICY.bureauFloor}`}
pass={bureau === null ? null : bureau >= POLICY.bureauFloor}
/>
<Test
label="CIBIL MSME Rank"
value={cmr === null ? '—' : `CMR-${cmr}`}
threshold={`ceiling CMR-${POLICY.cmrCeiling}`}
pass={cmr === null ? null : cmr <= POLICY.cmrCeiling}
/>
<Test
label="Live DPD"
value={dpd === null ? '—' : `${dpd} d`}
threshold={`ceiling ${POLICY.dpdCeiling} days`}
pass={dpd === null ? null : dpd <= POLICY.dpdCeiling}
/>
<Test
label="Cheque returns, 12 months"
value={bounces === null ? '—' : String(bounces)}
threshold={`ceiling ${POLICY.bounceCeiling}`}
pass={bounces === null ? null : bounces < POLICY.bounceCeiling}
/>
</div>
</Card>
{/* ---------------- The outcome ---------------- */}
<Card
title="Outcome"
subtitle="Routed by the stage gate on the computed outcome — not on the narrative"
actions={<Badge tone={outcomeTone}>{outcomeLabel}</Badge>}
>
<div className="space-y-3 px-4 py-4">
{codes.length === 0 ? (
<p className="text-sm text-faint">No reason codes recorded.</p>
) : (
<ul className="space-y-1.5">
{codes.map((c) => {
const knockout = c.startsWith('KO-');
const refer = c.startsWith('REF-');
return (
<li
key={c}
className={`tnum rounded-md px-3 py-2 text-sm ring-1 ring-inset ${
knockout
? 'bg-bad-soft text-bad ring-bad-edge'
: refer
? 'bg-warn-soft text-warn ring-warn-edge'
: 'bg-panel2 text-muted ring-line'
}`}
>
{c}
</li>
);
})}
</ul>
)}
{citations && (
<Facts
cols={1}
rows={[{ label: 'Policy cited', value: citations, wide: true }]}
/>
)}
</div>
</Card>
{/* ---------------- Written by the agent ----------------
Violet, and the only violet on the screen. Everything above was
arithmetic; this is the part a model produced, and a reader should
never have to guess which is which. */}
{analysis && (
<section className="lift rounded-lg border border-ai-edge bg-ai-soft">
<header className="flex items-center gap-2 border-b border-ai-edge px-4 py-3">
<Cpu className="h-4 w-4 text-ai" />
<h2 className="text-sm font-semibold text-ai">Written by the assessing agent</h2>
<span className="ml-auto text-[11px] text-ai/70">narrative, not verdict</span>
</header>
<div className="px-4 py-4">
<p className="text-sm leading-relaxed whitespace-pre-wrap text-ink">{analysis}</p>
</div>
</section>
)}
{/* The advisory offer, if the advisor has run. Also violet it is a
suggestion, and it lives in different columns from the committed
offer precisely so it can never be mistaken for one. */}
{num(row.ai_suggested_amount) !== null && (
<section className="lift rounded-lg border border-ai-edge bg-ai-soft">
<header className="flex items-center gap-2 border-b border-ai-edge px-4 py-3">
<Cpu className="h-4 w-4 text-ai" />
<h2 className="text-sm font-semibold text-ai">Suggested structure</h2>
<span className="ml-auto text-[11px] text-ai/70">non-binding the RM decides</span>
</header>
<div className="px-4 py-4">
<Facts
cols={2}
rows={[
{ label: 'Amount', value: money(row.ai_suggested_amount) },
{ label: 'Rate', value: `${ratio(row.ai_suggested_roi)}%` },
{ label: 'Tenure', value: `${str(row.ai_suggested_tenure)} months` },
{ label: 'Indicative EMI', value: money(row.ai_suggested_emi) },
{ label: 'Rationale', value: str(row.ai_rationale), wide: true },
]}
/>
</div>
</section>
)}
</div>
);
}

View File

@ -0,0 +1,52 @@
/**
* The bank's mark: a blue block with a red square inset, set beside the
* wordmark.
*
* This is a demo asset, drawn from tokens rather than shipped as an image
* file the build is promoted between environments unchanged, so an
* `/logo.png` would resolve against whatever mount path the server chose.
* Drawing it also means it obeys the theme: on the navy header the white
* separator band has to be white, and on a light panel it has to be the panel
* colour, which an image cannot do.
*/
export function HdfcMark({ size = 26, onNavy = false }: { size?: number; onNavy?: boolean }) {
// The inner band reads as the ground showing through. On navy chrome that is
// white; on a light surface it is the panel.
const band = onNavy ? '#ffffff' : 'var(--color-panel)';
return (
<svg
width={size}
height={size}
viewBox="0 0 32 32"
aria-hidden="true"
className="shrink-0"
role="presentation"
>
<rect width="32" height="32" rx="2" fill="var(--color-navy)" />
<rect x="5" y="5" width="22" height="22" fill={band} />
<rect x="8.5" y="8.5" width="15" height="15" fill="var(--color-signal)" />
</svg>
);
}
export function HdfcWordmark({ onNavy = false }: { onNavy?: boolean }) {
return (
<div className="flex items-center gap-2.5">
<HdfcMark onNavy={onNavy} />
<div className="leading-none">
<div
className={`text-[15px] font-bold tracking-tight ${onNavy ? 'text-white' : 'text-ink'}`}
>
HDFC BANK
</div>
<div
className={`mt-1 text-[10px] font-semibold tracking-[0.14em] uppercase ${
onNavy ? 'text-white/70' : 'text-faint'
}`}
>
Loan Desk
</div>
</div>
</div>
);
}

63
src/components/Stage.tsx Normal file
View File

@ -0,0 +1,63 @@
import { Badge } from './core';
import { STAGES, STAGE_TONE, TERMINAL_BRANCH, stageStep } from '../api/config';
/** A file's current stage, coloured by meaning. Amber is the referred queue. */
export function StageBadge({ stage, dot = false }: { stage?: string; dot?: boolean }) {
const s = stage ?? '';
return (
<Badge tone={STAGE_TONE[s] ?? 'slate'} dot={dot}>
{s || 'Unknown'}
</Badge>
);
}
/**
* The progress rail.
*
* Shows where the file is in the pipeline, and the part that earns its keep
* where it *stopped*. A declined file does not render a half-finished rail: it
* renders the stage it died at, because "declined at Offer Made" and "declined
* before we looked at documents" are different facts about the same word.
*/
export function StageRail({ stage }: { stage?: string }) {
const declined = stage === TERMINAL_BRANCH;
const step = stageStep(stage);
if (declined) {
return (
<div className="flex items-center gap-2">
<Badge tone="rose">Declined</Badge>
<span className="text-xs text-faint">this file is closed</span>
</div>
);
}
return (
<ol className="flex flex-wrap items-center gap-x-1 gap-y-2">
{STAGES.map((s, i) => {
const done = i < step;
const here = i === step;
return (
<li key={s} className="flex items-center gap-1">
<span
className={`rounded px-2 py-1 text-[11px] font-semibold whitespace-nowrap ${
here
? 'bg-brand text-brand-fg'
: done
? 'bg-panel3 text-muted'
: 'bg-panel2 text-faint'
}`}
>
{s}
</span>
{i < STAGES.length - 1 && (
<span aria-hidden="true" className={done ? 'text-muted' : 'text-line'}>
</span>
)}
</li>
);
})}
</ol>
);
}

View File

@ -0,0 +1,21 @@
import { Moon, Sun } from 'lucide-react';
import { useTheme } from '../theme';
export function ThemeToggle({ onNavy = false }: { onNavy?: boolean }) {
const { theme, toggle } = useTheme();
const next = theme === 'dark' ? 'light' : 'dark';
return (
<button
onClick={toggle}
title={`Switch to ${next} mode`}
aria-label={`Switch to ${next} mode`}
className={`rounded-md p-2 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 ${
onNavy
? 'text-white/75 hover:bg-white/10 hover:text-white focus-visible:outline-white'
: 'text-muted hover:bg-panel2 hover:text-ink focus-visible:outline-brand'
}`}
>
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
);
}

View File

@ -0,0 +1,336 @@
import type { ReactNode } from 'react';
import { useEffect } from 'react';
import { Loader2, X } from 'lucide-react';
import type { Tone } from '../../api/config';
/**
* The whole UI kit, in one file.
*
* Every colour comes from a token defined in styles.css never a literal and
* never a raw Tailwind shade. A hex or a `text-emerald-700` in here is
* invisible to the theme toggle and would be wrong in one of the two themes.
*/
// ---------------------------------------------------------------------------
// Tone → token classes. One place, so a badge and a stat value that mean the
// same thing are literally the same colour.
// ---------------------------------------------------------------------------
const TONE_TEXT: Record<Tone, string> = {
slate: 'text-idle',
amber: 'text-warn',
sky: 'text-info',
emerald: 'text-good',
rose: 'text-bad',
violet: 'text-ai',
};
const TONE_CHIP: Record<Tone, string> = {
slate: 'bg-idle-soft text-idle ring-idle-edge',
amber: 'bg-warn-soft text-warn ring-warn-edge',
sky: 'bg-info-soft text-info ring-info-edge',
emerald: 'bg-good-soft text-good ring-good-edge',
rose: 'bg-bad-soft text-bad ring-bad-edge',
violet: 'bg-ai-soft text-ai ring-ai-edge',
};
export function toneText(tone: Tone): string {
return TONE_TEXT[tone];
}
// ---------------------------------------------------------------------------
// Badge
// ---------------------------------------------------------------------------
export function Badge({
children,
tone = 'slate',
dot = false,
className = '',
}: {
children: ReactNode;
tone?: Tone;
dot?: boolean;
className?: string;
}) {
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold ring-1 ring-inset ${TONE_CHIP[tone]} ${className}`}
>
{dot && <span className="live-dot h-1.5 w-1.5 rounded-full bg-current" />}
{children}
</span>
);
}
// ---------------------------------------------------------------------------
// Button
// ---------------------------------------------------------------------------
type ButtonKind = 'primary' | 'secondary' | 'danger' | 'ghost';
const BUTTON_KIND: Record<ButtonKind, string> = {
primary: 'bg-brand text-brand-fg hover:bg-brand-hover',
secondary: 'bg-panel2 text-ink ring-1 ring-inset ring-line hover:bg-panel3',
// HDFC red, and the only place besides the logo it appears. A red button
// here always means the file stops.
danger: 'bg-signal text-white hover:bg-signal-hover',
ghost: 'text-muted hover:bg-panel2 hover:text-ink',
};
export function Button({
children,
onClick,
kind = 'secondary',
disabled = false,
busy = false,
type = 'button',
className = '',
title,
}: {
children: ReactNode;
onClick?: () => void;
kind?: ButtonKind;
disabled?: boolean;
busy?: boolean;
type?: 'button' | 'submit';
className?: string;
title?: string;
}) {
return (
<button
type={type}
title={title}
onClick={onClick}
disabled={disabled || busy}
className={`inline-flex items-center justify-center gap-2 rounded-md px-3.5 py-2 text-sm font-semibold transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand disabled:cursor-not-allowed disabled:opacity-50 ${BUTTON_KIND[kind]} ${className}`}
>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
{children}
</button>
);
}
// ---------------------------------------------------------------------------
// Card
// ---------------------------------------------------------------------------
export function Card({
children,
className = '',
title,
subtitle,
actions,
}: {
children?: ReactNode;
className?: string;
title?: ReactNode;
subtitle?: ReactNode;
actions?: ReactNode;
}) {
return (
<section className={`lift rounded-lg border border-line bg-panel ${className}`}>
{(title || actions) && (
<header className="flex items-start justify-between gap-4 border-b border-line px-4 py-3">
<div className="min-w-0">
{title && <h2 className="text-sm font-semibold text-ink">{title}</h2>}
{subtitle && <p className="mt-0.5 text-xs text-faint">{subtitle}</p>}
</div>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</header>
)}
{children}
</section>
);
}
// ---------------------------------------------------------------------------
// Form controls
// ---------------------------------------------------------------------------
const FIELD =
'w-full rounded-md border border-line bg-panel2 px-3 py-2 text-sm text-ink placeholder:text-faint focus:border-brand focus:bg-panel focus:outline-none focus:ring-2 focus:ring-brand/25';
export function Label({ children, required }: { children: ReactNode; required?: boolean }) {
return (
<label className="mb-1.5 block text-xs font-semibold tracking-wide text-muted uppercase">
{children}
{required && <span className="ml-1 text-signal">*</span>}
</label>
);
}
export function Input({
value,
onChange,
type = 'text',
placeholder,
disabled,
}: {
value: string;
onChange: (v: string) => void;
type?: string;
placeholder?: string;
disabled?: boolean;
}) {
return (
<input
className={FIELD}
type={type}
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
/>
);
}
export function Textarea({
value,
onChange,
rows = 4,
placeholder,
}: {
value: string;
onChange: (v: string) => void;
rows?: number;
placeholder?: string;
}) {
return (
<textarea
className={FIELD}
rows={rows}
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
);
}
export function Select({
value,
onChange,
options,
placeholder = 'Select…',
}: {
value: string;
onChange: (v: string) => void;
options: Array<{ label: string; value: string }>;
placeholder?: string;
}) {
return (
<select className={FIELD} value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">{placeholder}</option>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
// ---------------------------------------------------------------------------
// Feedback
// ---------------------------------------------------------------------------
export function Spinner({ label }: { label?: string }) {
return (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-faint">
<Loader2 className="h-4 w-4 animate-spin" />
{label ?? 'Loading…'}
</div>
);
}
export function ErrorNote({ children }: { children: ReactNode }) {
return (
<div className="rounded-md border border-bad-edge bg-bad-soft px-3 py-2 text-sm text-bad">
{children}
</div>
);
}
export function Empty({ children }: { children: ReactNode }) {
return <div className="py-12 text-center text-sm text-faint">{children}</div>;
}
// ---------------------------------------------------------------------------
// Modal
// ---------------------------------------------------------------------------
export function Modal({
open,
onClose,
title,
subtitle,
children,
wide = false,
}: {
open: boolean;
onClose: () => void;
title: ReactNode;
subtitle?: ReactNode;
children: ReactNode;
wide?: boolean;
}) {
// Escape closes, and the body does not scroll behind the dialogue.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = prev;
};
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-scrim p-4 sm:p-8">
<div
role="dialog"
aria-modal="true"
className={`lift-lg w-full rounded-lg border border-line bg-panel ${wide ? 'max-w-3xl' : 'max-w-xl'}`}
>
<header className="flex items-start justify-between gap-4 border-b border-line px-5 py-4">
<div className="min-w-0">
<h2 className="text-base font-semibold text-ink">{title}</h2>
{subtitle && <p className="mt-0.5 text-xs text-faint">{subtitle}</p>}
</div>
<button
onClick={onClose}
aria-label="Close"
className="-m-1 rounded p-1 text-faint hover:bg-panel2 hover:text-ink focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand"
>
<X className="h-4 w-4" />
</button>
</header>
<div className="px-5 py-4">{children}</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Definition list — the workhorse for showing a file's fields
// ---------------------------------------------------------------------------
export function Facts({
rows,
cols = 2,
}: {
rows: Array<{ label: string; value: ReactNode; tone?: Tone; wide?: boolean }>;
cols?: 1 | 2 | 3;
}) {
const grid = cols === 1 ? 'sm:grid-cols-1' : cols === 3 ? 'sm:grid-cols-3' : 'sm:grid-cols-2';
return (
<dl className={`grid grid-cols-1 gap-x-6 gap-y-3.5 ${grid}`}>
{rows.map((r) => (
<div key={r.label} className={r.wide ? 'sm:col-span-full' : undefined}>
<dt className="text-[11px] font-semibold tracking-wide text-faint uppercase">{r.label}</dt>
<dd className={`tnum mt-0.5 text-sm ${r.tone ? TONE_TEXT[r.tone] : 'text-ink'}`}>
{r.value === '' || r.value == null ? <span className="text-faint"></span> : r.value}
</dd>
</div>
))}
</dl>
);
}

105
src/format.ts Normal file
View File

@ -0,0 +1,105 @@
/**
* 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);
}

111
src/layout/Shell.tsx Normal file
View File

@ -0,0 +1,111 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { LogOut, Plus } from 'lucide-react';
import { useZino } from '../api/provider';
import { QUEUES, prettyRole } from '../api/config';
import { HdfcWordmark } from '../components/HdfcMark';
import { ThemeToggle } from '../components/ThemeToggle';
import { Button } from '../components/core';
/**
* The chrome.
*
* The sidebar IS the pipeline: every entry is the record view filtered on
* current_state_name, in the order a file actually moves. That is the whole
* navigation model, and it means a person learns the process by using the app.
*
* Nothing here is role-gated. Hiding a queue does not protect anything the
* workflow re-checks RBAC on every submission, so an unauthorised action is
* refused by the server whatever this file renders. What role-gating the nav
* *did* achieve on an earlier console was hiding a queue from the person who
* needed it, which is a worse failure than showing a button that comes back
* "not permitted".
*/
export function Shell() {
const { user, logout } = useZino();
const navigate = useNavigate();
return (
<div className="flex min-h-full flex-col">
{/* Header — navy in both themes. The bank's colour is not a surface. */}
<header className="sticky top-0 z-30 border-b border-deepnavy bg-navy">
<div className="flex items-center gap-4 px-4 py-2.5">
<HdfcWordmark onNavy />
<div className="ml-auto flex items-center gap-3">
<Button
kind="secondary"
onClick={() => navigate('/new')}
className="!bg-white/10 !text-white !ring-white/20 hover:!bg-white/20"
>
<Plus className="h-4 w-4" />
New Application
</Button>
{user && (
<div className="hidden text-right sm:block">
<div className="text-[13px] leading-tight font-semibold text-white">
{user.name || user.email}
</div>
<div className="text-[11px] leading-tight text-white/65">
{(user.roles ?? []).map(prettyRole).join(' · ') || 'No role'}
</div>
</div>
)}
<ThemeToggle onNavy />
<button
onClick={logout}
title="Sign out"
aria-label="Sign out"
className="rounded-md p-2 text-white/75 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
>
<LogOut className="h-4 w-4" />
</button>
</div>
</div>
</header>
<div className="flex flex-1 flex-col lg:flex-row">
{/* Queues */}
<nav className="shrink-0 border-b border-line bg-panel lg:w-60 lg:border-r lg:border-b-0">
<ul className="flex gap-1 overflow-x-auto p-2 lg:flex-col lg:overflow-visible">
{QUEUES.map((q) => (
<li key={q.path} className="shrink-0 lg:shrink">
<NavLink
to={`/q/${q.path}`}
className={({ isActive }) =>
`block rounded-md px-3 py-2 transition-colors ${
isActive
? 'bg-brand text-brand-fg'
: 'text-muted hover:bg-panel2 hover:text-ink'
}`
}
>
{({ isActive }) => (
<>
<span className="block text-[13px] font-semibold whitespace-nowrap">
{q.label}
</span>
<span
className={`hidden text-[11px] lg:block ${
isActive ? 'text-brand-fg/70' : 'text-faint'
}`}
>
{q.hint}
</span>
</>
)}
</NavLink>
</li>
))}
</ul>
</nav>
<main className="min-w-0 flex-1 p-4 lg:p-6">
<Outlet />
</main>
</div>
</div>
);
}

31
src/main.tsx Normal file
View File

@ -0,0 +1,31 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import { ZinoProvider } from './api/provider';
import { basePath, requireConfigValue } from './runtimeConfig';
import './styles.css';
/**
* The API base URL is read at RUNTIME, from the config.js the server writes
* when it places the build never compiled in. One artifact is promoted
* between environments unchanged, so a build-time URL would point every
* environment at whichever backend happened to build it.
*
* requireConfigValue throws rather than falling back, so a production build
* with no config.js fails loudly on the first paint instead of quietly calling
* the wrong bank's backend.
*/
const baseUrl = requireConfigValue('VITE_ZINO_API_URL');
createRoot(document.getElementById('root')!).render(
<StrictMode>
{/* basename comes from the <base href> the server wrote, so one build
serves any mount path without a rebuild. */}
<BrowserRouter basename={basePath()}>
<ZinoProvider baseUrl={baseUrl}>
<App />
</ZinoProvider>
</BrowserRouter>
</StrictMode>,
);

77
src/pages/Login.tsx Normal file
View File

@ -0,0 +1,77 @@
import { useState } from 'react';
import { useZino } from '../api/provider';
import { ORG_ID } from '../api/config';
import { Button, ErrorNote, Input, Label } from '../components/core';
import { HdfcWordmark } from '../components/HdfcMark';
import { ThemeToggle } from '../components/ThemeToggle';
/**
* Sign in.
*
* The org is fixed this build serves one tenant so it is not a field the
* user has to know. It still goes over the wire as a STRING: the gateway
* rejects a number with "cannot unmarshal number into Go struct field
* LoginRequest.org_id", which is a confusing failure to hit from a login form.
*/
export function Login() {
const { login } = useZino();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
async function submit() {
if (!email || !password) {
setErr('Enter your email and password');
return;
}
setBusy(true);
setErr(null);
try {
await login(email, password, ORG_ID);
} catch (e) {
setErr((e as { message?: string })?.message ?? 'Could not sign in');
} finally {
setBusy(false);
}
}
return (
<div className="flex min-h-full flex-col bg-bg">
<header className="flex items-center justify-between border-b border-deepnavy bg-navy px-4 py-2.5">
<HdfcWordmark onNavy />
<ThemeToggle onNavy />
</header>
<div className="flex flex-1 items-center justify-center p-4">
<form
onSubmit={(e) => {
e.preventDefault();
void submit();
}}
className="lift w-full max-w-sm rounded-lg border border-line bg-panel p-6"
>
<h1 className="text-lg font-semibold text-ink">Sign in</h1>
<p className="mt-1 text-sm text-muted">
MSME and personal loan origination.
</p>
<div className="mt-5 space-y-3">
{err && <ErrorNote>{err}</ErrorNote>}
<div>
<Label required>Email</Label>
<Input value={email} onChange={setEmail} type="email" placeholder="name@hdfc.example" />
</div>
<div>
<Label required>Password</Label>
<Input value={password} onChange={setPassword} type="password" placeholder="••••••••" />
</div>
<Button kind="primary" type="submit" busy={busy} className="w-full">
Sign in
</Button>
</div>
</form>
</div>
</div>
);
}

47
src/runtimeConfig.ts Normal file
View File

@ -0,0 +1,47 @@
declare global {
interface Window {
__RUNTIME_CONFIG__?: Record<string, string>;
}
}
/**
* Runtime configuration, written by the server as `config.js` when it places a
* build never baked in.
*
* One build is promoted between environments unchanged, so an API URL compiled
* at build time would be the *source* environment's URL everywhere it lands.
*
* SAME_ORIGIN exists because an empty string is falsy and could never survive
* the `||` chain, which is exactly what "use the current origin" has to do.
*/
export function resolveConfigValue(key: string, fallback = ''): string {
const runtime = (window.__RUNTIME_CONFIG__ || {})[key];
// Gated on DEV: a production build with no config.js must fail loudly rather
// than quietly calling whichever backend happened to build it.
const devFallback = import.meta.env.DEV ? ((import.meta.env as Record<string, string>)[key] ?? '') : '';
const value = runtime || devFallback || fallback;
return value === 'SAME_ORIGIN' ? window.location.origin.replace(/\/$/, '') : value;
}
export function requireConfigValue(key: string): string {
const value = resolveConfigValue(key);
if (!value) {
throw new Error(
'Missing runtime config "' + key + '". The server writes config.js when it ' +
'places the build; for local dev set ' + key + ' in .env.',
);
}
return value;
}
/**
* The mount path this app is served under, read from the `<base href>` the
* server writes into index.html. Drives the router basename, so one build
* serves any mount path without a rebuild.
*
* `import.meta.env.BASE_URL` is NOT usable here with Vite's relative base it
* resolves to "./".
*/
export function basePath(): string {
return new URL(document.baseURI).pathname;
}

View File

@ -0,0 +1,265 @@
import { useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { ArrowLeft, FileCheck2, ShieldCheck } from 'lucide-react';
import { useQuery, useZino } from '../api/provider';
import { DETAIL_VIEW, RECORD_VIEW, actionsFor } from '../api/config';
import type { Action } from '../api/config';
import { Badge, Button, Card, ErrorNote, Facts, Spinner } from '../components/core';
import { StageBadge, StageRail } from '../components/Stage';
import { EvidencePanel } from '../components/EvidencePanel';
import { ActivityForm } from '../components/ActivityForm';
import type { Row } from '../api/types';
import {
dateTime,
money,
num,
productLabel,
ratio,
relationshipLabel,
str,
} from '../format';
/**
* One application, in full.
*
* The layout is an argument, not a dump of columns: the evidence panel comes
* FIRST, above the offer and the sanction, because a credit manager opening a
* referred file needs the reasoning before the terms. Whoever reads this screen
* top to bottom reads the decision in the order it was actually made.
*/
export function ApplicationScreen() {
const { id = '' } = useParams();
const { client, user } = useZino();
const [openAction, setOpenAction] = useState<Action | null>(null);
// The detail view is the right endpoint for one instance. The record view
// filtered on instance_id is kept as a fallback because it returns the same
// 68 fields through the code path the queue screens already exercise — so a
// surprise in the detail-view response shape degrades to a working screen
// rather than a blank one.
const file = useQuery(
async (): Promise<Row | null> => {
try {
const dv = await client.detailView(DETAIL_VIEW, id);
if (dv?.data && Object.keys(dv.data).length > 0) return dv.data;
} catch {
/* fall through to the record view */
}
const rows = await client.rows(RECORD_VIEW, {
limit: 1,
filters: [{ field_key: 'instance_id', value: id, data_type: 'number' }],
});
return rows[0] ?? null;
},
[id],
true,
15000,
);
const row = file.data;
const stage = str(row?.current_state_name);
const actions = actionsFor(stage, user?.roles);
if (file.loading && !row) return <Spinner label="Loading file…" />;
if (file.error) return <ErrorNote>{file.error}</ErrorNote>;
if (!row) return <ErrorNote>File {id} not found.</ErrorNote>;
return (
<div className="space-y-4">
{/* ---------------- Header ---------------- */}
<div className="space-y-3">
<Link
to="/q/all"
className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-ink"
>
<ArrowLeft className="h-4 w-4" />
All files
</Link>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2.5">
<h1 className="text-xl font-semibold text-ink">
{str(row.firm_name) || str(row.applicant_name) || `File ${id}`}
</h1>
<StageBadge stage={stage} />
{str(row.relationship_status) === 'new_to_bank' && (
<Badge tone="sky">New to Bank</Badge>
)}
</div>
<p className="tnum mt-1 text-sm text-muted">
{str(row.application_no) || `File ${id}`} · {productLabel(row.loan_product_family)} ·
requested {money(row.requested_amount)} over {str(row.requested_tenure_months)} months
</p>
</div>
{actions.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
{actions.map((a) => (
<Button key={a.activity} kind={a.kind} onClick={() => setOpenAction(a)}>
{a.label}
</Button>
))}
</div>
)}
</div>
<StageRail stage={stage} />
</div>
{/* ---------------- The file ---------------- */}
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
{/* Evidence first — see the note at the top of this file. */}
<div className="space-y-4 xl:col-span-2">
<EvidencePanel row={row} />
</div>
<div className="space-y-4">
<Card title="Applicant">
<div className="px-4 py-4">
<Facts
cols={1}
rows={[
{ label: 'Firm', value: str(row.firm_name) },
{ label: 'Promoter / applicant', value: str(row.applicant_name) },
{ label: 'Relationship', value: relationshipLabel(row.relationship_status) },
{ label: 'Mobile', value: str(row.mobile) },
{ label: 'PAN', value: str(row.entity_pan) },
{ label: 'GSTIN', value: str(row.gstin) },
{ label: 'Udyam', value: str(row.udyam_no) },
{
label: 'Business vintage',
value: num(row.business_vintage_months)
? `${row.business_vintage_months} months`
: '',
},
{ label: 'Purpose', value: str(row.purpose) },
{ label: 'Relationship manager', value: str(row.owner_rm) },
{ label: 'Branch', value: str(row.owner_branch) },
{ label: 'Received', value: dateTime(row.created_at) },
]}
/>
</div>
</Card>
{/* The human decision trail. Only rendered once someone has acted:
an empty "Credit review" card on a file nobody has touched
suggests a step was skipped. */}
{(num(row.cm_approved_amount) !== null || str(row.cm_remarks)) && (
<Card
title={
<span className="inline-flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-muted" />
Credit review
</span>
}
subtitle="Decided by a person"
>
<div className="px-4 py-4">
<Facts
cols={1}
rows={[
{ label: 'Approved amount', value: money(row.cm_approved_amount) },
{ label: 'Deviation', value: str(row.cm_deviation_note) },
{ label: 'Remarks', value: str(row.cm_remarks) },
{ label: 'Prepared by', value: str(row.maker) },
]}
/>
</div>
</Card>
)}
{num(row.offered_amount) !== null && (
<Card
title={
<span className="inline-flex items-center gap-2">
<FileCheck2 className="h-4 w-4 text-muted" />
Offer &amp; KFS
</span>
}
subtitle="Committed by the relationship manager"
>
<div className="px-4 py-4">
<Facts
cols={2}
rows={[
{ label: 'Amount', value: money(row.offered_amount) },
{ label: 'Rate', value: `${ratio(row.offered_roi)}%` },
{ label: 'Tenure', value: `${str(row.offered_tenure_months)} mo` },
{ label: 'EMI', value: money(row.offered_emi) },
{ label: 'Processing fee', value: money(row.offered_fee) },
// The APR and the KFS reference are the compliance
// artefacts: under the RBI Digital Lending Directions the
// borrower must have these BEFORE the contract executes.
{ label: 'APR', value: `${ratio(row.apr)}%`, tone: 'sky' },
{ label: 'KFS issued', value: str(row.kfs_ref), tone: 'sky', wide: true },
]}
/>
</div>
</Card>
)}
{num(row.sanctioned_amount) !== null && (
<Card title="Sanction" subtitle="Four-eyes: prepared and approved by different people">
<div className="px-4 py-4">
<Facts
cols={2}
rows={[
{ label: 'Amount', value: money(row.sanctioned_amount) },
{ label: 'Rate', value: `${ratio(row.sanctioned_roi)}%` },
{ label: 'Tenure', value: `${str(row.sanctioned_tenure_months)} mo` },
{ label: 'Sanctioned by', value: str(row.checker) },
{ label: 'Conditions', value: str(row.sanction_conditions), wide: true },
]}
/>
</div>
</Card>
)}
{num(row.disbursed_amount) !== null && (
<Card title="Disbursal" subtitle="Released through the guarded write path">
<div className="px-4 py-4">
<Facts
cols={1}
rows={[
{ label: 'Amount', value: money(row.disbursed_amount), tone: 'emerald' },
{ label: 'UTR', value: str(row.utr_ref) },
{ label: 'Released', value: dateTime(row.disbursed_at) },
]}
/>
</div>
</Card>
)}
{str(row.decline_reason) && (
<Card title="Declined">
<div className="px-4 py-4">
<Facts
cols={1}
rows={[
{ label: 'Reason', value: str(row.decline_reason), tone: 'rose' },
{ label: 'By', value: str(row.declined_by) },
]}
/>
</div>
</Card>
)}
</div>
</div>
{openAction && (
<ActivityForm
open
onClose={() => setOpenAction(null)}
activityUid={openAction.activity}
instanceId={id}
title={openAction.label}
subtitle={`${str(row.application_no) || `File ${id}`} · ${stage}`}
submitLabel={openAction.label}
danger={openAction.kind === 'danger'}
onDone={() => file.refetch()}
/>
)}
</div>
);
}

View File

@ -0,0 +1,62 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { FilePlus2 } from 'lucide-react';
import { ACT } from '../api/config';
import { Button, Card } from '../components/core';
import { ActivityForm } from '../components/ActivityForm';
/**
* Capture a new application.
*
* The form itself is the INIT activity's live schema, so this screen holds no
* field definitions at all it exists to open the form and to send the user
* to the file it created.
*/
export function NewApplicationScreen() {
const navigate = useNavigate();
const [open, setOpen] = useState(true);
return (
<div className="mx-auto max-w-2xl space-y-4">
<div>
<h1 className="text-xl font-semibold text-ink">New application</h1>
<p className="mt-0.5 text-sm text-muted">
Capture the application. The file then needs its document set before it can be assessed.
</p>
</div>
<Card>
<div className="flex flex-col items-center gap-3 px-4 py-10 text-center">
<span className="rounded-full bg-brand/10 p-3 text-brand">
<FilePlus2 className="h-6 w-6" />
</span>
<p className="max-w-sm text-sm text-muted">
An MSME or personal loan application. Fields come from the workflow, so whatever the
activity asks for is what appears here.
</p>
<div className="flex gap-2">
<Button kind="primary" onClick={() => setOpen(true)}>
Open the form
</Button>
<Button kind="ghost" onClick={() => navigate('/q/all')}>
Back to files
</Button>
</div>
</div>
</Card>
<ActivityForm
open={open}
onClose={() => setOpen(false)}
activityUid={ACT.CAPTURE}
title="Capture Application"
subtitle="A new loan file"
submitLabel="Create file"
onDone={(res) => {
if (res.instance_id) navigate(`/f/${res.instance_id}`);
else navigate('/q/inbox');
}}
/>
</div>
);
}

View File

@ -0,0 +1,185 @@
import { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Search } from 'lucide-react';
import { useQuery, useZino } from '../api/provider';
import { POLICY, QUEUES, RECORD_VIEW } from '../api/config';
import { Badge, Card, Empty, ErrorNote, Spinner } from '../components/core';
import { StageBadge } from '../components/Stage';
import type { Row } from '../api/types';
import { dateTime, moneyShort, num, pct, productLabel, str } from '../format';
/**
* A queue.
*
* Every queue is the same record view filtered on `current_state_name` which
* is why that column is seeded `is_filter` in 04_views.sql. The filter is
* applied SERVER-SIDE rather than by fetching everything and filtering here:
* a desk with ten thousand files would otherwise download all of them to show
* eleven.
*
* Polling is on because files move without anyone in this browser doing
* something. Once the assessing agent is wired, a file leaves Documents
* Collected and lands in the Credit Queue on its own, and a stale list would
* read as a stuck system.
*/
export function PipelineScreen() {
const { q = 'all' } = useParams();
const navigate = useNavigate();
const { client } = useZino();
const [search, setSearch] = useState('');
const queue = useMemo(() => QUEUES.find((x) => x.path === q) ?? QUEUES[0], [q]);
const rows = useQuery(
() =>
client.rows(RECORD_VIEW, {
limit: 200,
sortBy: 'instance_id',
sortDir: 'desc',
filters: queue.stage
? [{ field_key: 'current_state_name', value: queue.stage, data_type: 'string' }]
: [],
}),
[queue.stage],
true,
15000,
);
const data = rows.data ?? [];
// Search is client-side on purpose: the list is already narrowed to one
// stage, so this is a filter over a handful of rows and a round trip per
// keystroke would be slower and worse.
const visible = useMemo(() => {
const needle = search.trim().toLowerCase();
if (!needle) return data;
return data.filter((r) =>
['application_no', 'firm_name', 'applicant_name', 'mobile', 'entity_pan', 'gstin']
.map((k) => str(r[k]).toLowerCase())
.some((v) => v.includes(needle)),
);
}, [data, search]);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-ink">{queue.label}</h1>
<p className="mt-0.5 text-sm text-muted">{queue.hint}</p>
</div>
<div className="w-full sm:w-72">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-faint" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Firm, applicant, PAN, GSTIN…"
className="w-full rounded-md border border-line bg-panel2 py-2 pr-3 pl-9 text-sm text-ink placeholder:text-faint focus:border-brand focus:bg-panel focus:outline-none focus:ring-2 focus:ring-brand/25"
/>
</div>
</div>
</div>
<Card
title={`${visible.length} ${visible.length === 1 ? 'file' : 'files'}`}
subtitle={queue.stage ? `Stage: ${queue.stage}` : 'All stages'}
>
{rows.loading && !rows.data && <Spinner label="Loading files…" />}
{rows.error && (
<div className="p-4">
<ErrorNote>{rows.error}</ErrorNote>
</div>
)}
{!rows.loading && !rows.error && visible.length === 0 && (
<Empty>
{search ? 'Nothing matches that search.' : 'This queue is empty.'}
</Empty>
)}
{visible.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full min-w-[52rem] text-sm">
<thead>
<tr className="border-b border-line text-left">
{['File', 'Applicant', 'Product', 'Requested', 'Stage', 'Assessment', 'Received'].map(
(h) => (
<th
key={h}
className="px-4 py-2.5 text-[11px] font-semibold tracking-wide text-faint uppercase"
>
{h}
</th>
),
)}
</tr>
</thead>
<tbody>
{visible.map((r) => (
<FileRow
key={String(r.instance_id)}
row={r}
onOpen={() => navigate(`/f/${r.instance_id}`)}
/>
))}
</tbody>
</table>
</div>
)}
</Card>
</div>
);
}
function FileRow({ row, onOpen }: { row: Row; onOpen: () => void }) {
const variance = num(row.income_variance_pct);
const breach = variance !== null && variance > POLICY.varianceTolerancePct;
const outcome = str(row.assessment_outcome);
return (
<tr
onClick={onOpen}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpen();
}
}}
className="cursor-pointer border-b border-line last:border-0 hover:bg-panel2 focus-visible:bg-panel2 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand"
>
<td className="px-4 py-3">
<div className="tnum font-semibold text-ink">{str(row.application_no) || `#${row.instance_id}`}</div>
<div className="tnum text-xs text-faint">File {String(row.instance_id)}</div>
</td>
<td className="px-4 py-3">
<div className="font-medium text-ink">{str(row.firm_name) || str(row.applicant_name) || '—'}</div>
{str(row.firm_name) && str(row.applicant_name) && (
<div className="text-xs text-faint">{str(row.applicant_name)}</div>
)}
</td>
<td className="px-4 py-3 text-muted">{productLabel(row.loan_product_family)}</td>
<td className="tnum px-4 py-3 font-medium text-ink">{moneyShort(row.requested_amount)}</td>
<td className="px-4 py-3">
<StageBadge stage={str(row.current_state_name)} />
</td>
<td className="px-4 py-3">
{outcome ? (
<div className="flex flex-wrap items-center gap-1.5">
<Badge tone={outcome === 'approve' ? 'emerald' : outcome === 'reject' ? 'rose' : 'amber'}>
{outcome === 'approve' ? 'Approve' : outcome === 'reject' ? 'Reject' : 'Refer'}
</Badge>
{/* The variance is surfaced in the LIST, not just on the file.
A credit manager scanning the queue should be able to see which
referrals are corroboration questions before opening any of
them. */}
{breach && <span className="tnum text-xs font-semibold text-warn">Δ {pct(variance)}</span>}
</div>
) : (
<span className="text-xs text-faint">not assessed</span>
)}
</td>
<td className="px-4 py-3 text-xs text-faint">{dateTime(row.created_at)}</td>
</tr>
);
}

242
src/styles.css Normal file
View File

@ -0,0 +1,242 @@
@import 'tailwindcss';
/* HDFC Bank Loan Desk. Light and dark.
=====================================================================
HOW THEMING WORKS HERE
`@theme` below defines the LIGHT theme, and light is also the fallback:
every token has a value here, so a document with no `data-theme` at all
renders correctly rather than unstyled. The dark theme is the
`:root[data-theme='dark']` block further down, which redefines the SAME
token names. Tailwind v4 compiles `bg-panel` to
`background-color: var(--color-panel)`, so overriding the variable is
what swaps the entire app including opacity modifiers like `bg-brand/10`,
which compile to a `color-mix()` over the same var.
Consequently: NEVER put a literal colour in a component. A hex in a
className is invisible to the toggle and will be wrong in one of the two
themes. That is what the semantic tone tokens below are for.
THE PALETTE IS HDFC'S TWO COLOURS. Their identity is a navy blue and a
red, and the discipline here is that RED IS NEVER DECORATION. It marks
the logo block, a decline, and nothing else so when red appears in the
queue it means something. The pipeline's own accent work is done by the
blue and by the amber that marks the referred queue.
Every colour used for text is checked at WCAG AA against the surfaces it
actually appears on, in both themes. */
@theme {
/* --- Structural ---------------------------------------------------- */
--color-ink: #10243d; /* primary text */
--color-muted: #4a5f7a; /* secondary text */
--color-faint: #6b7f96; /* labels, placeholders, dashes */
--color-bg: #f6f8fb; /* page canvas */
--color-panel: #ffffff; /* card face */
--color-panel2: #f1f5fa; /* input fill, row hover */
--color-panel3: #e4ecf5; /* pressed / hover on panel2 */
--color-line: #d8e2ee; /* borders */
/* Brand accent. Used BOTH as a fill and as text, so it carries a
companion for whatever sits on top of it. */
--color-brand: #004c8f; /* HDFC blue */
--color-brand-fg: #ffffff;
--color-brand-hover: #003a6f;
/* --- HDFC palette, fixed in both themes ---------------------------- */
/* Surfaces invert between themes; the bank's own colours do not. The
header is navy in dark mode too. */
--color-navy: #004c8f;
--color-deepnavy: #002e5f;
--color-signal: #ed232a; /* HDFC red — declines and the logo block */
--color-signal-hover: #c91d23;
/* --- Semantic tones ------------------------------------------------
Six meanings, three roles each: `<tone>` is text, `<tone>-soft` is the
chip wash, `<tone>-edge` is the ring or rule. Every `<tone>` passes AA
on its own wash AND on the page and panel, because these are used both
inside a badge and as bare text (a stat value, a ratio).
`idle` is warm while `info` is cool: they sit next to each other in the
queue as New Application against Documents Collected, and at equal
lightness in the same hue family they were indistinguishable. */
--color-idle: #5c5346;
--color-idle-soft: #f2efeb;
--color-idle-edge: #ddd5c9;
/* THE most important tone in this app. Amber is the referred queue
the pile a human owns and it is deliberately the only amber in the
pipeline so it reads as "needs you" without being read as an error.
A referred file is not a bad file. */
--color-warn: #8a5a00;
--color-warn-soft: #fff8e8;
--color-warn-edge: #f0c368;
--color-info: #0f4c81;
--color-info-soft: #e8f1f9;
--color-info-edge: #a9c9e6;
--color-good: #0a6b4a;
--color-good-soft: #eafaf3;
--color-good-edge: #94ceb4;
/* Drawn from HDFC red rather than Tailwind's rose, so a Declined badge
and a Decline button are visibly the same red rather than two
unrelated ones. */
--color-bad: #b31e23;
--color-bad-soft: #fdeced;
--color-bad-edge: #eda9ac;
/* The AI's own colour. Everything an agent produced is tinted with it
assessment narrative, suggested offer, reason codes so a reader can
tell at a glance which numbers came from a model and which from a
rule. Rules are never violet. */
--color-ai: #5b2ca0;
--color-ai-soft: #f5f1fd;
--color-ai-edge: #c3adec;
/* Dialogue scrim. Navy rather than neutral black: a black wash greys the
whole console, where a navy one reads as the brand dimming behind the
dialogue. */
--color-scrim: rgb(0 24 48 / 0.45);
/* HDFC's own typeface is not licensable for redistribution, so the stack
leads with what a bank desktop is likely to have and degrades through
humanist relatives. */
--font-sans:
'Segoe UI', Inter, 'Source Sans 3', system-ui, -apple-system, 'Helvetica Neue', Arial,
sans-serif;
}
/* Non-Tailwind vars: consumed by the hand-rolled rules at the bottom rather
than by generated utilities, so they live outside `@theme`. */
:root {
/* Tinted with navy rather than black, so the lift reads as part of the
palette. In dark it goes near-black and deeper a navy shadow on a
navy canvas is invisible, so dark leans on the border instead. */
--lift: 0 1px 2px rgb(0 43 90 / 0.07), 0 1px 1px rgb(0 43 90 / 0.04);
--lift-lg: 0 12px 32px rgb(0 43 90 / 0.16), 0 2px 8px rgb(0 43 90 / 0.08);
--sb-thumb: #c9d6e6;
--sb-thumb-hover: #adbed3;
/* Makes the browser render native widgets date picker, select dropdown,
scrollbar gutter to match. Without it a dark console opens a blinding
white calendar. */
color-scheme: light;
}
/* =====================================================================
DARK the navy-led palette. Only tokens are redefined; no utility,
component or layout rule is duplicated.
===================================================================== */
:root[data-theme='dark'] {
--color-ink: #e8f0f9;
--color-muted: #a6c0dc;
--color-faint: #8aa6c6;
--color-bg: #00152b;
--color-panel: #052b52;
--color-panel2: #0b3762;
--color-panel3: #124471;
--color-line: #1a4373;
/* The accent lightens and the text on it darkens the inverse of light.
A #004C8F fill would vanish into the canvas, and #004C8F text on navy
is unreadable. */
--color-brand: #6fa8e8;
--color-brand-fg: #00152b;
--color-brand-hover: #8fbdf0;
/* HDFC red is unchanged: white on it clears AA either way, and it still
clears 3:1 against the navy canvas as a large component. */
--color-signal-hover: #f04a50;
--color-idle: #d6cdbf;
--color-idle-soft: #2a2724;
--color-idle-edge: #4b453b;
--color-warn: #f7cd7a;
--color-warn-soft: #3a2a06;
--color-warn-edge: #8a681f;
--color-info: #b8d4ee;
--color-info-soft: #0f3763;
--color-info-edge: #467cb8;
--color-good: #7fd6a8;
--color-good-soft: #08301f;
--color-good-edge: #2c6a4a;
--color-bad: #f4a3a6;
--color-bad-soft: #3c1416;
--color-bad-edge: #8c3a3d;
--color-ai: #c9b3f2;
--color-ai-soft: #221542;
--color-ai-edge: #5a3fa2;
--color-scrim: rgb(0 4 12 / 0.66);
--lift: 0 1px 2px rgb(0 0 0 / 0.4);
--lift-lg: 0 16px 40px rgb(0 0 0 / 0.55), 0 2px 8px rgb(0 0 0 / 0.4);
--sb-thumb: #1c4a7c;
--sb-thumb-hover: #285f95;
color-scheme: dark;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background: var(--color-bg);
color: var(--color-ink);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
/* Tabular figures everywhere numbers line up in a column amounts,
ratios, file numbers. Proportional digits make a money table look
ragged, and this app is mostly money. */
.tnum {
font-variant-numeric: tabular-nums;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--sb-thumb);
border-radius: 6px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--sb-thumb-hover);
}
@keyframes hdfc-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.live-dot {
animation: hdfc-pulse 1.6s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.live-dot {
animation: none;
}
}
.lift {
box-shadow: var(--lift);
}
.lift-lg {
box-shadow: var(--lift-lg);
}

104
src/theme.ts Normal file
View File

@ -0,0 +1,104 @@
import { useCallback, useEffect, useState } from 'react';
/**
* Light / dark theme, persisted per browser.
*
* WHAT THE SWITCH ACTUALLY DOES
*
* Sets `data-theme` on <html>. Nothing else. Every colour in the app is a
* CSS custom property that `styles.css` redefines under
* `:root[data-theme='dark']`, so one attribute swaps the whole console
* there is no second stylesheet, no per-component dark variant, and no
* `dark:` prefix anywhere in the codebase.
*
* FIRST VISIT FOLLOWS THE OPERATING SYSTEM
*
* A desk that runs dark everywhere else should not open blinding white,
* and one that runs light should not open dark. So with no stored choice
* we take `prefers-color-scheme`, and we keep tracking it but an
* EXPLICIT choice wins permanently and stops listening. Someone who
* deliberately picked light at 3am does not want the OS flipping them at
* sunrise.
*/
export type Theme = 'light' | 'dark';
/** Also read by the inline script in index.html. If you rename this, rename
* it there too see the note in that file about why it is duplicated. */
export const THEME_KEY = 'hdfc-loan-desk-theme';
function isTheme(v: unknown): v is Theme {
return v === 'light' || v === 'dark';
}
/** The user's explicit choice, or null if they have never chosen. */
export function storedTheme(): Theme | null {
try {
const v = localStorage.getItem(THEME_KEY);
return isTheme(v) ? v : null;
} catch {
// Private mode / blocked storage. Not a reason to fail to render — the
// theme just stops persisting across reloads.
return null;
}
}
export function systemTheme(): Theme {
return typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
export function resolveTheme(): Theme {
return storedTheme() ?? systemTheme();
}
export function applyTheme(theme: Theme): void {
document.documentElement.setAttribute('data-theme', theme);
// Keep the mobile browser chrome in step with the header it sits above.
document
.querySelector('meta[name="theme-color"]')
?.setAttribute('content', theme === 'dark' ? '#00152b' : '#004c8f');
}
export function useTheme(): { theme: Theme; setTheme: (t: Theme) => void; toggle: () => void } {
const [theme, setThemeState] = useState<Theme>(resolveTheme);
// Reconciles React with whatever the index.html inline script already put
// on <html>. On first mount these agree; this exists for the case where
// they cannot, e.g. storage threw in one place and not the other.
useEffect(() => {
applyTheme(theme);
}, [theme]);
// Track the OS only while the user has made no explicit choice.
useEffect(() => {
if (storedTheme()) return;
const mq = window.matchMedia?.('(prefers-color-scheme: dark)');
if (!mq) return;
const onChange = (e: MediaQueryListEvent) => setThemeState(e.matches ? 'dark' : 'light');
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, [theme]);
const setTheme = useCallback((t: Theme) => {
try {
localStorage.setItem(THEME_KEY, t);
} catch {
// Unpersisted is still switchable for this session.
}
setThemeState(t);
}, []);
const toggle = useCallback(() => {
setTheme(resolveThemeOpposite());
}, [setTheme]);
return { theme, setTheme, toggle };
}
/** Read from the DOM rather than from React state so a rapid double-click
* cannot toggle off a stale value. */
function resolveThemeOpposite(): Theme {
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

20
tsconfig.app.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

4
tsconfig.json Normal file
View File

@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

14
tsconfig.node.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}

13
vite.config.ts Normal file
View File

@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
// Relative base: emitted asset refs become "./assets/…", so the <base href> the
// server writes at placement decides where they resolve. One build therefore
// serves any mount path. Do NOT reintroduce a build-time base — the artifact is
// promoted between environments unchanged and would stop being placeable.
export default defineConfig({
plugins: [react(), tailwindcss()],
base: './',
server: { port: 5174 },
});