| <!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-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 { 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 { 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-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); } |
| |
| |
| .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-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-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-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-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); } |
| |
| |
| .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-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-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> |
|
|