// CredNX dashboard — Phase 1 enhancements. // Adds: Home, Sessions (filtered), Insights, Settings, Command Palette, Notifications. // All visual; no new API endpoints required. Mock data is marked NEEDS BACKEND. // ============================================================ // Time-aware greeting // ============================================================ function greeting(name) { const h = new Date().getHours(); const w = h < 5 ? 'Working late' : h < 12 ? 'Good morning' : h < 17 ? 'Good afternoon' : h < 22 ? 'Good evening' : 'Working late'; return `${w}, ${(name || '').split(' ')[0] || 'there'}`; } function todayLabel() { const d = new Date(); return d.toLocaleDateString('en-GB', { weekday:'long', day:'numeric', month:'long' }); } // ============================================================ // Sparkline (small line for KPI tiles) // ============================================================ const Sparkline = ({ data, w = 100, h = 28, color = 'hsl(var(--primary))', fill = 'hsl(var(--accent-soft))' }) => { if (!data || data.length < 2) return
; const max = Math.max(...data), min = Math.min(...data); const span = max - min || 1; const pts = data.map((d, i) => [ (i / (data.length - 1)) * w, h - 2 - ((d - min) / span) * (h - 4), ]); const dstr = 'M' + pts.map(p => p.join(',')).join(' L'); const area = dstr + ` L${w},${h} L0,${h} Z`; return ( ); }; // ============================================================ // Stat tile (richer KPI used on Home) // ============================================================ function StatTile({ label, value, delta, deltaTone = 'success', icon, accent = 'brand', spark }) { const accentMap = { brand: { bg:'hsl(var(--accent-soft))', fg:'hsl(var(--primary))', line:'hsl(var(--primary))', fill:'hsl(var(--accent-soft))' }, success: { bg:'hsl(var(--success-bg))', fg:'hsl(var(--success-fg))', line:'hsl(var(--success))', fill:'hsl(var(--success-bg))' }, warning: { bg:'hsl(var(--warning-bg))', fg:'hsl(var(--warning-fg))', line:'hsl(var(--warning))', fill:'hsl(var(--warning-bg))' }, info: { bg:'hsl(var(--accent-soft))', fg:'hsl(var(--accent))', line:'hsl(var(--accent))', fill:'hsl(var(--accent-soft))' }, }; const a = accentMap[accent] || accentMap.brand; const deltaColor = deltaTone === 'danger' ? 'hsl(var(--danger))' : deltaTone === 'warning' ? 'hsl(var(--warning-fg))' : 'hsl(var(--success-fg))'; return (
{icon}
{delta && {delta}}
{label}
{value}
{spark && }
); } // ============================================================ // Activity feed // ============================================================ // NEEDS BACKEND: replace this with /api/v1/activity feed of org-wide events. const ACTIVITY_FALLBACK = [ { kind:'session_completed', who:'Aditya Rahman', target:'BRW-2847', detail:'Bank statement analysis finished', when:'2 min ago' }, { kind:'session_started', who:'Siti Nuraini', target:'BRW-3104', detail:'Started credit bureau analysis', when:'18 min ago' }, { kind:'decision_approved', who:'Fina Lestari', target:'BRW-2931', detail:'Approved facility · Rp 5.2B at 11.5% p.a.', when:'42 min ago' }, { kind:'session_failed', who:'system', target:'BRW-3104', detail:'Analysis failed — file unreadable', when:'1 hour ago' }, { kind:'team_invited', who:'Fina Lestari', target:'rendra.p@bankx.id', detail:'Invited as Viewer', when:'3 hours ago' }, { kind:'session_completed', who:'Aditya Rahman', target:'BRW-3012', detail:'Bank statement analysis finished', when:'4 hours ago' }, ]; const KIND_META = { session_completed: { tone:'success', icon:, verb:'completed analysis for' }, session_started: { tone:'brand', icon:, verb:'started analysing' }, session_failed: { tone:'danger', icon:, verb:'failed analysis for' }, decision_approved: { tone:'success', icon:, verb:'approved' }, team_invited: { tone:'brand', icon:, verb:'invited' }, }; function ActivityFeed({ events }) { const list = events || ACTIVITY_FALLBACK; return (
{list.map((e, i) => { const m = KIND_META[e.kind] || { tone:'neutral', icon:, verb:'updated' }; const bg = `hsl(var(--${m.tone}-bg))`; const border = `hsl(var(--${m.tone}) / 0.3)`; const fg = `hsl(var(--${m.tone}-fg))`; return (
{m.icon}
{e.who} {m.verb}{' '} {e.target}
{e.detail}
{e.when}
); })}
); } // ============================================================ // Home page // ============================================================ function HomePage({ user, borrowers, onNavigate }) { // Derive stats from borrowers / sessions const allSessions = React.useMemo(() => { return (borrowers || []).flatMap(b => b.sessions || []); }, [borrowers]); const today = React.useMemo(() => { const start = new Date(); start.setHours(0,0,0,0); return allSessions.filter(s => s._raw?.created_at && new Date(s._raw.created_at) >= start); }, [allSessions]); const completedToday = today.filter(s => s.status === 'completed').length; const processing = allSessions.filter(s => s.status === 'processing').length; const failed = allSessions.filter(s => s.status === 'failed').length; const total = allSessions.length; const completedAll = allSessions.filter(s => s.status === 'completed').length; const approvalRate = total > 0 ? Math.round((completedAll / total) * 100) : 0; const pending = (borrowers || []).reduce((sum, b) => sum + ((b.total_sessions || 0) - (b.completed_sessions || 0)), 0); // Synthetic 7-day spark (NEEDS BACKEND: replace with real timeseries) const sevenDay = [3, 5, 4, 7, 6, 9, today.length || 1]; const approvalTrend = [62, 68, 65, 72, 70, 76, approvalRate || 78]; const queueTrend = [2, 4, 3, 5, 4, 6, pending]; const timeTrend = [5.2, 4.8, 4.5, 4.4, 4.0, 3.9, 4.2]; const recentBorrowers = (borrowers || []).slice(0, 5); return (
{/* Greeting strip */}
{greeting(user?.name)}.
{todayLabel()}
{processing > 0 ? <>{processing} session{processing === 1 ? '' : 's'} in progress · : null} {completedToday} completed today · {pending} pending review
{/* KPI tiles */}
} accent="brand" spark={sevenDay}/> 0 ? `${pending} to action` : 'Inbox zero'} deltaTone={pending > 0 ? 'warning' : 'success'} icon={} accent="warning" spark={queueTrend}/> = 70 ? '+5pp WoW' : '−2pp'} deltaTone={approvalRate >= 70 ? 'success' : 'danger'} icon={} accent="success" spark={approvalTrend}/> } accent="info" spark={timeTrend}/>
{/* Two-column row: Activity + Quick actions */}
Quick actions
Tip
Press ⌘ K anywhere to jump to borrowers, sessions, or run a quick action.
{/* Recent borrowers */} {borrowers === undefined ? (
Loading borrowers…
) : recentBorrowers.length === 0 ? (
No borrowers yet
Once you start your first analysis, borrowers will appear here.
) : (
{recentBorrowers.map((b, i) => (
onNavigate && onNavigate('borrowers')}>
{b.customer_id}
{b.total_sessions ?? 0} sessions · {b.completed_sessions ?? 0} completed
{(b.total_sessions || 0) > (b.completed_sessions || 0) && ( {(b.total_sessions || 0) - (b.completed_sessions || 0)} pending )}
))}
)}
); } // ============================================================ // Sessions (filtered) — flat across borrowers // ============================================================ function SessionsTable({ borrowers, onOpenSession }) { const [statusFilter, setStatusFilter] = React.useState('all'); const [docFilter, setDocFilter] = React.useState('all'); const [rangeFilter, setRangeFilter] = React.useState('all'); const [search, setSearch] = React.useState(''); const [sortKey, setSortKey] = React.useState('created'); const [sortDir, setSortDir] = React.useState('desc'); const allSessions = React.useMemo(() => (borrowers || []).flatMap(b => b.sessions || []), [borrowers]); const filtered = React.useMemo(() => { const now = Date.now(); const cutoff = rangeFilter === '7d' ? 7*86400000 : rangeFilter === '30d' ? 30*86400000 : rangeFilter === '24h' ? 86400000 : null; return allSessions.filter(s => { if (statusFilter !== 'all' && s.status !== statusFilter) return false; if (docFilter !== 'all' && s.type !== docFilter) return false; if (cutoff && s._raw?.created_at && (now - new Date(s._raw.created_at).getTime()) > cutoff) return false; if (search) { const q = search.toLowerCase(); if (!(s.id.toLowerCase().includes(q) || s.borrower.toLowerCase().includes(q) || (s.borrowerName||'').toLowerCase().includes(q))) return false; } return true; }); }, [allSessions, statusFilter, docFilter, rangeFilter, search]); const sorted = React.useMemo(() => { const arr = [...filtered]; arr.sort((a, b) => { let av, bv; if (sortKey === 'created') { av = new Date(a._raw?.created_at || 0).getTime(); bv = new Date(b._raw?.created_at || 0).getTime(); } else if (sortKey === 'borrower') { av = a.borrower; bv = b.borrower; } else if (sortKey === 'type') { av = a.type; bv = b.type; } else if (sortKey === 'docs') { av = a.docs; bv = b.docs; } else if (sortKey === 'status') { av = a.status; bv = b.status; } if (av < bv) return sortDir === 'asc' ? -1 : 1; if (av > bv) return sortDir === 'asc' ? 1 : -1; return 0; }); return arr; }, [filtered, sortKey, sortDir]); const docTypes = React.useMemo(() => Array.from(new Set(allSessions.map(s => s.type))).sort(), [allSessions]); const counts = { all: allSessions.length, completed: allSessions.filter(s => s.status === 'completed').length, processing: allSessions.filter(s => s.status === 'processing').length, failed: allSessions.filter(s => s.status === 'failed').length, }; const setSort = (k) => { if (sortKey === k) setSortDir(d => d === 'asc' ? 'desc' : 'asc'); else { setSortKey(k); setSortDir('desc'); } }; const sortGlyph = (k) => sortKey === k ? (sortDir === 'asc' ? ' ↑' : ' ↓') : ''; if (borrowers === undefined) { return (
Loading sessions…
); } return (
{/* Filter bar */}
{[ ['all','All',counts.all], ['completed','Completed',counts.completed,'success'], ['processing','Processing',counts.processing,'warning'], ['failed','Failed',counts.failed,'danger'], ].map(([key,label,n,tone]) => { const active = statusFilter === key; const accent = tone || 'brand'; return ( ); })}
setSearch(e.target.value)} placeholder="Search by session ID or borrower" style={{ flex:1, border:'none', outline:'none', fontSize:13, fontFamily:'inherit', color:'hsl(var(--fg))', background:'transparent' }}/> {search && }
{[['24h','24h'],['7d','7d'],['30d','30d'],['all','All']].map(([k,l],i) => { const active = rangeFilter === k; return ( ); })}
Showing {sorted.length} of {allSessions.length}
{sorted.length === 0 ? (
No sessions match
Try clearing filters or broadening your date range.
) : (
{[ ['borrower','Borrower'], ['type','Document type'], ['docs','Files'], ['created','Created'], ['status','Status'], ].map(([k,l]) => ( ))} {sorted.map((s, i) => ( onOpenSession && onOpenSession(s)}> ))}
setSort(k)}>{l}{sortGlyph(k)}
{s.borrower}
{s.id}
{s.type} {s.docs} {s.created} {s.status === 'processing' && Processing} {s.status === 'completed' && Completed} {s.status === 'failed' && Failed}
)}
); } // ============================================================ // Insights page // ============================================================ function InsightsPage({ borrowers }) { const [range, setRange] = React.useState('30d'); const allSessions = (borrowers || []).flatMap(b => b.sessions || []); const docMix = React.useMemo(() => { const m = {}; allSessions.forEach(s => { m[s.type] = (m[s.type] || 0) + 1; }); return Object.entries(m).map(([k, v]) => ({ name: k, count: v })); }, [allSessions]); const total = docMix.reduce((s, x) => s + x.count, 0) || 1; const colors = ['hsl(var(--primary))', 'hsl(var(--accent))', 'hsl(var(--gold))', 'hsl(var(--border-strong))']; // NEEDS BACKEND: replace with real timeseries const approvalTrend = [62, 68, 65, 72, 70, 76, 74, 78, 82, 80, 84, 82, 86, 84]; const volumeTrend = [3, 5, 4, 6, 8, 7, 9, 11, 10, 13, 12, 14, 16, 15]; return (
Portfolio insights
Cross-session analytics across {(borrowers || []).length} borrowers · derived live
{[['7d','7d'],['30d','30d'],['90d','90d'],['ytd','YTD']].map(([k,l],i) => { const active = range === k; return ( ); })}
{/* KPI row */}
} accent="brand" spark={volumeTrend}/> } accent="success" spark={approvalTrend}/> } accent="info" spark={[5.2,4.8,4.5,4.4,4.0,3.9,4.2]}/> } accent="warning" spark={[5.2,4.8,4.4,4.0,3.6,3.4,3.2]}/>
{/* Big trend */}
Approval rate · 14 days
Approval rate
Volume
{/* Doc mix */}
Document mix
{total > 0 ? (
{docMix.map((d, i) => (
{d.name}
{d.count}
{Math.round(d.count / total * 100)}%
))}
) : (
No sessions yet — start one to see the mix.
)}
{/* Top counterparties — NEEDS BACKEND for real cross-session aggregation */}
Top counterparties (across portfolio)
Sample · backend pending
{[ { name:'PT BUMI RESOURCES', dir:'in', vol:'Rp 4.8B', cnt:42 }, { name:'PT KARYA MANUFAKTUR', dir:'out', vol:'Rp 1.9B', cnt:28 }, { name:'CV KARYA JAYA', dir:'out', vol:'Rp 1.3B', cnt:24 }, { name:'PT SINAR MAS AGRO', dir:'in', vol:'Rp 962M', cnt:18 }, { name:'BCA CREDIT CARD', dir:'out', vol:'Rp 754M', cnt:32 }, ].map((r, i) => (
{r.name}
{r.cnt} transactions
{r.dir === 'in' ? '+' : '−'}{r.vol}
))}
); } // ============================================================ // Settings page // ============================================================ function SettingsPage({ user }) { const [section, setSection] = React.useState('profile'); const [keyVisible, setKeyVisible] = React.useState(false); const apiKey = (typeof sessionStorage !== 'undefined' && sessionStorage.getItem('crednx_auth_key')) || 'crednx_pk_••••••••••••••'; const masked = apiKey.length > 10 ? apiKey.slice(0, 8) + '•'.repeat(Math.max(8, apiKey.length - 12)) + apiKey.slice(-4) : '••••••••'; const [copied, setCopied] = React.useState(false); const sections = [ { id:'profile', label:'Profile', icon: }, { id:'apikeys', label:'API keys', icon: }, { id:'webhooks', label:'Webhooks', icon: }, { id:'theme', label:'Theme', icon: }, ]; function copy() { try { navigator.clipboard.writeText(apiKey); setCopied(true); setTimeout(() => setCopied(false), 1800); } catch {} } return (
Settings
Manage your account, API access and preferences.
{section === 'profile' && (
Profile
{user?.name || 'Unknown'}
{user?.email}
{user?.role || 'Member'}
{}}/> {}}/> {}}/> {}}/>
)} {section === 'apikeys' && (
API keys
Default key
Active
{keyVisible ? apiKey : masked}
Created May 2024 · Last used 2 hours ago
Treat your API keys like passwords. Anyone with this key can access your CredNX workspace.
)} {section === 'webhooks' && (
Webhooks
{[ { url:'https://crm.bankofsoutheast.id/webhooks/crednx', events:['session.completed','decision.approved'], active:true }, { url:'https://ops.bankofsoutheast.id/alerts', events:['session.failed'], active:true }, ].map((w, i) => (
{w.url}
{w.events.map(e => {e})}
{w.active ? 'Active' : 'Paused'}
))}
Receive HTTP POSTs when sessions complete, fail, or get a decision. View payload schema →
)} {section === 'theme' && (
Appearance
Switch between light and dark — your choice is remembered on this device.
{[ { id:'light', label:'Light', preview:'linear-gradient(135deg, hsl(42 30% 96%), hsl(42 22% 93%))' }, { id:'dark', label:'Dark', preview:'linear-gradient(135deg, hsl(155 42% 5%), hsl(155 36% 8%))' }, ].map(t => { const current = (typeof document !== 'undefined' && document.documentElement.getAttribute('data-theme')) || 'light'; const active = current === t.id; return ( ); })}
)}
); } // ============================================================ // Notifications drawer (NEEDS BACKEND for real notifications) // ============================================================ const NOTIF_FALLBACK = [ { id:1, kind:'session_completed', title:'Bank statement analysis completed', body:'BRW-2847 · 3 documents processed in 4.2 min', when:'2 min ago', unread:true, goto:{ page:'sessions' } }, { id:2, kind:'decision_approved', title:'Approval issued', body:'BRW-2931 · Rp 5.2B at 11.5% p.a.', when:'42 min ago', unread:true, goto:{ page:'sessions' } }, { id:3, kind:'session_failed', title:'Analysis failed', body:'BRW-3104 · Bank statement file unreadable', when:'1 hour ago', unread:false, goto:{ page:'sessions' } }, { id:4, kind:'team_invited', title:'rendra.p@bankx.id joined', body:'Invited by Fina Lestari · Viewer', when:'3 hours ago', unread:false, goto:{ page:'team' } }, { id:5, kind:'system', title:'Weekly report is ready', body:'78% approval rate (+5pp WoW) · 14 sessions', when:'Yesterday', unread:false, goto:{ page:'insights' } }, ]; function NotificationsDrawer({ open, onClose, onNavigate, notifications, setNotifications }) { const list = notifications; const unread = list.filter(n => n.unread).length; const markAllRead = () => setNotifications(list.map(n => ({ ...n, unread:false }))); return ( <> {/* Backdrop */}
{/* Drawer */} ); } function NotificationBell({ unread, onClick }) { return ( ); } // ============================================================ // Command palette (⌘K / Ctrl+K) // ============================================================ function CommandPalette({ open, onClose, borrowers, onNavigate, onLogout }) { const [query, setQuery] = React.useState(''); const [activeIdx, setActiveIdx] = React.useState(0); const inputRef = React.useRef(null); React.useEffect(() => { if (open) { setQuery(''); setActiveIdx(0); setTimeout(() => inputRef.current?.focus(), 30); } }, [open]); const allSessions = React.useMemo(() => (borrowers || []).flatMap(b => b.sessions || []), [borrowers]); const groups = React.useMemo(() => { const q = query.trim().toLowerCase(); const matches = (s) => !q || s.toLowerCase().includes(q); const pages = [ { kind:'page', label:'Home', icon:, action:() => onNavigate('home'), hint:'Go' }, { kind:'page', label:'Underwrite', icon:, action:() => onNavigate('underwrite'), hint:'Go' }, { kind:'page', label:'Sessions', icon:, action:() => onNavigate('sessions'), hint:'Go' }, { kind:'page', label:'Borrowers', icon:, action:() => onNavigate('borrowers'), hint:'Go' }, { kind:'page', label:'Insights', icon:, action:() => onNavigate('insights'), hint:'Go' }, { kind:'page', label:'Settings', icon:, action:() => onNavigate('settings'), hint:'Go' }, ].filter(p => matches(p.label)); const actions = [ { kind:'action', label:'New session', icon:, action:() => onNavigate('underwrite'), hint:'Action' }, { kind:'action', label:'Toggle dark mode', icon:, action:() => { const cur = document.documentElement.getAttribute('data-theme') || 'light'; const next = cur === 'dark' ? 'light' : 'dark'; document.documentElement.classList.add('theme-transition'); document.documentElement.setAttribute('data-theme', next); try { localStorage.setItem('crednx_theme', next); } catch {} setTimeout(() => document.documentElement.classList.remove('theme-transition'), 360); }, hint:'Action' }, { kind:'action', label:'Log out', icon:, action:() => onLogout && onLogout(), hint:'Action' }, ].filter(a => matches(a.label)); const borrowerHits = (borrowers || []).filter(b => matches(b.customer_id)) .slice(0, 8) .map(b => ({ kind:'borrower', label:b.customer_id, icon:, hint:`${b.total_sessions || 0} sessions`, action:() => onNavigate('borrowers') })); const sessionHits = q ? allSessions.filter(s => matches(s.id) || matches(s.borrower) || matches(s.type)).slice(0, 8) .map(s => ({ kind:'session', label:s.id, sub:`${s.borrower} · ${s.type}`, icon:, hint:s.status, action:() => onNavigate('sessions') })) : []; const out = []; if (pages.length) out.push({ title:'Pages', items:pages }); if (actions.length) out.push({ title:'Actions', items:actions }); if (borrowerHits.length) out.push({ title:'Borrowers', items:borrowerHits }); if (sessionHits.length) out.push({ title:'Sessions', items:sessionHits }); return out; }, [query, borrowers, allSessions, onNavigate, onLogout]); const flatItems = React.useMemo(() => groups.flatMap(g => g.items), [groups]); React.useEffect(() => { if (!open) return; const handler = (e) => { if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIdx(i => Math.min(flatItems.length - 1, i + 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIdx(i => Math.max(0, i - 1)); } else if (e.key === 'Enter') { e.preventDefault(); const it = flatItems[activeIdx]; if (it) { it.action(); onClose(); } } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [open, flatItems, activeIdx, onClose]); if (!open) return null; let runIdx = -1; return ( <>
e.stopPropagation()} className="cx-fadein" style={{ width:'100%', maxWidth:600, background:'hsl(var(--surface))', border:'1px solid hsl(var(--border))', borderRadius:18, boxShadow:'var(--shadow-xl)', overflow:'hidden', }}>
{ setQuery(e.target.value); setActiveIdx(0); }} placeholder="Search borrowers, sessions, pages…" style={{ flex:1, border:'none', outline:'none', fontFamily:'inherit', fontSize:15, color:'hsl(var(--fg))', background:'transparent' }}/> Esc
{flatItems.length === 0 ? (
No matches for "{query}"
) : groups.map(g => (
{g.title}
{g.items.map(it => { runIdx++; const active = runIdx === activeIdx; return ( ); })}
))}
Navigate Open CredNX
); } const kbdStyle = { padding:'2px 6px', borderRadius:4, background:'hsl(var(--surface))', fontFamily:'var(--font-mono)', fontSize:10, color:'hsl(var(--fg-muted))', border:'1px solid hsl(var(--border))', marginRight:4 }; Object.assign(window, { HomePage, SessionsTable, InsightsPage, SettingsPage, NotificationsDrawer, NotificationBell, NOTIF_FALLBACK, CommandPalette, ActivityFeed, StatTile, Sparkline, greeting, todayLabel, });