// CredNX Underwrite — app shell, sidebar, upload flow, sessions, processing. // Re-themed to Editorial Forest tokens. ALL fetch() calls, sessionStorage // usage and API endpoint paths are preserved verbatim. // document_type → icon key in I{...} const DOC_TYPE_ICON = { bank_statement: 'bank', credit_bureau: 'creditCard', invoice: 'receipt', id_document: 'fileText', tax_return: 'fileText', medical_report: 'fileText', medical_prescription: 'fileText', disbursement_report: 'fileText', }; // Fallback used when the /api/v1/documents endpoint is unreachable const FALLBACK_DOC_MASTER = [ {id:'DOC_001', name:'Bank Statement', icon:'bank', desc:'PDF statements, any bank'}, {id:'DOC_008', name:'Credit Bureau', icon:'creditCard', desc:'CIBIL / Experian reports'}, {id:'DOC_002', name:'Invoice', icon:'receipt', desc:'Supplier & buyer invoices'}, ]; function useDocMaster() { const [docs, setDocs] = React.useState(FALLBACK_DOC_MASTER); const [loading, setLoading] = React.useState(true); React.useEffect(() => { const authKey = sessionStorage.getItem('crednx_auth_key'); const headers = authKey ? { 'X-Auth-Key': authKey } : {}; fetch('/api/v1/documents', { headers }) .then(r => r.ok ? r.json() : Promise.reject(r.status)) .then(data => { const list = data?.result?.json?.documents; if (Array.isArray(list) && list.length > 0) { setDocs(list.map(d => ({ id: d.document_id, name: d.document_name, icon: DOC_TYPE_ICON[d.document_type] || 'fileText', desc: d.description, }))); } }) .catch(() => {/* keep fallback */}) .finally(() => setLoading(false)); }, []); return { docs, loading }; } const MOCK_BORROWERS = [ {id:'BRW-2847', name:'PT Sinar Mas Agro', sessions:4}, {id:'BRW-2931', name:'Budi Santoso', sessions:2}, {id:'BRW-3012', name:'CV Karya Jaya', sessions:1}, {id:'BRW-3104', name:'Rahmat Hidayat', sessions:7}, ]; // ——— Sidebar wordmark — uses the brand SVG. The wrapper is data-theme="dark" ——— // because the sidebar uses a dark deep-forest panel regardless of theme. const SidebarWordmark = () => ; function Sidebar({page, setPage, user, org, sessionCount}) { const [collapsed, setCollapsed] = React.useState(() => { try { return localStorage.getItem('crednx_sidebar_collapsed') === '1'; } catch { return false; } }); const toggleCollapsed = () => setCollapsed(c => { const next = !c; try { localStorage.setItem('crednx_sidebar_collapsed', next ? '1' : '0'); } catch {} return next; }); const W = collapsed ? 72 : 240; const sessionBadge = sessionCount == null ? null : String(sessionCount); const items = [ {id:'home', label:'Home', icon:, badge:null}, {id:'underwrite', label:'Underwrite', icon:, badge:null}, {id:'sessions', label:'Sessions', icon:, badge:sessionBadge}, {id:'borrowers', label:'Borrowers', icon:, badge:null}, {id:'insights', label:'Insights', icon:, badge:null}, {divider:true}, {id:'settings', label:'Settings', icon:, badge:null}, ...(user.role==='Admin' ? [{id:'team', label:'Team', icon:, badge:null}] : []), ]; return (
{collapsed ? : }
{collapsed ? (
) : (
{org.name}
{org.plan}
)}
{!collapsed && (
API credits
1,248 / 2,000
)}
); } function TopBar({user, onLogout, title, subtitle, actions}) { const [menu, setMenu] = React.useState(false); return (
{title}
{subtitle &&
{subtitle}
}
{actions}
e.currentTarget.style.background='hsl(var(--surface-2))'} onMouseLeave={e=>e.currentTarget.style.background='hsl(var(--surface))'} onClick={()=>setMenu(!menu)}>
{user.name}
{menu && setMenu(false)} onLogout={onLogout}/>}
); } function UserMenu({user, onClose, onLogout}) { return ( <>
{user.name}
{user.email}
{user.role}
{[ {label:'Account settings', icon:}, {label:'API keys', icon:}, {label:'Help & docs', icon:}, ].map(it=> )}
); } // ——— Underwrite / Upload ——— function DocTypeSelect({value, onChange, docMaster = FALLBACK_DOC_MASTER}) { const selected = docMaster.find(d=>d.id===value); return (
{selected && (
{I[selected.icon]()}
)}
{selected ? selected.name : 'Select document type'}
{selected &&
{selected.desc}
}
); } function UnderwriteA({onSubmit, docMaster = FALLBACK_DOC_MASTER, borrowers: propBorrowers}) { const [docType, setDocType] = React.useState('DOC_001'); const [borrowerId, setBorrowerId] = React.useState(''); const [borrowerNew, setBorrowerNew] = React.useState(''); const [ddOpen, setDd] = React.useState(false); const [files, setFiles] = React.useState([]); const [drag, setDrag] = React.useState(false); const fileInputRef = React.useRef(null); const selectedDoc = docMaster.find(d => d.id === docType); const allBorrowers = (propBorrowers && propBorrowers.length > 0) ? propBorrowers.map(b => ({ id: b.customer_id, sub: `${b.total_sessions ?? 0} sessions` })) : MOCK_BORROWERS.map(b => ({ id: b.id, sub: b.name })); function addFiles(rawFiles) { const list = Array.from(rawFiles).filter(f => f.name.toLowerCase().endsWith('.pdf')); setFiles(prev => { const existing = new Set(prev.map(f => f.name)); return [...prev, ...list.filter(f => !existing.has(f.name))]; }); } function fmtSize(bytes) { if (bytes < 1048576) return (bytes / 1024).toFixed(0) + ' KB'; return (bytes / 1048576).toFixed(1) + ' MB'; } const activeBorrower = borrowerId || borrowerNew; const stepNumStyle = {width:26, height:26, borderRadius:13, background:'hsl(var(--primary))', color:'hsl(var(--primary-fg))', fontSize:12, fontWeight:700, display:'flex', alignItems:'center', justifyContent:'center'}; return (
{ addFiles(e.target.files); e.target.value = ''; }}/>
1
Document type
Select the type of document to analyse. Each session processes one document type.
2
Borrower Reference ID
Files with the same reference ID are grouped into one borrower's session history.
{ddOpen && (
{allBorrowers.map(b => ( ))}
New borrower ID
setBorrowerNew(e.target.value)} style={{flex:1, height:34, padding:'0 10px', border:'1px solid hsl(var(--border))', borderRadius:10, fontSize:13, fontFamily:'inherit', outline:'none', color:'hsl(var(--fg))', background:'hsl(var(--surface))'}}/>
)}
3
Upload documents
Accepts PDF files, up to 50 MB each.
fileInputRef.current?.click()} onDragOver={e => { e.preventDefault(); setDrag(true); }} onDragLeave={() => setDrag(false)} onDrop={e => { e.preventDefault(); setDrag(false); addFiles(e.dataTransfer.files); }} style={{border:`2px dashed ${drag ? 'hsl(var(--primary))' : 'hsl(var(--border-strong))'}`, borderRadius:16, padding:'36px 20px', textAlign:'center', background: drag ? 'hsl(var(--accent-soft))' : 'hsl(var(--bg-soft))', transition:'all 200ms', cursor:'pointer'}}>
Drop files here
PDF only · 50 MB max · All files encrypted in transit
{files.length > 0 && (
{files.map((f, i) => (
{f.name}
{fmtSize(f.size)} {selectedDoc?.name}
))}
)}
Session summary
Document type {selectedDoc?.name || '—'}
Files {files.length}
Borrower {activeBorrower || '—'}
Estimated credits {files.length * 2}
Estimated time ~{Math.max(1, files.length)} min
Files are processed asynchronously. You'll be notified on completion.
); } function UnderwriteB({onSubmit, docMaster = FALLBACK_DOC_MASTER}) { const [step, setStep] = React.useState(1); const [docType, setDocType] = React.useState('DOC_001'); const [selectedBorrower, setSelectedBorrower] = React.useState(0); return (
{[{n:1,l:'Document type'},{n:2,l:'Borrower'},{n:3,l:'Upload'}].map((s)=>(
setStep(s.n)}>
=s.n?'hsl(var(--primary))':'hsl(var(--border))', color:step>=s.n?'hsl(var(--primary-fg))':'hsl(var(--fg-subtle))', fontSize:11, fontWeight:700, display:'flex', alignItems:'center', justifyContent:'center'}}> {step>s.n ? : s.n}
s.n?'hsl(var(--fg))':'hsl(var(--fg-subtle))'}}>{s.l}
))}
{step===1 &&
What type of document?
Select one document type. Each session processes one type.
{docMaster.map((d)=>{ const on = docType===d.id; return ( ); })}
} {step===2 &&
Who is this for?
Link this session to a borrower.
{MOCK_BORROWERS.slice(0,3).map((b,i)=>(
setSelectedBorrower(i)} style={{display:'flex', alignItems:'center', gap:12, padding:'12px 14px', border:selectedBorrower===i?'1.5px solid hsl(var(--primary))':'1px solid hsl(var(--border))', borderRadius:14, background:selectedBorrower===i?'hsl(var(--accent-soft))':'hsl(var(--surface))', cursor:'pointer', transition:'border-color 120ms, background 120ms'}}>
{b.id}
{b.name} · {b.sessions} sessions
{selectedBorrower===i && }
))}
} {step===3 &&
Upload your files
PDF only, up to 50 MB each.
Drag and drop here
2 files ready · bca_statement_jan.pdf, bni_statement_feb.pdf
}
); } // ——— Sessions list (kept for design canvas; production uses BorrowersPage) ——— const SESSIONS = [ {id:'sess_8a436c61', borrower:'BRW-2847', borrowerName:'PT Sinar Mas Agro', docs:3, type:'Bank Statement', created:'2 min ago', status:'processing', progress:64}, {id:'sess_6b8382c3', borrower:'BRW-2931', borrowerName:'Budi Santoso', docs:2, type:'Credit Bureau', created:'18 min ago', status:'completed', progress:100}, {id:'sess_be067ef3', borrower:'BRW-2847', borrowerName:'PT Sinar Mas Agro', docs:1, type:'Invoice', created:'1 hour ago', status:'completed', progress:100}, {id:'sess_d76c0cea', borrower:'BRW-3012', borrowerName:'CV Karya Jaya', docs:4, type:'Bank Statement', created:'3 hours ago', status:'completed', progress:100}, {id:'sess_d7e3a616', borrower:'BRW-3104', borrowerName:'Rahmat Hidayat', docs:2, type:'Bank Statement', created:'Yesterday', status:'failed', progress:0}, {id:'sess_2728e0cd', borrower:'BRW-2931', borrowerName:'Budi Santoso', docs:1, type:'Credit Bureau', created:'2 days ago', status:'completed', progress:100}, ]; function SessionsList({populated, onOpen, sessions: propSessions}) { const sessions = propSessions || SESSIONS; const [search, setSearch] = React.useState(''); const [expanded, setExpanded] = React.useState(sessions.length > 0 ? [sessions[0].borrower] : []); const toggle = (id) => setExpanded(prev => prev.includes(id) ? prev.filter(x=>x!==id) : [...prev, id]); if (propSessions === undefined) { return (
Loading sessions…
); } if (!populated) { return (
No sessions yet
Create your first underwriting session to analyse borrower documents.
); } const byBorrower = {}; sessions.forEach(s => { if (!byBorrower[s.borrower]) byBorrower[s.borrower] = {borrower:s.borrower, name:s.borrowerName||s.borrower, sessions:[]}; byBorrower[s.borrower].sessions.push(s); }); const groups = Object.values(byBorrower); const visible = search ? groups.filter(g => g.borrower.toLowerCase().includes(search.toLowerCase()) || g.name.toLowerCase().includes(search.toLowerCase())) : groups; return (
setSearch(e.target.value)} placeholder="Search by borrower ID or name" style={{flex:1, border:'none', outline:'none', fontSize:13, fontFamily:'inherit', color:'hsl(var(--fg))', background:'transparent'}}/>
{groups.length} borrower{groups.length!==1?'s':''} · {sessions.length} session{sessions.length!==1?'s':''}
{visible.map((g,gi)=>{ const open = expanded.includes(g.borrower); const completed = g.sessions.filter(s=>s.status==='completed').length; const processing = g.sessions.filter(s=>s.status==='processing').length; return (
{open && (
{g.sessions.map(s=>( onOpen(s)}> ))}
Session ID Type Docs Created Status
{s.id} {s.type} {s.docs} {s.created} {s.status==='processing' && Processing} {s.status==='completed' && Completed} {s.status==='failed' && Failed}
)}
); })}
); } function ProcessingScreen({onDone}) { const [progress, setProgress] = React.useState(0); const [stage, setStage] = React.useState(0); const stages = ['Uploading files','Extracting data','Standardising schema','Categorising transactions','Generating CAM analysis']; React.useEffect(()=>{ const t = setInterval(()=>{ setProgress(p=>{ const n = Math.min(100, p + 8); setStage(Math.floor(n/20)); if (n>=100) clearInterval(t); return n; }); }, 400); return ()=>clearInterval(t); },[]); return (
=100?'hsl(var(--success-bg))':'hsl(var(--accent-soft))', display:'flex', alignItems:'center', justifyContent:'center', color:progress>=100?'hsl(var(--success-fg))':'hsl(var(--primary))', position:'relative', transition:'background 300ms, color 300ms', border:`1px solid ${progress>=100?'hsl(var(--success) / 0.3)':'hsl(var(--accent) / 0.3)'}`}}> {progress>=100 ? : }
{progress>=100 ? 'Analysis complete' : 'Analysing documents'}
Session sess_8a436c61 · 3 files
{progress>=100 ? Completed : {progress}%}
{stages.map((s,i)=>(
{i : i===stage ? : {i+1}}
{s}
{iDone} {i===stage && In progress}
))}
{progress>=100 && (
Analysis complete
Report ready for review or download.
)}
); } Object.assign(window, { Sidebar, TopBar, UserMenu, UnderwriteA, UnderwriteB, SessionsList, ProcessingScreen, FALLBACK_DOC_MASTER, useDocMaster, SESSIONS, MOCK_BORROWERS });