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