// ==================== CITADEL SUB-PAGES (ENTERPRISE) ==================== // 8 module pages. // Document Intelligence is fully wired to the FastAPI backend (see backend/). // The remaining 7 modules are still mock-driven until their backends ship. console.log('%c[CITADEL] pages.jsx build 2026-05-02-c — Doc Intel: 100% backend-driven, NO mock files', 'background:#D4A843;color:#000;font-weight:700;padding:4px 10px;'); /* ====================================================================== GOVERNMENT MODULE 1 — DOCUMENT INTELLIGENCE · 100% backend-driven Backend: backend/app/modules/document_intelligence/router.py Tabs: Upload · Queue · Library · Templates · Analytics ====================================================================== */ // ----- shared helpers used across this module ----- const DI_DOC_TYPES = [ { ui: 'Auto-detect', api: 'auto_detect' }, { ui: 'Invoice', api: 'invoice' }, { ui: 'Contract', api: 'contract' }, { ui: 'ID Document', api: 'id_document' }, { ui: 'Government Permit', api: 'government_permit' }, { ui: 'Tender Notice', api: 'tender_notice' }, { ui: 'Permit', api: 'permit' }, { ui: 'Report', api: 'report' }, { ui: 'Receipt', api: 'receipt' }, { ui: 'Affidavit', api: 'affidavit' }, { ui: 'Certificate', api: 'certificate' }, ]; const DI_LANGS = [ { ui: 'English', api: 'en' }, { ui: 'Hindi', api: 'hi' }, { ui: 'English + Hindi', api: 'en+hi' }, ]; const DI_PRIORITIES = ['low', 'normal', 'high', 'urgent']; const DI_PRIO_BADGE = { urgent: 'red', high: 'gold', normal: 'default', low: 'default' }; const DI_TYPE_ICON = { invoice: '📋', contract: '📜', permit: '🏛', id_document: '🪪', receipt: '🧾', affidavit: '⚖', tender_notice: '📢', certificate: '🎓', government_permit: '🏛', report: '📊', }; const diTypeLabel = (t) => (DI_DOC_TYPES.find(x => x.api === t)?.ui) || (t || '').replace(/_/g, ' '); // Reusable backend caller that surfaces errors as toast const useDIApi = (toast) => React.useCallback(async (path, opts) => { try { return await apiFetch(path, opts); } catch (e) { toast?.(`API ${path}: ${e.message}`, 'error'); throw e; } }, [toast]); // ============================================================ // Top-level shell // ============================================================ const DocumentIntelligence = ({ onBack }) => { const [tab, setTab] = React.useState('upload'); const [refreshTick, setRefreshTick] = React.useState(0); // global poke for child tabs const [todayCount, setTodayCount] = React.useState(null); const bumpRefresh = React.useCallback(() => setRefreshTick(t => t + 1), []); // Live "today" count for the subtitle React.useEffect(() => { apiFetch('/api/dashboard/volume', { params: { period: '7d' } }) .then(r => setTodayCount(r.today_count)) .catch(() => setTodayCount(null)); }, [refreshTick]); const tabs = [ { key: 'upload', label: 'UPLOAD & ANALYZE', icon: '↑' }, { key: 'queue', label: 'QUEUE', icon: '▤' }, { key: 'library', label: 'LIBRARY', icon: '▣' }, { key: 'templates', label: 'TEMPLATES', icon: '◫' }, { key: 'analytics', label: 'ANALYTICS', icon: '◈' }, ]; const subtitle = ( <>OCR + NER + LAYOUT · PII REDACTION · AUDIT TRAIL · {todayCount === null ? ' …' : ` ${todayCount} DOC${todayCount === 1 ? '' : 'S'} TODAY`} ); return (
setTab('upload')} > + NEW INTAKE } /> {tab === 'upload' && setTab('queue')} />} {tab === 'queue' && } {tab === 'library' && } {tab === 'templates' && } {tab === 'analytics' && }
); }; // ============================================================ // 1. UPLOAD & ANALYZE — fully wired // ============================================================ const DocIntelUpload = ({ onUploaded, goToQueue }) => { const [files, setFiles] = React.useState([]); // [{name, size, _file}] const [docType, setDocType] = React.useState('auto_detect'); const [lang, setLang] = React.useState('en'); const [pii, setPii] = React.useState(true); const [sigDetect, setSigDetect] = React.useState(true); const [priority, setPriority] = React.useState('normal'); const [analyzing, setAnalyzing] = React.useState(false); const [progress, setProgress] = React.useState(0); const [batchId, setBatchId] = React.useState(null); const [results, setResults] = React.useState(null); const [error, setError] = React.useState(null); const [toast, toastHost] = useToast(); const fileInputRef = React.useRef(null); const triggerPick = () => fileInputRef.current?.click(); const onFilesPicked = (e) => { const picked = Array.from(e.target.files || []); setFiles(f => [ ...f, ...picked.map(file => ({ name: file.name, size: file.size >= 1024 * 1024 ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : `${(file.size / 1024).toFixed(0)} KB`, _file: file, })), ]); if (e.target) e.target.value = ''; }; const removeFile = (idx) => setFiles(f => f.filter((_, i) => i !== idx)); const analyze = async () => { if (files.length === 0) return; setError(null); setAnalyzing(true); setProgress(8); setResults(null); setBatchId(null); try { const fd = new FormData(); files.forEach(f => f._file && fd.append('files', f._file, f._file.name)); fd.append('document_type', docType); fd.append('language', lang); fd.append('priority', priority); fd.append('detect_redact_pii', String(pii)); fd.append('signature_validation', String(sigDetect)); fd.append('uploaded_by', 'RSD'); const upload = await apiFetch('/api/documents/upload', { form: fd }); setBatchId(upload.batch_id); setProgress(20); // Poll batch results until processing settles let attempts = 0; let result = null; while (attempts < 45) { await new Promise(r => setTimeout(r, 1000)); attempts++; try { const res = await apiFetch(`/api/documents/batch/${upload.batch_id}/results`); setProgress(Math.min(20 + attempts * 6, 95)); if (res.documents?.length && res.documents.every(d => d.status !== 'PROCESSING')) { result = res; break; } } catch (e) { /* keep polling */ } } if (!result) throw new Error('Backend did not return results within 45s'); setProgress(100); setResults(result); toast(`Processed ${result.successful}/${result.total_processed}`, result.failed ? 'warn' : 'success'); onUploaded?.(); } catch (e) { setError(e.message || String(e)); toast(`Upload failed: ${e.message}`, 'error'); } finally { setAnalyzing(false); } }; // Per-batch action on the resulting documents const bulkAction = async (action) => { if (!results?.documents?.length) return; const ids = results.documents.map(d => d.doc_id); try { const res = await apiFetch('/api/documents/bulk-action', { json: { doc_ids: ids, action, performed_by: 'RSD' }, }); toast(`${action.toUpperCase()} → ${res.successful}/${res.requested}`, res.failed ? 'warn' : 'success'); // Refresh batch view const fresh = await apiFetch(`/api/documents/batch/${batchId}/results`); setResults(fresh); } catch (e) { toast(`${action} failed: ${e.message}`, 'error'); } }; const downloadExport = (format) => { if (!batchId) return; const a = document.createElement('a'); a.href = `${API_BASE}/api/documents/batch/${batchId}/export?format=${format}`; a.download = `batch_${batchId}.${format}`; a.click(); toast(`Downloaded ${format.toUpperCase()}`, 'success'); }; return (
{error && (
Upload failed: {error}
)}
{/* ---------- LEFT: intake config ---------- */}
INTAKE CONFIGURATION
{files.length === 0 ? <> Click to browse — PDF · DOCX · PNG · JPG (≤10MB each, 10 per batch) : <>📁 {files.length} file{files.length !== 1 ? 's' : ''} ready}
{files.length > 0 && (
{files.map((f, i) => (
📄
{f.name}
{f.size}
))}
)}
{analyzing && (
{progress}%
)}
{/* ---------- RIGHT: extraction results ---------- */}
EXTRACTION RESULTS
{!results ? ( ) : ( <>
{results.documents.map((d, i) => { const piiTypes = Object.keys(d.pii_results || {}); const piiCount = piiTypes.reduce((s, k) => s + (d.pii_results[k].count || 0), 0); const sigStatus = d.signature_status; return (
{DI_TYPE_ICON[d.document_type] || '📄'} {d.doc_id} · {d.filename} {d.status}
{/* Pipeline run-status row — explicit visual feedback for PII/signature toggles */}
0 ? 'var(--red)' : 'var(--green)') : '#ccc', color: pii ? '#fff' : '#666' }}> 🛡 PII Detection: {pii ? (piiCount > 0 ? `${piiCount} ITEMS REDACTED` : 'CLEAN') : 'SKIPPED'} ✍ Signature: {sigDetect ? (sigStatus || 'unknown').toUpperCase() : 'SKIPPED'} 🔎 OCR/Extract: {Math.round(d.confidence || 0)}% confidence
Type {diTypeLabel(d.document_type)}
Tags {(d.tags || []).join(', ') || '—'}
Preview {(d.extracted_text_preview || '—').slice(0, 240)}...
{piiTypes.length > 0 && ( <>
PII DETECTED ({piiCount} items across {piiTypes.length} categories)
{Object.entries(d.pii_results).map(([k, v]) => (
🛡
{v.label || k}
{v.count} found · {v.status}
))}
)}
); })}
)}
{toastHost}
); }; // ============================================================ // 2. QUEUE — fully wired with per-row + bulk actions // ============================================================ const DocIntelQueue = ({ refreshTick, bumpRefresh }) => { const [filter, setFilter] = React.useState({ status: '', type: '', priority: '' }); const [search, setSearch] = React.useState(''); const [sortBy, setSortBy] = React.useState('created_at'); const [sortOrder, setSortOrder] = React.useState('desc'); const [page, setPage] = React.useState(1); const perPage = 20; const [selected, setSelected] = React.useState([]); const [data, setData] = React.useState(null); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); const [tick, setTick] = React.useState(0); const [detailDoc, setDetailDoc] = React.useState(null); const [toast, toastHost] = useToast(); React.useEffect(() => { setLoading(true); setError(null); apiFetch('/api/documents/queue', { params: { search, status: filter.status, type: filter.type, priority: filter.priority, sort_by: sortBy, sort_order: sortOrder, page, per_page: perPage }, }) .then(setData) .catch(e => setError(e.message)) .finally(() => setLoading(false)); }, [search, filter.status, filter.type, filter.priority, sortBy, sortOrder, page, tick, refreshTick]); const items = data?.items || []; const total = data?.total || 0; const totalPages = data?.total_pages || 1; const refresh = () => { setTick(t => t + 1); bumpRefresh?.(); }; const doBulk = async (action) => { if (selected.length === 0) return; // DataTable selects by row INDEX — map to doc IDs from the current rows const docIds = selected.map(i => rows[i]?.id).filter(Boolean); if (docIds.length === 0) { toast('Selection mismatch — please re-select rows', 'error'); return; } try { const res = await apiFetch('/api/documents/bulk-action', { json: { doc_ids: docIds, action, performed_by: 'RSD' }, }); toast(`${action.toUpperCase()} → ${res.successful}/${res.requested}`, res.failed ? 'warn' : 'success'); setSelected([]); refresh(); } catch (e) { toast(`${action} failed: ${e.message}`, 'error'); } }; const doSingle = async (docId, action) => { try { if (action === 'delete') { if (!window.confirm(`Permanently delete ${docId}? This removes it from the queue, library, and templates, plus its file from storage. This cannot be undone.`)) return; await apiFetch(`/api/documents/${docId}`, { method: 'DELETE', params: { performed_by: 'RSD' } }); } else { await apiFetch(`/api/documents/${docId}/${action}`, { method: 'POST', params: { performed_by: 'RSD' } }); } toast(`${docId} → ${action.toUpperCase()}`, 'success'); refresh(); } catch (e) { toast(`${action} failed: ${e.message}`, 'error'); } }; // map backend → row shape const rows = items.map(it => ({ id: it.doc_id, type: diTypeLabel(it.document_type), uploader: it.uploaded_by || '—', status: (it.status || '').toLowerCase(), conf: it.confidence == null ? 0 : Math.round(it.confidence), priority: (it.priority || 'normal').toUpperCase(), time: it.time_ago || '—', sla: it.sla_breached, pii: it.pii_count || 0, _raw: it, })); return (
{ setSearch(v); setPage(1); }} placeholder="Search by filename..." /> t.api !== 'auto_detect').map(t => ({ value: t.api, label: t.ui })) }, { key: 'priority', label: 'Priority', options: DI_PRIORITIES.map(p => ({ value: p, label: p.toUpperCase() })) }, ]} values={filter} onChange={(k, v) => { setFilter(f => ({ ...f, [k]: v })); setPage(1); }} onClear={() => { setFilter({ status: '', type: '', priority: '' }); setPage(1); }} />
{error && (
Backend error: {error}
)} {selected.length > 0 && (
{selected.length} selected
)}
{loading && rows.length === 0 ? (
) : rows.length === 0 ? ( ) : ( setDetailDoc(r._raw)} columns={[ { key: 'id', label: 'DOC ID', width: 110, render: (v, r) => {v} {r.sla && } }, { key: 'type', label: 'TYPE', width: 130 }, { key: 'uploader', label: 'UPLOADER' }, { key: 'priority', label: 'PRIORITY', width: 100, render: v => {v} }, { key: 'conf', label: 'CONFIDENCE', width: 150, render: v => v === 0 ? : }, { key: 'pii', label: 'PII', width: 60, align: 'right', render: v => v > 0 ? {v} : }, { key: 'status', label: 'STATUS', width: 150, render: v => }, { key: 'time', label: 'AGE', width: 80, align: 'right' }, { key: '_actions', label: '', width: 160, render: (_, r) => (
e.stopPropagation()}> {r.status === 'pending_review' && <> } {r.status === 'approved' && } {(r.status === 'rejected' || r.status === 'pending_review') && }
) }, ]} rows={rows} /> )}
{loading ? 'loading...' : `Showing ${rows.length} of ${total}`} {totalPages > 1 && ( )}
{detailDoc && setDetailDoc(null)} onChanged={refresh} />} {toastHost}
); }; // ----- Document detail modal: extracted text + audit log + actions ----- const DocDetailModal = ({ doc, onClose, onChanged }) => { const [audit, setAudit] = React.useState(null); const [editView, setEditView] = React.useState(null); const [busy, setBusy] = React.useState(false); const [toast, toastHost] = useToast(); React.useEffect(() => { Promise.all([ apiFetch(`/api/documents/${doc.doc_id}/audit`).catch(() => null), apiFetch(`/api/documents/${doc.doc_id}/edit-view`).catch(() => null), ]).then(([a, e]) => { setAudit(a); setEditView(e); }); }, [doc.doc_id]); const act = async (action) => { if (action === 'delete' && !window.confirm(`Permanently delete ${doc.doc_id}? Removes from queue, library, templates and storage. Cannot be undone.`)) return; setBusy(true); try { if (action === 'delete') { await apiFetch(`/api/documents/${doc.doc_id}`, { method: 'DELETE', params: { performed_by: 'RSD' } }); } else { await apiFetch(`/api/documents/${doc.doc_id}/${action}`, { method: 'POST', params: { performed_by: 'RSD' } }); } toast(`${action.toUpperCase()} OK`, 'success'); onChanged?.(); setTimeout(onClose, 300); } catch (e) { toast(`${action} failed: ${e.message}`, 'error'); } finally { setBusy(false); } }; const status = (doc.status || '').toLowerCase(); return ( {status === 'pending_review' && <> } {status === 'approved' && } {(status === 'rejected' || status === 'pending_review') && } } >
EXTRACTED TEXT
{!editView ? : (
              {(editView.extracted_text || '').slice(0, 6000) || '(no text extracted)'}
            
)} {Object.keys(doc.pii_results || {}).length > 0 && ( <>
PII
{Object.entries(doc.pii_results).map(([k, v]) => (
🛡
{v.label || k}
{v.count} · {v.status}
))}
)}
AUDIT LOG
{!audit ? : (
{(audit.entries || []).map((e, i) => (
{i + 1}
{e.action.toUpperCase()}
{new Date(e.created_at).toLocaleString()} · {e.performed_by}
{i < audit.entries.length - 1 &&
}
))}
)}
METADATA
Type: {diTypeLabel(doc.document_type)}
Status: {doc.status}
Priority: {(doc.priority || '').toUpperCase()}
Confidence: {doc.confidence ?? '—'}%
Signature: {doc.signature_status || '—'}
Tags: {(doc.tags || []).join(', ') || '—'}
Uploaded by: {doc.uploaded_by || '—'}
{toastHost}
); }; // ============================================================ // 3. LIBRARY — fully wired // ============================================================ const DocIntelLibrary = ({ refreshTick }) => { const [search, setSearch] = React.useState(''); const [view, setView] = React.useState('grid'); const [data, setData] = React.useState(null); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); const [tick, setTick] = React.useState(0); const [detailDoc, setDetailDoc] = React.useState(null); const [toast, toastHost] = useToast(); React.useEffect(() => { setLoading(true); setError(null); apiFetch('/api/documents/library', { params: { search, view } }) .then(setData) .catch(e => setError(e.message)) .finally(() => setLoading(false)); }, [search, view, tick, refreshTick]); const items = data?.items || []; const exportZip = async () => { try { const r = await fetch(`${API_BASE}/api/documents/library/export`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }); if (!r.ok) throw new Error(`HTTP ${r.status}`); const blob = await r.blob(); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `citadel_archive_${new Date().toISOString().slice(0, 10)}.zip`; a.click(); toast(`Downloaded ${(blob.size / 1024).toFixed(1)} KB`, 'success'); } catch (e) { toast(`Export failed: ${e.message}`, 'error'); } }; const downloadOne = async (docId) => { try { const detail = await apiFetch(`/api/documents/${docId}/edit-view`); const txt = detail.extracted_text || '(no text)'; const blob = new Blob([txt], { type: 'text/plain' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `${docId}_extracted.txt`; a.click(); toast(`Downloaded ${docId}.txt`, 'success'); } catch (e) { toast(`Download failed: ${e.message}`, 'error'); } }; const palette = ['var(--gold)', 'var(--red)', 'var(--cyan)', 'var(--green)']; return (
{error &&
Backend: {error}
} {loading && items.length === 0 ? (
{[1, 2, 3, 4].map(i =>
)}
) : items.length === 0 ? ( ) : view === 'grid' ? (
{items.map((d, i) => (
setDetailDoc(d)} >
{DI_TYPE_ICON[d.document_type] || '📄'}
{diTypeLabel(d.document_type)}
{d.title || d.filename}
{d.doc_id} · {d.file_size}
{(d.tags || []).slice(0, 4).map(t => )}
{d.department || '—'} {(d.archived_at || '').slice(0, 10)}
{d.storage_url && e.stopPropagation()}>⭳ ORIG}
))}
) : (
setDetailDoc(d)} columns={[ { key: 'doc_id', label: 'ID', width: 110 }, { key: 'title', label: 'TITLE', render: (v, r) => v || r.filename }, { key: 'document_type', label: 'TYPE', width: 140, render: diTypeLabel }, { key: 'department', label: 'DEPT', width: 130 }, { key: 'file_size', label: 'SIZE', width: 80 }, { key: 'archived_at', label: 'ARCHIVED', width: 110, render: v => (v || '').slice(0, 10) }, ]} rows={items} />
)} {detailDoc && setDetailDoc(null)} onChanged={() => setTick(t => t + 1)} />} {toastHost}
); }; // ============================================================ // 4. TEMPLATES — restructured: Type Cards → Doc List → Editable Doc // Each TYPE shows the actual processed/archived documents of that type. // Some types are SECURED — open them only after passport-key unlock. // ============================================================ // Doc types that hold sensitive PII and require unlock const DI_SECURED_TYPES = new Set(['id_document', 'government_permit']); const DocIntelTemplates = ({ refreshTick }) => { const [allDocs, setAllDocs] = React.useState([]); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); const [tick, setTick] = React.useState(0); const [openType, setOpenType] = React.useState(null); // selected type to drill into const [unlockedTypes, setUnlocked] = React.useState(() => new Set()); const [pendingUnlock, setPendingUnlock] = React.useState(null); // type pending key entry const [editingDoc, setEditingDoc] = React.useState(null); const [toast, toastHost] = useToast(); // Fetch ALL processed docs (any status that ran through pipeline) React.useEffect(() => { setLoading(true); setError(null); apiFetch('/api/documents/queue', { params: { per_page: 100, sort_by: 'created_at', sort_order: 'desc' } }) .then(res => setAllDocs(res.items || [])) .catch(e => setError(e.message)) .finally(() => setLoading(false)); }, [tick, refreshTick]); const refresh = () => setTick(t => t + 1); // Group docs by document_type. All 10 known types listed even if 0 docs. const docsByType = React.useMemo(() => { const groups = {}; DI_DOC_TYPES.filter(t => t.api !== 'auto_detect').forEach(t => { groups[t.api] = []; }); for (const d of allDocs) { if (!groups[d.document_type]) groups[d.document_type] = []; groups[d.document_type].push(d); } return groups; }, [allDocs]); const handleTypeClick = (typeApi) => { if (DI_SECURED_TYPES.has(typeApi) && !unlockedTypes.has(typeApi)) { setPendingUnlock(typeApi); } else { setOpenType(typeApi); } }; const onUnlock = (typeApi) => { setUnlocked(prev => { const next = new Set(prev); next.add(typeApi); return next; }); setPendingUnlock(null); setOpenType(typeApi); }; // ---- LEVEL 2: doc list for a selected type ---- if (openType) { const docs = docsByType[openType] || []; const typeUI = DI_DOC_TYPES.find(t => t.api === openType)?.ui || openType; return (
{DI_TYPE_ICON[openType] || '📄'} {typeUI.toUpperCase()} {docs.length} document{docs.length !== 1 ? 's' : ''} · most recent first {DI_SECURED_TYPES.has(openType) && 🔒 SECURED — UNLOCKED}
{docs.length === 0 ? ( ) : (
{docs.map((d, i) => (
setEditingDoc(d)}>
{DI_TYPE_ICON[openType] || '📄'}
{(d.status || '').replace('_', ' ')}
{d.title || d.filename}
{d.doc_id} · {Math.round(d.confidence || 0)}% conf
{(d.tags || []).slice(0, 3).map(t => )}
{d.uploaded_by || '—'} {(d.created_at || '').slice(0, 10)}
))}
)} {editingDoc && setEditingDoc(null)} onSaved={() => { setEditingDoc(null); refresh(); }} />} {toastHost}
); } // ---- LEVEL 1: type grid ---- return (
Documents organized by type. Click a type to view, edit, or update individual documents. {' · '}{allDocs.length} total processed
{error &&
Backend: {error}
} {loading && allDocs.length === 0 ? (
{[1, 2, 3, 4, 5, 6].map(i =>
)}
) : (
{Object.entries(docsByType).map(([typeApi, docs], i) => { const typeUI = DI_DOC_TYPES.find(t => t.api === typeApi)?.ui || typeApi; const secured = DI_SECURED_TYPES.has(typeApi); const unlocked = unlockedTypes.has(typeApi); return (
handleTypeClick(typeApi)} >
0 ? 'var(--gold)' : '#e0ddd0' }}> {DI_TYPE_ICON[typeApi] || '📄'}
{typeUI}
0 ? '#000' : '#888' }}> {docs.length} document{docs.length !== 1 ? 's' : ''} {secured && ( {unlocked ? 🔓 UNLOCKED : 🔒 SECURED} )}
); })}
)} {pendingUnlock && ( setPendingUnlock(null)} onSuccess={() => onUnlock(pendingUnlock)} /> )} {toastHost}
); }; // ----- Passport unlock modal for secured types ----- const PassportUnlockModal = ({ typeApi, onClose, onSuccess }) => { const typeUI = DI_DOC_TYPES.find(t => t.api === typeApi)?.ui || typeApi; const [key, setKey] = React.useState(''); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); const tryUnlock = async () => { if (key.length !== 6) { setErr('Passport key must be 6 characters'); return; } setBusy(true); setErr(null); try { // Hit the passport-key verification endpoint via any system template const tpls = await apiFetch('/api/templates'); const sysTpl = (tpls.templates || []).find(t => t.is_system); if (!sysTpl) { setErr('No system template configured for verification'); setBusy(false); return; } const res = await apiFetch(`/api/templates/${sysTpl.id}/verify-access`, { json: { passport_key: key } }); if (res.access_granted) onSuccess(); else setErr(res.reason || 'Access denied'); } catch (e) { setErr(e.message || String(e)); } finally { setBusy(false); } }; return ( } >

This document type contains sensitive information (Aadhaar, government records, etc.) viewable only by authorized officials. Enter your 6-character passport key to access.

setKey(e.target.value.toUpperCase().slice(0, 6))} maxLength={6} autoFocus onKeyDown={e => { if (e.key === 'Enter') tryUnlock(); }} placeholder="A3X9K2" style={{ letterSpacing: 8, fontFamily: 'var(--font-mono)', fontSize: 18, textAlign: 'center' }} /> {err &&
{err}
}
); }; // ----- Doc edit modal: actual document editing (extracted text + structured fields) ----- const DocEditModal = ({ doc, onClose, onSaved }) => { const [view, setView] = React.useState(null); // edit-view payload const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); const [text, setText] = React.useState(''); const [title, setTitle] = React.useState(doc.title || ''); const [tags, setTags] = React.useState((doc.tags || []).join(', ')); const [department, setDepartment] = React.useState(doc.department || ''); const [fields, setFields] = React.useState({}); const [passportKey, setPassportKey] = React.useState(''); const [toast, toastHost] = useToast(); React.useEffect(() => { apiFetch(`/api/documents/${doc.doc_id}/edit-view`) .then(v => { setView(v); setText(v.extracted_text || ''); setFields(v.extracted_fields || {}); }) .catch(e => setErr(e.message)); }, [doc.doc_id]); const setField = (k, v) => setFields(f => ({ ...f, [k]: v })); const save = async () => { setBusy(true); setErr(null); try { const body = { fields, extracted_text: text, title: title || null, tags: tags ? tags.split(',').map(s => s.trim()).filter(Boolean) : [], department: department || null, }; if (view?.requires_passport_key) { if (passportKey.length !== 6) { setErr('Passport key required (6 chars)'); setBusy(false); return; } body.passport_key = passportKey; } const res = await apiFetch(`/api/documents/${doc.doc_id}/edit-save`, { method: 'PUT', json: body }); toast(`Saved · re-indexed: ${res.reindexed ? 'yes' : 'no'}`, 'success'); onSaved?.(); } catch (e) { setErr(e.message || String(e)); } finally { setBusy(false); } }; const reqKey = view?.requires_passport_key; return ( } > {!view ? : ( <> {reqKey && (
🔒
This document is archived + urgent. Enter your 6-char passport key to save edits.
)}
setTitle(e.target.value)} placeholder="Optional human-readable title" />
setDepartment(e.target.value)} placeholder="e.g. Finance, PWD" />
setTags(e.target.value)} placeholder="invoice, q2, finance" /> {/* Template-mapped structured fields */} {(view.template_fields || []).length > 0 && ( <>
STRUCTURED FIELDS · {(view.template_fields || []).length} per template
{(view.template_fields || []).map(f => (
setField(f.name, e.target.value)} placeholder={f.type} />
))}
)}
DOCUMENT CONTENT — Word-style editor (formatting preserved)
{reqKey && ( <> setPassportKey(e.target.value.toUpperCase().slice(0, 6))} maxLength={6} style={{ letterSpacing: 8, fontFamily: 'var(--font-mono)', fontSize: 16, textAlign: 'center' }} placeholder="A3X9K2" /> )} {err &&
{err}
} )} {toastHost}
); }; // ============================================================ // 5. ANALYTICS — fully wired to /api/dashboard/* // ============================================================ const DocIntelAnalytics = ({ refreshTick }) => { const [period, setPeriod] = React.useState('30d'); const [data, setData] = React.useState(null); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); const [tick, setTick] = React.useState(0); React.useEffect(() => { setLoading(true); setError(null); Promise.all([ apiFetch('/api/dashboard/stats', { params: { period } }), apiFetch('/api/dashboard/volume', { params: { period: period === '7d' ? '7d' : '14d' } }), apiFetch('/api/dashboard/by-type', { params: { period } }), apiFetch('/api/dashboard/top-uploaders', { params: { period, limit: 5 } }), apiFetch('/api/dashboard/sla-distribution', { params: { period } }), ]) .then(([stats, volume, byType, top, sla]) => setData({ stats, volume, byType, top, sla })) .catch(e => setError(e.message)) .finally(() => setLoading(false)); }, [period, tick, refreshTick]); const palette = ['var(--gold)', 'var(--red)', 'var(--cyan)', 'var(--green)']; const fmtPct = (n) => (n > 0 ? `+${n}%` : `${n}%`); if (loading && !data) { return (
{[1, 2, 3, 4].map(i =>
)}
); } if (error || !data) { return (
); } const { stats, volume, byType, top, sla } = data; const volumeData = volume.data.map(b => b.count); const segments = byType.breakdown.map((b, i) => ({ label: diTypeLabel(b.type), value: b.count, color: palette[i % palette.length] })); return (
DOCS BY TYPE
TOP UPLOADERS
{top.uploaders.length === 0 ? ( ) : (
{top.uploaders.map((r, i) => (
#{r.rank} {r.name} {r.count}
))}
)}
PROCESSING SLA
{[ { key: 'under_1h', label: '< 1 hour', color: 'var(--green)' }, { key: '1_to_4h', label: '1 — 4 hours', color: 'var(--cyan)' }, { key: '4_to_24h', label: '4 — 24 hours', color: 'var(--gold)' }, { key: 'over_24h', label: '> 24 hours', color: 'var(--red)' }, ].map(b => (
{b.label} {sla[b.key]?.count || 0} {sla[b.key]?.percent || 0}%
))}
); }; /* ====================================================================== GOVERNMENT MODULE 2 — RESUME SCREENING Tabs: Jobs · Pipeline · Candidates · Analytics · Settings ====================================================================== */ const ResumeScreening = ({ onBack }) => { const [tab, setTab] = React.useState('jobs'); const [refreshTick, setRefreshTick] = React.useState(0); const [jobsCount, setJobsCount] = React.useState(0); const [candCount, setCandCount] = React.useState(0); const bumpRefresh = React.useCallback(() => setRefreshTick(t => t + 1), []); React.useEffect(() => { apiFetch('/api/resume/jobs').then(r => setJobsCount((r.jobs || []).length)).catch(() => {}); apiFetch('/api/resume/candidates', { params: { per_page: 1 } }).then(r => setCandCount(r.total || 0)).catch(() => {}); }, [refreshTick]); const tabs = [ { key: 'jobs', label: 'ACTIVE JOBS', badge: jobsCount }, { key: 'pipeline', label: 'PIPELINE' }, { key: 'candidates', label: 'CANDIDATES', badge: candCount }, { key: 'analytics', label: 'ANALYTICS' }, { key: 'settings', label: 'SETTINGS' }, ]; return (
BERT + Groq · SKILL MATCHING · BIAS DETECTION · {candCount} ACTIVE CANDIDATES · {jobsCount} OPEN ROLES} accentColor="var(--red)" onBack={onBack} actions={ } /> {tab === 'jobs' && } {tab === 'pipeline' && } {tab === 'candidates' && } {tab === 'analytics' && } {tab === 'settings' && }
); }; // ============================================================ // 1. ACTIVE JOBS — list of cards + Add Job modal + per-card actions // ============================================================ const ResumeJobs = ({ refreshTick, bumpRefresh, setTab }) => { const [jobs, setJobs] = React.useState([]); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); const [tick, setTick] = React.useState(0); const [openJob, setOpenJob] = React.useState(null); // job for detail modal const [uploadJob, setUploadJob] = React.useState(null); // job for upload modal const [pipelineJob, setPipelineJob] = React.useState(null); // job for pipeline modal const [adding, setAdding] = React.useState(false); const [toast, toastHost] = useToast(); // Listen for the global "open add job" event from header NEW REQUISITION button React.useEffect(() => { const h = () => setAdding(true); window.addEventListener('citadel:open-add-job', h); return () => window.removeEventListener('citadel:open-add-job', h); }, []); React.useEffect(() => { setLoading(true); setError(null); apiFetch('/api/resume/jobs') .then(r => setJobs(r.jobs || [])) .catch(e => setError(e.message)) .finally(() => setLoading(false)); }, [tick, refreshTick]); const refresh = () => { setTick(t => t + 1); bumpRefresh?.(); }; const deleteJob = async (job) => { if (!window.confirm(`Permanently delete ${job.code} (${job.title})? Removes the job + all its candidate records + CV files.`)) return; try { await apiFetch(`/api/resume/jobs/${job.code}`, { method: 'DELETE' }); toast(`Deleted ${job.code}`, 'success'); refresh(); } catch (e) { toast(`Delete failed: ${e.message}`, 'error'); } }; return (
{jobs.length} active requisition{jobs.length !== 1 ? 's' : ''} · click any card to open full job description
{error &&
Backend: {error}
} {loading && jobs.length === 0 ? (
{[1, 2, 3].map(i =>
)}
) : jobs.length === 0 ? ( ) : (
{jobs.map((j, i) => (
setOpenJob(j)}>
{j.title}
{j.code} · {j.department || 'Unassigned'}
{j.urgency}
{j.applicants}
APPLICANTS
{j.shortlisted}
SHORTLISTED
{j.days_open}d
OPEN
Conversion: {j.applicants ? Math.round((j.shortlisted / j.applicants) * 100) : 0}%
e.stopPropagation()}>
))}
)} {openJob && setOpenJob(null)} onChanged={refresh} />} {uploadJob && setUploadJob(null)} onUploaded={refresh} />} {pipelineJob && setPipelineJob(null)} onChanged={refresh} />} {adding && setAdding(false)} onCreated={() => { setAdding(false); refresh(); }} />} {toastHost}
); }; // ----- Add Job modal: paste text or upload JD file → preview → create ----- const ResumeAddJobModal = ({ onClose, onCreated }) => { const [step, setStep] = React.useState('input'); // input | parsed const [tab, setTab] = React.useState('text'); // text | file const [text, setText] = React.useState(''); const fileInputRef = React.useRef(null); const [filename, setFilename] = React.useState(''); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); const [parsed, setParsed] = React.useState(null); const [rawText, setRawText] = React.useState(''); const [overrides, setOverrides] = React.useState({}); const parse = async () => { setErr(null); setBusy(true); try { let res; if (tab === 'file') { const file = fileInputRef.current?.files?.[0]; if (!file) { setErr('Pick a file first'); setBusy(false); return; } const fd = new FormData(); fd.append('file', file); res = await apiFetch('/api/resume/jd/parse', { form: fd }); } else { if (!text.trim() || text.trim().length < 30) { setErr('Paste at least 30 characters of JD'); setBusy(false); return; } const fd = new FormData(); fd.append('text', text); res = await apiFetch('/api/resume/jd/parse', { form: fd }); } setParsed(res.parsed_jd); setRawText(res.raw_text || text); setOverrides({ title: res.parsed_jd.title || '', department: res.parsed_jd.department || '', urgency: res.parsed_jd.urgency || 'NORMAL', openings: res.parsed_jd.openings || 1, }); setStep('parsed'); } catch (e) { setErr(e.message); } finally { setBusy(false); } }; const create = async () => { setBusy(true); setErr(null); try { await apiFetch('/api/resume/jobs', { json: { title: overrides.title, department: overrides.department, urgency: overrides.urgency, openings: parseInt(overrides.openings) || 1, raw_jd_text: rawText, parsed_jd: { ...parsed, title: overrides.title, department: overrides.department, urgency: overrides.urgency, openings: parseInt(overrides.openings) || 1 }, posted_by: 'RSD', }, }); onCreated?.(); } catch (e) { setErr(e.message); } finally { setBusy(false); } }; return ( : <> } > {step === 'input' ? ( <>
{tab === 'text' ? ( <>
{attachments.length > 0 && (
{attachments.map((a, i) => (
{a.kind === 'photo' ? '📷' : a.kind === 'video' ? '🎬' : a.kind === 'voice' ? '🎙' : a.kind === 'location' ? '📍' : '📎'} {a.name}{a.file ? ` (${(a.file.size / 1024).toFixed(0)} KB)` : ''}
))}
)}
{aiPreview && (
⚡ AI PRE-ANALYSIS
Category:{aiPreview.display_category}
Priority:{aiPreview.predicted_priority}
Route to:{aiPreview.predicted_department.replace(/_/g, ' ')}
Expected SLA:{aiPreview.predicted_sla_label}
Sentiment:{aiPreview.sentiment}
Confidence:{Math.round(aiPreview.confidence * 100)}%
{aiPreview.reasoning_summary}
engine: {aiPreview.source === 'groq' ? 'LLM (Groq)' : 'keyword rules'} {aiPreview.quota_exhausted ? ' · ⏳ LLM quota drained' : ''}
)}
QUICK TEMPLATES
{templates.length === 0 && (
Templates unavailable — backend offline?
)} {templates.map(t => ( ))}
{toastHost} ); }; const TicketMine = () => { const [tickets, setTickets] = React.useState(null); const [stats, setStats] = React.useState(null); const [expanded, setExpanded] = React.useState(null); const [detail, setDetail] = React.useState(null); // full ticket for the expanded card const [composer, setComposer] = React.useState(''); // ADD UPDATE text const [busy, setBusy] = React.useState(false); const [toast, toastHost] = useToast(); const refresh = React.useCallback(() => { tkApi('/mine', { params: { limit: 50 } }).then(r => setTickets(r.data)).catch(() => setTickets([])); tkApi('/stats').then(setStats).catch(() => {}); }, []); React.useEffect(() => { refresh(); }, [refresh]); const openDetail = (id) => { if (expanded === id) { setExpanded(null); setDetail(null); return; } setExpanded(id); setDetail(null); setComposer(''); tkApi(`/${id}`).then(setDetail).catch(e => toast(`Load failed: ${e.message || e}`, 'error')); }; const act = (fn, okMsg) => { setBusy(true); fn() .then(() => { toast(okMsg, 'success'); refresh(); if (expanded) tkApi(`/${expanded}`).then(setDetail).catch(() => {}); }) .catch(e => toast(`${e.message || e}`, 'warn')) .finally(() => setBusy(false)); }; const addUpdate = () => { const text = composer.trim(); if (!text) { toast('Write the update first', 'warn'); return; } act(() => tkApi(`/${expanded}/updates`, { json: { text } }).then(() => setComposer('')), 'Update posted'); }; const rate = (n) => act(() => tkApi(`/${expanded}/rate`, { json: { rating: n } }), `Rated ${n}/5`); const reopen = () => { const reason = window.prompt('Why are you reopening this ticket?'); if (!reason) return; act(() => tkApi(`/${expanded}/reopen`, { json: { reason } }), 'Ticket reopened'); }; const fmtTime = (iso) => iso ? iso.replace('T', ' ').slice(5, 16) : ''; return (
{tickets === null &&
Loading tickets…
} {tickets && tickets.length === 0 && (
No tickets yet — submit your first civic issue from the SUBMIT NEW tab.
)} {(tickets || []).map((t, i) => (
{t.id}
{t.subject}
{t.display_category} {t.priority} Dept: {t.department.replace(/_/g, ' ')} Age: {t.age} {t.sla_status !== 'on_track' && ( SLA {t.sla_status.replace('_', ' ').toUpperCase()} )} {t.rating && ⭐ {t.rating}/5}
{expanded === t.id && (
{!detail &&
Loading detail…
} {detail && (
{detail.updates.length === 0 && (
No updates yet.
)} {detail.updates.map(u => (
{u.text}
{u.actor_label} · {fmtTime(u.created_at)}
))}
{detail.attachments.length > 0 && (
{detail.attachments.map(a => (
{a.type === 'photo' ? '📷' : a.type === 'video' ? '🎬' : a.type === 'voice' ? '🎙' : '📎'} {a.name} ({(a.size_bytes / 1024).toFixed(0)} KB) {a.download_url && ( VIEW ↗ )}
))}
)}
setComposer(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') addUpdate(); }} />
{['resolved', 'closed'].includes(detail.status) && !detail.rating && ( RATE: {[1, 2, 3, 4, 5].map(n => ( ))} )} {['resolved', 'closed'].includes(detail.status) && ( )}
)}
)}
))}
{toastHost}
); }; const TicketCommunity = () => { const [rows, setRows] = React.useState(null); const [sort, setSort] = React.useState('TRENDING'); const [query, setQuery] = React.useState(''); const [commentsFor, setCommentsFor] = React.useState(null); // ticket id with open thread const [thread, setThread] = React.useState([]); const [commentText, setCommentText] = React.useState(''); const [toast, toastHost] = useToast(); const geoRef = React.useRef(null); // cached coords for NEARBY const load = React.useCallback((s, q) => { const params = { sort: s.toLowerCase(), limit: 25 }; if (q) params.q = q; const run = (p) => tkApi('/community', { params: p }) .then(r => setRows(r.data)) .catch(e => { setRows([]); toast(`Feed failed: ${e.message || e}`, 'error'); }); if (s === 'NEARBY') { if (geoRef.current) { run({ ...params, lat: geoRef.current.lat, lng: geoRef.current.lng }); } else if (navigator.geolocation) { navigator.geolocation.getCurrentPosition( pos => { geoRef.current = { lat: pos.coords.latitude, lng: pos.coords.longitude }; run({ ...params, lat: geoRef.current.lat, lng: geoRef.current.lng }); }, () => { toast('Location denied — showing TRENDING instead', 'warn'); setSort('TRENDING'); run({ ...params, sort: 'trending' }); }, { timeout: 8000 }, ); } else { toast('Geolocation unavailable — showing TRENDING', 'warn'); setSort('TRENDING'); run({ ...params, sort: 'trending' }); } } else { run(params); } }, [toast]); React.useEffect(() => { load(sort, query); }, [sort]); // search triggers via its own debounce React.useEffect(() => { const t = setTimeout(() => load(sort, query), 400); return () => clearTimeout(t); }, [query]); const toggleSupport = (t) => { const method = t.has_upvoted ? 'DELETE' : 'POST'; tkApi(`/${t.id}/upvote`, { method }) .then(r => { setRows(rs => rs.map(x => x.id === t.id ? { ...x, upvotes: r.upvotes, has_upvoted: r.has_upvoted } : x)); if (r.priority_escalated_to) toast(`${t.id} escalated to ${r.priority_escalated_to} by community support`, 'success'); }) .catch(e => toast(`${e.message || e}`, 'warn')); }; const openThread = (id) => { if (commentsFor === id) { setCommentsFor(null); return; } setCommentsFor(id); setThread([]); setCommentText(''); tkApi(`/${id}/comments`).then(setThread).catch(() => {}); }; const postComment = () => { const text = commentText.trim(); if (!text) return; tkApi(`/${commentsFor}/comments`, { json: { text } }) .then(c => { setThread(th => [...th, c]); setCommentText(''); setRows(rs => rs.map(x => x.id === commentsFor ? { ...x, response_count: x.response_count + 1 } : x)); }) .catch(e => toast(`${e.message || e}`, 'warn')); }; return (
{rows === null &&
Loading community feed…
} {rows && rows.length === 0 && (
No tickets match.
)} {(rows || []).map((t, i) => (
{t.upvotes} {t.has_upvoted ? 'SUPPORTED' : 'SUPPORT'}
{t.title}
{t.id} {t.location_label && 📍 {t.location_label}} {t.distance_km != null && {t.distance_km} km} {t.display_category} {t.response_count} responses {t.age}
{commentsFor === t.id && (
{thread.map(c => (
{c.author_label} {c.text} {c.age}
))} {thread.length === 0 &&
No comments yet.
}
setCommentText(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') postComment(); }} />
)}
))}
{toastHost}
); }; const TicketMap = () => { // Default center = the demo city (Bengaluru). Geolocation replaces it on // permission, and any pan refetches for the new viewport, so the default // only seeds the very first view. const DEFAULT_CENTER = [12.9716, 77.5946]; const [radiusKm, setRadiusKm] = React.useState('5 KM'); const [data, setData] = React.useState(null); const [toast, toastHost] = useToast(); const mapRef = React.useRef(null); const mapObj = React.useRef(null); const markersRef = React.useRef(null); const debounceRef = React.useRef(null); const radius = parseInt(radiusKm, 10); const fetchNearby = React.useCallback((lat, lng, r) => { tkApi('/map/nearby', { params: { lat: lat.toFixed(5), lng: lng.toFixed(5), radius_km: r } }) .then(setData) .catch(e => toast(`Map data failed: ${e.message || e}`, 'error')); }, [toast]); // init Leaflet once React.useEffect(() => { if (!window.L || !mapRef.current || mapObj.current) return; const map = window.L.map(mapRef.current, { zoomControl: true, attributionControl: true }) .setView(DEFAULT_CENTER, 14); window.L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap contributors', }).addTo(map); markersRef.current = window.L.layerGroup().addTo(map); mapObj.current = map; map.on('moveend', () => { clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { const c = map.getCenter(); fetchNearby(c.lat, c.lng, parseInt(localStorage.getItem('citadel_tk_radius') || '5', 10)); }, 400); }); if (navigator.geolocation) { navigator.geolocation.getCurrentPosition( pos => map.setView([pos.coords.latitude, pos.coords.longitude], 14), () => {}, { timeout: 6000 }, ); } fetchNearby(DEFAULT_CENTER[0], DEFAULT_CENTER[1], 5); return () => { map.remove(); mapObj.current = null; }; }, []); // radius change → persist for the moveend closure + refetch React.useEffect(() => { try { localStorage.setItem('citadel_tk_radius', String(radius)); } catch (e) {} if (mapObj.current) { const c = mapObj.current.getCenter(); fetchNearby(c.lat, c.lng, radius); } }, [radiusKm]); // redraw markers when data changes React.useEffect(() => { if (!data || !markersRef.current || !window.L) return; const layer = markersRef.current; layer.clearLayers(); data.pins.forEach(p => { const color = TK_SEV_COLOR[p.severity] || 'var(--cyan)'; window.L.circleMarker([p.lat, p.lng], { radius: 9, color: '#000', weight: 2, fillColor: color, fillOpacity: 0.9, }).bindPopup( `
${p.id} · ${p.severity}
${p.title}
${p.display_category} · ${p.status.replace(/_/g, ' ')} · ▲${p.upvotes} · ${p.distance_km} km
` ).addTo(layer); }); }, [data]); return (
{data ? `${data.count} open issues in view · ${data.clusters.length} zones` : 'Loading…'}
NEIGHBORHOOD TICKETS · {radiusKm} RADIUS · LIVE
NEARBY ISSUES
{!data &&
Loading…
} {data && data.pins.length === 0 && (
No located open tickets within {radius} km. Pan the map or submit one with GPS attached.
)} {(data ? data.pins.slice(0, 8) : []).map(p => (
mapObj.current && mapObj.current.setView([p.lat, p.lng], 16)}>
{p.title}
{p.id} · {p.distance_km} km · ▲{p.upvotes}
))}
{toastHost}
); }; /* ====================================================================== CITIZEN MODULE 4 — EXPENSE CATEGORIZER Tabs: Add · Dashboard · Budget · Receipts · Reports ====================================================================== */ /* ---- Expense Categorizer: live API plumbing --------------------------- */ const EX_BASE = '/api/v1/expenses'; // Same persisted browser identity the tickets module mints — one citizen // uuid across citizen modules until Phase 3 JWT. const exUid = tkUid; const exApi = (path, opts = {}) => apiFetch(`${EX_BASE}${path}`, { ...opts, headers: { 'x-user-id': exUid, 'x-user-role': 'citizen', ...(opts.headers || {}) } }); const EX_CATEGORIES = ['Food & Dining', 'Groceries', 'Transport', 'Utilities', 'Healthcare', 'Shopping', 'Education', 'Entertainment', 'Housing', 'Insurance', 'Other']; const EX_CAT_ICON = { 'Food & Dining': '🍕', 'Groceries': '🛒', 'Transport': '🚗', 'Utilities': '💡', 'Healthcare': '💊', 'Shopping': '🛍', 'Education': '📚', 'Entertainment': '🎬', 'Housing': '🏠', 'Insurance': '🛡', 'Other': '📦', }; const EX_CAT_COLOR = { 'Food & Dining': 'var(--gold)', 'Groceries': '#7cb518', 'Transport': 'var(--cyan)', 'Utilities': 'var(--green)', 'Healthcare': '#d946ef', 'Shopping': 'var(--red)', 'Education': '#6366f1', 'Entertainment': '#f97316', 'Housing': '#8b5cf6', 'Insurance': '#0ea5e9', 'Other': '#888', }; const exInr = (v) => '₹' + Number(v || 0).toLocaleString('en-IN', { maximumFractionDigits: 0 }); const ExpenseCategorizer = ({ onBack }) => { const [tab, setTab] = React.useState('dashboard'); const tabs = [ { key: 'add', label: 'ADD EXPENSE' }, { key: 'dashboard', label: 'DASHBOARD' }, { key: 'budget', label: 'BUDGET' }, { key: 'receipts', label: 'RECEIPTS' }, { key: 'reports', label: 'REPORTS' }, ]; return (
{tab === 'add' && } {tab === 'dashboard' && } {tab === 'budget' && } {tab === 'receipts' && } {tab === 'reports' && }
); }; const ExpenseAdd = () => { const [desc, setDesc] = React.useState(''); const [amt, setAmt] = React.useState(''); const [cat, setCat] = React.useState('Auto-detect'); const [merchant, setMerchant] = React.useState(''); const [aiCat, setAiCat] = React.useState(null); const [busy, setBusy] = React.useState(false); const [lastOcr, setLastOcr] = React.useState(null); // last scanned receipt const [ocrBusy, setOcrBusy] = React.useState(false); const [importBatch, setImportBatch] = React.useState(null); const [importBusy, setImportBusy] = React.useState(false); const [toast, toastHost] = useToast(); const receiptRef = React.useRef(); const stmtRef = React.useRef(); // Live prediction on typing pause. The backend answers from its local // layers only (lexicon/cache/model — never the LLM on partial text), // so this is free to call per pause. React.useEffect(() => { if (!desc && !merchant) { setAiCat(null); return; } const t = setTimeout(() => { exApi('/categorize', { json: { description: desc, merchant: merchant || null } }) .then(setAiCat).catch(() => {}); }, 500); return () => clearTimeout(t); }, [desc, merchant]); const add = () => { if (!desc || !amt) { toast('Please fill description and amount', 'warn'); return; } setBusy(true); exApi('', { json: { description: desc, merchant: merchant || null, amount_inr: Number(amt), category: cat === 'Auto-detect' ? null : cat, } }) .then(r => { const e = r.expense; toast(`Added ₹${amt} — ${e.category} [${r.classification.source}]` + (e.is_anomaly ? ` · ⚠ ${e.anomaly_reason}` : ''), e.is_anomaly ? 'warn' : 'success'); setDesc(''); setAmt(''); setMerchant(''); setCat('Auto-detect'); setAiCat(null); }) .catch(e => toast(`Add failed: ${e.message || e}`, 'error')) .finally(() => setBusy(false)); }; const scanReceipt = (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; if (!f) return; setOcrBusy(true); const form = new FormData(); form.append('file', f); exApi('/receipts/upload', { form }) .then(r => { setLastOcr(r); toast(`Receipt scanned — review & confirm`, 'success'); }) .catch(err => toast(`Scan failed: ${err.message || err}`, 'error')) .finally(() => setOcrBusy(false)); }; const confirmReceipt = () => { if (!lastOcr) return; exApi(`/receipts/${lastOcr.id}/confirm`, { json: {} }) .then(r => { toast(`${r.expense.description}: ₹${r.expense.amount_inr.toLocaleString()} added as ${r.expense.category}`, 'success'); setLastOcr({ ...lastOcr, status: 'confirmed', expense_id: r.expense.id }); }) .catch(err => toast(`${err.message || err}`, 'warn')); }; const importStatement = (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; if (!f) return; setImportBusy(true); const form = new FormData(); form.append('file', f); const path = f.name.toLowerCase().endsWith('.pdf') ? '/imports/pdf' : '/imports/csv'; exApi(path, { form }) .then(b => { setImportBatch(b); toast(`${b.parsed_rows} transactions parsed · ${b.duplicate_rows} duplicate(s) flagged`, 'success'); }) .catch(err => toast(`Import failed: ${err.message || err}`, 'error')) .finally(() => setImportBusy(false)); }; const commitImport = () => { if (!importBatch) return; exApi(`/imports/${importBatch.id}/confirm`, { json: {} }) .then(r => { toast(`${r.committed} expense(s) imported · ${r.skipped_duplicates} duplicate(s) skipped`, 'success'); setImportBatch(r.batch); }) .catch(err => toast(`${err.message || err}`, 'warn')); }; return (
MANUAL ENTRY
setDesc(e.target.value)} /> setMerchant(e.target.value)} />
setAmt(e.target.value)} />
{aiCat && (
⚡ AI predicts: {aiCat.category} {Math.round(aiCat.confidence * 100)}% · {aiCat.source} {aiCat.needs_review ? ' · will flag for review' : ''}
)}
SCAN RECEIPT
!ocrBusy && receiptRef.current.click()}> 📷 {ocrBusy ? 'Scanning… (OCR in progress)' : 'Drop receipt image or click to scan'}
JPG, PNG, WEBP, HEIC. OCR extracts merchant, amount, tax, and date.
{lastOcr && (
LAST OCR · {(lastOcr.ocr_engine || '').toUpperCase()}
Merchant:{lastOcr.merchant || '—'}
Items:{lastOcr.items.length || '—'}
Subtotal:{lastOcr.subtotal_inr != null ? exInr(lastOcr.subtotal_inr) : '—'}
Tax (GST):{lastOcr.tax_inr != null ? exInr(lastOcr.tax_inr) : '—'}
Total:{lastOcr.total_inr != null ? exInr(lastOcr.total_inr) : '—'}
Category:{lastOcr.predicted_category ? {lastOcr.predicted_category} : }
{lastOcr.status !== 'confirmed' ? ( ) : (
✓ Confirmed — expense created.
)}
)}
IMPORT SOURCES
{importBatch && (
IMPORT · {importBatch.source_label}
Parsed:{importBatch.parsed_rows} rows
Duplicates:{importBatch.duplicate_rows}
Committed:{importBatch.committed_rows}
{(importBatch.notes || []).map((n, i) =>
{n}
)} {importBatch.status !== 'committed' ? ( ) : (
✓ Committed to expenses.
)}
)}
{toastHost}
); }; const ExpenseDashboard = () => { const [kpis, setKpis] = React.useState(null); const [byCat, setByCat] = React.useState(null); const [trend, setTrend] = React.useState(null); const [recent, setRecent] = React.useState(null); React.useEffect(() => { exApi('/dashboard/kpis').then(setKpis).catch(() => setKpis(false)); exApi('/dashboard/by-category').then(setByCat).catch(() => setByCat([])); exApi('/dashboard/daily-trend').then(setTrend).catch(() => {}); exApi('/dashboard/recent', { params: { limit: 10 } }).then(setRecent).catch(() => setRecent([])); }, []); const donutSegments = (byCat || []).map(s => ({ label: s.label, value: s.value_inr, color: EX_CAT_COLOR[s.label] || '#888', })); const totalLabel = kpis ? exInr(kpis.total_month_inr) : '…'; return (
0 ? 'REVIEW' : undefined} />
BY CATEGORY · {kpis ? kpis.month_label : ''}
{donutSegments.length > 0 ? :
{byCat === null ? 'Loading…' : 'No expenses this month yet.'}
}
DAILY TREND (30D)
{trend && p.total_inr)} color="var(--gold)" />} {trend && trend.highest_expense && (
Highest spend: {exInr(trend.highest_expense.amount_inr)} on {trend.highest_expense.spent_at} ({trend.highest_expense.description.slice(0, 30)})
)} {!trend &&
Loading…
}
RECENT TRANSACTIONS
{recent === null &&
Loading…
} {recent && recent.length === 0 && (
No transactions yet — add one from the ADD EXPENSE tab.
)} {(recent || []).map(e => (
{EX_CAT_ICON[e.category] || '📦'}
{e.description}
{e.category} {e.tax_deductible && TAX ✓} {e.is_anomaly && ⚠ ANOMALY} {e.needs_review && REVIEW} {e.when} {e.source !== 'manual' && via {e.source.replace(/_/g, ' ')}}
{e.is_anomaly && e.anomaly_reason && (
{e.anomaly_reason}
)}
{exInr(e.amount_inr)}
))}
); }; const ExpenseBudget = () => { const [budgets, setBudgets] = React.useState(null); const [showForm, setShowForm] = React.useState(false); const [formCat, setFormCat] = React.useState(EX_CATEGORIES[0]); const [formAmt, setFormAmt] = React.useState(''); const [toast, toastHost] = useToast(); const refresh = React.useCallback(() => { exApi('/budgets').then(setBudgets).catch(() => setBudgets([])); }, []); React.useEffect(() => { refresh(); }, [refresh]); const create = () => { if (!formAmt || Number(formAmt) <= 0) { toast('Enter a budget amount', 'warn'); return; } exApi('/budgets', { json: { category: formCat, amount_inr: Number(formAmt) } }) .then(() => { toast(`Budget set: ${formCat} ${exInr(formAmt)}/month`, 'success'); setShowForm(false); setFormAmt(''); refresh(); }) .catch(e => toast(`${e.message || e}`, 'warn')); }; const remove = (b) => { exApi(`/budgets/${b.id}`, { method: 'DELETE' }) .then(() => { toast(`${b.category} budget removed`, 'success'); refresh(); }) .catch(e => toast(`${e.message || e}`, 'warn')); }; const monthLabel = budgets && budgets[0] ? budgets[0].period_label : ''; return (
Monthly budgets{monthLabel ? ` · ${monthLabel}` : ''} · alerts at the 80% threshold
{showForm && (
setFormAmt(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') create(); }} />
)}
{budgets === null &&
Loading budgets…
} {budgets && budgets.length === 0 && (
No budgets yet — set one per category and spending tracks against it live.
)} {(budgets || []).map((b, i) => { const pct = b.pct_used; const over = b.status === 'over'; const near = b.status === 'near'; return (
{EX_CAT_ICON[b.category] || '📦'}
{b.category}
{exInr(b.spent_inr)} of {exInr(b.amount_inr)}
{over && ⚠ OVER} {near && ⚠ NEAR}
{Math.round(pct)}% used {over ? `${exInr(-b.remaining_inr)} over` : `${exInr(b.remaining_inr)} left`}
); })}
{toastHost}
); }; const ExpenseReceipts = () => { const [receipts, setReceipts] = React.useState(null); const [query, setQuery] = React.useState(''); const [catFilter, setCatFilter] = React.useState(''); const [toast, toastHost] = useToast(); React.useEffect(() => { const params = { limit: 60 }; if (catFilter) params.cat = catFilter; exApi('/receipts', { params }).then(r => setReceipts(r.data)).catch(() => setReceipts([])); }, [catFilter]); const visible = (receipts || []).filter(r => !query || (r.merchant || '').toLowerCase().includes(query.toLowerCase())); const openImage = (r) => { exApi(`/receipts/${r.id}/image`) .then(d => window.open(d.download_url, '_blank', 'noopener')) .catch(e => toast(`${e.message || e}`, 'warn')); }; return (
{receipts === null &&
Loading receipts…
} {receipts && visible.length === 0 && (
{receipts.length === 0 ? 'No receipts yet — scan one from the ADD EXPENSE tab.' : 'No receipts match.'}
)} {visible.map((r, i) => (
openImage(r)}>
🧾
{r.merchant || 'Unknown merchant'}
{r.total_inr != null ? exInr(r.total_inr) : '—'}
{r.purchase_date || String(r.created_at).slice(0, 10)}
{r.predicted_category && } {r.tax_inr != null && r.tax_inr > 0 && }
))}
{toastHost}
); }; const ExpenseReports = () => { const [fy, setFy] = React.useState(null); const [trend, setTrend] = React.useState(null); const [tax, setTax] = React.useState(null); const [toast, toastHost] = useToast(); React.useEffect(() => { exApi('/reports/fy-summary').then(setFy).catch(() => {}); exApi('/reports/monthly-trend').then(setTrend).catch(() => {}); exApi('/reports/tax-summary').then(setTax).catch(() => {}); }, []); // Authenticated Blob download: fetch with the Bearer header, then hand // the bytes to the browser via an object URL. No identity in the query // string — tokens or uids in URLs leak into logs and history. const download = (format) => { const session = czAuth.get(); fetch(`${API_BASE}${EX_BASE}/reports/export?format=${format}`, { headers: { 'ngrok-skip-browser-warning': 'true', ...(session && session.access_token ? { Authorization: `Bearer ${session.access_token}` } : { 'x-user-id': exUid }), }, }) .then(async r => { if (!r.ok) throw new Error(`export failed (${r.status})`); const blob = await r.blob(); const cd = r.headers.get('content-disposition') || ''; const name = (cd.match(/filename="([^"]+)"/) || [])[1] || `citadel-export.${format === 'tax_package' ? 'zip' : format}`; const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 5000); }) .catch(e => toast(e.message || 'Export failed', 'error')); }; const fyLabel = fy ? fy.fy : 'FY'; return (
Savings % and net-worth Δ need income data the platform doesn't hold — shown as — rather than invented.
MONTHLY TREND ({fyLabel})
{trend ? p.total_inr)} color="var(--gold)" labels={[trend[0] && trend[0].label, trend[trend.length - 1] && trend[trend.length - 1].label]} /> :
Loading…
}
TAX SUMMARY {fyLabel}
{tax ? (
Section 80C Investments{exInr(tax.section_80c_inr)}
Section 80D Health Insurance{exInr(tax.section_80d_inr)}
Business Expenses{exInr(tax.business_inr)}
HRA Claims{exInr(tax.hra_inr)}
{tax.other_deductible_inr > 0 && (
Other deductible (untagged){exInr(tax.other_deductible_inr)}
)}
TOTAL DEDUCTIBLE{exInr(tax.total_deductible_inr)}
{tax.note}
) :
Loading…
}
{toastHost}
); }; /* ====================================================================== SYSTEM ADMIN CONSOLE (Phase 6) — gov_admin only Tabs: Users · Audit Trail · Platform Stats. All live via /api/v1/admin/*. ====================================================================== */ const ADMIN_ROLES = ['gov_admin', 'gov_officer', 'gov_analyst', 'citizen']; const adminApi = (path, opts) => apiFetch('/api/v1/admin' + path, opts); const fmtWhen = (iso) => iso ? String(iso).replace('T', ' ').slice(0, 16) : '—'; const AdminUsers = () => { const [rows, setRows] = React.useState(null); const [q, setQ] = React.useState(''); const [toast, toastHost] = useToast(); const me = (czAuth.user() || {}).id; const load = React.useCallback(() => { adminApi('/users' + (q ? '?q=' + encodeURIComponent(q) : '')) .then(setRows).catch(e => { setRows([]); toast(e.message || 'Load failed', 'error'); }); }, [q]); React.useEffect(() => { const t = setTimeout(load, 250); return () => clearTimeout(t); }, [load]); const patch = (id, body) => { adminApi('/users/' + id, { method: 'PATCH', json: body }) .then(() => { toast('Updated', 'success'); load(); }) .catch(e => toast(e.message || 'Update failed', 'warn')); }; return (
{rows ? rows.length + ' account(s)' : 'loading…'}
{rows === null && } {rows && rows.length === 0 && } {(rows || []).map(u => ( ))}
UsernameNameRoleStatusLast loginActions
Loading…
No accounts match.
{u.username || '—'}{u.id === me && YOU} {u.display_name || '—'} {u.is_active ? ACTIVE : DISABLED} {fmtWhen(u.last_login_at)} {u.id !== me && ( )}
{toastHost}
); }; const AdminAudit = () => { const [rows, setRows] = React.useState(null); const [action, setAction] = React.useState(''); const ACTIONS = ['', 'login', 'login_failed', 'lockout', 'logout', 'token_reuse_detected', 'register', 'claim_anonymous', 'admin_user_update', 'fine_updated']; React.useEffect(() => { adminApi('/audit?limit=150' + (action ? '&action=' + action : '')) .then(setRows).catch(() => setRows([])); }, [action]); const tone = (a) => /fail|lockout|reuse|reject/.test(a) ? 'var(--red)' : /login|register|claim|update|fine/.test(a) ? 'var(--gold)' : 'var(--cyan)'; return (
setAction(v === 'ALL' ? '' : v === 'LOGIN' ? 'login' : v === 'FAILED' ? 'login_failed' : 'admin_user_update')} accent="var(--gold)" /> {rows ? rows.length + ' entries' : 'loading…'} · append-only
{rows === null &&
Loading audit trail…
} {rows && rows.length === 0 &&
No entries.
} {(rows || []).map(r => (
{r.action} {(r.performed_by || 'system').slice(0, 20)} {JSON.stringify(r.details || {}).slice(0, 80)} {fmtWhen(r.created_at)}
))}
); }; const AdminStats = () => { const [s, setS] = React.useState(null); React.useEffect(() => { adminApi('/stats').then(setS).catch(() => setS(false)); }, []); const n = v => (v == null ? '—' : String(v)); return (
{!s &&
Loading platform stats…
} {s && ( <>
ACCOUNTS BY ROLE
{ADMIN_ROLES.map(r => (
{r} {n((s.users_by_role || {})[r])}
))}
PLATFORM VOLUME
{[['Support tickets', s.tickets], ['Expenses logged', s.expenses], ['Fake-news analyses', s.fake_news_analyses], ['Traffic incidents', s.tv_incidents], ['Challans issued', s.tv_challans]].map(([l, v]) => (
{l}{n(v)}
))}
)}
); }; const AdminPanel = ({ onBack }) => { const [tab, setTab] = React.useState('users'); const tabs = [{ key: 'users', label: 'USERS' }, { key: 'audit', label: 'AUDIT TRAIL' }, { key: 'stats', label: 'PLATFORM STATS' }]; return (
{tab === 'users' && } {tab === 'audit' && } {tab === 'stats' && }
); }; // Export all modules Object.assign(window, { DocumentIntelligence, ResumeScreening, TrafficViolations, AnomalyMonitoring, RAGChatbot, FakeNewsDetector, SupportTickets, ExpenseCategorizer, AdminPanel });