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:
commit
2e8cc580ac
5
.env.example
Normal file
5
.env.example
Normal 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
12
.gitignore
vendored
Normal 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
122
README.md
Normal 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
51
index.html
Normal 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
2409
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal 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
36
src/App.tsx
Normal 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
244
src/api/client.ts
Normal 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
190
src/api/config.ts
Normal 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
123
src/api/provider.tsx
Normal 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
124
src/api/types.ts
Normal 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>;
|
||||||
|
}
|
||||||
255
src/components/ActivityForm.tsx
Normal file
255
src/components/ActivityForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
329
src/components/EvidencePanel.tsx
Normal file
329
src/components/EvidencePanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/components/HdfcMark.tsx
Normal file
52
src/components/HdfcMark.tsx
Normal 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
63
src/components/Stage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/components/ThemeToggle.tsx
Normal file
21
src/components/ThemeToggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
336
src/components/core/index.tsx
Normal file
336
src/components/core/index.tsx
Normal 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
105
src/format.ts
Normal 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
111
src/layout/Shell.tsx
Normal 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
31
src/main.tsx
Normal 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
77
src/pages/Login.tsx
Normal 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
47
src/runtimeConfig.ts
Normal 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;
|
||||||
|
}
|
||||||
265
src/screens/ApplicationScreen.tsx
Normal file
265
src/screens/ApplicationScreen.tsx
Normal 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/screens/NewApplicationScreen.tsx
Normal file
62
src/screens/NewApplicationScreen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
src/screens/PipelineScreen.tsx
Normal file
185
src/screens/PipelineScreen.tsx
Normal 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
242
src/styles.css
Normal 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
104
src/theme.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
20
tsconfig.app.json
Normal file
20
tsconfig.app.json
Normal 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
4
tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
14
tsconfig.node.json
Normal file
14
tsconfig.node.json
Normal 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
13
vite.config.ts
Normal 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 },
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user