feat: OCR-driven capture and document upload
This commit is contained in:
parent
602b570064
commit
0b4c5576e4
@ -6,12 +6,13 @@ import type {
|
|||||||
DetailViewResponse,
|
DetailViewResponse,
|
||||||
FormScreenResponse,
|
FormScreenResponse,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
|
OcrResult,
|
||||||
RecordViewParams,
|
RecordViewParams,
|
||||||
RecordViewResponse,
|
RecordViewResponse,
|
||||||
Row,
|
Row,
|
||||||
User,
|
User,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { APP_ID } from './config';
|
import { APP_ID, WORKFLOW } from './config';
|
||||||
|
|
||||||
const TOKEN_KEY = 'hdfc_loan_desk_token';
|
const TOKEN_KEY = 'hdfc_loan_desk_token';
|
||||||
|
|
||||||
@ -187,6 +188,66 @@ export class ZinoClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Document intelligence ----------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run OCR on an uploaded document for an `ocr` field.
|
||||||
|
*
|
||||||
|
* The extraction contract is NOT sent from here. The endpoint resolves the
|
||||||
|
* field's `ocr_config` server-side from the deployed workflow config and
|
||||||
|
* ignores anything a client supplies — so which values come back is decided
|
||||||
|
* by the field's configuration, and the response keys are exactly that
|
||||||
|
* config's `extraction_fields[].key`.
|
||||||
|
*
|
||||||
|
* `instanceId` is omitted on an INIT activity, where no instance exists yet.
|
||||||
|
*
|
||||||
|
* This deliberately bypasses `request()`: it must NOT set Content-Type. The
|
||||||
|
* browser has to write `multipart/form-data; boundary=…` itself, and setting
|
||||||
|
* that header by hand omits the boundary, after which the server rejects the
|
||||||
|
* body as malformed multipart.
|
||||||
|
*/
|
||||||
|
async ocrExtract(
|
||||||
|
activityUid: string,
|
||||||
|
fieldId: string,
|
||||||
|
file: File,
|
||||||
|
instanceId?: number | string,
|
||||||
|
): Promise<OcrResult> {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
fd.append('workflow_uuid', WORKFLOW);
|
||||||
|
fd.append('activity_id', activityUid);
|
||||||
|
fd.append('field_id', fieldId);
|
||||||
|
if (instanceId != null && instanceId !== '') {
|
||||||
|
fd.append('instance_id', String(numericId(instanceId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
||||||
|
|
||||||
|
const res = await fetch(`${this.baseUrl}/app/${APP_ID}/ocr-extract`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
return (await res.json()) as OcrResult;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Workflow execution -------------------------------------------------
|
// --- Workflow execution -------------------------------------------------
|
||||||
|
|
||||||
/** Start a new instance (an INIT activity such as Register Flight). */
|
/** Start a new instance (an INIT activity such as Register Flight). */
|
||||||
|
|||||||
@ -115,6 +115,23 @@ export interface FormScreenResponse {
|
|||||||
field_defaults?: Record<string, unknown>;
|
field_defaults?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What `/ocr-extract` answers.
|
||||||
|
*
|
||||||
|
* `extracted` is keyed by the field's configured `extraction_fields[].key`, and
|
||||||
|
* those keys are deliberately named after the form fields they populate — so
|
||||||
|
* merging is a key-for-key copy with no translation table to drift.
|
||||||
|
*
|
||||||
|
* `parse_error` arrives WITH a 200: the model answered but its output was not
|
||||||
|
* parseable as the requested JSON. `raw` still carries the text, so the operator
|
||||||
|
* sees what came back instead of losing it.
|
||||||
|
*/
|
||||||
|
export interface OcrResult {
|
||||||
|
extracted?: Record<string, unknown>;
|
||||||
|
raw?: string;
|
||||||
|
parse_error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ActivityResult {
|
export interface ActivityResult {
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
import { FileText, Loader2, Upload } from 'lucide-react';
|
||||||
import { useQuery, useZino } from '../api/provider';
|
import { useQuery, useZino } from '../api/provider';
|
||||||
import { Button, ErrorNote, Input, Label, Modal, Select, Spinner, Textarea } from './core';
|
import { Button, ErrorNote, Input, Label, Modal, Select, Spinner, Textarea } from './core';
|
||||||
import type { FormField } from '../api/types';
|
import type { FormField } from '../api/types';
|
||||||
@ -56,14 +57,7 @@ export function ActivityForm({
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
const all = schema.data?.fields ?? [];
|
const fields = 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(
|
const initial = useMemo(
|
||||||
() => ({ ...(schema.data?.field_defaults ?? {}), ...(seed ?? {}) }),
|
() => ({ ...(schema.data?.field_defaults ?? {}), ...(seed ?? {}) }),
|
||||||
@ -87,6 +81,44 @@ export function ActivityForm({
|
|||||||
setErr(null);
|
setErr(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge an OCR result into the form.
|
||||||
|
*
|
||||||
|
* The response keys are the field ids they belong to — that is arranged in
|
||||||
|
* the field's ocr_config, precisely so this needs no mapping table. What it
|
||||||
|
* DOES need is coercion: the extractor returns JSON, and a number field
|
||||||
|
* handed the string "1500000" would submit a string into an integer column.
|
||||||
|
* So each value is cast to the target field's own data_type, and anything
|
||||||
|
* with no matching field on this form is ignored rather than smuggled into
|
||||||
|
* the payload.
|
||||||
|
*/
|
||||||
|
function mergeExtracted(extracted: Record<string, unknown>): string[] {
|
||||||
|
const byId = new Map(fields.map((f) => [f.id, f]));
|
||||||
|
const filled: string[] = [];
|
||||||
|
const next: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const [key, raw] of Object.entries(extracted)) {
|
||||||
|
const target = byId.get(key);
|
||||||
|
if (!target) continue;
|
||||||
|
if (raw === null || raw === undefined || raw === '') continue;
|
||||||
|
|
||||||
|
if (target.data_type === 'number') {
|
||||||
|
// Strip anything a model might helpfully add — "Rs.", commas, spaces.
|
||||||
|
const n = Number(String(raw).replace(/[^0-9.-]/g, ''));
|
||||||
|
if (!Number.isFinite(n)) continue;
|
||||||
|
next[key] = n;
|
||||||
|
} else if (target.data_type === 'boolean') {
|
||||||
|
next[key] = raw === true || String(raw).toLowerCase() === 'true';
|
||||||
|
} else {
|
||||||
|
next[key] = String(raw);
|
||||||
|
}
|
||||||
|
filled.push(target.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(next).length) setValues((s) => ({ ...s, ...next }));
|
||||||
|
return filled;
|
||||||
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
setTouched(true);
|
setTouched(true);
|
||||||
if (missing.length) return;
|
if (missing.length) return;
|
||||||
@ -121,7 +153,7 @@ export function ActivityForm({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{err && <ErrorNote>{err}</ErrorNote>}
|
{err && <ErrorNote>{err}</ErrorNote>}
|
||||||
|
|
||||||
{fields.length === 0 && pendingDocs.length === 0 && (
|
{fields.length === 0 && (
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
This activity takes no input — submitting records it as performed.
|
This activity takes no input — submitting records it as performed.
|
||||||
</p>
|
</p>
|
||||||
@ -136,24 +168,13 @@ export function ActivityForm({
|
|||||||
readOnly={seed ? Object.prototype.hasOwnProperty.call(seed, f.id) : false}
|
readOnly={seed ? Object.prototype.hasOwnProperty.call(seed, f.id) : false}
|
||||||
error={touched && missing.includes(f.id) ? 'Required' : null}
|
error={touched && missing.includes(f.id) ? 'Required' : null}
|
||||||
onChange={(v) => setValues((s) => ({ ...s, [f.id]: v }))}
|
onChange={(v) => setValues((s) => ({ ...s, [f.id]: v }))}
|
||||||
|
activityUid={activityUid}
|
||||||
|
instanceId={instanceId}
|
||||||
|
onExtract={mergeExtracted}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<div className="flex items-center justify-end gap-2 border-t border-line pt-4">
|
||||||
<Button kind="ghost" onClick={onClose} disabled={busy}>
|
<Button kind="ghost" onClick={onClose} disabled={busy}>
|
||||||
Cancel
|
Cancel
|
||||||
@ -174,15 +195,35 @@ function FieldControl({
|
|||||||
onChange,
|
onChange,
|
||||||
readOnly,
|
readOnly,
|
||||||
error,
|
error,
|
||||||
|
activityUid,
|
||||||
|
instanceId,
|
||||||
|
onExtract,
|
||||||
}: {
|
}: {
|
||||||
field: FormField;
|
field: FormField;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
onChange: (v: unknown) => void;
|
onChange: (v: unknown) => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
|
activityUid: string;
|
||||||
|
instanceId?: number | string;
|
||||||
|
onExtract: (extracted: Record<string, unknown>) => string[];
|
||||||
}) {
|
}) {
|
||||||
const opts = field.properties?.options ?? [];
|
const opts = field.properties?.options ?? [];
|
||||||
|
|
||||||
|
// A document, not a value the operator types. It fills the other fields.
|
||||||
|
if (field.data_type === 'ocr') {
|
||||||
|
return (
|
||||||
|
<OcrUpload
|
||||||
|
field={field}
|
||||||
|
activityUid={activityUid}
|
||||||
|
instanceId={instanceId}
|
||||||
|
onExtract={onExtract}
|
||||||
|
onFilename={onChange}
|
||||||
|
filename={String(value ?? '')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Long prose gets a textarea whatever the schema says, because a
|
// Long prose gets a textarea whatever the schema says, because a
|
||||||
// single-line input for a credit narrative is unusable — and the narrative
|
// single-line input for a credit narrative is unusable — and the narrative
|
||||||
// is the field a credit manager actually reads.
|
// is the field a credit manager actually reads.
|
||||||
@ -253,3 +294,121 @@ function FieldControl({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The document-intelligence control.
|
||||||
|
*
|
||||||
|
* Pick a file, and the values it contains land in the other fields on this
|
||||||
|
* form. What it extracts is not this component's decision — the field's
|
||||||
|
* `ocr_config` on the server decides that, which is why there is no field list
|
||||||
|
* anywhere in here.
|
||||||
|
*
|
||||||
|
* Three states worth designing for, not two:
|
||||||
|
* * read the call takes 5-20 seconds against a vision model, so the
|
||||||
|
* control says what it is doing rather than looking hung
|
||||||
|
* * filled it names EVERY field it populated. On the application form
|
||||||
|
* that is fifteen at once, and the operator has to be able to
|
||||||
|
* check the machine rather than trust it
|
||||||
|
* * partial `parse_error` arrives WITH a 200 — the model answered but not
|
||||||
|
* in the requested shape. That is a different failure from a
|
||||||
|
* network error and it says so, because the fix is different
|
||||||
|
*/
|
||||||
|
function OcrUpload({
|
||||||
|
field,
|
||||||
|
activityUid,
|
||||||
|
instanceId,
|
||||||
|
onExtract,
|
||||||
|
onFilename,
|
||||||
|
filename,
|
||||||
|
}: {
|
||||||
|
field: FormField;
|
||||||
|
activityUid: string;
|
||||||
|
instanceId?: number | string;
|
||||||
|
onExtract: (extracted: Record<string, unknown>) => string[];
|
||||||
|
onFilename: (v: unknown) => void;
|
||||||
|
filename: string;
|
||||||
|
}) {
|
||||||
|
const { client } = useZino();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [filled, setFilled] = useState<string[] | null>(null);
|
||||||
|
const [warn, setWarn] = useState<string | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function run(file: File) {
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
setWarn(null);
|
||||||
|
setFilled(null);
|
||||||
|
try {
|
||||||
|
const res = await client.ocrExtract(activityUid, field.id, file, instanceId);
|
||||||
|
onFilename(file.name);
|
||||||
|
const names = onExtract(res.extracted ?? {});
|
||||||
|
setFilled(names);
|
||||||
|
if (res.parse_error) {
|
||||||
|
setWarn(`The document was read but the response could not be parsed fully: ${res.parse_error}`);
|
||||||
|
} else if (names.length === 0) {
|
||||||
|
setWarn('Nothing was extracted. Check this is the right document for this field.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as { message?: string })?.message ?? 'Could not read the document');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sm:col-span-full">
|
||||||
|
<Label>{field.name}</Label>
|
||||||
|
<div className="rounded-md border border-dashed border-line bg-panel2 px-3 py-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<label
|
||||||
|
className={`inline-flex cursor-pointer items-center gap-2 rounded-md bg-panel px-3 py-1.5 text-sm font-semibold text-brand ring-1 ring-inset ring-line transition-colors hover:bg-panel3 ${
|
||||||
|
busy ? 'pointer-events-none opacity-60' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
{filename ? 'Replace document' : 'Choose document'}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept="application/pdf,image/png,image/jpeg"
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
// Reset the input so choosing the SAME file again re-runs.
|
||||||
|
e.target.value = '';
|
||||||
|
if (f) void run(f);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{busy && (
|
||||||
|
<span className="inline-flex items-center gap-2 text-sm text-muted">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Reading the document…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!busy && filename && (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-sm text-muted">
|
||||||
|
<FileText className="h-4 w-4 text-faint" />
|
||||||
|
{filename}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <p className="mt-2 text-sm text-bad">{err}</p>}
|
||||||
|
{warn && <p className="mt-2 text-sm text-warn">{warn}</p>}
|
||||||
|
|
||||||
|
{filled && filled.length > 0 && (
|
||||||
|
<div className="mt-2.5 rounded-md bg-good-soft px-3 py-2 ring-1 ring-inset ring-good-edge">
|
||||||
|
<p className="text-sm font-semibold text-good">
|
||||||
|
Filled {filled.length} {filled.length === 1 ? 'field' : 'fields'} from this document
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-good">{filled.join(' · ')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -117,7 +117,9 @@ export function EvidencePanel({ row }: { row: Row }) {
|
|||||||
const variance = num(row.income_variance_pct);
|
const variance = num(row.income_variance_pct);
|
||||||
const varianceBreach = variance !== null && variance > POLICY.varianceTolerancePct;
|
const varianceBreach = variance !== null && variance > POLICY.varianceTolerancePct;
|
||||||
|
|
||||||
|
const isPersonal = str(row.loan_product_family) === 'personal';
|
||||||
const dscr = num(row.dscr);
|
const dscr = num(row.dscr);
|
||||||
|
const foir = num(row.foir);
|
||||||
const maxElig = num(row.max_eligible_amount);
|
const maxElig = num(row.max_eligible_amount);
|
||||||
const requested = num(row.requested_amount);
|
const requested = num(row.requested_amount);
|
||||||
const bureau = num(row.bureau_score);
|
const bureau = num(row.bureau_score);
|
||||||
@ -197,6 +199,20 @@ export function EvidencePanel({ row }: { row: Row }) {
|
|||||||
subtitle={`Server-side, reproducible from stored evidence · policy ${POLICY.version}`}
|
subtitle={`Server-side, reproducible from stored evidence · policy ${POLICY.version}`}
|
||||||
>
|
>
|
||||||
<div className="px-4 py-1">
|
<div className="px-4 py-1">
|
||||||
|
{/* Which capacity test GATES the file depends on the product, and only
|
||||||
|
the gating one is shown as a pass/fail. Both figures are computed
|
||||||
|
for every file, but a DSCR on a salaried personal loan is not a
|
||||||
|
test anybody applies — showing it with a cross beside it would
|
||||||
|
have a reader querying a number that decided nothing. */}
|
||||||
|
{isPersonal ? (
|
||||||
|
<Test
|
||||||
|
label="FOIR — obligations against income"
|
||||||
|
value={ratioPct(row.foir)}
|
||||||
|
threshold={`ceiling ${(POLICY.foirCeiling * 100).toFixed(0)}%`}
|
||||||
|
pass={foir === null ? null : foir <= POLICY.foirCeiling}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Test
|
<Test
|
||||||
label="DSCR — debt service coverage"
|
label="DSCR — debt service coverage"
|
||||||
value={ratio(dscr)}
|
value={ratio(dscr)}
|
||||||
@ -207,8 +223,11 @@ export function EvidencePanel({ row }: { row: Row }) {
|
|||||||
label="Debt service ratio"
|
label="Debt service ratio"
|
||||||
value={ratioPct(row.foir)}
|
value={ratioPct(row.foir)}
|
||||||
threshold="share of monthly cash flow committed to debt"
|
threshold="share of monthly cash flow committed to debt"
|
||||||
|
note="Shown for context — DSCR is the test that gates an MSME file."
|
||||||
pass={null}
|
pass={null}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Test
|
<Test
|
||||||
label="Maximum eligible amount"
|
label="Maximum eligible amount"
|
||||||
value={moneyShort(maxElig)}
|
value={moneyShort(maxElig)}
|
||||||
@ -221,12 +240,16 @@ export function EvidencePanel({ row }: { row: Row }) {
|
|||||||
threshold={`floor ${POLICY.bureauFloor}`}
|
threshold={`floor ${POLICY.bureauFloor}`}
|
||||||
pass={bureau === null ? null : bureau >= POLICY.bureauFloor}
|
pass={bureau === null ? null : bureau >= POLICY.bureauFloor}
|
||||||
/>
|
/>
|
||||||
|
{/* Commercial bureau only. A personal file has no MSME rank, and a
|
||||||
|
row reading "CMR-0 —" is noise on a screen built to be scanned. */}
|
||||||
|
{!isPersonal && (
|
||||||
<Test
|
<Test
|
||||||
label="CIBIL MSME Rank"
|
label="CIBIL MSME Rank"
|
||||||
value={cmr === null ? '—' : `CMR-${cmr}`}
|
value={cmr === null || cmr === 0 ? '—' : `CMR-${cmr}`}
|
||||||
threshold={`ceiling CMR-${POLICY.cmrCeiling}`}
|
threshold={`ceiling CMR-${POLICY.cmrCeiling}`}
|
||||||
pass={cmr === null ? null : cmr <= POLICY.cmrCeiling}
|
pass={cmr === null || cmr === 0 ? null : cmr <= POLICY.cmrCeiling}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<Test
|
<Test
|
||||||
label="Live DPD"
|
label="Live DPD"
|
||||||
value={dpd === null ? '—' : `${dpd} d`}
|
value={dpd === null ? '—' : `${dpd} d`}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user