docuscan-ai / frontend.html
pramodmisra's picture
Add email forwarding β€” SendGrid Inbound Parse with multi-tenant routing
db992a6
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DocuScan AI β€” Enterprise Document Intelligence</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.23.9/babel.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-primary: #f5f7fa;
--bg-secondary: #ffffff;
--bg-tertiary: #f0f2f5;
--bg-elevated: #ffffff;
--bg-hover: #e8ebf0;
--border: #dde1e8;
--border-light: #c8ced8;
--text-primary: #1a1d26;
--text-secondary: #5a6070;
--text-muted: #8b90a0;
--accent: #4f6df5;
--accent-hover: #3b5be0;
--accent-bg: rgba(79, 109, 245, 0.08);
--accent-border: rgba(79, 109, 245, 0.2);
--success: #16a34a;
--success-bg: rgba(22, 163, 74, 0.08);
--warning: #d97706;
--warning-bg: rgba(217, 119, 6, 0.08);
--danger: #dc2626;
--danger-bg: rgba(220, 38, 38, 0.08);
--purple: #7c3aed;
--purple-bg: rgba(124, 58, 237, 0.08);
--cyan: #0891b2;
--cyan-bg: rgba(8, 145, 178, 0.08);
--gradient-1: linear-gradient(135deg, #4f6df5 0%, #7c3aed 100%);
--gradient-2: linear-gradient(135deg, #16a34a 0%, #4f6df5 100%);
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06);
--shadow-md: 0 4px 16px rgba(0,0,0,0.08);
--shadow-lg: 0 8px 32px rgba(0,0,0,0.1);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
--font: 'DM Sans', -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
body {
font-family: var(--font);
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #c0c5d0; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #a0a8b8; }
@keyframes fadeInUp { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideInRight { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } }
@keyframes pulse-ring { 0% { transform: scale(0.8); opacity: 1; } 100% { transform: scale(1.4); opacity: 0; } }
@keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }
@keyframes typing-dot { 0%, 60%, 100% { opacity: 0.3; transform: translateY(0); } 30% { opacity: 1; transform: translateY(-4px); } }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes countUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.animate-in { animation: fadeInUp 0.4s ease-out forwards; }
/* ─── Login Page ─────────────────────────────── */
.login-wrapper { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg-primary); position: relative; overflow: hidden; }
.login-wrapper::before { content: ''; position: absolute; width: 600px; height: 600px; background: radial-gradient(circle, rgba(79,109,245,0.1) 0%, transparent 70%); top: -200px; right: -100px; pointer-events: none; }
.login-card { width: 100%; max-width: 420px; padding: 44px 40px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius-xl); position: relative; z-index: 1; animation: fadeInUp 0.5s ease-out; box-shadow: var(--shadow-lg); }
.login-logo { display: flex; align-items: center; gap: 10px; margin-bottom: 32px; }
.login-logo-icon { width: 40px; height: 40px; background: var(--gradient-1); border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 700; color: #fff; }
.login-logo-text { font-size: 20px; font-weight: 700; background: var(--gradient-1); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.login-title { font-size: 24px; font-weight: 600; margin-bottom: 6px; }
.login-subtitle { font-size: 14px; color: var(--text-secondary); margin-bottom: 28px; }
.form-group { margin-bottom: 18px; }
.form-label { display: block; font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 6px; }
.form-input { width: 100%; padding: 11px 14px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-primary); font-family: var(--font); font-size: 14px; transition: border-color 0.2s, box-shadow 0.2s; outline: none; }
.form-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(108,138,255,0.1); }
.form-input::placeholder { color: var(--text-muted); }
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 11px 20px; border: none; border-radius: var(--radius-sm); font-family: var(--font); font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; outline: none; text-decoration: none; }
.btn-primary { background: var(--accent); color: #fff; width: 100%; }
.btn-primary:hover { background: var(--accent-hover); transform: translateY(-1px); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.btn-ghost { background: transparent; color: var(--accent); padding: 6px 0; font-size: 13px; }
.btn-ghost:hover { color: var(--accent-hover); }
.btn-secondary { background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border); }
.btn-secondary:hover { background: var(--bg-hover); border-color: var(--border-light); }
.btn-danger { background: var(--danger-bg); color: var(--danger); border: 1px solid rgba(248,113,113,0.2); }
.btn-danger:hover { background: rgba(248,113,113,0.15); }
.btn-icon { width: 36px; height: 36px; padding: 0; border-radius: var(--radius-sm); background: var(--bg-tertiary); border: 1px solid var(--border); color: var(--text-secondary); cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; }
.btn-icon:hover { background: var(--bg-hover); color: var(--text-primary); }
.btn-sm { padding: 7px 14px; font-size: 13px; }
.form-error { background: var(--danger-bg); border: 1px solid rgba(248,113,113,0.2); border-radius: var(--radius-sm); padding: 10px 14px; font-size: 13px; color: var(--danger); margin-bottom: 16px; }
.form-success { background: var(--success-bg); border: 1px solid rgba(52,211,153,0.2); border-radius: var(--radius-sm); padding: 10px 14px; font-size: 13px; color: var(--success); margin-bottom: 16px; }
.form-footer { text-align: center; margin-top: 18px; }
.login-creds { margin-top: 24px; padding: 14px; background: var(--bg-tertiary); border: 1px dashed var(--border); border-radius: var(--radius-sm); font-size: 12px; color: var(--text-muted); }
.login-creds strong { color: var(--text-secondary); }
.login-creds code { font-family: var(--font-mono); font-size: 11px; background: var(--bg-elevated); padding: 1px 5px; border-radius: 3px; color: var(--accent); }
/* ─── App Shell ──────────────────────────────── */
.app-shell { display: flex; height: 100vh; }
.sidebar { width: 240px; min-width: 240px; background: var(--bg-secondary); border-right: 1px solid var(--border); display: flex; flex-direction: column; padding: 20px 0; }
.sidebar-logo { display: flex; align-items: center; gap: 10px; padding: 0 20px; margin-bottom: 28px; }
.sidebar-logo-icon { width: 32px; height: 32px; background: var(--gradient-1); border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 700; color: #fff; }
.sidebar-logo-text { font-size: 16px; font-weight: 700; background: var(--gradient-1); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.nav-section { padding: 0 12px; margin-bottom: 24px; }
.nav-section-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); padding: 0 8px; margin-bottom: 6px; }
.nav-item { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-radius: var(--radius-sm); font-size: 13.5px; font-weight: 450; color: var(--text-secondary); cursor: pointer; transition: all 0.15s; border: none; background: none; width: 100%; text-align: left; font-family: var(--font); }
.nav-item:hover { background: var(--bg-hover); color: var(--text-primary); }
.nav-item.active { background: var(--accent-bg); color: var(--accent); }
.nav-item svg { width: 18px; height: 18px; flex-shrink: 0; }
.sidebar-footer { margin-top: auto; padding: 16px 12px; border-top: 1px solid var(--border); }
.user-info { display: flex; align-items: center; gap: 10px; padding: 8px; }
.user-avatar { width: 32px; height: 32px; background: var(--gradient-1); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 600; color: #fff; }
.user-name { font-size: 13px; font-weight: 500; }
.user-role { font-size: 11px; color: var(--text-muted); }
.main-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* Billing Bar */
.billing-bar { background: var(--bg-secondary); border-bottom: 1px solid var(--border); padding: 12px 28px; display: flex; align-items: center; gap: 28px; }
.billing-stat { display: flex; align-items: center; gap: 10px; }
.billing-stat-icon { width: 36px; height: 36px; border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; }
.billing-stat-icon.blue { background: var(--accent-bg); color: var(--accent); }
.billing-stat-icon.green { background: var(--success-bg); color: var(--success); }
.billing-stat-icon.amber { background: var(--warning-bg); color: var(--warning); }
.billing-stat-value { font-size: 18px; font-weight: 700; font-family: var(--font-mono); animation: countUp 0.3s ease-out; }
.billing-stat-label { font-size: 11px; color: var(--text-muted); font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; }
.billing-progress-wrap { flex: 1; max-width: 300px; }
.billing-progress-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.billing-progress-label { font-size: 11px; color: var(--text-muted); }
.billing-progress-value { font-size: 12px; font-weight: 600; font-family: var(--font-mono); color: var(--text-secondary); }
.billing-progress-bar { height: 6px; background: var(--bg-tertiary); border-radius: 3px; overflow: hidden; }
.billing-progress-fill { height: 100%; border-radius: 3px; background: var(--gradient-2); transition: width 0.8s cubic-bezier(0.16, 1, 0.3, 1); }
.billing-period { margin-left: auto; font-size: 12px; color: var(--text-muted); display: flex; align-items: center; gap: 6px; }
.page-content { flex: 1; overflow-y: auto; padding: 28px; }
.page-header { margin-bottom: 24px; }
.page-title { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
.page-desc { font-size: 14px; color: var(--text-secondary); }
/* ─── Upload Area ────────────────────────────── */
.upload-zone { border: 2px dashed #c0c8d8; border-radius: var(--radius-lg); padding: 48px; text-align: center; cursor: pointer; transition: all 0.2s; background: var(--bg-secondary); margin-bottom: 28px; }
.upload-zone:hover, .upload-zone.dragover { border-color: var(--accent); background: var(--accent-bg); }
.upload-zone-icon { width: 56px; height: 56px; background: var(--accent-bg); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 16px; color: var(--accent); }
.upload-zone-title { font-size: 16px; font-weight: 600; margin-bottom: 6px; }
.upload-zone-desc { font-size: 13px; color: var(--text-muted); }
/* Document Cards */
.doc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px; }
.doc-card { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 18px; transition: all 0.2s; animation: fadeInUp 0.3s ease-out; box-shadow: var(--shadow-sm); }
.doc-card:hover { border-color: var(--border-light); box-shadow: var(--shadow-md); }
.doc-card-header { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 12px; }
.doc-icon { width: 40px; height: 40px; background: var(--accent-bg); border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; color: var(--accent); font-size: 18px; flex-shrink: 0; }
.doc-filename { font-size: 14px; font-weight: 600; word-break: break-word; }
.doc-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.doc-summary { font-size: 13px; color: var(--text-secondary); line-height: 1.5; margin-bottom: 12px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.doc-card-footer { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.status-badge { display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px; border-radius: 20px; font-size: 11px; font-weight: 500; }
.status-badge.ready { background: var(--success-bg); color: var(--success); }
.status-badge.processing { background: var(--warning-bg); color: var(--warning); }
.status-badge.error { background: var(--danger-bg); color: var(--danger); }
.status-badge.running { background: var(--warning-bg); color: var(--warning); }
.status-badge.completed { background: var(--success-bg); color: var(--success); }
.status-dot { width: 6px; height: 6px; border-radius: 50%; }
.status-dot.ready { background: var(--success); }
.status-dot.processing { background: var(--warning); animation: pulse-ring 1.5s infinite; }
.status-dot.error { background: var(--danger); }
/* Doc type badges */
.doc-type-badge { display: inline-flex; padding: 3px 8px; border-radius: 4px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
.doc-type-badge.acord { background: var(--accent-bg); color: var(--accent); }
.doc-type-badge.coi { background: var(--success-bg); color: var(--success); }
.doc-type-badge.claim { background: var(--danger-bg); color: var(--danger); }
.doc-type-badge.policy { background: var(--purple-bg); color: var(--purple); }
.doc-type-badge.carrier_statement { background: var(--warning-bg); color: var(--warning); }
.doc-type-badge.loss_run { background: var(--cyan-bg); color: var(--cyan); }
.doc-type-badge.other { background: var(--bg-tertiary); color: var(--text-muted); }
.extracted-badge { display: inline-flex; padding: 3px 8px; border-radius: 4px; font-size: 10px; font-weight: 600; background: var(--success-bg); color: var(--success); }
.shimmer { background: linear-gradient(90deg, #e8ebf0 25%, #d8dce5 50%, #e8ebf0 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: var(--radius-sm); }
/* ─── Chat Interface ─────────────────────────── */
.chat-layout { display: flex; height: 100%; overflow: hidden; margin: -28px; }
.chat-sidebar { width: 280px; min-width: 280px; background: var(--bg-secondary); border-right: 1px solid var(--border); padding: 20px; overflow-y: auto; }
.chat-sidebar-title { font-size: 13px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; }
.doc-select-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: var(--radius-sm); cursor: pointer; transition: all 0.15s; margin-bottom: 4px; font-size: 13px; }
.doc-select-item:hover { background: var(--bg-hover); }
.doc-select-item.selected { background: var(--accent-bg); color: var(--accent); }
.doc-select-check { width: 18px; height: 18px; border: 2px solid var(--border); border-radius: 4px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: all 0.15s; }
.doc-select-item.selected .doc-select-check { background: var(--accent); border-color: var(--accent); }
.chat-main { flex: 1; display: flex; flex-direction: column; }
.chat-messages { flex: 1; overflow-y: auto; padding: 28px; }
.chat-empty { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-muted); text-align: center; }
.chat-empty-icon { width: 64px; height: 64px; background: var(--accent-bg); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-bottom: 16px; font-size: 28px; color: var(--accent); }
.chat-empty-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin-bottom: 6px; }
.chat-empty-desc { font-size: 14px; max-width: 400px; }
.chat-message { margin-bottom: 20px; animation: fadeInUp 0.3s ease-out; }
.chat-msg-user { display: flex; justify-content: flex-end; }
.chat-msg-user .chat-bubble { background: var(--accent); color: #fff; border-radius: var(--radius-md) var(--radius-md) 4px var(--radius-md); max-width: 70%; padding: 12px 16px; font-size: 14px; }
.chat-msg-ai { display: flex; gap: 12px; }
.chat-msg-ai-avatar { width: 32px; height: 32px; background: var(--gradient-1); border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 700; color: #fff; flex-shrink: 0; margin-top: 2px; }
.chat-msg-ai .chat-bubble { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 4px var(--radius-md) var(--radius-md) var(--radius-md); max-width: 80%; padding: 14px 18px; font-size: 14px; line-height: 1.65; color: var(--text-primary); white-space: pre-wrap; }
.chat-sources { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
.chat-source-label { font-size: 11px; color: var(--text-muted); font-weight: 500; margin-bottom: 4px; }
.chat-source-tag { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; background: var(--accent-bg); border-radius: 4px; font-size: 11px; color: var(--accent); margin-right: 6px; margin-top: 2px; }
.typing-indicator { display: flex; gap: 4px; padding: 8px 0; }
.typing-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted); }
.typing-dot:nth-child(1) { animation: typing-dot 1.4s infinite 0s; }
.typing-dot:nth-child(2) { animation: typing-dot 1.4s infinite 0.2s; }
.typing-dot:nth-child(3) { animation: typing-dot 1.4s infinite 0.4s; }
.chat-input-area { padding: 16px 28px 20px; border-top: 1px solid var(--border); background: var(--bg-primary); }
.chat-input-wrap { display: flex; gap: 10px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 6px 6px 6px 16px; align-items: flex-end; transition: border-color 0.2s; }
.chat-input-wrap:focus-within { border-color: var(--accent); }
.chat-input { flex: 1; border: none; background: none; color: var(--text-primary); font-family: var(--font); font-size: 14px; padding: 8px 0; outline: none; resize: none; min-height: 20px; max-height: 120px; }
.chat-input::placeholder { color: var(--text-muted); }
.chat-send-btn { width: 38px; height: 38px; background: var(--accent); color: #fff; border: none; border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s; flex-shrink: 0; }
.chat-send-btn:hover { background: var(--accent-hover); }
.chat-send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* ─── Billing Page ───────────────────────────── */
.billing-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; margin-bottom: 28px; }
.billing-card { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 22px; box-shadow: var(--shadow-sm); }
.billing-card-label { font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
.billing-card-value { font-size: 28px; font-weight: 700; font-family: var(--font-mono); }
.billing-card-sub { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
.events-table { width: 100%; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; }
.events-table th { text-align: left; padding: 12px 18px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); background: var(--bg-tertiary); border-bottom: 1px solid var(--border); }
.events-table td { padding: 12px 18px; font-size: 13px; border-bottom: 1px solid var(--border); color: var(--text-secondary); }
.events-table tr:last-child td { border-bottom: none; }
.events-table .mono { font-family: var(--font-mono); font-size: 12px; }
/* ─── Workflow & Extracted Data Pages ─────── */
.workflow-config { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 24px; margin-bottom: 24px; }
.workflow-config-row { display: flex; gap: 16px; align-items: flex-end; flex-wrap: wrap; }
.workflow-config-field { flex: 1; min-width: 200px; }
.workflow-config-field label { display: block; font-size: 13px; font-weight: 500; color: var(--text-secondary); margin-bottom: 6px; }
.workflow-config-field select { width: 100%; padding: 10px 14px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-primary); font-family: var(--font); font-size: 14px; outline: none; cursor: pointer; }
.workflow-config-field select:focus { border-color: var(--accent); }
.workflow-result { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 22px; margin-bottom: 16px; animation: fadeInUp 0.3s ease-out; box-shadow: var(--shadow-sm); }
.workflow-result-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.workflow-result-title { font-size: 16px; font-weight: 600; }
.workflow-steps { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
.workflow-step { padding: 4px 10px; background: var(--success-bg); color: var(--success); border-radius: 4px; font-size: 11px; font-weight: 500; }
.data-section { margin-bottom: 20px; }
.data-section-title { font-size: 13px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
.data-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; }
.data-field { padding: 10px 14px; background: var(--bg-tertiary); border-radius: var(--radius-sm); }
.data-field-label { font-size: 11px; color: var(--text-muted); font-weight: 500; text-transform: uppercase; letter-spacing: 0.3px; margin-bottom: 3px; }
.data-field-value { font-size: 14px; color: var(--text-primary); word-break: break-word; }
.data-field-value.mono { font-family: var(--font-mono); font-size: 13px; }
.severity-badge { display: inline-flex; padding: 3px 8px; border-radius: 4px; font-size: 10px; font-weight: 600; text-transform: uppercase; }
.severity-badge.critical, .severity-badge.high { background: var(--danger-bg); color: var(--danger); }
.severity-badge.medium { background: var(--warning-bg); color: var(--warning); }
.severity-badge.low { background: var(--success-bg); color: var(--success); }
.severity-badge.info, .severity-badge.none { background: var(--accent-bg); color: var(--accent); }
/* ─── Download Outputs ────────────────────── */
.doc-outputs { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
.doc-outputs-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.doc-outputs-grid { display: flex; flex-direction: column; gap: 6px; }
.output-item { display: flex; align-items: center; gap: 10px; padding: 9px 12px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: var(--radius-sm); cursor: pointer; transition: all 0.15s; text-decoration: none; }
.output-item:hover { background: var(--accent-bg); border-color: var(--accent-border); }
.output-icon { width: 30px; height: 30px; border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-size: 13px; }
.output-icon.excel { background: rgba(52,211,153,0.12); color: var(--success); }
.output-icon.docx { background: rgba(108,138,255,0.12); color: var(--accent); }
.output-icon.markdown { background: rgba(251,191,36,0.12); color: var(--warning); }
.output-icon.image { background: rgba(167,139,250,0.12); color: #a78bfa; }
.output-info { flex: 1; min-width: 0; }
.output-label { font-size: 13px; font-weight: 500; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.output-detail { font-size: 11px; color: var(--text-muted); }
.output-dl-icon { color: var(--text-muted); flex-shrink: 0; transition: color 0.15s; }
.output-item:hover .output-dl-icon { color: var(--accent); }
.output-size { font-size: 11px; color: var(--text-muted); font-family: var(--font-mono); flex-shrink: 0; }
/* ─── Suggestions ─────────────────────────── */
.suggestions-panel { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
.suggestions-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--accent); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.suggestion-item { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: var(--radius-sm); cursor: pointer; transition: all 0.15s; margin-bottom: 6px; }
.suggestion-item:hover { background: var(--accent-bg); border-color: var(--accent-border); }
.suggestion-item.critical { border-left: 3px solid var(--danger); }
.suggestion-item.high { border-left: 3px solid var(--warning); }
.suggestion-item.medium { border-left: 3px solid var(--accent); }
.suggestion-item.low { border-left: 3px solid var(--border-light); }
.suggestion-icon { width: 28px; height: 28px; border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-size: 13px; }
.suggestion-icon.critical { background: var(--danger-bg); color: var(--danger); }
.suggestion-icon.high { background: var(--warning-bg); color: var(--warning); }
.suggestion-icon.medium { background: var(--accent-bg); color: var(--accent); }
.suggestion-icon.low { background: var(--bg-hover); color: var(--text-muted); }
.suggestion-title { font-size: 13px; font-weight: 600; color: var(--text-primary); margin-bottom: 2px; }
.suggestion-desc { font-size: 11px; color: var(--text-secondary); line-height: 1.4; }
/* ─── Toast ──────────────────────────────────── */
.toast-container { position: fixed; top: 20px; right: 20px; z-index: 9999; display: flex; flex-direction: column; gap: 8px; }
.toast { padding: 12px 18px; border-radius: var(--radius-sm); font-size: 13px; font-weight: 500; animation: slideInRight 0.3s ease-out; box-shadow: var(--shadow-md); max-width: 360px; }
.toast.success { background: #f0fdf4; color: #166534; border: 1px solid #86efac; }
.toast.error { background: #fef2f2; color: #991b1b; border: 1px solid #fca5a5; }
.toast.info { background: #eff6ff; color: #1e40af; border: 1px solid #93c5fd; }
.spinner { width: 18px; height: 18px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
.spinner.dark { border-color: var(--border); border-top-color: var(--accent); }
.spinner.light { border-color: var(--border); border-top-color: var(--accent); }
.empty-state { text-align: center; padding: 60px 20px; color: var(--text-muted); }
.empty-state-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.5; }
.empty-state-title { font-size: 16px; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
.empty-state-desc { font-size: 13px; }
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useRef, useCallback } = React;
const API = '';
// ─── Icons ───────────────────────────────────────────────────────────────
const Icons = {
Docs: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>,
Chat: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>,
Billing: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="1" y="4" width="22" height="16" rx="2" ry="2"/><line x1="1" y1="10" x2="23" y2="10"/></svg>,
Workflow: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>,
Extract: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>,
Upload: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>,
Send: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>,
Logout: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>,
File: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>,
Trash: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>,
Scan: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M2 7l4.41-4.41A2 2 0 0 1 7.83 2h8.34a2 2 0 0 1 1.42.59L22 7"/><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><path d="M15 22v-4a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4"/><path d="M2 7h20"/></svg>,
Dollar: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>,
Zap: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>,
Download: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>,
Table: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>,
Code: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>,
Image: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>,
Play: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>,
Users: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
Plus: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>,
Cloud: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/></svg>,
Folder: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>,
Check: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg>,
Search: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>,
ChevronRight: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 18 15 12 9 6"/></svg>,
RefreshCw: () => <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>,
};
// ─── Toast System ────────────────────────────────────────────────────────
function ToastContainer({ toasts }) {
return <div className="toast-container">{toasts.map(t => <div key={t.id} className={`toast ${t.type}`}>{t.message}</div>)}</div>;
}
// ─── Login Page ──────────────────────────────────────────────────────────
function LoginPage({ onLogin }) {
const [view, setView] = useState('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [resetToken, setResetToken] = useState('');
const [newPassword, setNewPassword] = useState('');
const handleLogin = async (e) => {
e.preventDefault(); setError(''); setLoading(true);
try {
const res = await fetch(`${API}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }) });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Login failed');
onLogin(data);
} catch (err) { setError(err.message); } finally { setLoading(false); }
};
const handleForgot = async (e) => {
e.preventDefault(); setError(''); setLoading(true);
try {
const res = await fetch(`${API}/api/auth/forgot-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }) });
const data = await res.json();
if (data.demo_token) { setResetToken(data.demo_token); setSuccess('Reset link generated!'); setView('reset'); } else { setSuccess(data.message); }
} catch (err) { setError(err.message); } finally { setLoading(false); }
};
const handleReset = async (e) => {
e.preventDefault(); setError(''); setLoading(true);
try {
const res = await fetch(`${API}/api/auth/reset-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: resetToken, password: newPassword }) });
const data = await res.json();
if (!res.ok) throw new Error(data.detail);
setSuccess('Password reset!'); setPassword(newPassword);
setTimeout(() => { setView('login'); setSuccess(''); }, 1500);
} catch (err) { setError(err.message); } finally { setLoading(false); }
};
return (
<div className="login-wrapper">
<div className="login-card">
<div className="login-logo"><div className="login-logo-icon">D</div><div className="login-logo-text">DocuScan AI</div></div>
{view === 'login' && (<>
<div className="login-title">Welcome back</div>
<div className="login-subtitle">Sign in to your document intelligence platform</div>
{error && <div className="form-error">{error}</div>}
<form onSubmit={handleLogin}>
<div className="form-group"><label className="form-label">Email</label><input className="form-input" type="email" value={email} onChange={e => setEmail(e.target.value)} placeholder="you@company.com" /></div>
<div className="form-group"><label className="form-label">Password</label><input className="form-input" type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="β€’β€’β€’β€’β€’β€’β€’β€’" /></div>
<button className="btn btn-primary" type="submit" disabled={loading}>{loading ? <span className="spinner" /> : 'Sign In'}</button>
</form>
<div className="form-footer"><button className="btn btn-ghost" onClick={() => { setView('forgot'); setError(''); setSuccess(''); }}>Forgot password?</button></div>
</>)}
{view === 'forgot' && (<>
<div className="login-title">Reset password</div><div className="login-subtitle">Enter your email to receive a reset link</div>
{error && <div className="form-error">{error}</div>}{success && <div className="form-success">{success}</div>}
<form onSubmit={handleForgot}><div className="form-group"><label className="form-label">Email</label><input className="form-input" type="email" value={email} onChange={e => setEmail(e.target.value)} /></div><button className="btn btn-primary" type="submit" disabled={loading}>{loading ? <span className="spinner" /> : 'Send Reset Link'}</button></form>
<div className="form-footer"><button className="btn btn-ghost" onClick={() => { setView('login'); setError(''); setSuccess(''); }}>Back to login</button></div>
</>)}
{view === 'reset' && (<>
<div className="login-title">Set new password</div><div className="login-subtitle">Enter your new password below</div>
{error && <div className="form-error">{error}</div>}{success && <div className="form-success">{success}</div>}
<form onSubmit={handleReset}><div className="form-group"><label className="form-label">New Password</label><input className="form-input" type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} /></div><button className="btn btn-primary" type="submit" disabled={loading}>{loading ? <span className="spinner" /> : 'Reset Password'}</button></form>
<div className="form-footer"><button className="btn btn-ghost" onClick={() => { setView('login'); setError(''); setSuccess(''); }}>Back to login</button></div>
</>)}
</div>
</div>
);
}
// ─── Billing Bar ─────────────────────────────────────────────────────────
function BillingBar({ token }) {
const [billing, setBilling] = useState(null);
const fetchBilling = useCallback(async () => {
try { const res = await fetch(`${API}/api/billing/usage`, { headers: { 'Authorization': `Bearer ${token}` } }); if (res.ok) setBilling(await res.json()); } catch {}
}, [token]);
useEffect(() => { fetchBilling(); const iv = setInterval(fetchBilling, 10000); return () => clearInterval(iv); }, [fetchBilling]);
if (!billing) return null;
const pct = Math.min((billing.total_scanned / billing.quota) * 100, 100);
return (
<div className="billing-bar">
<div className="billing-stat"><div className="billing-stat-icon blue"><Icons.Scan /></div><div><div className="billing-stat-value">{billing.total_scanned.toLocaleString()}</div><div className="billing-stat-label">Docs Scanned</div></div></div>
<div className="billing-stat"><div className="billing-stat-icon green"><Icons.Zap /></div><div><div className="billing-stat-value">{(billing.credits_used || 0).toLocaleString()}</div><div className="billing-stat-label">Credits Used</div></div></div>
<div className="billing-progress-wrap"><div className="billing-progress-header"><span className="billing-progress-label">Credits</span><span className="billing-progress-value">{pct.toFixed(1)}%</span></div><div className="billing-progress-bar"><div className="billing-progress-fill" style={{ width: `${pct}%` }} /></div></div>
<div className="billing-period"><Icons.Zap />{billing.current_period}</div>
</div>
);
}
// ─── Documents Page ──────────────────────────────────────────────────────
function DocumentsPage({ token, toast, onRunWorkflow, onGoChat }) {
const [docs, setDocs] = useState([]);
const [uploading, setUploading] = useState(false);
const [dragover, setDragover] = useState(false);
const fileRef = useRef(null);
const fetchDocs = useCallback(async () => {
try { const res = await fetch(`${API}/api/documents`, { headers: { 'Authorization': `Bearer ${token}` } }); if (res.ok) { const data = await res.json(); setDocs(data.documents); } } catch {}
}, [token]);
useEffect(() => { fetchDocs(); const iv = setInterval(fetchDocs, 3000); return () => clearInterval(iv); }, [fetchDocs]);
const uploadFile = async (file) => {
setUploading(true);
try {
const form = new FormData(); form.append('file', file);
const res = await fetch(`${API}/api/documents/upload`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, body: form });
if (res.ok) { toast('Document uploaded β€” processing started', 'success'); fetchDocs(); } else { const data = await res.json(); toast(data.detail || 'Upload failed', 'error'); }
} catch (err) { toast('Upload failed: ' + err.message, 'error'); } finally { setUploading(false); }
};
const handleDrop = (e) => { e.preventDefault(); setDragover(false); const file = e.dataTransfer.files[0]; if (file) uploadFile(file); };
const deleteDoc = async (docId) => { try { await fetch(`${API}/api/documents/${docId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); toast('Document deleted', 'info'); fetchDocs(); } catch {} };
const downloadFile = async (docId, filename) => {
try {
const res = await fetch(`${API}/api/documents/${docId}/download/${filename}`, { headers: { 'Authorization': `Bearer ${token}` } });
if (!res.ok) { toast('Download failed', 'error'); return; }
const blob = await res.blob(); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);
} catch (err) { toast('Download failed', 'error'); }
};
const formatSize = (bytes) => { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB'; return (bytes/(1024*1024)).toFixed(1) + ' MB'; };
const docTypeLabel = (t) => ({ acord:'ACORD', coi:'COI', claim:'Claim', policy:'Policy', carrier_statement:'Statement', loss_run:'Loss Run', other:'Other' }[t] || t);
return (
<div>
<div className="page-header"><div className="page-title">Documents</div><div className="page-desc">Upload, parse, and index documents for intelligent search</div></div>
<div className={`upload-zone ${dragover ? 'dragover' : ''}`} onClick={() => fileRef.current?.click()} onDragOver={e => { e.preventDefault(); setDragover(true); }} onDragLeave={() => setDragover(false)} onDrop={handleDrop}>
<input ref={fileRef} type="file" hidden accept=".pdf,.docx,.txt,.md,.csv,.xlsx" onChange={e => e.target.files[0] && uploadFile(e.target.files[0])} />
<div className="upload-zone-icon">{uploading ? <span className="spinner dark" /> : <Icons.Upload />}</div>
<div className="upload-zone-title">{uploading ? 'Uploading...' : 'Drop a document here or click to upload'}</div>
<div className="upload-zone-desc">Supports PDF, DOCX, TXT, MD, CSV, XLSX β€” Parsed with LlamaParse, indexed with LlamaIndex</div>
</div>
{docs.length === 0 ? (
<div className="empty-state"><div className="empty-state-title">No documents yet</div><div className="empty-state-desc">Upload your first document to get started</div></div>
) : (
<div className="doc-grid">
{docs.map((doc, i) => (
<div className="doc-card" key={doc.id} style={{ animationDelay: `${i * 0.05}s` }}>
<div className="doc-card-header">
<div className="doc-icon"><Icons.File /></div>
<div style={{flex:1}}>
<div className="doc-filename">{doc.filename}</div>
<div className="doc-meta">{formatSize(doc.size)} Β· {doc.pages > 0 ? `${doc.pages} page${doc.pages > 1 ? 's' : ''}` : 'β€”'} Β· {new Date(doc.uploaded_at).toLocaleString()}</div>
</div>
</div>
{(['processing','parsing','indexing','summarizing','converting'].includes(doc.status)) ? (
<div>
<div style={{ fontSize: 12, color: 'var(--warning)', marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="spinner dark" style={{ width: 14, height: 14, borderWidth: '1.5px' }} />
{doc.status === 'parsing' ? 'Parsing with LlamaParse...' : doc.status === 'indexing' ? 'Building vector index...' : doc.status === 'summarizing' ? 'Generating summary...' : doc.status === 'converting' ? 'Converting formats...' : 'Processing...'}
</div>
<div className="shimmer" style={{ height: 14, width: '80%', marginBottom: 8 }} /><div className="shimmer" style={{ height: 14, width: '60%' }} />
</div>
) : doc.summary ? <div className="doc-summary">{doc.summary}</div> : null}
{doc.outputs && doc.outputs.length > 0 && doc.status === 'ready' && (
<div className="doc-outputs">
<div className="doc-outputs-title"><Icons.Download /> Downloads ({doc.outputs.length})</div>
<div className="doc-outputs-grid">
{doc.outputs.map((out, oi) => (
<a key={oi} className="output-item" href="#" onClick={(e) => { e.preventDefault(); downloadFile(doc.id, out.filename); }}>
<div className={`output-icon ${out.type}`}>{out.type === 'excel' ? <Icons.Table /> : out.type === 'docx' ? <Icons.Docs /> : out.type === 'markdown' ? <Icons.Code /> : out.type === 'image' ? <Icons.Image /> : <Icons.File />}</div>
<div className="output-info"><div className="output-label">{out.label}</div><div className="output-detail">{out.detail}</div></div>
<span className="output-size">{formatSize(out.size)}</span>
<div className="output-dl-icon" style={{ width: 16, height: 16 }}><Icons.Download /></div>
</a>
))}
</div>
</div>
)}
{doc.suggestions && doc.suggestions.length > 0 && doc.status === 'ready' && (
<div className="suggestions-panel">
<div className="suggestions-title"><Icons.Zap /> Suggestions ({doc.suggestions.length})</div>
{doc.suggestions.slice(0, 3).map((s, si) => (
<div key={si} className={`suggestion-item ${s.severity}`}
onClick={() => {
if (s.action_type === 'workflow' && s.action) { onRunWorkflow && onRunWorkflow(doc.id, s.action); }
else if (s.action_type === 'chat') { onGoChat && onGoChat(); }
}}>
<div className={`suggestion-icon ${s.severity}`}>
{s.icon === 'alert' ? '!' : s.icon === 'warning' ? '!' : s.icon === 'shield' ? <Icons.File /> : s.icon === 'calendar' ? <Icons.Billing /> : s.icon === 'calculator' ? <Icons.Table /> : s.icon === 'chat' ? <Icons.Chat /> : s.icon === 'download' ? <Icons.Download /> : <Icons.Zap />}
</div>
<div>
<div className="suggestion-title">{s.title}</div>
<div className="suggestion-desc">{s.description}</div>
</div>
</div>
))}
</div>
)}
<div className="doc-card-footer">
<span className={`status-badge ${doc.status}`}><span className={`status-dot ${doc.status}`} />{doc.status === 'ready' ? 'Indexed' : doc.status === 'error' ? 'Error' : 'Processing'}</span>
{doc.doc_type && doc.doc_type !== 'other' && <span className={`doc-type-badge ${doc.doc_type}`}>{docTypeLabel(doc.doc_type)}</span>}
{doc.extracted_data && <span className="extracted-badge">Extracted</span>}
{doc.error && <span style={{ fontSize: 11, color: 'var(--danger)' }}>{doc.error}</span>}
<div style={{ marginLeft: 'auto', display: 'flex', gap: 6 }}>
<button className="btn-icon" onClick={() => deleteDoc(doc.id)} title="Delete"><Icons.Trash /></button>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
// ─── Chat Page (Non-streaming RAG) ───────────────────────────────────────
function ChatPage({ token, toast }) {
const [docs, setDocs] = useState([]);
const [selectedDocs, setSelectedDocs] = useState([]);
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [sessionId] = useState(() => 'sess_' + Math.random().toString(36).substr(2, 8));
const messagesEndRef = useRef(null);
const inputRef = useRef(null);
useEffect(() => {
(async () => {
try { const res = await fetch(`${API}/api/documents`, { headers: { 'Authorization': `Bearer ${token}` } }); if (res.ok) { const data = await res.json(); const rd = data.documents.filter(d => d.status === 'ready'); setDocs(rd); setSelectedDocs(rd.map(d => d.id)); } } catch {}
})();
}, [token]);
// Load chat history
useEffect(() => {
(async () => {
try { const res = await fetch(`${API}/api/chat/history/${sessionId}`, { headers: { 'Authorization': `Bearer ${token}` } }); if (res.ok) { const data = await res.json(); if (data.messages && data.messages.length > 0) setMessages(data.messages); } } catch {}
})();
}, [token, sessionId]);
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
const toggleDoc = (docId) => setSelectedDocs(prev => prev.includes(docId) ? prev.filter(d => d !== docId) : [...prev, docId]);
const sendMessage = async () => {
if (!input.trim() || loading) return;
if (selectedDocs.length === 0) { toast('Select at least one document', 'error'); return; }
const question = input.trim();
setInput('');
setMessages(prev => [...prev, { role: 'user', content: question }]);
setLoading(true);
try {
const res = await fetch(`${API}/api/query`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ question, doc_ids: selectedDocs, session_id: sessionId }) });
if (!res.ok) { const err = await res.json(); throw new Error(err.detail || 'Query failed'); }
const data = await res.json();
setMessages(prev => [...prev, { role: 'assistant', content: data.answer, sources: data.sources }]);
} catch (err) {
toast(err.message, 'error');
setMessages(prev => [...prev, { role: 'assistant', content: `Error: ${err.message}`, sources: [] }]);
} finally { setLoading(false); inputRef.current?.focus(); }
};
const handleKeyDown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } };
return (
<div className="chat-layout">
<div className="chat-sidebar">
<div className="chat-sidebar-title">Query Documents</div>
{docs.length === 0 ? <div style={{ fontSize: 13, color: 'var(--text-muted)', padding: '20px 0' }}>No indexed documents yet.</div> :
docs.map(doc => (
<div key={doc.id} className={`doc-select-item ${selectedDocs.includes(doc.id) ? 'selected' : ''}`} onClick={() => toggleDoc(doc.id)}>
<div className="doc-select-check">{selectedDocs.includes(doc.id) && <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg>}</div>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{doc.filename}</span>
</div>
))
}
<div style={{ marginTop: 16, padding: '10px 0', borderTop: '1px solid var(--border)', fontSize: 11, color: 'var(--text-muted)' }}>Powered by LlamaCloud</div>
</div>
<div className="chat-main">
<div className="chat-messages">
{messages.length === 0 ? (
<div className="chat-empty">
<div className="chat-empty-icon"><Icons.Chat /></div>
<div className="chat-empty-title">Ask anything about your documents</div>
<div className="chat-empty-desc">Select documents on the left, then ask questions. Powered by LlamaCloud RAG.</div>
</div>
) : messages.map((msg, i) => (
<div key={i} className={`chat-message ${msg.role === 'user' ? 'chat-msg-user' : 'chat-msg-ai'}`}>
{msg.role === 'assistant' && <div className="chat-msg-ai-avatar">D</div>}
<div className="chat-bubble">
{msg.content}
{msg.sources && msg.sources.length > 0 && (
<div className="chat-sources"><div className="chat-source-label">Sources</div>
{msg.sources.map((s, j) => <span key={j} className="chat-source-tag">{s.filename} ({(s.score * 100).toFixed(0)}%)</span>)}
</div>
)}
</div>
</div>
))}
{loading && <div className="chat-message chat-msg-ai"><div className="chat-msg-ai-avatar">D</div><div className="chat-bubble"><div className="typing-indicator"><div className="typing-dot" /><div className="typing-dot" /><div className="typing-dot" /></div></div></div>}
<div ref={messagesEndRef} />
</div>
<div className="chat-input-area">
<div className="chat-input-wrap">
<textarea ref={inputRef} className="chat-input" placeholder="Ask a question about your documents..." value={input} onChange={e => setInput(e.target.value)} onKeyDown={handleKeyDown} rows={1} />
<button className="chat-send-btn" onClick={sendMessage} disabled={!input.trim() || loading}><Icons.Send /></button>
</div>
</div>
</div>
</div>
);
}
// ─── Workflows Page ──────────────────────────────────────────────────────
function WorkflowsPage({ token, toast, preSelectedDocId, preSelectedType }) {
const [docs, setDocs] = useState([]);
const [workflowType, setWorkflowType] = useState('claims_intake');
const [selectedDoc, setSelectedDoc] = useState(preSelectedDocId || '');
const [runs, setRuns] = useState([]);
const [runningId, setRunningId] = useState(null);
const [currentResult, setCurrentResult] = useState(null);
useEffect(() => {
(async () => {
try {
const [docsRes, runsRes] = await Promise.all([
fetch(`${API}/api/documents`, { headers: { 'Authorization': `Bearer ${token}` } }),
fetch(`${API}/api/workflows`, { headers: { 'Authorization': `Bearer ${token}` } }),
]);
if (docsRes.ok) { const d = await docsRes.json(); setDocs(d.documents.filter(x => x.status === 'ready')); }
if (runsRes.ok) { const r = await runsRes.json(); setRuns(r.workflows || []); }
} catch {}
})();
}, [token]);
useEffect(() => { if (preSelectedDocId) setSelectedDoc(preSelectedDocId); }, [preSelectedDocId]);
useEffect(() => { if (preSelectedType) setWorkflowType(preSelectedType); }, [preSelectedType]);
const runWorkflow = async () => {
if (!selectedDoc) { toast('Select a document first', 'error'); return; }
try {
const res = await fetch(`${API}/api/workflows/run`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ workflow_type: workflowType, doc_ids: [selectedDoc] }) });
if (!res.ok) { const e = await res.json(); throw new Error(e.detail); }
const data = await res.json();
setRunningId(data.run_id);
toast(`Workflow started: ${data.run_id}`, 'success');
pollWorkflow(data.run_id);
} catch (err) { toast(err.message, 'error'); }
};
const pollWorkflow = async (runId) => {
const poll = async () => {
try {
const res = await fetch(`${API}/api/workflows/${runId}`, { headers: { 'Authorization': `Bearer ${token}` } });
if (res.ok) {
const data = await res.json();
if (data.status === 'completed' || data.status === 'error') {
setRunningId(null);
setCurrentResult(data);
setRuns(prev => [data, ...prev.filter(r => r.run_id !== runId)]);
if (data.status === 'error') toast(`Workflow failed: ${data.error}`, 'error');
else toast('Workflow completed!', 'success');
return;
}
}
} catch {}
setTimeout(poll, 2000);
};
poll();
};
const workflowLabels = { claims_intake: 'Claims Intake', policy_review: 'Policy Review', coi_tracking: 'COI Tracking', carrier_reconciliation: 'Carrier Statement Reconciliation' };
return (
<div>
<div className="page-header"><div className="page-title">Insurance Workflows</div><div className="page-desc">Run automated insurance document processing workflows</div></div>
<div className="workflow-config">
<div className="workflow-config-row">
<div className="workflow-config-field">
<label>Workflow Type</label>
<select value={workflowType} onChange={e => setWorkflowType(e.target.value)}>
{Object.entries(workflowLabels).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div className="workflow-config-field">
<label>Document</label>
<select value={selectedDoc} onChange={e => setSelectedDoc(e.target.value)}>
<option value="">Select a document...</option>
{docs.map(d => <option key={d.id} value={d.id}>{d.filename} {d.doc_type !== 'other' ? `(${d.doc_type})` : ''}</option>)}
</select>
</div>
<button className="btn btn-primary" style={{ width: 'auto', minWidth: 160 }} onClick={runWorkflow} disabled={!!runningId || !selectedDoc}>
{runningId ? <><span className="spinner" /> Running...</> : <><Icons.Play /> Run Workflow</>}
</button>
</div>
</div>
{currentResult && <WorkflowResultCard result={currentResult} />}
{runs.length > 0 && (
<div>
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 16, marginTop: 24 }}>History</h3>
<table className="events-table">
<thead><tr><th>Run ID</th><th>Workflow</th><th>Status</th><th>Started</th><th>Actions</th></tr></thead>
<tbody>
{runs.map(r => (
<tr key={r.run_id}>
<td className="mono">{r.run_id}</td>
<td>{workflowLabels[r.workflow_type] || r.workflow_type}</td>
<td><span className={`status-badge ${r.status}`}>{r.status}</span></td>
<td className="mono">{r.started_at ? new Date(r.started_at).toLocaleString() : 'β€”'}</td>
<td>{r.status === 'completed' && <button className="btn btn-secondary btn-sm" onClick={() => setCurrentResult(r)}>View</button>}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
function WorkflowResultCard({ result }) {
if (!result || !result.result) return null;
const r = result.result;
const data = r.data || {};
const workflowLabels = { claims_intake: 'Claims Intake', policy_review: 'Policy Review', coi_tracking: 'COI Tracking', carrier_reconciliation: 'Carrier Reconciliation' };
return (
<div className="workflow-result">
<div className="workflow-result-header">
<div className="workflow-result-title">{workflowLabels[r.workflow_type] || r.workflow_type} Results</div>
<span className={`status-badge ${result.status}`}>{result.status}</span>
</div>
{r.steps_completed && <div className="workflow-steps">{r.steps_completed.map((s, i) => <span key={i} className="workflow-step">{s}</span>)}</div>}
{data.extracted_data && (
<div className="data-section">
<div className="data-section-title">Extracted Data</div>
<div className="data-grid">
{Object.entries(data.extracted_data).filter(([k]) => !['raw_text','status'].includes(k)).map(([k, v]) => (
<div className="data-field" key={k}>
<div className="data-field-label">{k.replace(/_/g, ' ')}</div>
<div className="data-field-value">{typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v || 'β€”')}</div>
</div>
))}
</div>
</div>
)}
{data.validation && (
<div className="data-section">
<div className="data-section-title">Validation</div>
<div className="data-grid">
<div className="data-field"><div className="data-field-label">Complete</div><div className="data-field-value">{data.validation.is_complete ? 'Yes' : 'No'} ({data.validation.completeness_pct}%)</div></div>
{data.validation.missing_fields && data.validation.missing_fields.length > 0 && <div className="data-field"><div className="data-field-label">Missing Fields</div><div className="data-field-value" style={{color:'var(--danger)'}}>{data.validation.missing_fields.join(', ')}</div></div>}
</div>
</div>
)}
{data.routing && (
<div className="data-section">
<div className="data-section-title">Routing</div>
<div className="data-grid">
<div className="data-field"><div className="data-field-label">Priority</div><div className="data-field-value"><span className={`severity-badge ${data.routing.priority}`}>{data.routing.priority}</span></div></div>
<div className="data-field"><div className="data-field-label">Estimated Amount</div><div className="data-field-value mono">${(data.routing.estimated_amount || 0).toLocaleString()}</div></div>
</div>
</div>
)}
{data.gaps && data.gaps.length > 0 && (
<div className="data-section">
<div className="data-section-title">Coverage Gaps ({data.gaps.length})</div>
{data.gaps.map((g, i) => (
<div key={i} className="data-field" style={{ marginBottom: 8 }}>
<div className="data-field-label">{g.coverage_type} <span className={`severity-badge ${g.severity}`}>{g.severity}</span></div>
<div className="data-field-value">{g.recommendation}</div>
</div>
))}
</div>
)}
{data.expiration_flags && data.expiration_flags.length > 0 && (
<div className="data-section">
<div className="data-section-title">Expiration Tracking</div>
<div className="data-grid">
{data.expiration_flags.map((f, i) => (
<div className="data-field" key={i}>
<div className="data-field-label">{f.policy_type} <span className={`severity-badge ${f.severity}`}>{f.status}</span></div>
<div className="data-field-value">Expires: {f.expiration_date} ({f.days_until_expiry} days)</div>
</div>
))}
</div>
</div>
)}
{data.discrepancies && data.discrepancies.length > 0 && (
<div className="data-section">
<div className="data-section-title">Discrepancies ({data.discrepancies.length})</div>
{data.discrepancies.map((d, i) => (
<div key={i} className="data-field" style={{ marginBottom: 8 }}>
<div className="data-field-label">{d.type} <span className={`severity-badge ${d.severity}`}>{d.severity}</span></div>
<div className="data-field-value">{d.message}</div>
</div>
))}
</div>
)}
{data.recommendations && (
<div className="data-section">
<div className="data-section-title">Recommendations</div>
{data.recommendations.map((rec, i) => (
<div key={i} className="data-field" style={{ marginBottom: 8 }}>
<div className="data-field-label"><span className={`severity-badge ${rec.severity}`}>{rec.type}</span></div>
<div className="data-field-value">{rec.message}</div>
</div>
))}
</div>
)}
{r.errors && r.errors.length > 0 && (
<div className="data-section">
<div className="data-section-title" style={{ color: 'var(--danger)' }}>Errors</div>
{r.errors.map((e, i) => <div key={i} className="data-field"><div className="data-field-label">{e.step}</div><div className="data-field-value" style={{color:'var(--danger)'}}>{e.error}</div></div>)}
</div>
)}
</div>
);
}
// ─── Extracted Data Page ─────────────────────────────────────────────────
function ExtractedDataPage({ token }) {
const [docs, setDocs] = useState([]);
const [selectedDoc, setSelectedDoc] = useState(null);
useEffect(() => {
(async () => {
try {
const res = await fetch(`${API}/api/documents`, { headers: { 'Authorization': `Bearer ${token}` } });
if (res.ok) { const data = await res.json(); setDocs(data.documents.filter(d => d.extracted_data)); }
} catch {}
})();
}, [token]);
const docTypeLabel = (t) => ({ acord:'ACORD', coi:'COI', claim:'Claim', policy:'Policy', carrier_statement:'Carrier Statement', loss_run:'Loss Run' }[t] || t);
return (
<div>
<div className="page-header"><div className="page-title">Extracted Data</div><div className="page-desc">Structured data extracted from insurance documents</div></div>
{docs.length === 0 ? (
<div className="empty-state"><div className="empty-state-title">No extracted data yet</div><div className="empty-state-desc">Upload insurance documents (ACORD forms, COIs, claims, etc.) to see structured extractions</div></div>
) : (
<div>
<div className="doc-grid" style={{ marginBottom: 24 }}>
{docs.map(doc => (
<div key={doc.id} className="doc-card" style={{ cursor: 'pointer', borderColor: selectedDoc?.id === doc.id ? 'var(--accent)' : undefined }} onClick={() => setSelectedDoc(doc)}>
<div className="doc-card-header">
<div className="doc-icon"><Icons.Extract /></div>
<div>
<div className="doc-filename">{doc.filename}</div>
<div className="doc-meta"><span className={`doc-type-badge ${doc.doc_type}`}>{docTypeLabel(doc.doc_type)}</span></div>
</div>
</div>
</div>
))}
</div>
{selectedDoc && selectedDoc.extracted_data && (
<div className="workflow-result" style={{ animation: 'fadeInUp 0.3s ease-out' }}>
<div className="workflow-result-header">
<div className="workflow-result-title">{selectedDoc.filename}</div>
<span className={`doc-type-badge ${selectedDoc.doc_type}`}>{docTypeLabel(selectedDoc.doc_type)}</span>
</div>
<div className="data-grid">
{Object.entries(selectedDoc.extracted_data).filter(([k]) => !['raw_text','status'].includes(k)).map(([k, v]) => (
<div className="data-field" key={k}>
<div className="data-field-label">{k.replace(/_/g, ' ')}</div>
<div className="data-field-value">{typeof v === 'object' && v !== null ? (Array.isArray(v) ? v.map((item, i) => <div key={i} style={{marginBottom:4,padding:'4px 0',borderBottom:i<v.length-1?'1px solid var(--border)':'none'}}>{typeof item === 'object' ? Object.entries(item).map(([ik,iv]) => <div key={ik}><span style={{color:'var(--text-muted)',fontSize:11}}>{ik}:</span> {String(iv)}</div>) : String(item)}</div>) : JSON.stringify(v, null, 2)) : String(v || 'β€”')}</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
// ─── Billing Page ────────────────────────────────────────────────────────
function BillingPage({ token }) {
const [billing, setBilling] = useState(null);
const [events, setEvents] = useState([]);
useEffect(() => {
(async () => {
try {
const [u, e] = await Promise.all([
fetch(`${API}/api/billing/usage`, { headers: { 'Authorization': `Bearer ${token}` } }),
fetch(`${API}/api/billing/events`, { headers: { 'Authorization': `Bearer ${token}` } }),
]);
if (u.ok) setBilling(await u.json());
if (e.ok) { const d = await e.json(); setEvents(d.events || []); }
} catch {}
})();
}, [token]);
if (!billing) return <div style={{ padding: 40, textAlign: 'center' }}><span className="spinner dark" /></div>;
return (
<div>
<div className="page-header"><div className="page-title">Billing & Usage</div><div className="page-desc">Track document scans, LlamaCloud credits, and billing events</div></div>
<div className="billing-cards">
<div className="billing-card"><div className="billing-card-label">Documents Scanned</div><div className="billing-card-value" style={{ color: 'var(--accent)' }}>{billing.total_scanned.toLocaleString()}</div><div className="billing-card-sub">of {billing.quota.toLocaleString()} free tier</div></div>
<div className="billing-card"><div className="billing-card-label">Credits Used</div><div className="billing-card-value" style={{ color: 'var(--success)' }}>{(billing.credits_used || 0).toLocaleString()}</div><div className="billing-card-sub">10,000 credits/month</div></div>
<div className="billing-card"><div className="billing-card-label">Remaining</div><div className="billing-card-value" style={{ color: 'var(--warning)' }}>{(billing.credits_remaining || 0).toLocaleString()}</div><div className="billing-card-sub">{billing.current_period}</div></div>
</div>
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 16 }}>Recent Events</h3>
{events.length === 0 ? <div className="empty-state"><div className="empty-state-title">No billing events yet</div></div> : (
<table className="events-table">
<thead><tr><th>Event ID</th><th>Document</th><th>Type</th><th>Credits</th><th>Timestamp</th></tr></thead>
<tbody>
{events.map(evt => (
<tr key={evt.id}><td className="mono">{evt.id}</td><td>{evt.filename}</td><td><span className="status-badge ready">{evt.type}</span></td><td className="mono">{evt.credits_used || 0}</td><td className="mono">{new Date(evt.timestamp).toLocaleString()}</td></tr>
))}
</tbody>
</table>
)}
</div>
);
}
// ─── Pipeline Page ────────────────────────────────────────────────────────
function PipelinePage({ token, toast }) {
const [docs, setDocs] = useState([]);
const [runs, setRuns] = useState([]);
const [selectedDoc, setSelectedDoc] = useState('');
const [runningId, setRunningId] = useState(null);
const [auditView, setAuditView] = useState(null);
const headers = { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' };
useEffect(() => {
(async () => {
const [docsRes, runsRes] = await Promise.all([
fetch(`${API}/api/documents`, { headers }).then(r => r.ok ? r.json() : {documents:[]}),
fetch(`${API}/api/pipeline/runs`, { headers }).then(r => r.ok ? r.json() : {runs:[]}),
]);
setDocs((docsRes.documents || []).filter(d => d.status === 'ready'));
setRuns(runsRes.runs || []);
})();
}, [token]);
const startPipeline = async () => {
if (!selectedDoc) { toast('Select a document', 'error'); return; }
try {
const res = await fetch(`${API}/api/pipeline/run`, { method: 'POST', headers, body: JSON.stringify({ doc_id: selectedDoc }) });
const data = await res.json();
if (!res.ok) throw new Error(data.detail);
toast(`Pipeline started: ${data.run_id}`, 'success');
setRunningId(data.run_id);
// Refresh runs
setTimeout(async () => {
const r = await fetch(`${API}/api/pipeline/runs`, { headers }); if (r.ok) setRuns((await r.json()).runs || []);
setRunningId(null);
}, 2000);
} catch (err) { toast(err.message, 'error'); }
};
const viewAudit = async (runId) => {
try {
const res = await fetch(`${API}/api/pipeline/${runId}/audit`, { headers });
if (res.ok) setAuditView(await res.json());
} catch {}
};
const stageIcons = { intake: '1', classify: '2', extract: '3', match: '4', validate: '5', route: '6', act: '7', confirm: '8' };
const stageColors = { completed: 'var(--success)', running: 'var(--warning)', error: 'var(--danger)', pending: 'var(--text-muted)', skipped: 'var(--text-muted)' };
return (
<div>
<div className="page-header"><div className="page-title">Document Pipeline</div><div className="page-desc">End-to-end processing: Intake β†’ Classify β†’ Extract β†’ Match β†’ Validate β†’ Route β†’ Act β†’ Confirm</div></div>
<div className="workflow-config">
<div className="workflow-config-row">
<div className="workflow-config-field">
<label>Document</label>
<select value={selectedDoc} onChange={e => setSelectedDoc(e.target.value)} style={{ width:'100%', padding:'10px 14px', background:'var(--bg-tertiary)', border:'1px solid var(--border)', borderRadius:'var(--radius-sm)', color:'var(--text-primary)', fontFamily:'var(--font)', fontSize:'14px' }}>
<option value="">Select a document...</option>
{docs.map(d => <option key={d.id} value={d.id}>{d.filename} {d.doc_type !== 'other' ? `(${d.doc_type})` : ''}</option>)}
</select>
</div>
<button className="btn btn-primary" style={{ width: 'auto', minWidth: 180 }} onClick={startPipeline} disabled={!!runningId || !selectedDoc}>
{runningId ? <><span className="spinner" /> Processing...</> : <><Icons.Zap /> Run Pipeline</>}
</button>
</div>
</div>
{auditView && (
<div className="workflow-result" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div className="workflow-result-title">Audit Trail β€” {auditView.run.run_id}</div>
<div style={{display:'flex',gap:8}}>
<span className={`status-badge ${auditView.run.status === 'completed' ? 'ready' : auditView.run.status === 'review_pending' ? 'processing' : auditView.run.status}`}>{auditView.run.status}</span>
<button className="btn btn-secondary btn-sm" onClick={() => setAuditView(null)}>Close</button>
</div>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 20, flexWrap: 'wrap' }}>
{auditView.stages.map((s, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 28, height: 28, borderRadius: '50%', background: stageColors[s.status] || 'var(--text-muted)', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700 }}>{stageIcons[s.stage_name] || '?'}</div>
<span style={{ fontSize: 11, color: stageColors[s.status], fontWeight: 500, minWidth: 50 }}>{s.stage_name}</span>
{i < auditView.stages.length - 1 && <span style={{ color: 'var(--border)', margin: '0 2px' }}>β†’</span>}
</div>
))}
</div>
{auditView.stages.map((s, i) => (
<div key={i} className="data-section">
<div className="data-section-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ color: stageColors[s.status] }}>{s.stage_name.toUpperCase()}</span>
{s.confidence_score != null && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>Confidence: {(s.confidence_score * 100).toFixed(0)}%</span>}
{s.requires_review && <span className="severity-badge high">Needs Review</span>}
</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 8 }}>{s.input_summary}</div>
{s.output_data && (
<div className="data-grid">
{Object.entries(s.output_data).filter(([k]) => !['items','checks','confidence_factors','stages_completed','stages_with_errors'].includes(k)).map(([k, v]) => (
<div className="data-field" key={k}>
<div className="data-field-label">{k.replace(/_/g, ' ')}</div>
<div className="data-field-value">{typeof v === 'object' ? JSON.stringify(v) : String(v ?? 'β€”')}</div>
</div>
))}
</div>
)}
{s.output_data?.checks && (
<div style={{ marginTop: 8 }}>
{s.output_data.checks.map((c, ci) => (
<div key={ci} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', fontSize: 12 }}>
<span style={{ color: c.passed ? 'var(--success)' : 'var(--danger)', fontWeight: 700 }}>{c.passed ? 'βœ“' : 'βœ—'}</span>
<span style={{ fontWeight: 500 }}>{c.name.replace(/_/g, ' ')}</span>
<span style={{ color: 'var(--text-muted)' }}>{c.detail}</span>
</div>
))}
</div>
)}
</div>
))}
{auditView.review_items.length > 0 && (
<div className="data-section">
<div className="data-section-title">Review Items ({auditView.review_items.length})</div>
{auditView.review_items.map((ri, i) => (
<div key={i} className="data-field" style={{ marginBottom: 8 }}>
<div className="data-field-label">{ri.item_type} <span className={`severity-badge ${ri.status === 'pending' ? 'medium' : ri.status === 'approved' ? 'low' : 'high'}`}>{ri.status}</span> {ri.confidence_score != null && `(${(ri.confidence_score*100).toFixed(0)}%)`}</div>
<div className="data-field-value">{ri.reason}</div>
{ri.reviewed_by && <div style={{fontSize:11,color:'var(--text-muted)',marginTop:4}}>Reviewed by {ri.reviewed_by}: {ri.review_notes || 'β€”'}</div>}
</div>
))}
</div>
)}
{auditView.run.result?.next_steps && auditView.run.result.next_steps.length > 0 && (
<div className="data-section">
<div className="data-section-title">Next Steps</div>
{auditView.run.result.next_steps.map((ns, i) => <div key={i} style={{ fontSize: 13, color: 'var(--text-secondary)', padding: '4px 0' }}>β€’ {ns}</div>)}
</div>
)}
</div>
)}
{runs.length > 0 && (
<div>
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 16 }}>Pipeline Runs</h3>
<table className="events-table">
<thead><tr><th>Run ID</th><th>Document</th><th>Type</th><th>Stage</th><th>Status</th><th>Items</th><th>Actions</th></tr></thead>
<tbody>
{runs.map(r => (
<tr key={r.run_id}>
<td className="mono">{r.run_id}</td>
<td className="mono" style={{fontSize:12}}>{r.doc_id}</td>
<td>{r.pipeline_type}</td>
<td><span style={{fontSize:12,color:'var(--accent)',fontWeight:500}}>{r.current_stage}</span></td>
<td><span className={`status-badge ${r.status === 'completed' ? 'ready' : r.status === 'review_pending' ? 'processing' : r.status === 'error' ? 'error' : 'processing'}`}>{r.status}</span></td>
<td style={{fontSize:12}}>{r.auto_count}A / {r.review_count}R / {r.reject_count}X</td>
<td style={{display:'flex',gap:4}}>
<button className="btn btn-secondary btn-sm" onClick={() => viewAudit(r.run_id)}>Audit</button>
{r.status === 'completed' || r.status === 'review_pending' ? <a className="btn btn-secondary btn-sm" href={`${API}/api/pipeline/${r.run_id}/export?format=csv`} target="_blank" style={{textDecoration:'none'}}>Export CSV</a> : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
// ─── Review Queue Page ───────────────────────────────────────────────────
function ReviewQueuePage({ token, toast }) {
const [items, setItems] = useState([]);
const [stats, setStats] = useState({});
const [reviewNotes, setReviewNotes] = useState({});
const headers = { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' };
const fetchData = async () => {
const [itemsRes, statsRes] = await Promise.all([
fetch(`${API}/api/review/pending`, { headers }).then(r => r.ok ? r.json() : {items:[]}),
fetch(`${API}/api/review/stats`, { headers }).then(r => r.ok ? r.json() : {}),
]);
setItems(itemsRes.items || []);
setStats(statsRes);
};
useEffect(() => { fetchData(); }, [token]);
const handleAction = async (itemId, action) => {
try {
const res = await fetch(`${API}/api/review/${itemId}/${action}`, { method: 'POST', headers, body: JSON.stringify({ notes: reviewNotes[itemId] || '' }) });
if (res.ok) { toast(`Item ${action}d`, 'success'); fetchData(); } else { const e = await res.json(); toast(e.detail, 'error'); }
} catch (err) { toast(err.message, 'error'); }
};
return (
<div>
<div className="page-header"><div className="page-title">Review Queue</div><div className="page-desc">Items flagged for human review before posting to Epic</div></div>
<div className="billing-cards" style={{ marginBottom: 24 }}>
<div className="billing-card"><div className="billing-card-label">Pending Review</div><div className="billing-card-value" style={{ color: 'var(--warning)' }}>{stats.pending || 0}</div></div>
<div className="billing-card"><div className="billing-card-label">Approved</div><div className="billing-card-value" style={{ color: 'var(--success)' }}>{stats.approved || 0}</div></div>
<div className="billing-card"><div className="billing-card-label">Rejected</div><div className="billing-card-value" style={{ color: 'var(--danger)' }}>{stats.rejected || 0}</div></div>
<div className="billing-card"><div className="billing-card-label">Pipeline Runs</div><div className="billing-card-value" style={{ color: 'var(--accent)' }}>{stats.total_runs || 0}</div></div>
</div>
{items.length === 0 ? (
<div className="empty-state"><div className="empty-state-title">No items pending review</div><div className="empty-state-desc">All caught up! Run a pipeline on a document to generate review items.</div></div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{items.map(item => (
<div key={item.item_id} className="workflow-result">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
<div>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 4 }}>{item.item_type.replace(/_/g, ' ')} β€” {item.item_id}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>Run: {item.run_id} Β· Stage: {item.stage_name}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{item.confidence_score != null && <span style={{ fontSize: 12, fontWeight: 600, color: item.confidence_score >= 0.8 ? 'var(--warning)' : 'var(--danger)' }}>{(item.confidence_score * 100).toFixed(0)}% confidence</span>}
</div>
</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12, padding: '8px 12px', background: 'var(--bg-tertiary)', borderRadius: 'var(--radius-sm)' }}>{item.reason}</div>
{item.item_data && (
<div className="data-grid" style={{ marginBottom: 12 }}>
{Object.entries(item.item_data).filter(([k]) => k !== 'extracted_fields').map(([k, v]) => (
<div className="data-field" key={k}>
<div className="data-field-label">{k.replace(/_/g, ' ')}</div>
<div className="data-field-value">{typeof v === 'object' ? JSON.stringify(v) : String(v ?? 'β€”')}</div>
</div>
))}
</div>
)}
{item.matched_policy && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', marginBottom: 6 }}>Best Match</div>
<div className="data-grid">
{Object.entries(item.matched_policy).map(([k, v]) => (
<div className="data-field" key={k}><div className="data-field-label">{k.replace(/_/g, ' ')}</div><div className="data-field-value">{String(v ?? 'β€”')}</div></div>
))}
</div>
</div>
)}
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<input className="form-input" style={{ flex: 1, padding: '8px 12px' }} placeholder="Review notes (optional)..." value={reviewNotes[item.item_id] || ''} onChange={e => setReviewNotes({...reviewNotes, [item.item_id]: e.target.value})} />
<button className="btn btn-primary" style={{ width: 'auto', background: 'var(--success)' }} onClick={() => handleAction(item.item_id, 'approve')}>Approve</button>
<button className="btn btn-danger" style={{ width: 'auto' }} onClick={() => handleAction(item.item_id, 'reject')}>Reject</button>
</div>
</div>
))}
</div>
)}
</div>
);
}
// ─── Admin Page ──────────────────────────────────────────────────────────
function AdminPage({ token, toast, currentUser }) {
const [tab, setTab] = useState(currentUser.role === 'super_admin' ? 'orgs' : 'users');
const [orgs, setOrgs] = useState([]);
const [users, setUsers] = useState([]);
const [showForm, setShowForm] = useState(null); // 'org' | 'user' | null
const [formData, setFormData] = useState({});
const [editingUser, setEditingUser] = useState(null);
const headers = { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' };
const fetchOrgs = async () => { try { const r = await fetch(`${API}/api/admin/orgs`, { headers }); if (r.ok) { const d = await r.json(); setOrgs(d.organizations || []); } } catch {} };
const fetchUsers = async () => { try { const r = await fetch(`${API}/api/admin/users`, { headers }); if (r.ok) { const d = await r.json(); setUsers(d.users || []); } } catch {} };
useEffect(() => { fetchOrgs(); fetchUsers(); }, [token]);
const createOrg = async () => {
try { const r = await fetch(`${API}/api/admin/orgs`, { method: 'POST', headers, body: JSON.stringify(formData) }); if (r.ok) { toast('Organization created', 'success'); setShowForm(null); setFormData({}); fetchOrgs(); } else { const e = await r.json(); toast(e.detail, 'error'); } } catch (e) { toast(e.message, 'error'); }
};
const createUser = async () => {
try { const r = await fetch(`${API}/api/admin/users`, { method: 'POST', headers, body: JSON.stringify(formData) }); if (r.ok) { toast('User created', 'success'); setShowForm(null); setFormData({}); fetchUsers(); } else { const e = await r.json(); toast(e.detail, 'error'); } } catch (e) { toast(e.message, 'error'); }
};
const updateUser = async (userId, updates) => {
try { const r = await fetch(`${API}/api/admin/users/${userId}`, { method: 'PUT', headers, body: JSON.stringify(updates) }); if (r.ok) { toast('User updated', 'success'); setEditingUser(null); fetchUsers(); } else { const e = await r.json(); toast(e.detail, 'error'); } } catch (e) { toast(e.message, 'error'); }
};
const deactivateUser = async (userId) => {
if (!confirm('Deactivate this user?')) return;
try { const r = await fetch(`${API}/api/admin/users/${userId}`, { method: 'DELETE', headers }); if (r.ok) { toast('User deactivated', 'success'); fetchUsers(); } else { const e = await r.json(); toast(e.detail, 'error'); } } catch (e) { toast(e.message, 'error'); }
};
const roleColors = { super_admin: 'var(--danger)', admin: 'var(--accent)', manager: 'var(--warning)', user: 'var(--success)' };
return (
<div>
<div className="page-header"><div className="page-title">Administration</div><div className="page-desc">Manage organizations, users, and roles</div></div>
{currentUser.role === 'super_admin' && (
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
<button className={`btn ${tab === 'orgs' ? 'btn-primary' : 'btn-secondary'}`} style={{ width: 'auto' }} onClick={() => setTab('orgs')}>Organizations</button>
<button className={`btn ${tab === 'users' ? 'btn-primary' : 'btn-secondary'}`} style={{ width: 'auto' }} onClick={() => setTab('users')}>Users</button>
</div>
)}
{tab === 'orgs' && currentUser.role === 'super_admin' && (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h3 style={{ fontSize: 16, fontWeight: 600 }}>Organizations</h3>
<button className="btn btn-primary" style={{ width: 'auto' }} onClick={() => { setShowForm('org'); setFormData({}); }}><Icons.Plus /> New Organization</button>
</div>
{showForm === 'org' && (
<div className="workflow-config" style={{ marginBottom: 16 }}>
<div className="workflow-config-row">
<div className="workflow-config-field"><label>Name</label><input className="form-input" value={formData.name || ''} onChange={e => setFormData({...formData, name: e.target.value})} placeholder="Organization Name" /></div>
<div className="workflow-config-field"><label>Slug</label><input className="form-input" value={formData.slug || ''} onChange={e => setFormData({...formData, slug: e.target.value})} placeholder="org-slug" /></div>
<button className="btn btn-primary" style={{ width: 'auto' }} onClick={createOrg}>Create</button>
<button className="btn btn-secondary" style={{ width: 'auto' }} onClick={() => setShowForm(null)}>Cancel</button>
</div>
</div>
)}
<table className="events-table">
<thead><tr><th>ID</th><th>Name</th><th>Slug</th><th>Users</th><th>Status</th><th>Created</th></tr></thead>
<tbody>
{orgs.map(o => (
<tr key={o.id}><td className="mono">{o.id}</td><td style={{fontWeight:500}}>{o.name}</td><td className="mono">{o.slug}</td><td>{o.user_count}</td><td><span className={`status-badge ${o.is_active ? 'ready' : 'error'}`}>{o.is_active ? 'Active' : 'Inactive'}</span></td><td className="mono">{new Date(o.created_at).toLocaleDateString()}</td></tr>
))}
</tbody>
</table>
</div>
)}
{(tab === 'users' || currentUser.role === 'admin') && (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h3 style={{ fontSize: 16, fontWeight: 600 }}>Users</h3>
<button className="btn btn-primary" style={{ width: 'auto' }} onClick={() => { setShowForm('user'); setFormData({ role: 'user', org_id: currentUser.org_id }); }}><Icons.Plus /> New User</button>
</div>
{showForm === 'user' && (
<div className="workflow-config" style={{ marginBottom: 16 }}>
<div className="workflow-config-row" style={{ marginBottom: 12 }}>
<div className="workflow-config-field"><label>Full Name</label><input className="form-input" value={formData.full_name || ''} onChange={e => setFormData({...formData, full_name: e.target.value})} placeholder="Jane Smith" /></div>
<div className="workflow-config-field"><label>Email</label><input className="form-input" type="email" value={formData.email || ''} onChange={e => setFormData({...formData, email: e.target.value})} placeholder="jane@company.com" /></div>
</div>
<div className="workflow-config-row">
<div className="workflow-config-field"><label>Password</label><input className="form-input" type="password" value={formData.password || ''} onChange={e => setFormData({...formData, password: e.target.value})} placeholder="Strong password" /></div>
<div className="workflow-config-field"><label>Role</label>
<select value={formData.role || 'user'} onChange={e => setFormData({...formData, role: e.target.value})} style={{ width:'100%', padding:'10px 14px', background:'var(--bg-tertiary)', border:'1px solid var(--border)', borderRadius:'var(--radius-sm)', color:'var(--text-primary)', fontFamily:'var(--font)', fontSize:'14px' }}>
{currentUser.role === 'super_admin' && <option value="super_admin">Super Admin</option>}
<option value="admin">Admin</option>
<option value="manager">Manager</option>
<option value="user">User</option>
</select>
</div>
{currentUser.role === 'super_admin' && (
<div className="workflow-config-field"><label>Organization</label>
<select value={formData.org_id || ''} onChange={e => setFormData({...formData, org_id: parseInt(e.target.value) || null})} style={{ width:'100%', padding:'10px 14px', background:'var(--bg-tertiary)', border:'1px solid var(--border)', borderRadius:'var(--radius-sm)', color:'var(--text-primary)', fontFamily:'var(--font)', fontSize:'14px' }}>
<option value="">Select org...</option>
{orgs.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
</div>
)}
<button className="btn btn-primary" style={{ width: 'auto' }} onClick={createUser}>Create</button>
<button className="btn btn-secondary" style={{ width: 'auto' }} onClick={() => setShowForm(null)}>Cancel</button>
</div>
</div>
)}
<table className="events-table">
<thead><tr><th>ID</th><th>Name</th><th>Email</th><th>Role</th><th>Organization</th><th>Actions</th></tr></thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
<td className="mono">{u.id}</td>
<td style={{fontWeight:500}}>{editingUser === u.id ? <input className="form-input" style={{padding:'4px 8px',width:150}} defaultValue={u.full_name} onBlur={e => updateUser(u.id, {full_name: e.target.value})} /> : u.full_name}</td>
<td className="mono" style={{fontSize:12}}>{u.email}</td>
<td>
{editingUser === u.id ? (
<select defaultValue={u.role} onChange={e => updateUser(u.id, {role: e.target.value})} style={{padding:'4px 8px',background:'var(--bg-tertiary)',border:'1px solid var(--border)',borderRadius:4,color:'var(--text-primary)',fontSize:12}}>
{currentUser.role === 'super_admin' && <option value="super_admin">Super Admin</option>}
<option value="admin">Admin</option>
<option value="manager">Manager</option>
<option value="user">User</option>
</select>
) : (
<span style={{ color: roleColors[u.role] || 'var(--text-secondary)', fontWeight: 500, fontSize: 12, textTransform: 'uppercase' }}>{u.role.replace('_', ' ')}</span>
)}
</td>
<td>{u.org_name || 'β€”'}</td>
<td style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-secondary btn-sm" onClick={() => setEditingUser(editingUser === u.id ? null : u.id)}>{editingUser === u.id ? 'Done' : 'Edit'}</button>
{u.id !== currentUser.id && <button className="btn btn-danger btn-sm" onClick={() => deactivateUser(u.id)}>Deactivate</button>}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
// ─── Cloud Import Page ────────────────────────────────────────────────────
function CloudImportPage({ token, toast }) {
const [connections, setConnections] = useState([]);
const [selectedConn, setSelectedConn] = useState(null);
const [browseItems, setBrowseItems] = useState([]);
const [browsePath, setBrowsePath] = useState('/');
const [pathStack, setPathStack] = useState([{id: '/', name: 'Root'}]);
const [selectedFiles, setSelectedFiles] = useState({});
const [importing, setImporting] = useState(false);
const [imports, setImports] = useState([]);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState(null);
const [browsing, setBrowsing] = useState(false);
const [showS3Form, setShowS3Form] = useState(false);
const [s3Form, setS3Form] = useState({ access_key: '', secret_key: '', bucket: '', region: 'us-east-1' });
const [emailAddress, setEmailAddress] = useState('');
const [emailImports, setEmailImports] = useState([]);
const [emailCopied, setEmailCopied] = useState(false);
const hdrs = { Authorization: `Bearer ${token}` };
const fetchConnections = useCallback(async () => {
try {
const res = await fetch(`${API}/api/cloud/connections`, { headers: hdrs });
const data = await res.json();
setConnections(data.connections || []);
} catch (e) { console.error(e); }
}, [token]);
const fetchImports = useCallback(async () => {
try {
const res = await fetch(`${API}/api/cloud/imports`, { headers: hdrs });
const data = await res.json();
setImports(data.imports || []);
} catch (e) { console.error(e); }
}, [token]);
const fetchEmailAddress = useCallback(async () => {
try {
const res = await fetch(`${API}/api/cloud/email/address`, { headers: hdrs });
const data = await res.json();
setEmailAddress(data.address || '');
} catch (e) { console.error(e); }
}, [token]);
const fetchEmailImports = useCallback(async () => {
try {
const res = await fetch(`${API}/api/cloud/email/imports`, { headers: hdrs });
const data = await res.json();
setEmailImports(data.imports || []);
} catch (e) { console.error(e); }
}, [token]);
useEffect(() => { fetchConnections(); fetchImports(); fetchEmailAddress(); fetchEmailImports(); }, [fetchConnections, fetchImports, fetchEmailAddress, fetchEmailImports]);
const startOAuth = (provider) => {
const url = `${API}/api/cloud/${provider}/auth-start?token=${token}`;
const popup = window.open(url, '_blank', 'popup,width=600,height=700');
const handler = (event) => {
if (event.data && (event.data.type === 'cloud-auth-complete' || event.data.type === 'cloud-auth-error')) {
window.removeEventListener('message', handler);
if (event.data.type === 'cloud-auth-complete') {
toast('Cloud storage connected!', 'success');
fetchConnections();
} else {
toast(`Connection failed: ${event.data.error || 'Unknown error'}`, 'error');
}
}
};
window.addEventListener('message', handler);
};
const connectS3 = async () => {
if (!s3Form.access_key || !s3Form.secret_key || !s3Form.bucket) { toast('All fields required', 'error'); return; }
try {
const res = await fetch(`${API}/api/cloud/s3/connect`, { method: 'POST', headers: { ...hdrs, 'Content-Type': 'application/json' }, body: JSON.stringify(s3Form) });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Failed');
toast(data.message, 'success');
setShowS3Form(false);
setS3Form({ access_key: '', secret_key: '', bucket: '', region: 'us-east-1' });
fetchConnections();
} catch (e) { toast(e.message, 'error'); }
};
const revokeConnection = async (connId) => {
try {
await fetch(`${API}/api/cloud/connections/${connId}`, { method: 'DELETE', headers: hdrs });
toast('Connection revoked', 'success');
fetchConnections();
if (selectedConn && selectedConn.connection_id === connId) { setSelectedConn(null); setBrowseItems([]); }
} catch (e) { toast('Failed to revoke', 'error'); }
};
const browseFolder = async (conn, folderId, folderName) => {
setBrowsing(true);
setSearchResults(null);
setSearchQuery('');
try {
const path = folderId || '/';
const res = await fetch(`${API}/api/cloud/${conn.connection_id}/browse?path=${encodeURIComponent(path)}`, { headers: hdrs });
const data = await res.json();
setBrowseItems(data.items || []);
setBrowsePath(path);
if (folderId === '/' || folderId === 'root' || !folderId) {
setPathStack([{id: '/', name: 'Root'}]);
} else {
setPathStack(prev => [...prev, {id: folderId, name: folderName || folderId}]);
}
} catch (e) { toast('Failed to browse', 'error'); } finally { setBrowsing(false); }
};
const navigateBreadcrumb = (index) => {
const target = pathStack[index];
setPathStack(pathStack.slice(0, index + 1));
browseFolder(selectedConn, target.id, target.name);
};
const searchFiles = async () => {
if (!searchQuery.trim() || !selectedConn) return;
setBrowsing(true);
try {
const res = await fetch(`${API}/api/cloud/${selectedConn.connection_id}/search?q=${encodeURIComponent(searchQuery)}`, { headers: hdrs });
const data = await res.json();
setSearchResults(data.items || []);
} catch (e) { toast('Search failed', 'error'); } finally { setBrowsing(false); }
};
const toggleFile = (item) => {
setSelectedFiles(prev => {
const next = { ...prev };
if (next[item.id]) delete next[item.id]; else next[item.id] = item;
return next;
});
};
const importSelected = async () => {
const files = Object.values(selectedFiles);
if (!files.length || !selectedConn) return;
setImporting(true);
try {
const res = await fetch(`${API}/api/cloud/import`, { method: 'POST', headers: { ...hdrs, 'Content-Type': 'application/json' }, body: JSON.stringify({ connection_id: selectedConn.connection_id, files: files.map(f => ({ cloud_file_id: f.id, name: f.name, path: f.path_lower || f.id, size: f.size })) }) });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Import failed');
toast(`${data.message}`, 'success');
setSelectedFiles({});
fetchImports();
} catch (e) { toast(e.message, 'error'); } finally { setImporting(false); }
};
const selectConnection = (conn) => {
setSelectedConn(conn);
setSelectedFiles({});
setBrowseItems([]);
setSearchResults(null);
setSearchQuery('');
setPathStack([{id: '/', name: 'Root'}]);
browseFolder(conn, '/', 'Root');
};
const providerColors = { google_drive: '#16a34a', onedrive: '#0078d4', s3: '#ff9900', dropbox: '#0061ff' };
const providerNames = { google_drive: 'Google Drive', onedrive: 'OneDrive', s3: 'AWS S3', dropbox: 'Dropbox' };
const providerIcons = { google_drive: 'G', onedrive: 'O', s3: 'S3', dropbox: 'D' };
const formatSize = (b) => b > 1048576 ? (b/1048576).toFixed(1)+' MB' : b > 1024 ? (b/1024).toFixed(1)+' KB' : b+' B';
const displayItems = searchResults !== null ? searchResults : browseItems;
return (
<div style={{ padding: 24, maxWidth: 1200 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 24 }}>
<div><h2 style={{ fontSize: 22, fontWeight: 700, color: 'var(--text-primary)' }}>Cloud Import</h2><p style={{ color: 'var(--text-secondary)', fontSize: 13, marginTop: 4 }}>Connect cloud storage and import carrier statements directly</p></div>
<button className="btn btn-secondary btn-sm" onClick={() => { fetchConnections(); fetchImports(); fetchEmailAddress(); fetchEmailImports(); }} style={{ display: 'flex', alignItems: 'center', gap: 6 }}><Icons.RefreshCw /> Refresh</button>
</div>
{/* Provider Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 12, marginBottom: 24 }}>
{[
{ key: 'google', provider: 'google_drive', name: 'Google Drive', color: '#16a34a', icon: 'G' },
{ key: 'onedrive', provider: 'onedrive', name: 'OneDrive', color: '#0078d4', icon: 'O' },
{ key: 's3', provider: 's3', name: 'AWS S3', color: '#ff9900', icon: 'S3' },
{ key: 'dropbox', provider: 'dropbox', name: 'Dropbox', color: '#0061ff', icon: 'D' },
].map(p => {
const existing = connections.filter(c => c.provider === p.provider && c.status === 'active');
return (
<div key={p.key} style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', padding: 16, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 36, height: 36, borderRadius: 8, background: p.color + '18', color: p.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700, fontSize: 13 }}>{p.icon}</div>
<div><div style={{ fontWeight: 600, fontSize: 14 }}>{p.name}</div><div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{existing.length ? `${existing.length} connected` : 'Not connected'}</div></div>
</div>
{existing.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{existing.map(c => (
<div key={c.connection_id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: 11, padding: '4px 8px', background: 'var(--bg-tertiary)', borderRadius: 6 }}>
<span style={{ cursor: 'pointer', color: 'var(--accent)', fontWeight: 500 }} onClick={() => selectConnection(c)}>{c.provider_user_email || c.display_name || c.connection_id}</span>
<button onClick={() => revokeConnection(c.connection_id)} style={{ background: 'none', border: 'none', color: 'var(--danger)', cursor: 'pointer', fontSize: 11, padding: '2px 4px' }}>Revoke</button>
</div>
))}
<button className="btn btn-secondary btn-sm" style={{ fontSize: 11, marginTop: 4 }} onClick={() => p.provider === 's3' ? setShowS3Form(true) : startOAuth(p.key === 'google' ? 'google' : p.key)}>+ Add Another</button>
</div>
) : (
<button className="btn btn-primary btn-sm" style={{ background: p.color, borderColor: p.color }} onClick={() => p.provider === 's3' ? setShowS3Form(true) : startOAuth(p.key === 'google' ? 'google' : p.key)}>Connect</button>
)}
</div>
);
})}
{/* Email Forwarding */}
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', padding: 16, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 36, height: 36, borderRadius: 8, background: '#7c3aed18', color: '#7c3aed', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16, fontWeight: 700 }}>@</div>
<div><div style={{ fontWeight: 600, fontSize: 14 }}>Email Forwarding</div><div style={{ fontSize: 11, color: emailImports.length > 0 ? 'var(--success)' : 'var(--text-muted)' }}>{emailImports.length > 0 ? `${emailImports.length} received` : 'Forward statements here'}</div></div>
</div>
{emailAddress && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, background: 'var(--bg-tertiary)', borderRadius: 6, padding: '6px 10px', cursor: 'pointer' }} onClick={() => { navigator.clipboard.writeText(emailAddress); setEmailCopied(true); setTimeout(() => setEmailCopied(false), 2000); toast('Email address copied!', 'success'); }}>
<span style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--accent)', fontWeight: 500, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{emailAddress}</span>
<span style={{ fontSize: 10, color: emailCopied ? 'var(--success)' : 'var(--text-muted)', flexShrink: 0 }}>{emailCopied ? 'Copied!' : 'Copy'}</span>
</div>
)}
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>Forward carrier statements to this address β€” attachments auto-import</div>
</div>
</div>
{/* S3 Credential Form */}
{showS3Form && (
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', padding: 20, marginBottom: 24 }}>
<div style={{ fontWeight: 600, marginBottom: 12 }}>Connect AWS S3 Bucket</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<input className="input" placeholder="Access Key ID" value={s3Form.access_key} onChange={e => setS3Form({...s3Form, access_key: e.target.value})} />
<input className="input" type="password" placeholder="Secret Access Key" value={s3Form.secret_key} onChange={e => setS3Form({...s3Form, secret_key: e.target.value})} />
<input className="input" placeholder="Bucket Name" value={s3Form.bucket} onChange={e => setS3Form({...s3Form, bucket: e.target.value})} />
<input className="input" placeholder="Region (us-east-1)" value={s3Form.region} onChange={e => setS3Form({...s3Form, region: e.target.value})} />
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<button className="btn btn-primary btn-sm" onClick={connectS3}>Connect</button>
<button className="btn btn-secondary btn-sm" onClick={() => setShowS3Form(false)}>Cancel</button>
</div>
</div>
)}
{/* File Browser */}
{selectedConn && (
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', overflow: 'hidden', marginBottom: 24 }}>
{/* Browser Header */}
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'var(--bg-tertiary)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 24, height: 24, borderRadius: 6, background: (providerColors[selectedConn.provider] || '#666') + '18', color: providerColors[selectedConn.provider] || '#666', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700, fontSize: 10 }}>{providerIcons[selectedConn.provider] || '?'}</div>
<span style={{ fontWeight: 600, fontSize: 14 }}>{selectedConn.display_name || providerNames[selectedConn.provider]}</span>
<span style={{ color: 'var(--text-muted)', fontSize: 11 }}>|</span>
{/* Breadcrumbs */}
{pathStack.map((p, i) => (
<React.Fragment key={i}>
{i > 0 && <Icons.ChevronRight />}
<span onClick={() => navigateBreadcrumb(i)} style={{ cursor: 'pointer', color: i === pathStack.length - 1 ? 'var(--text-primary)' : 'var(--accent)', fontSize: 12, fontWeight: i === pathStack.length - 1 ? 600 : 400 }}>{p.name}</span>
</React.Fragment>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
<input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && searchFiles()} placeholder="Search files..." style={{ border: 'none', outline: 'none', padding: '6px 10px', fontSize: 12, width: 180, background: 'var(--bg-secondary)' }} />
<button onClick={searchFiles} style={{ background: 'none', border: 'none', padding: '6px 8px', cursor: 'pointer', color: 'var(--text-muted)' }}><Icons.Search /></button>
</div>
{searchResults !== null && <button className="btn btn-secondary btn-sm" style={{ fontSize: 11 }} onClick={() => { setSearchResults(null); browseFolder(selectedConn, pathStack[pathStack.length-1].id); }}>Clear Search</button>}
<button className="btn btn-secondary btn-sm" onClick={() => setSelectedConn(null)} style={{ fontSize: 11 }}>Close</button>
</div>
</div>
{/* File List */}
<div style={{ maxHeight: 400, overflowY: 'auto' }}>
{browsing ? (
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-muted)' }}><span className="spinner dark" /> Loading...</div>
) : displayItems.length === 0 ? (
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-muted)' }}>{searchResults !== null ? 'No results found' : 'This folder is empty'}</div>
) : displayItems.map((item, i) => (
<div key={item.id || i} style={{ display: 'flex', alignItems: 'center', padding: '10px 16px', borderBottom: '1px solid var(--border)', cursor: item.type === 'folder' ? 'pointer' : 'default', background: selectedFiles[item.id] ? 'var(--accent-bg)' : 'transparent' }}
onClick={() => item.type === 'folder' ? browseFolder(selectedConn, item.id, item.name) : null}>
{item.type === 'file' && (
<input type="checkbox" checked={!!selectedFiles[item.id]} onChange={() => toggleFile(item)} disabled={!item.importable} style={{ marginRight: 12, cursor: item.importable ? 'pointer' : 'not-allowed' }} onClick={e => e.stopPropagation()} />
)}
<div style={{ width: 20, height: 20, marginRight: 10, color: item.type === 'folder' ? '#f59e0b' : 'var(--text-muted)' }}>
{item.type === 'folder' ? <Icons.Folder /> : <Icons.File />}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: item.type === 'folder' ? 600 : 400, color: item.importable || item.type === 'folder' ? 'var(--text-primary)' : 'var(--text-muted)' }}>{item.name}</div>
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', minWidth: 70, textAlign: 'right' }}>{item.type === 'file' && item.size ? formatSize(item.size) : ''}</div>
{item.type === 'folder' && <div style={{ width: 16, height: 16, color: 'var(--text-muted)', marginLeft: 8 }}><Icons.ChevronRight /></div>}
</div>
))}
</div>
{/* Import Bar */}
{Object.keys(selectedFiles).length > 0 && (
<div style={{ padding: '12px 16px', borderTop: '1px solid var(--border)', background: 'var(--accent-bg)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, fontWeight: 500 }}>{Object.keys(selectedFiles).length} file(s) selected</span>
<button className="btn btn-primary btn-sm" onClick={importSelected} disabled={importing}>
{importing ? <><span className="spinner" /> Importing...</> : <><Icons.Download /> Import Selected</>}
</button>
</div>
)}
</div>
)}
{/* Import History */}
{imports.length > 0 && (
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', overflow: 'hidden' }}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', fontWeight: 600, fontSize: 14, background: 'var(--bg-tertiary)' }}>Recent Imports</div>
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
{imports.map(imp => (
<div key={imp.import_id} style={{ display: 'flex', alignItems: 'center', padding: '10px 16px', borderBottom: '1px solid var(--border)', gap: 12 }}>
<div style={{ width: 28, height: 28, borderRadius: 6, background: (providerColors[imp.provider] || '#666') + '18', color: providerColors[imp.provider] || '#666', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700, fontSize: 9 }}>{providerIcons[imp.provider] || '?'}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{imp.cloud_file_name}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{imp.created_at ? new Date(imp.created_at).toLocaleString() : ''}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, fontWeight: 500, background: imp.status === 'ready' ? 'var(--success-bg)' : imp.status === 'error' ? 'var(--danger-bg)' : 'var(--warning-bg)', color: imp.status === 'ready' ? 'var(--success)' : imp.status === 'error' ? 'var(--danger)' : 'var(--warning)' }}>{imp.status}</span>
{imp.doc_id && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{imp.doc_id}</span>}
</div>
{imp.error && <div style={{ fontSize: 11, color: 'var(--danger)', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={imp.error}>{imp.error}</div>}
</div>
))}
</div>
</div>
)}
{/* Email Import History */}
{emailImports.length > 0 && (
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', overflow: 'hidden', marginBottom: 24 }}>
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', fontWeight: 600, fontSize: 14, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ color: '#7c3aed' }}>@</span> Email Imports
</div>
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
{emailImports.map(imp => (
<div key={imp.import_id} style={{ display: 'flex', alignItems: 'center', padding: '10px 16px', borderBottom: '1px solid var(--border)', gap: 12 }}>
<div style={{ width: 28, height: 28, borderRadius: 6, background: '#7c3aed18', color: '#7c3aed', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700, fontSize: 12 }}>@</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{imp.attachment_name}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>From: {imp.from_email} {imp.subject ? `β€” ${imp.subject}` : ''}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, fontWeight: 500, background: imp.status === 'ready' ? 'var(--success-bg)' : imp.status === 'error' ? 'var(--danger-bg)' : 'var(--warning-bg)', color: imp.status === 'ready' ? 'var(--success)' : imp.status === 'error' ? 'var(--danger)' : 'var(--warning)' }}>{imp.status}</span>
{imp.doc_id && <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{imp.doc_id}</span>}
</div>
</div>
))}
</div>
</div>
)}
{/* Empty state when no connections and no email imports */}
{connections.length === 0 && emailImports.length === 0 && !showS3Form && (
<div style={{ textAlign: 'center', padding: 60, color: 'var(--text-muted)' }}>
<div style={{ width: 48, height: 48, margin: '0 auto 16px', color: 'var(--text-muted)', opacity: 0.5 }}><Icons.Cloud /></div>
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 8, color: 'var(--text-secondary)' }}>No cloud storage connected</div>
<div style={{ fontSize: 13 }}>Connect cloud storage or forward carrier statements to your email address to get started</div>
</div>
)}
</div>
);
}
// ─── App (Root) ──────────────────────────────────────────────────────────
function App() {
const [auth, setAuth] = useState(null);
const [page, setPage] = useState('documents');
const [toasts, setToasts] = useState([]);
const [workflowDocId, setWorkflowDocId] = useState('');
const [workflowPreType, setWorkflowPreType] = useState('');
const toast = (message, type = 'info') => { const id = Date.now(); setToasts(prev => [...prev, { id, message, type }]); setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 4000); };
const handleLogin = (data) => { setAuth(data); toast(`Welcome back, ${data.user.name}!`, 'success'); };
const handleLogout = () => { setAuth(null); setPage('documents'); };
const handleRunWorkflow = (docId, wfType) => { setWorkflowDocId(docId); if (wfType) setWorkflowPreType(wfType); setPage('workflows'); };
const handleGoChat = () => { setPage('chat'); };
if (!auth) return <><ToastContainer toasts={toasts} /><LoginPage onLogin={handleLogin} /></>;
const { user, access_token } = auth;
return (
<>
<ToastContainer toasts={toasts} />
<div className="app-shell">
<div className="sidebar">
<div className="sidebar-logo"><div className="sidebar-logo-icon">D</div><div className="sidebar-logo-text">DocuScan AI</div></div>
<div className="nav-section">
<div className="nav-section-label">Platform</div>
<button className={`nav-item ${page === 'documents' ? 'active' : ''}`} onClick={() => setPage('documents')}><Icons.Docs /> Documents</button>
<button className={`nav-item ${page === 'chat' ? 'active' : ''}`} onClick={() => setPage('chat')}><Icons.Chat /> AI Chat</button>
<button className={`nav-item ${page === 'workflows' ? 'active' : ''}`} onClick={() => setPage('workflows')}><Icons.Workflow /> Workflows</button>
<button className={`nav-item ${page === 'extracted' ? 'active' : ''}`} onClick={() => setPage('extracted')}><Icons.Extract /> Extracted Data</button>
<button className={`nav-item ${page === 'cloud' ? 'active' : ''}`} onClick={() => setPage('cloud')}><Icons.Cloud /> Cloud Import</button>
<button className={`nav-item ${page === 'billing' ? 'active' : ''}`} onClick={() => setPage('billing')}><Icons.Billing /> Billing</button>
</div>
<div className="nav-section">
<div className="nav-section-label">Processing</div>
<button className={`nav-item ${page === 'pipeline' ? 'active' : ''}`} onClick={() => setPage('pipeline')}><Icons.Zap /> Pipeline</button>
<button className={`nav-item ${page === 'review' ? 'active' : ''}`} onClick={() => setPage('review')}><Icons.Scan /> Review Queue</button>
</div>
{(user.role === 'super_admin' || user.role === 'admin') && (
<div className="nav-section">
<div className="nav-section-label">Admin</div>
<button className={`nav-item ${page === 'admin' ? 'active' : ''}`} onClick={() => setPage('admin')}><Icons.Users /> Management</button>
</div>
)}
<div className="sidebar-footer">
<div className="user-info"><div className="user-avatar">{user.name.split(' ').map(n => n[0]).join('')}</div><div><div className="user-name">{user.name}</div><div className="user-role">{user.role} Β· {user.org_name || 'No Org'}</div></div></div>
<button className="nav-item" onClick={handleLogout} style={{ marginTop: 8, color: 'var(--danger)' }}><Icons.Logout /> Sign Out</button>
</div>
</div>
<div className="main-area">
<BillingBar token={access_token} />
<div className="page-content">
{page === 'documents' && <DocumentsPage token={access_token} toast={toast} onRunWorkflow={handleRunWorkflow} onGoChat={handleGoChat} />}
{page === 'chat' && <ChatPage token={access_token} toast={toast} />}
{page === 'workflows' && <WorkflowsPage token={access_token} toast={toast} preSelectedDocId={workflowDocId} preSelectedType={workflowPreType} />}
{page === 'extracted' && <ExtractedDataPage token={access_token} />}
{page === 'cloud' && <CloudImportPage token={access_token} toast={toast} />}
{page === 'billing' && <BillingPage token={access_token} />}
{page === 'pipeline' && <PipelinePage token={access_token} toast={toast} />}
{page === 'review' && <ReviewQueuePage token={access_token} toast={toast} />}
{page === 'admin' && <AdminPage token={access_token} toast={toast} currentUser={user} />}
</div>
</div>
</div>
</>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>