Download index.html from FurqanAli12345/finance-analyst-concept: direct link, hf CLI and curl.
- Browser
- Download file 90.7 kB
-
https://huggingface.co/spaces/FurqanAli12345/finance-analyst-concept/resolve/main/index.html
- Command line
-
hf download hf://spaces/FurqanAli12345/finance-analyst-concept/index.html
-
curl -L -o index.html https://huggingface.co/spaces/FurqanAli12345/finance-analyst-concept/resolve/main/index.html
90.7 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Agentic Finance Analyst · Enterprise Review Platform</title> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css"> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400;14..32,500;14..32,600;14..32,700&display=swap" rel="stylesheet"> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| :root { | |
| --bg-primary: #0a0f1a; | |
| --bg-secondary: #111827; | |
| --bg-tertiary: #1a2332; | |
| --bg-elevated: #1e2a3d; | |
| --border-subtle: #1f2e45; | |
| --border-light: #2d4260; | |
| --text-primary: #f1f5f9; | |
| --text-secondary: #94a3b8; | |
| --text-muted: #64748b; | |
| --accent-blue: #3b82f6; | |
| --accent-blue-glow: rgba(59, 130, 246, 0.25); | |
| --accent-emerald: #10b981; | |
| --accent-amber: #f59e0b; | |
| --accent-rose: #f43f5e; | |
| --accent-violet: #8b5cf6; | |
| --accent-cyan: #06b6d4; | |
| --radius-card: 18px; | |
| --radius-sm: 12px; | |
| --shadow-card: 0 4px 24px rgba(0, 0, 0, 0.4); | |
| --shadow-elevated: 0 12px 36px rgba(0, 0, 0, 0.55); | |
| --transition-fast: 0.15s ease; | |
| --transition-smooth: 0.25s cubic-bezier(0.2, 0, 0, 1); | |
| } | |
| html { scroll-behavior: smooth; } | |
| body { | |
| background: var(--bg-primary); | |
| font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; | |
| font-size: 14px; | |
| color: var(--text-primary); | |
| padding: 24px 32px; | |
| display: flex; | |
| justify-content: center; | |
| min-height: 100vh; | |
| -webkit-font-smoothing: antialiased; | |
| line-height: 1.55; | |
| } | |
| .dashboard { max-width: 1560px; width: 100%; } | |
| /* ===== TOP NAV ===== */ | |
| .top-nav { | |
| display: flex; justify-content: space-between; | |
| align-items: center; margin-bottom: 28px; | |
| padding-bottom: 20px; border-bottom: 1px solid var(--border-subtle); | |
| } | |
| .brand-area { display: flex; align-items: center; gap: 16px; } | |
| .brand-icon { | |
| width: 48px; height: 48px; | |
| background: linear-gradient(135deg, var(--accent-emerald), var(--accent-cyan)); | |
| border-radius: 14px; | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 22px; color: white; | |
| box-shadow: 0 4px 14px rgba(16, 185, 129, 0.3); | |
| } | |
| .brand-text h1 { | |
| font-size: 19px; font-weight: 700; | |
| letter-spacing: -0.03em; | |
| } | |
| .brand-text .subtitle { | |
| font-size: 12px; font-weight: 500; | |
| color: var(--text-secondary); | |
| display: flex; align-items: center; gap: 8px; | |
| margin-top: 2px; | |
| } | |
| .env-badge { | |
| background: var(--bg-tertiary); | |
| padding: 2px 10px; border-radius: 30px; | |
| border: 1px solid var(--border-light); | |
| font-size: 10px; font-weight: 600; | |
| text-transform: uppercase; letter-spacing: 0.05em; | |
| color: var(--accent-emerald); | |
| } | |
| .nav-actions { display: flex; gap: 10px; align-items: center; } | |
| .btn { | |
| background: var(--bg-secondary); | |
| border: 1px solid var(--border-light); | |
| color: var(--text-primary); | |
| padding: 10px 18px; border-radius: 12px; | |
| font-weight: 500; font-size: 13px; | |
| font-family: 'Inter', sans-serif; | |
| display: inline-flex; align-items: center; gap: 8px; | |
| cursor: pointer; | |
| transition: all var(--transition-fast); | |
| white-space: nowrap; | |
| } | |
| .btn:hover { | |
| background: var(--bg-tertiary); | |
| border-color: #3b5f8a; | |
| transform: translateY(-1px); | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); | |
| } | |
| .btn-primary { | |
| background: var(--accent-blue); | |
| border-color: var(--accent-blue); | |
| color: white; font-weight: 600; | |
| } | |
| .btn-primary:hover { background: #2563eb; box-shadow: 0 4px 16px var(--accent-blue-glow); } | |
| .btn-success { | |
| background: var(--accent-emerald); | |
| border-color: var(--accent-emerald); | |
| color: white; font-weight: 600; | |
| } | |
| .btn-success:hover { background: #059669; box-shadow: 0 4px 16px rgba(16, 185, 129, 0.3); } | |
| .btn-danger { | |
| background: rgba(244, 63, 94, 0.15); | |
| border-color: rgba(244, 63, 94, 0.4); | |
| color: var(--accent-rose); | |
| } | |
| .btn-danger:hover { background: rgba(244, 63, 94, 0.25); border-color: var(--accent-rose); } | |
| .btn-icon-only { padding: 10px 12px; border-radius: 12px; } | |
| /* ===== STATS BAR ===== */ | |
| .stats-bar { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); | |
| gap: 12px; | |
| margin-bottom: 20px; | |
| } | |
| .stat-item { | |
| background: var(--bg-secondary); | |
| border: 1px solid var(--border-subtle); | |
| border-radius: 12px; | |
| padding: 14px 18px; | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| transition: all var(--transition-fast); | |
| } | |
| .stat-item:hover { border-color: var(--border-light); } | |
| .stat-icon { | |
| width: 36px; height: 36px; border-radius: 10px; | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 14px; | |
| flex-shrink: 0; | |
| } | |
| .stat-icon.green { background: rgba(16, 185, 129, 0.15); color: var(--accent-emerald); } | |
| .stat-icon.blue { background: rgba(59, 130, 246, 0.15); color: var(--accent-blue); } | |
| .stat-icon.violet { background: rgba(139, 92, 246, 0.15); color: var(--accent-violet); } | |
| .stat-icon.amber { background: rgba(245, 158, 11, 0.15); color: var(--accent-amber); } | |
| .stat-info h4 { | |
| font-size: 10.5px; font-weight: 600; | |
| text-transform: uppercase; letter-spacing: 0.05em; | |
| color: var(--text-muted); | |
| } | |
| .stat-info p { | |
| font-size: 18px; font-weight: 700; | |
| color: var(--text-primary); | |
| line-height: 1.2; margin-top: 2px; | |
| } | |
| /* ===== MONTH NAVIGATOR ===== */ | |
| .month-navigator { | |
| background: var(--bg-secondary); | |
| border: 1px solid var(--border-subtle); | |
| border-radius: var(--radius-card); | |
| padding: 16px 24px; | |
| margin-bottom: 20px; | |
| display: flex; justify-content: space-between; | |
| align-items: center; flex-wrap: wrap; gap: 14px; | |
| box-shadow: var(--shadow-card); | |
| } | |
| .month-nav-left { display: flex; align-items: center; gap: 16px; } | |
| .month-nav-left i { font-size: 18px; color: var(--accent-emerald); } | |
| .month-nav-left h2 { font-size: 15px; font-weight: 600; letter-spacing: -0.01em; } | |
| .month-nav-left p { font-size: 11px; color: var(--text-muted); margin-top: 1px; } | |
| .month-tabs { display: flex; gap: 6px; flex-wrap: wrap; } | |
| .month-tab { | |
| padding: 8px 16px; border-radius: 10px; | |
| font-size: 12.5px; font-weight: 500; | |
| background: var(--bg-primary); | |
| border: 1px solid var(--border-subtle); | |
| color: var(--text-secondary); | |
| cursor: pointer; | |
| transition: all var(--transition-fast); | |
| display: flex; align-items: center; gap: 6px; | |
| white-space: nowrap; | |
| } | |
| .month-tab:hover { | |
| background: var(--bg-tertiary); | |
| border-color: var(--border-light); | |
| color: var(--text-primary); | |
| } | |
| .month-tab.active { | |
| background: rgba(16, 185, 129, 0.12); | |
| border-color: var(--accent-emerald); | |
| color: var(--accent-emerald); | |
| font-weight: 600; | |
| } | |
| .month-tab .file-count { | |
| background: rgba(255, 255, 255, 0.1); | |
| font-size: 10px; font-weight: 700; | |
| padding: 1px 7px; border-radius: 20px; | |
| min-width: 18px; text-align: center; | |
| } | |
| .month-tab.active .file-count { background: rgba(16, 185, 129, 0.25); } | |
| .month-tab.add-month { | |
| border-style: dashed; | |
| border-color: var(--border-light); | |
| color: var(--text-muted); | |
| } | |
| .month-tab.add-month:hover { | |
| border-color: var(--accent-emerald); | |
| color: var(--accent-emerald); | |
| } | |
| /* ===== UPLOAD ZONE ===== */ | |
| .upload-hero { | |
| background: linear-gradient(135deg, var(--bg-secondary) 0%, #16233a 100%); | |
| border: 1px solid var(--border-subtle); | |
| border-radius: var(--radius-card); | |
| padding: 28px 32px; | |
| margin-bottom: 24px; | |
| position: relative; overflow: hidden; | |
| box-shadow: var(--shadow-card); | |
| } | |
| .upload-hero::before { | |
| content: ''; | |
| position: absolute; top: -50%; right: -10%; | |
| width: 500px; height: 500px; | |
| background: radial-gradient(circle, rgba(16, 185, 129, 0.06) 0%, transparent 70%); | |
| pointer-events: none; | |
| } | |
| .upload-header { | |
| display: flex; justify-content: space-between; | |
| align-items: center; margin-bottom: 20px; | |
| position: relative; z-index: 1; | |
| } | |
| .upload-header-left { display: flex; align-items: center; gap: 14px; } | |
| .upload-header-icon { | |
| width: 44px; height: 44px; border-radius: 14px; | |
| background: linear-gradient(135deg, var(--accent-emerald), var(--accent-cyan)); | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 18px; color: white; | |
| box-shadow: 0 4px 14px rgba(16, 185, 129, 0.3); | |
| } | |
| .upload-header-text h2 { font-size: 17px; font-weight: 700; letter-spacing: -0.02em; } | |
| .upload-header-text p { font-size: 12px; color: var(--text-secondary); margin-top: 2px; } | |
| .upload-zone { | |
| border: 2px dashed var(--border-light); | |
| border-radius: 16px; | |
| padding: 36px 24px; | |
| text-align: center; | |
| transition: all var(--transition-smooth); | |
| cursor: pointer; | |
| background: rgba(11, 17, 32, 0.4); | |
| position: relative; z-index: 1; | |
| } | |
| .upload-zone:hover { | |
| border-color: var(--accent-emerald); | |
| background: rgba(16, 185, 129, 0.04); | |
| transform: scale(1.005); | |
| } | |
| .upload-zone.dragover { | |
| border-color: var(--accent-emerald); | |
| background: rgba(16, 185, 129, 0.08); | |
| border-style: solid; | |
| } | |
| .upload-zone-icon { | |
| font-size: 42px; | |
| color: var(--accent-emerald); | |
| margin-bottom: 14px; opacity: 0.8; | |
| } | |
| .upload-zone h3 { font-size: 15px; font-weight: 600; margin-bottom: 6px; } | |
| .upload-zone p { font-size: 12.5px; color: var(--text-muted); margin-bottom: 16px; } | |
| .upload-formats { | |
| display: flex; justify-content: center; | |
| gap: 8px; flex-wrap: wrap; margin-top: 14px; | |
| } | |
| .format-pill { | |
| background: var(--bg-tertiary); | |
| border: 1px solid var(--border-subtle); | |
| padding: 4px 12px; border-radius: 20px; | |
| font-size: 11px; font-weight: 600; | |
| color: var(--text-secondary); | |
| letter-spacing: 0.03em; | |
| } | |
| .format-pill i { color: var(--accent-emerald); margin-right: 4px; } | |
| /* ===== FILE SECTIONS ===== */ | |
| .file-sections { | |
| display: flex; flex-direction: column; | |
| gap: 16px; margin-bottom: 24px; | |
| } | |
| .month-section { | |
| background: var(--bg-secondary); | |
| border: 1px solid var(--border-subtle); | |
| border-radius: var(--radius-card); | |
| overflow: hidden; | |
| box-shadow: var(--shadow-card); | |
| transition: all var(--transition-smooth); | |
| } | |
| .month-section:hover { border-color: var(--border-light); box-shadow: var(--shadow-elevated); } | |
| .month-section-header { | |
| display: flex; justify-content: space-between; | |
| align-items: center; padding: 16px 24px; | |
| background: var(--bg-tertiary); | |
| border-bottom: 1px solid var(--border-subtle); | |
| cursor: pointer; | |
| transition: background var(--transition-fast); | |
| user-select: none; | |
| } | |
| .month-section-header:hover { background: var(--bg-elevated); } | |
| .month-section-left { display: flex; align-items: center; gap: 14px; } | |
| .month-section-chevron { | |
| font-size: 12px; color: var(--text-muted); | |
| transition: transform var(--transition-fast); | |
| width: 16px; | |
| } | |
| .month-section.collapsed .month-section-chevron { transform: rotate(-90deg); } | |
| .month-section-icon { | |
| width: 36px; height: 36px; border-radius: 10px; | |
| background: rgba(16, 185, 129, 0.12); | |
| border: 1px solid rgba(16, 185, 129, 0.25); | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 14px; color: var(--accent-emerald); | |
| } | |
| .month-section-title h3 { font-size: 14px; font-weight: 600; letter-spacing: -0.01em; } | |
| .month-section-title p { font-size: 11px; color: var(--text-muted); margin-top: 1px; } | |
| .month-section-right { display: flex; align-items: center; gap: 12px; } | |
| .month-stat { | |
| display: flex; align-items: center; gap: 6px; | |
| font-size: 11px; color: var(--text-muted); | |
| background: var(--bg-primary); | |
| padding: 4px 12px; border-radius: 20px; | |
| border: 1px solid var(--border-subtle); | |
| } | |
| .month-stat i { color: var(--accent-blue); font-size: 10px; } | |
| .month-section-body { | |
| padding: 20px 24px; | |
| transition: all var(--transition-smooth); | |
| overflow: hidden; | |
| } | |
| .month-section.collapsed .month-section-body { | |
| max-height: 0; padding-top: 0; padding-bottom: 0; opacity: 0; | |
| } | |
| .uploaded-files { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); | |
| gap: 12px; | |
| } | |
| .empty-state { text-align: center; padding: 32px 20px; color: var(--text-muted); } | |
| .empty-state i { font-size: 32px; opacity: 0.3; display: block; margin-bottom: 10px; } | |
| .empty-state p { font-size: 12.5px; } | |
| .file-card { | |
| background: var(--bg-primary); | |
| border: 1px solid var(--border-subtle); | |
| border-radius: 12px; | |
| padding: 14px 16px; | |
| display: flex; align-items: center; gap: 12px; | |
| transition: all var(--transition-fast); | |
| animation: slideIn 0.3s ease; | |
| } | |
| @keyframes slideIn { | |
| from { opacity: 0; transform: translateY(-6px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .file-card:hover { | |
| border-color: var(--accent-blue); | |
| background: var(--bg-secondary); | |
| transform: translateY(-1px); | |
| } | |
| .file-icon { | |
| width: 40px; height: 40px; border-radius: 10px; | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 16px; flex-shrink: 0; | |
| } | |
| .file-icon.pdf { background: rgba(244, 63, 94, 0.15); color: var(--accent-rose); border: 1px solid rgba(244, 63, 94, 0.3); } | |
| .file-icon.excel { background: rgba(16, 185, 129, 0.15); color: var(--accent-emerald); border: 1px solid rgba(16, 185, 129, 0.3); } | |
| .file-icon.csv { background: rgba(139, 92, 246, 0.15); color: var(--accent-violet); border: 1px solid rgba(139, 92, 246, 0.3); } | |
| .file-icon.img { background: rgba(6, 182, 212, 0.15); color: var(--accent-cyan); border: 1px solid rgba(6, 182, 212, 0.3); } | |
| .file-info { flex: 1; min-width: 0; } | |
| .file-info h4 { | |
| font-size: 12.5px; font-weight: 600; | |
| white-space: nowrap; overflow: hidden; text-overflow: ellipsis; | |
| } | |
| .file-info p { font-size: 10.5px; color: var(--text-muted); margin-top: 3px; } | |
| .file-status { | |
| font-size: 10px; font-weight: 600; | |
| padding: 3px 9px; border-radius: 20px; | |
| text-transform: uppercase; letter-spacing: 0.04em; | |
| white-space: nowrap; | |
| } | |
| .file-status.complete { background: rgba(16, 185, 129, 0.15); color: var(--accent-emerald); border: 1px solid rgba(16, 185, 129, 0.3); } | |
| .file-status.saving { background: rgba(245, 158, 11, 0.15); color: var(--accent-amber); border: 1px solid rgba(245, 158, 11, 0.3); } | |
| .file-actions { | |
| display: flex; gap: 4px; align-items: center; | |
| } | |
| .file-action-btn { | |
| width: 32px; height: 32px; border-radius: 8px; | |
| background: transparent; | |
| border: 1px solid var(--border-subtle); | |
| color: var(--text-muted); | |
| display: flex; align-items: center; justify-content: center; | |
| cursor: pointer; | |
| transition: all var(--transition-fast); | |
| font-size: 12px; | |
| } | |
| .file-action-btn:hover { | |
| background: var(--bg-tertiary); | |
| border-color: var(--border-light); | |
| color: var(--text-primary); | |
| } | |
| .file-action-btn.download:hover { color: var(--accent-blue); border-color: var(--accent-blue); } | |
| .file-action-btn.remove:hover { color: var(--accent-rose); border-color: var(--accent-rose); } | |
| /* ===== KPI STRIP ===== */ | |
| .kpi-strip { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | |
| gap: 16px; | |
| margin-bottom: 24px; | |
| } | |
| .kpi-card { | |
| background: var(--bg-secondary); | |
| border: 1px solid var(--border-subtle); | |
| border-radius: var(--radius-sm); | |
| padding: 18px 22px; | |
| transition: all var(--transition-smooth); | |
| cursor: pointer; | |
| position: relative; overflow: hidden; | |
| } | |
| .kpi-card::before { | |
| content: ''; | |
| position: absolute; top: 0; left: 0; | |
| width: 4px; height: 100%; | |
| background: var(--accent-emerald); | |
| opacity: 0; transition: opacity var(--transition-fast); | |
| } | |
| .kpi-card:hover { | |
| border-color: var(--border-light); | |
| box-shadow: var(--shadow-card); | |
| transform: translateY(-2px); | |
| } | |
| .kpi-card:hover::before { opacity: 1; } | |
| .kpi-label { | |
| font-size: 11px; font-weight: 600; | |
| text-transform: uppercase; letter-spacing: 0.06em; | |
| color: var(--text-muted); margin-bottom: 8px; | |
| display: flex; align-items: center; gap: 6px; | |
| } | |
| .kpi-value { | |
| font-size: 26px; font-weight: 700; | |
| letter-spacing: -0.03em; line-height: 1.2; | |
| } | |
| .kpi-change { | |
| font-size: 12px; font-weight: 500; | |
| margin-top: 6px; display: flex; align-items: center; gap: 4px; | |
| } | |
| .kpi-change.positive { color: var(--accent-emerald); } | |
| .kpi-change.negative { color: var(--accent-rose); } | |
| .kpi-change.neutral { color: var(--text-muted); } | |
| /* ===== MAIN GRID ===== */ | |
| .main-grid { | |
| display: grid; | |
| grid-template-columns: 1.6fr 1fr; | |
| gap: 20px; margin-bottom: 20px; | |
| } | |
| .secondary-grid { | |
| display: grid; | |
| grid-template-columns: 1.6fr 1fr; | |
| gap: 20px; | |
| } | |
| .card { | |
| background: var(--bg-secondary); | |
| border: 1px solid var(--border-subtle); | |
| border-radius: var(--radius-card); | |
| padding: 24px 28px; | |
| transition: all var(--transition-smooth); | |
| box-shadow: var(--shadow-card); | |
| position: relative; | |
| } | |
| .card:hover { border-color: var(--border-light); box-shadow: var(--shadow-elevated); } | |
| .card-header { | |
| display: flex; justify-content: space-between; | |
| align-items: flex-start; margin-bottom: 20px; | |
| } | |
| .card-title { | |
| font-size: 15px; font-weight: 600; | |
| display: flex; align-items: center; gap: 10px; | |
| letter-spacing: -0.01em; | |
| } | |
| .card-title i { font-size: 16px; color: var(--accent-emerald); width: 20px; text-align: center; } | |
| .card-subtitle { font-size: 12px; color: var(--text-muted); margin-top: 4px; } | |
| .badge { | |
| font-size: 10px; font-weight: 700; | |
| text-transform: uppercase; letter-spacing: 0.05em; | |
| padding: 4px 12px; border-radius: 20px; white-space: nowrap; | |
| } | |
| .badge-live { background: rgba(16, 185, 129, 0.15); color: var(--accent-emerald); border: 1px solid rgba(16, 185, 129, 0.3); } | |
| .badge-active { background: rgba(59, 130, 246, 0.15); color: var(--accent-blue); border: 1px solid rgba(59, 130, 246, 0.3); } | |
| .badge-warning { background: rgba(245, 158, 11, 0.15); color: var(--accent-amber); border: 1px solid rgba(245, 158, 11, 0.3); } | |
| .badge-neutral { background: rgba(100, 116, 139, 0.15); color: var(--text-muted); border: 1px solid rgba(100, 116, 139, 0.3); } | |
| .badge-success { background: rgba(16, 185, 129, 0.15); color: var(--accent-emerald); border: 1px solid rgba(16, 185, 129, 0.3); } | |
| .chart-container { position: relative; height: 220px; margin-top: 10px; } | |
| /* ===== AGENT LIST ===== */ | |
| .agent-list { display: flex; flex-direction: column; gap: 10px; margin-top: 4px; } | |
| .agent-item { | |
| display: flex; align-items: center; | |
| justify-content: space-between; | |
| background: var(--bg-primary); | |
| padding: 12px 16px; border-radius: 12px; | |
| border-left: 3px solid var(--accent-blue); | |
| transition: all var(--transition-fast); | |
| cursor: pointer; | |
| } | |
| .agent-item:hover { | |
| background: var(--bg-tertiary); | |
| border-left-color: var(--accent-emerald); | |
| transform: translateX(4px); | |
| } | |
| .agent-info { display: flex; align-items: center; gap: 12px; } | |
| .agent-icon { | |
| width: 34px; height: 34px; border-radius: 10px; | |
| background: var(--bg-tertiary); | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 14px; color: var(--accent-emerald); | |
| border: 1px solid var(--border-subtle); | |
| } | |
| .agent-name { font-size: 13px; font-weight: 600; } | |
| .agent-role { font-size: 10.5px; color: var(--text-muted); margin-top: 1px; } | |
| .agent-status { | |
| font-size: 10px; font-weight: 600; | |
| padding: 3px 10px; border-radius: 20px; | |
| } | |
| .status-active { background: rgba(16, 185, 129, 0.15); color: var(--accent-emerald); } | |
| .status-processing { background: rgba(245, 158, 11, 0.15); color: var(--accent-amber); } | |
| .status-idle { background: rgba(100, 116, 139, 0.15); color: var(--text-muted); } | |
| /* ===== FINDINGS ===== */ | |
| .finding-item { | |
| background: var(--bg-primary); | |
| border: 1px solid var(--border-subtle); | |
| border-radius: 12px; | |
| padding: 14px 16px; margin-bottom: 10px; | |
| border-left: 3px solid var(--accent-amber); | |
| transition: all var(--transition-fast); | |
| cursor: pointer; | |
| } | |
| .finding-item:hover { | |
| border-left-color: var(--accent-emerald); | |
| background: var(--bg-tertiary); | |
| transform: translateX(4px); | |
| } | |
| .finding-item.critical { border-left-color: var(--accent-rose); } | |
| .finding-item.success { border-left-color: var(--accent-emerald); } | |
| .finding-item.info { border-left-color: var(--accent-blue); } | |
| .finding-header { | |
| display: flex; justify-content: space-between; | |
| align-items: center; margin-bottom: 6px; | |
| } | |
| .finding-title { | |
| font-size: 13px; font-weight: 600; | |
| display: flex; align-items: center; gap: 8px; | |
| } | |
| .finding-severity { | |
| font-size: 9.5px; font-weight: 700; | |
| padding: 3px 9px; border-radius: 20px; | |
| text-transform: uppercase; letter-spacing: 0.05em; | |
| } | |
| .severity-critical { background: rgba(244, 63, 94, 0.15); color: var(--accent-rose); } | |
| .severity-warning { background: rgba(245, 158, 11, 0.15); color: var(--accent-amber); } | |
| .severity-info { background: rgba(59, 130, 246, 0.15); color: var(--accent-blue); } | |
| .severity-success { background: rgba(16, 185, 129, 0.15); color: var(--accent-emerald); } | |
| .finding-desc { font-size: 12px; color: var(--text-secondary); line-height: 1.5; } | |
| .finding-meta { | |
| display: flex; gap: 14px; margin-top: 8px; | |
| font-size: 10.5px; color: var(--text-muted); | |
| } | |
| /* ===== DATA TABLE ===== */ | |
| .data-table-wrapper { overflow-x: auto; margin: 0 -4px; padding: 0 4px; } | |
| .data-table { width: 100%; border-collapse: collapse; font-size: 12.5px; } | |
| .data-table thead th { | |
| text-align: left; padding: 10px 14px; | |
| font-size: 10.5px; font-weight: 600; | |
| text-transform: uppercase; letter-spacing: 0.06em; | |
| color: var(--text-muted); | |
| border-bottom: 1px solid var(--border-subtle); | |
| white-space: nowrap; | |
| } | |
| .data-table tbody td { | |
| padding: 12px 14px; | |
| border-bottom: 1px solid var(--border-subtle); | |
| color: var(--text-primary); font-weight: 400; | |
| white-space: nowrap; | |
| } | |
| .data-table tbody tr { transition: background var(--transition-fast); cursor: pointer; } | |
| .data-table tbody tr:hover { background: rgba(59, 130, 246, 0.06); } | |
| .data-table tbody tr:last-child td { border-bottom: none; } | |
| .text-mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11.5px; font-weight: 500; } | |
| .text-positive { color: var(--accent-emerald); font-weight: 600; } | |
| .text-negative { color: var(--accent-rose); font-weight: 600; } | |
| .text-warning { color: var(--accent-amber); font-weight: 600; } | |
| .text-muted { color: var(--text-muted); } | |
| /* ===== LOG CONSOLE ===== */ | |
| .log-console { | |
| background: #060b14; | |
| border: 1px solid var(--border-subtle); | |
| border-radius: 12px; | |
| padding: 16px 18px; | |
| font-family: 'SF Mono', 'Fira Code', monospace; | |
| font-size: 11.5px; line-height: 1.7; | |
| max-height: 240px; overflow-y: auto; | |
| color: #b8d4ff; | |
| box-shadow: inset 0 0 30px rgba(0, 0, 0, 0.6); | |
| margin-top: 12px; | |
| } | |
| .log-entry { display: flex; gap: 12px; padding: 1px 0; white-space: nowrap; } | |
| .log-timestamp { color: #3b5a7a; flex-shrink: 0; font-size: 10.5px; } | |
| .log-level { font-weight: 600; flex-shrink: 0; width: 72px; font-size: 10.5px; } | |
| .log-level.info { color: #3b82f6; } | |
| .log-level.success { color: #10b981; } | |
| .log-level.warn { color: #f59e0b; } | |
| .log-level.error { color: #f43f5e; } | |
| .log-message { color: #a5c2e0; white-space: pre-wrap; word-break: break-word; } | |
| .log-message .highlight { color: #60a5fa; font-weight: 500; } | |
| .log-message .success { color: #34d399; } | |
| .log-message .warn { color: #fbbf24; } | |
| /* ===== STATUS BAR ===== */ | |
| .status-bar { | |
| display: flex; justify-content: space-between; | |
| align-items: center; margin-top: 24px; | |
| padding-top: 20px; | |
| border-top: 1px solid var(--border-subtle); | |
| font-size: 12px; color: var(--text-muted); | |
| } | |
| .status-left { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; } | |
| .status-item { display: flex; align-items: center; gap: 6px; } | |
| .status-item i { font-size: 11px; color: var(--accent-emerald); } | |
| /* ===== SCROLLBAR ===== */ | |
| ::-webkit-scrollbar { width: 5px; height: 5px; } | |
| ::-webkit-scrollbar-track { background: transparent; } | |
| ::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 10px; } | |
| ::-webkit-scrollbar-thumb:hover { background: #3b5f8a; } | |
| /* ===== RESPONSIVE ===== */ | |
| @media (max-width: 1200px) { | |
| .main-grid, .secondary-grid { grid-template-columns: 1fr; } | |
| } | |
| @media (max-width: 768px) { | |
| body { padding: 16px; } | |
| .top-nav { flex-direction: column; align-items: flex-start; gap: 16px; } | |
| .nav-actions { width: 100%; flex-wrap: wrap; } | |
| .btn { flex: 1; justify-content: center; padding: 10px 14px; font-size: 12px; } | |
| .kpi-strip { grid-template-columns: repeat(2, 1fr); } | |
| .kpi-value { font-size: 20px; } | |
| .card { padding: 18px 20px; } | |
| .upload-hero { padding: 20px; } | |
| .upload-header { flex-direction: column; align-items: flex-start; gap: 12px; } | |
| .month-navigator { flex-direction: column; align-items: flex-start; } | |
| .month-tabs { width: 100%; } | |
| .stats-bar { grid-template-columns: repeat(2, 1fr); } | |
| } | |
| @media (max-width: 480px) { | |
| .kpi-strip { grid-template-columns: 1fr; } | |
| .uploaded-files { grid-template-columns: 1fr; } | |
| .stats-bar { grid-template-columns: 1fr; } | |
| } | |
| /* ===== UTILITY ===== */ | |
| .flex-between { display: flex; justify-content: space-between; align-items: center; } | |
| .gap-2 { gap: 8px; } | |
| .mt-1 { margin-top: 8px; } | |
| .mt-2 { margin-top: 16px; } | |
| .mt-3 { margin-top: 24px; } | |
| .mb-1 { margin-bottom: 8px; } | |
| .mb-2 { margin-bottom: 16px; } | |
| .text-xs { font-size: 11px; } | |
| .text-sm { font-size: 12px; } | |
| #fileInput { display: none; } | |
| .spinner { | |
| display: inline-block; | |
| width: 12px; height: 12px; | |
| border: 2px solid rgba(16, 185, 129, 0.2); | |
| border-top-color: var(--accent-emerald); | |
| border-radius: 50%; | |
| animation: spin 0.8s linear infinite; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| @keyframes shake { | |
| 0%, 100% { transform: translateX(0); } | |
| 25% { transform: translateX(-8px); } | |
| 75% { transform: translateX(8px); } | |
| } | |
| .toast { | |
| position: fixed; | |
| bottom: 30px; right: 30px; | |
| background: var(--bg-elevated); | |
| border: 1px solid var(--border-light); | |
| border-radius: 12px; | |
| padding: 14px 20px; | |
| box-shadow: var(--shadow-elevated); | |
| display: flex; align-items: center; gap: 10px; | |
| font-size: 13px; | |
| z-index: 9999; | |
| animation: slideUp 0.3s ease; | |
| max-width: 380px; | |
| } | |
| @keyframes slideUp { | |
| from { opacity: 0; transform: translateY(20px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .toast.success { border-left: 3px solid var(--accent-emerald); } | |
| .toast.info { border-left: 3px solid var(--accent-blue); } | |
| .toast.warn { border-left: 3px solid var(--accent-amber); } | |
| .toast.error { border-left: 3px solid var(--accent-rose); } | |
| .toast i { font-size: 16px; } | |
| .toast.success i { color: var(--accent-emerald); } | |
| .toast.info i { color: var(--accent-blue); } | |
| .toast.warn i { color: var(--accent-amber); } | |
| .toast.error i { color: var(--accent-rose); } | |
| /* Loading state */ | |
| .loading-overlay { | |
| position: fixed; inset: 0; | |
| background: rgba(10, 15, 26, 0.85); | |
| backdrop-filter: blur(8px); | |
| display: flex; align-items: center; justify-content: center; | |
| z-index: 10000; | |
| flex-direction: column; gap: 20px; | |
| } | |
| .loading-spinner { | |
| width: 48px; height: 48px; | |
| border: 4px solid rgba(16, 185, 129, 0.2); | |
| border-top-color: var(--accent-emerald); | |
| border-radius: 50%; | |
| animation: spin 0.8s linear infinite; | |
| } | |
| .loading-text { | |
| font-size: 14px; | |
| color: var(--text-secondary); | |
| font-weight: 500; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="dashboard"> | |
| <!-- ===== TOP NAV ===== --> | |
| <nav class="top-nav"> | |
| <div class="brand-area"> | |
| <div class="brand-icon"> | |
| <i class="fas fa-chart-pie"></i> | |
| </div> | |
| <div class="brand-text"> | |
| <h1>Agentic Finance Analyst</h1> | |
| <div class="subtitle"> | |
| <span>Enterprise Review Platform</span> | |
| <span class="env-badge"><i class="fas fa-circle" style="font-size: 6px; margin-right: 4px;"></i>Persistent Storage</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="nav-actions"> | |
| <button class="btn btn-danger" id="clearAllBtn" title="Clear all stored data"> | |
| <i class="fas fa-trash-can"></i> Clear All | |
| </button> | |
| <button class="btn" id="exportBtn"> | |
| <i class="fas fa-download"></i> Export | |
| </button> | |
| <button class="btn btn-success" id="analyzeBtn"> | |
| <i class="fas fa-wand-magic-sparkles"></i> Run Analysis | |
| </button> | |
| </div> | |
| </nav> | |
| <!-- ===== STATS BAR ===== --> | |
| <div class="stats-bar"> | |
| <div class="stat-item"> | |
| <div class="stat-icon green"><i class="fas fa-folder"></i></div> | |
| <div class="stat-info"> | |
| <h4>Active Periods</h4> | |
| <p id="statMonths">0</p> | |
| </div> | |
| </div> | |
| <div class="stat-item"> | |
| <div class="stat-icon blue"><i class="fas fa-file"></i></div> | |
| <div class="stat-info"> | |
| <h4>Total Files</h4> | |
| <p id="statFiles">0</p> | |
| </div> | |
| </div> | |
| <div class="stat-item"> | |
| <div class="stat-icon violet"><i class="fas fa-database"></i></div> | |
| <div class="stat-info"> | |
| <h4>Storage Used</h4> | |
| <p id="statSize">0 B</p> | |
| </div> | |
| </div> | |
| <div class="stat-item"> | |
| <div class="stat-icon amber"><i class="fas fa-clock"></i></div> | |
| <div class="stat-info"> | |
| <h4>Last Upload</h4> | |
| <p id="statLast">—</p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ===== MONTH NAVIGATOR ===== --> | |
| <div class="month-navigator"> | |
| <div class="month-nav-left"> | |
| <i class="fas fa-calendar-days"></i> | |
| <div> | |
| <h2>Financial Periods</h2> | |
| <p>Files are auto-saved to IndexedDB and survive refresh</p> | |
| </div> | |
| </div> | |
| <div class="month-tabs" id="monthTabs"></div> | |
| </div> | |
| <!-- ===== UPLOAD HERO ===== --> | |
| <div class="upload-hero"> | |
| <div class="upload-header"> | |
| <div class="upload-header-left"> | |
| <div class="upload-header-icon"> | |
| <i class="fas fa-cloud-arrow-up"></i> | |
| </div> | |
| <div class="upload-header-text"> | |
| <h2>Upload Financial Documents</h2> | |
| <p>Files are auto-saved to <strong id="currentMonthLabel">Current Month</strong> · Persistent IndexedDB storage</p> | |
| </div> | |
| </div> | |
| <span class="badge badge-live"><i class="fas fa-shield-halved" style="margin-right: 4px;"></i>Auto-Saved</span> | |
| </div> | |
| <div class="upload-zone" id="uploadZone"> | |
| <div class="upload-zone-icon"> | |
| <i class="fas fa-file-invoice-dollar"></i> | |
| </div> | |
| <h3>Drop files here or click to browse</h3> | |
| <p>Files persist across browser sessions · Max 50MB per file</p> | |
| <div class="upload-formats"> | |
| <span class="format-pill"><i class="fas fa-file-pdf"></i>PDF</span> | |
| <span class="format-pill"><i class="fas fa-file-excel"></i>XLSX</span> | |
| <span class="format-pill"><i class="fas fa-file-csv"></i>CSV</span> | |
| <span class="format-pill"><i class="fas fa-image"></i>Images</span> | |
| </div> | |
| <input type="file" id="fileInput" multiple accept=".pdf,.xlsx,.xls,.csv,.png,.jpg,.jpeg"> | |
| </div> | |
| </div> | |
| <!-- ===== FILE SECTIONS ===== --> | |
| <div class="file-sections" id="fileSections"></div> | |
| <!-- ===== KPI STRIP ===== --> | |
| <div class="kpi-strip"> | |
| <div class="kpi-card" data-kpi="revenue"> | |
| <div class="kpi-label"><i class="fas fa-dollar-sign"></i> Total Revenue</div> | |
| <div class="kpi-value" id="kpiRevenue">$0</div> | |
| <div class="kpi-change positive"><i class="fas fa-arrow-trend-up"></i> +12.4% YoY</div> | |
| </div> | |
| <div class="kpi-card" data-kpi="expenses"> | |
| <div class="kpi-label"><i class="fas fa-receipt"></i> Total Expenses</div> | |
| <div class="kpi-value" id="kpiExpenses">$0</div> | |
| <div class="kpi-change negative"><i class="fas fa-arrow-trend-up"></i> +8.7% YoY</div> | |
| </div> | |
| <div class="kpi-card" data-kpi="profit"> | |
| <div class="kpi-label"><i class="fas fa-chart-line"></i> Net Profit</div> | |
| <div class="kpi-value" id="kpiProfit">$0</div> | |
| <div class="kpi-change positive"><i class="fas fa-arrow-trend-up"></i> +18.2%</div> | |
| </div> | |
| <div class="kpi-card" data-kpi="margin"> | |
| <div class="kpi-label"><i class="fas fa-percent"></i> Profit Margin</div> | |
| <div class="kpi-value" id="kpiMargin">0%</div> | |
| <div class="kpi-change positive"><i class="fas fa-arrow-trend-up"></i> +1.5pp</div> | |
| </div> | |
| <div class="kpi-card" data-kpi="cashflow"> | |
| <div class="kpi-label"><i class="fas fa-money-bill-wave"></i> Cash Flow</div> | |
| <div class="kpi-value" id="kpiCashflow">$0</div> | |
| <div class="kpi-change positive"><i class="fas fa-arrow-trend-up"></i> +6.1%</div> | |
| </div> | |
| </div> | |
| <!-- ===== MAIN GRID ===== --> | |
| <div class="main-grid"> | |
| <div class="card"> | |
| <div class="card-header"> | |
| <div> | |
| <div class="card-title"><i class="fas fa-chart-column"></i> Revenue vs Expenses</div> | |
| <div class="card-subtitle">Last 12 months · Auto-generated</div> | |
| </div> | |
| <span class="badge badge-active" id="chartStatusBadge">Live</span> | |
| </div> | |
| <div class="chart-container"> | |
| <canvas id="financialChart"></canvas> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <div class="card-header"> | |
| <div> | |
| <div class="card-title"><i class="fas fa-users-gear"></i> AI Analyst Crew</div> | |
| <div class="card-subtitle">4 specialist agents</div> | |
| </div> | |
| <span class="badge badge-live"><i class="fas fa-circle" style="font-size: 6px; margin-right: 4px;"></i>Ready</span> | |
| </div> | |
| <div class="agent-list"> | |
| <div class="agent-item"> | |
| <div class="agent-info"> | |
| <div class="agent-icon"><i class="fas fa-file-import"></i></div> | |
| <div> | |
| <div class="agent-name">Document Ingestion</div> | |
| <div class="agent-role">Parses PDFs, Excel & CSV</div> | |
| </div> | |
| </div> | |
| <span class="agent-status status-active" id="agentIngestion">Ready</span> | |
| </div> | |
| <div class="agent-item"> | |
| <div class="agent-info"> | |
| <div class="agent-icon"><i class="fas fa-magnifying-glass-chart"></i></div> | |
| <div> | |
| <div class="agent-name">Financial Extractor</div> | |
| <div class="agent-role">Normalizes line items</div> | |
| </div> | |
| </div> | |
| <span class="agent-status status-idle" id="agentExtraction">Idle</span> | |
| </div> | |
| <div class="agent-item"> | |
| <div class="agent-info"> | |
| <div class="agent-icon"><i class="fas fa-calculator"></i></div> | |
| <div> | |
| <div class="agent-name">Financial Analyst</div> | |
| <div class="agent-role">Ratios & anomalies</div> | |
| </div> | |
| </div> | |
| <span class="agent-status status-idle" id="agentAnalysis">Idle</span> | |
| </div> | |
| <div class="agent-item"> | |
| <div class="agent-info"> | |
| <div class="agent-icon"><i class="fas fa-lightbulb"></i></div> | |
| <div> | |
| <div class="agent-name">Strategy Advisor</div> | |
| <div class="agent-role">Recommendations</div> | |
| </div> | |
| </div> | |
| <span class="agent-status status-idle" id="agentAdvisor">Idle</span> | |
| </div> | |
| </div> | |
| <div class="mt-2 text-xs text-muted" style="font-size: 0.7rem;"> | |
| <i class="fas fa-circle" style="color: var(--accent-emerald); font-size: 5px;"></i> | |
| <span id="agentSummary">Awaiting documents</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ===== SECONDARY GRID ===== --> | |
| <div class="secondary-grid"> | |
| <div class="card"> | |
| <div class="card-header"> | |
| <div> | |
| <div class="card-title"><i class="fas fa-clipboard-check"></i> Key Findings</div> | |
| <div class="card-subtitle">AI-generated insights</div> | |
| </div> | |
| <span class="badge badge-warning" id="findingsCount">0 findings</span> | |
| </div> | |
| <div id="findingsList"> | |
| <div class="finding-item info"> | |
| <div class="finding-header"> | |
| <div class="finding-title"><i class="fas fa-info-circle" style="color: var(--accent-blue);"></i> Awaiting Documents</div> | |
| <span class="finding-severity severity-info">Info</span> | |
| </div> | |
| <div class="finding-desc">Upload files to generate insights. All files are saved persistently in your browser.</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <div class="card-header"> | |
| <div> | |
| <div class="card-title"><i class="fas fa-chart-pie"></i> Expense Breakdown</div> | |
| <div class="card-subtitle">By category</div> | |
| </div> | |
| <span class="badge badge-neutral">FY 2026</span> | |
| </div> | |
| <div class="chart-container" style="height: 200px;"> | |
| <canvas id="expenseChart"></canvas> | |
| </div> | |
| <div class="mt-2" id="expenseLegend"></div> | |
| </div> | |
| </div> | |
| <!-- ===== DATA TABLE + LOGS ===== --> | |
| <div class="secondary-grid" style="margin-top: 20px;"> | |
| <div class="card"> | |
| <div class="card-header"> | |
| <div> | |
| <div class="card-title"><i class="fas fa-table-list"></i> Extracted Financial Data</div> | |
| <div class="card-subtitle">Normalized line items</div> | |
| </div> | |
| <span class="badge badge-active" id="dataRowsBadge">0 rows</span> | |
| </div> | |
| <div class="data-table-wrapper"> | |
| <table class="data-table"> | |
| <thead> | |
| <tr> | |
| <th>Account</th> | |
| <th>Category</th> | |
| <th>Amount</th> | |
| <th>Variance</th> | |
| <th>Status</th> | |
| </tr> | |
| </thead> | |
| <tbody id="dataTableBody"> | |
| <tr> | |
| <td colspan="5" class="text-muted" style="text-align: center; padding: 30px;"> | |
| <i class="fas fa-inbox" style="font-size: 24px; opacity: 0.3; display: block; margin-bottom: 8px;"></i> | |
| No data yet | |
| </td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <div class="card-header"> | |
| <div> | |
| <div class="card-title"><i class="fas fa-terminal"></i> Processing Console</div> | |
| <div class="card-subtitle">Real-time activity</div> | |
| </div> | |
| <span class="badge badge-live"><i class="fas fa-circle" style="font-size: 6px; margin-right: 4px;"></i>Streaming</span> | |
| </div> | |
| <div class="log-console" id="logConsole"> | |
| <div class="log-entry"> | |
| <span class="log-timestamp">--:--:--</span> | |
| <span class="log-level info">[INFO]</span> | |
| <span class="log-message"><span class="highlight">[System]</span> Initializing persistent storage...</span> | |
| </div> | |
| </div> | |
| <div class="mt-2 flex-between text-xs text-muted"> | |
| <span><i class="fas fa-shield-halved" style="color: var(--accent-emerald);"></i> IndexedDB active</span> | |
| <span><i class="fas fa-database"></i> <span id="totalDocsCount">0</span> files stored</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ===== STATUS BAR ===== --> | |
| <div class="status-bar"> | |
| <div class="status-left"> | |
| <div class="status-item"><i class="fas fa-server"></i> Parser: Operational</div> | |
| <div class="status-item"><i class="fas fa-database"></i> Storage: IndexedDB</div> | |
| <div class="status-item"><i class="fas fa-shield-halved"></i> Persistent</div> | |
| <div class="status-item"><i class="fas fa-clock"></i> Uptime: 99.99%</div> | |
| </div> | |
| <div class="status-left"> | |
| <div class="status-item"><i class="fas fa-code-branch"></i> Build 5.0.0</div> | |
| <div class="status-item"><i class="fas fa-globe"></i> Region: Local</div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| (function() { | |
| 'use strict'; | |
| // ============================================================ | |
| // INDEXEDDB PERSISTENT STORAGE | |
| // ============================================================ | |
| const DB_NAME = 'AgenticFinanceDB'; | |
| const DB_VERSION = 1; | |
| const STORE_FILES = 'files'; // File metadata + blob | |
| const STORE_META = 'meta'; // App metadata | |
| let db = null; | |
| function openDB() { | |
| return new Promise((resolve, reject) => { | |
| const req = indexedDB.open(DB_NAME, DB_VERSION); | |
| req.onupgradeneeded = (e) => { | |
| const database = e.target.result; | |
| if (!database.objectStoreNames.contains(STORE_FILES)) { | |
| const store = database.createObjectStore(STORE_FILES, { keyPath: 'id' }); | |
| store.createIndex('month', 'month', { unique: false }); | |
| store.createIndex('uploadedAt', 'uploadedAt', { unique: false }); | |
| } | |
| if (!database.objectStoreNames.contains(STORE_META)) { | |
| database.createObjectStore(STORE_META, { keyPath: 'key' }); | |
| } | |
| }; | |
| req.onsuccess = (e) => { db = e.target.result; resolve(db); }; | |
| req.onerror = (e) => reject(e.target.error); | |
| }); | |
| } | |
| // Store a file (with blob) in IndexedDB | |
| function dbPutFile(fileRecord) { | |
| return new Promise((resolve, reject) => { | |
| const tx = db.transaction(STORE_FILES, 'readwrite'); | |
| const store = tx.objectStore(STORE_FILES); | |
| const req = store.put(fileRecord); | |
| req.onsuccess = () => resolve(fileRecord); | |
| req.onerror = (e) => reject(e.target.error); | |
| }); | |
| } | |
| // Get all files | |
| function dbGetAllFiles() { | |
| return new Promise((resolve, reject) => { | |
| const tx = db.transaction(STORE_FILES, 'readonly'); | |
| const store = tx.objectStore(STORE_FILES); | |
| const req = store.getAll(); | |
| req.onsuccess = () => resolve(req.result || []); | |
| req.onerror = (e) => reject(e.target.error); | |
| }); | |
| } | |
| // Get files by month | |
| function dbGetFilesByMonth(month) { | |
| return new Promise((resolve, reject) => { | |
| const tx = db.transaction(STORE_FILES, 'readonly'); | |
| const store = tx.objectStore(STORE_FILES); | |
| const idx = store.index('month'); | |
| const req = idx.getAll(month); | |
| req.onsuccess = () => resolve(req.result || []); | |
| req.onerror = (e) => reject(e.target.error); | |
| }); | |
| } | |
| // Delete file by id | |
| function dbDeleteFile(id) { | |
| return new Promise((resolve, reject) => { | |
| const tx = db.transaction(STORE_FILES, 'readwrite'); | |
| const store = tx.objectStore(STORE_FILES); | |
| const req = store.delete(id); | |
| req.onsuccess = () => resolve(); | |
| req.onerror = (e) => reject(e.target.error); | |
| }); | |
| } | |
| // Clear all files | |
| function dbClearAll() { | |
| return new Promise((resolve, reject) => { | |
| const tx = db.transaction(STORE_FILES, 'readwrite'); | |
| const store = tx.objectStore(STORE_FILES); | |
| const req = store.clear(); | |
| req.onsuccess = () => resolve(); | |
| req.onerror = (e) => reject(e.target.error); | |
| }); | |
| } | |
| // Get / set meta | |
| function dbGetMeta(key) { | |
| return new Promise((resolve, reject) => { | |
| const tx = db.transaction(STORE_META, 'readonly'); | |
| const store = tx.objectStore(STORE_META); | |
| const req = store.get(key); | |
| req.onsuccess = () => resolve(req.result ? req.result.value : null); | |
| req.onerror = (e) => reject(e.target.error); | |
| }); | |
| } | |
| function dbSetMeta(key, value) { | |
| return new Promise((resolve, reject) => { | |
| const tx = db.transaction(STORE_META, 'readwrite'); | |
| const store = tx.objectStore(STORE_META); | |
| const req = store.put({ key, value }); | |
| req.onsuccess = () => resolve(); | |
| req.onerror = (e) => reject(e.target.error); | |
| }); | |
| } | |
| // ============================================================ | |
| // STATE | |
| // ============================================================ | |
| const state = { | |
| activeMonth: null, | |
| files: [], // all file records from DB | |
| chart: null, | |
| expenseChart: null, | |
| processed: false, | |
| collapsedMonths: new Set() | |
| }; | |
| // ============================================================ | |
| // DOM REFS | |
| // ============================================================ | |
| const uploadZone = document.getElementById('uploadZone'); | |
| const fileInput = document.getElementById('fileInput'); | |
| const monthTabs = document.getElementById('monthTabs'); | |
| const fileSections = document.getElementById('fileSections'); | |
| const analyzeBtn = document.getElementById('analyzeBtn'); | |
| const logConsole = document.getElementById('logConsole'); | |
| const currentMonthLabel = document.getElementById('currentMonthLabel'); | |
| const totalDocsCount = document.getElementById('totalDocsCount'); | |
| // ============================================================ | |
| // HELPERS | |
| // ============================================================ | |
| function getMonthKey(date = new Date()) { | |
| const y = date.getFullYear(); | |
| const m = String(date.getMonth() + 1).padStart(2, '0'); | |
| return `${y}-${m}`; | |
| } | |
| function getMonthLabel(key) { | |
| const [y, m] = key.split('-'); | |
| const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', | |
| 'July', 'August', 'September', 'October', 'November', 'December']; | |
| return `${monthNames[parseInt(m) - 1]} ${y}`; | |
| } | |
| function getMonthShortLabel(key) { | |
| const [y, m] = key.split('-'); | |
| const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', | |
| 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; | |
| return `${monthNames[parseInt(m) - 1]} ${y.slice(2)}`; | |
| } | |
| function 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'; | |
| } | |
| function getFileType(name) { | |
| const ext = name.split('.').pop().toLowerCase(); | |
| if (ext === 'pdf') return { class: 'pdf', icon: 'fa-file-pdf', label: 'PDF' }; | |
| if (ext === 'xlsx' || ext === 'xls') return { class: 'excel', icon: 'fa-file-excel', label: 'Excel' }; | |
| if (ext === 'csv') return { class: 'csv', icon: 'fa-file-csv', label: 'CSV' }; | |
| if (['png', 'jpg', 'jpeg'].includes(ext)) return { class: 'img', icon: 'fa-image', label: 'Image' }; | |
| return { class: 'excel', icon: 'fa-file', label: 'File' }; | |
| } | |
| function genId() { | |
| return 'file_' + Date.now() + '_' + Math.random().toString(36).slice(2, 9); | |
| } | |
| function addLog(level, source, message) { | |
| const now = new Date(); | |
| const timeStr = now.toTimeString().split(' ')[0]; | |
| const levelMap = { | |
| 'info': { label: '[INFO]', cls: 'info' }, | |
| 'success': { label: '[OK]', cls: 'success' }, | |
| 'warn': { label: '[WARN]', cls: 'warn' }, | |
| 'error': { label: '[ERROR]', cls: 'error' } | |
| }; | |
| const lvl = levelMap[level] || levelMap.info; | |
| const entry = document.createElement('div'); | |
| entry.className = 'log-entry'; | |
| entry.innerHTML = ` | |
| <span class="log-timestamp">${timeStr}</span> | |
| <span class="log-level ${lvl.cls}">${lvl.label}</span> | |
| <span class="log-message"><span class="highlight">[${source}]</span> ${message}</span> | |
| `; | |
| logConsole.appendChild(entry); | |
| logConsole.scrollTop = logConsole.scrollHeight; | |
| while (logConsole.children.length > 60) { | |
| logConsole.removeChild(logConsole.firstChild); | |
| } | |
| } | |
| function showToast(type, message) { | |
| const toast = document.createElement('div'); | |
| toast.className = `toast ${type}`; | |
| const icons = { | |
| success: 'fa-circle-check', | |
| info: 'fa-circle-info', | |
| warn: 'fa-triangle-exclamation', | |
| error: 'fa-circle-xmark' | |
| }; | |
| toast.innerHTML = `<i class="fas ${icons[type]}"></i><span>${message}</span>`; | |
| document.body.appendChild(toast); | |
| setTimeout(() => { | |
| toast.style.opacity = '0'; | |
| toast.style.transition = 'opacity 0.3s'; | |
| setTimeout(() => toast.remove(), 300); | |
| }, 3200); | |
| } | |
| function groupFilesByMonth(files) { | |
| const grouped = {}; | |
| files.forEach(f => { | |
| if (!grouped[f.month]) grouped[f.month] = []; | |
| grouped[f.month].push(f); | |
| }); | |
| // Sort files within each month by upload date (newest first) | |
| Object.keys(grouped).forEach(m => { | |
| grouped[m].sort((a, b) => new Date(b.uploadedDate) - new Date(a.uploadedDate)); | |
| }); | |
| return grouped; | |
| } | |
| // ============================================================ | |
| // INITIALIZATION | |
| // ============================================================ | |
| async function init() { | |
| try { | |
| await openDB(); | |
| addLog('success', 'Storage', 'IndexedDB connected — persistent storage ready.'); | |
| // Load all files | |
| state.files = await dbGetAllFiles(); | |
| addLog('info', 'Storage', `Loaded ${state.files.length} file(s) from persistent storage.`); | |
| // Determine active month | |
| const savedActiveMonth = await dbGetMeta('activeMonth'); | |
| const currentMonth = getMonthKey(); | |
| if (savedActiveMonth) { | |
| state.activeMonth = savedActiveMonth; | |
| } else { | |
| state.activeMonth = currentMonth; | |
| await dbSetMeta('activeMonth', currentMonth); | |
| } | |
| renderMonthTabs(); | |
| renderFileSections(); | |
| updateStats(); | |
| addLog('info', 'System', `Active period: ${getMonthLabel(state.activeMonth)}`); | |
| } catch (e) { | |
| console.error('Init failed:', e); | |
| addLog('error', 'Storage', 'IndexedDB initialization failed: ' + e.message); | |
| showToast('error', 'Storage initialization failed'); | |
| } | |
| } | |
| // ============================================================ | |
| // MONTH TABS | |
| // ============================================================ | |
| function renderMonthTabs() { | |
| const grouped = groupFilesByMonth(state.files); | |
| const months = Object.keys(grouped); | |
| // Always include active month | |
| if (!months.includes(state.activeMonth)) { | |
| months.push(state.activeMonth); | |
| } | |
| // Sort newest first | |
| months.sort((a, b) => b.localeCompare(a)); | |
| monthTabs.innerHTML = months.map(m => { | |
| const count = (grouped[m] || []).length; | |
| const isActive = m === state.activeMonth; | |
| return ` | |
| <div class="month-tab ${isActive ? 'active' : ''}" data-month="${m}"> | |
| <i class="fas fa-calendar-day" style="font-size: 10px; opacity: 0.7;"></i> | |
| ${getMonthShortLabel(m)} | |
| <span class="file-count">${count}</span> | |
| </div> | |
| `; | |
| }).join('') + ` | |
| <div class="month-tab add-month" id="addMonthBtn"> | |
| <i class="fas fa-plus"></i> New Month | |
| </div> | |
| `; | |
| document.querySelectorAll('.month-tab[data-month]').forEach(tab => { | |
| tab.addEventListener('click', async function() { | |
| const month = this.dataset.month; | |
| state.activeMonth = month; | |
| await dbSetMeta('activeMonth', month); | |
| renderMonthTabs(); | |
| renderFileSections(); | |
| updateMonthLabel(); | |
| addLog('info', 'Period', `Switched to ${getMonthLabel(month)}`); | |
| }); | |
| }); | |
| document.getElementById('addMonthBtn')?.addEventListener('click', createNewMonth); | |
| } | |
| async function createNewMonth() { | |
| const now = new Date(); | |
| const defaultVal = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; | |
| const input = prompt('Enter month (YYYY-MM format):', defaultVal); | |
| if (!input) return; | |
| const match = input.match(/^(\d{4})-(\d{1,2})$/); | |
| if (!match) { | |
| showToast('error', 'Invalid format. Use YYYY-MM (e.g., 2026-03)'); | |
| return; | |
| } | |
| const year = match[1]; | |
| const month = String(parseInt(match[2])).padStart(2, '0'); | |
| const key = `${year}-${month}`; | |
| state.activeMonth = key; | |
| await dbSetMeta('activeMonth', key); | |
| renderMonthTabs(); | |
| renderFileSections(); | |
| updateMonthLabel(); | |
| addLog('success', 'Period', `Created new period: ${getMonthLabel(key)}`); | |
| showToast('success', `Switched to ${getMonthLabel(key)}`); | |
| } | |
| function updateMonthLabel() { | |
| currentMonthLabel.textContent = getMonthLabel(state.activeMonth); | |
| } | |
| // ============================================================ | |
| // FILE SECTIONS | |
| // ============================================================ | |
| function renderFileSections() { | |
| const grouped = groupFilesByMonth(state.files); | |
| // Show all months that have files, plus active month | |
| const monthsToShow = Object.keys(grouped).filter(m => grouped[m].length > 0); | |
| if (!monthsToShow.includes(state.activeMonth)) { | |
| monthsToShow.push(state.activeMonth); | |
| } | |
| monthsToShow.sort((a, b) => b.localeCompare(a)); | |
| fileSections.innerHTML = monthsToShow.map(month => { | |
| const files = grouped[month] || []; | |
| const isCollapsed = state.collapsedMonths.has(month); | |
| const isActive = month === state.activeMonth; | |
| const totalSize = files.reduce((sum, f) => sum + (f.size || 0), 0); | |
| return ` | |
| <div class="month-section ${isCollapsed ? 'collapsed' : ''}" data-month="${month}"> | |
| <div class="month-section-header" data-month="${month}"> | |
| <div class="month-section-left"> | |
| <i class="fas fa-chevron-down month-section-chevron"></i> | |
| <div class="month-section-icon"> | |
| <i class="fas fa-folder-open"></i> | |
| </div> | |
| <div class="month-section-title"> | |
| <h3> | |
| ${getMonthLabel(month)} | |
| ${isActive ? '<span class="badge badge-success" style="margin-left: 8px; font-size: 9px;">Active</span>' : ''} | |
| </h3> | |
| <p>${files.length} file${files.length !== 1 ? 's' : ''} · ${formatSize(totalSize)}</p> | |
| </div> | |
| </div> | |
| <div class="month-section-right"> | |
| <div class="month-stat"> | |
| <i class="fas fa-file"></i> ${files.length} | |
| </div> | |
| ${files.length > 0 ? ` | |
| <button class="btn btn-icon-only" data-clear-month="${month}" title="Clear month" style="padding: 6px 10px;"> | |
| <i class="fas fa-trash-can" style="font-size: 11px; color: var(--accent-rose);"></i> | |
| </button> | |
| ` : ''} | |
| </div> | |
| </div> | |
| <div class="month-section-body"> | |
| ${files.length === 0 ? ` | |
| <div class="empty-state"> | |
| <i class="fas fa-folder-plus"></i> | |
| <p>No files uploaded for this month yet</p> | |
| </div> | |
| ` : ` | |
| <div class="uploaded-files"> | |
| ${files.map(f => { | |
| const type = getFileType(f.name); | |
| return ` | |
| <div class="file-card" data-file-id="${f.id}"> | |
| <div class="file-icon ${type.class}"> | |
| <i class="fas ${type.icon}"></i> | |
| </div> | |
| <div class="file-info"> | |
| <h4 title="${f.name}">${f.name}</h4> | |
| <p>${formatSize(f.size)} · ${type.label} · ${f.uploadedAt}</p> | |
| </div> | |
| <div class="file-actions"> | |
| <button class="file-action-btn download" data-download-id="${f.id}" title="Download"> | |
| <i class="fas fa-download"></i> | |
| </button> | |
| <button class="file-action-btn remove" data-remove-id="${f.id}" title="Delete"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| </div> | |
| </div> | |
| `; | |
| }).join('')} | |
| </div> | |
| `} | |
| </div> | |
| </div> | |
| `; | |
| }).join(''); | |
| // Collapse handlers | |
| document.querySelectorAll('.month-section-header').forEach(header => { | |
| header.addEventListener('click', function(e) { | |
| if (e.target.closest('button') || e.target.closest('.file-action-btn')) return; | |
| const month = this.dataset.month; | |
| const section = this.closest('.month-section'); | |
| if (state.collapsedMonths.has(month)) { | |
| state.collapsedMonths.delete(month); | |
| section.classList.remove('collapsed'); | |
| } else { | |
| state.collapsedMonths.add(month); | |
| section.classList.add('collapsed'); | |
| } | |
| }); | |
| }); | |
| // Download handlers | |
| document.querySelectorAll('[data-download-id]').forEach(btn => { | |
| btn.addEventListener('click', async function(e) { | |
| e.stopPropagation(); | |
| const id = this.dataset.downloadId; | |
| await downloadFile(id); | |
| }); | |
| }); | |
| // Remove handlers | |
| document.querySelectorAll('[data-remove-id]').forEach(btn => { | |
| btn.addEventListener('click', async function(e) { | |
| e.stopPropagation(); | |
| const id = this.dataset.removeId; | |
| await removeFile(id); | |
| }); | |
| }); | |
| // Clear month handlers | |
| document.querySelectorAll('[data-clear-month]').forEach(btn => { | |
| btn.addEventListener('click', async function(e) { | |
| e.stopPropagation(); | |
| const month = this.dataset.clearMonth; | |
| await clearMonth(month); | |
| }); | |
| }); | |
| } | |
| // ============================================================ | |
| // FILE OPERATIONS | |
| // ============================================================ | |
| async function handleFiles(fileList) { | |
| const validExtensions = ['pdf', 'xlsx', 'xls', 'csv', 'png', 'jpg', 'jpeg']; | |
| const validFiles = Array.from(fileList).filter(f => { | |
| const ext = f.name.split('.').pop().toLowerCase(); | |
| return validExtensions.includes(ext); | |
| }); | |
| if (validFiles.length === 0) { | |
| addLog('error', 'Upload', 'No valid files detected.'); | |
| showToast('error', 'Invalid file type. Use PDF, XLSX, CSV, or images.'); | |
| return; | |
| } | |
| const month = state.activeMonth; | |
| const now = new Date(); | |
| const timeStr = now.toTimeString().split(' ')[0].slice(0, 5); | |
| let savedCount = 0; | |
| for (const file of validFiles) { | |
| try { | |
| // Read file as ArrayBuffer for persistent storage | |
| const arrayBuffer = await file.arrayBuffer(); | |
| const record = { | |
| id: genId(), | |
| name: file.name, | |
| size: file.size, | |
| mimeType: file.type || 'application/octet-stream', | |
| type: getFileType(file.name).label, | |
| month: month, | |
| uploadedAt: timeStr, | |
| uploadedDate: now.toISOString(), | |
| blob: arrayBuffer // Store actual file content | |
| }; | |
| await dbPutFile(record); | |
| state.files.push(record); | |
| savedCount++; | |
| addLog('success', 'Upload', `Saved "${file.name}" to ${getMonthLabel(month)} (${formatSize(file.size)})`); | |
| } catch (err) { | |
| console.error('Save failed for', file.name, err); | |
| addLog('error', 'Upload', `Failed to save "${file.name}": ${err.message}`); | |
| } | |
| } | |
| if (savedCount > 0) { | |
| renderMonthTabs(); | |
| renderFileSections(); | |
| updateStats(); | |
| showToast('success', `${savedCount} file${savedCount > 1 ? 's' : ''} saved to ${getMonthLabel(month)}`); | |
| addLog('info', 'Storage', `Persisted to IndexedDB · Total: ${state.files.length} files`); | |
| } | |
| } | |
| async function downloadFile(id) { | |
| const file = state.files.find(f => f.id === id); | |
| if (!file) { | |
| showToast('error', 'File not found'); | |
| return; | |
| } | |
| try { | |
| const blob = new Blob([file.blob], { type: file.mimeType }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = file.name; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| addLog('success', 'Download', `Downloaded "${file.name}"`); | |
| showToast('success', `Downloaded "${file.name}"`); | |
| } catch (e) { | |
| addLog('error', 'Download', `Failed: ${e.message}`); | |
| showToast('error', 'Download failed'); | |
| } | |
| } | |
| async function removeFile(id) { | |
| const file = state.files.find(f => f.id === id); | |
| if (!file) return; | |
| if (!confirm(`Remove "${file.name}"? This cannot be undone.`)) return; | |
| try { | |
| await dbDeleteFile(id); | |
| state.files = state.files.filter(f => f.id !== id); | |
| renderMonthTabs(); | |
| renderFileSections(); | |
| updateStats(); | |
| addLog('warn', 'Storage', `Removed "${file.name}"`); | |
| showToast('info', `Removed "${file.name}"`); | |
| } catch (e) { | |
| addLog('error', 'Storage', `Delete failed: ${e.message}`); | |
| showToast('error', 'Delete failed'); | |
| } | |
| } | |
| async function clearMonth(month) { | |
| const files = state.files.filter(f => f.month === month); | |
| if (files.length === 0) return; | |
| if (!confirm(`Delete all ${files.length} file(s) from ${getMonthLabel(month)}?`)) return; | |
| try { | |
| for (const f of files) { | |
| await dbDeleteFile(f.id); | |
| } | |
| state.files = state.files.filter(f => f.month !== month); | |
| renderMonthTabs(); | |
| renderFileSections(); | |
| updateStats(); | |
| addLog('warn', 'Storage', `Cleared ${files.length} file(s) from ${getMonthLabel(month)}`); | |
| showToast('warn', `Cleared ${getMonthLabel(month)}`); | |
| } catch (e) { | |
| addLog('error', 'Storage', `Clear failed: ${e.message}`); | |
| showToast('error', 'Clear failed'); | |
| } | |
| } | |
| async function clearAllData() { | |
| if (state.files.length === 0) { | |
| showToast('info', 'No data to clear'); | |
| return; | |
| } | |
| if (!confirm(`Delete ALL ${state.files.length} file(s) from persistent storage? This cannot be undone.`)) return; | |
| try { | |
| await dbClearAll(); | |
| state.files = []; | |
| renderMonthTabs(); | |
| renderFileSections(); | |
| updateStats(); | |
| addLog('warn', 'Storage', 'All files cleared from IndexedDB'); | |
| showToast('success', 'All data cleared'); | |
| } catch (e) { | |
| addLog('error', 'Storage', `Clear all failed: ${e.message}`); | |
| showToast('error', 'Clear failed'); | |
| } | |
| } | |
| function updateStats() { | |
| const grouped = groupFilesByMonth(state.files); | |
| const totalFiles = state.files.length; | |
| const totalSize = state.files.reduce((sum, f) => sum + (f.size || 0), 0); | |
| const monthsCount = Object.keys(grouped).length; | |
| document.getElementById('statMonths').textContent = monthsCount; | |
| document.getElementById('statFiles').textContent = totalFiles; | |
| document.getElementById('statSize').textContent = formatSize(totalSize); | |
| // Last upload | |
| if (state.files.length > 0) { | |
| const sorted = [...state.files].sort((a, b) => new Date(b.uploadedDate) - new Date(a.uploadedDate)); | |
| const last = sorted[0]; | |
| document.getElementById('statLast').textContent = last.uploadedAt || '—'; | |
| } else { | |
| document.getElementById('statLast').textContent = '—'; | |
| } | |
| totalDocsCount.textContent = totalFiles; | |
| updateMonthLabel(); | |
| } | |
| // ============================================================ | |
| // UPLOAD EVENT HANDLERS | |
| // ============================================================ | |
| uploadZone.addEventListener('click', () => fileInput.click()); | |
| uploadZone.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| uploadZone.classList.add('dragover'); | |
| }); | |
| uploadZone.addEventListener('dragleave', () => { | |
| uploadZone.classList.remove('dragover'); | |
| }); | |
| uploadZone.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| uploadZone.classList.remove('dragover'); | |
| handleFiles(e.dataTransfer.files); | |
| }); | |
| fileInput.addEventListener('change', (e) => { | |
| handleFiles(e.target.files); | |
| fileInput.value = ''; | |
| }); | |
| // ============================================================ | |
| // SAMPLE DATA / ANALYSIS | |
| // ============================================================ | |
| function animateValue(id, start, end, duration, isCurrency, suffix = '') { | |
| const el = document.getElementById(id); | |
| if (!el) return; | |
| const startTime = performance.now(); | |
| function update(currentTime) { | |
| const elapsed = currentTime - startTime; | |
| const progress = Math.min(elapsed / duration, 1); | |
| const eased = 1 - Math.pow(1 - progress, 3); | |
| const current = start + (end - start) * eased; | |
| let display; | |
| if (isCurrency) { | |
| if (current >= 1000000) display = '$' + (current / 1000000).toFixed(2) + 'M'; | |
| else if (current >= 1000) display = '$' + (current / 1000).toFixed(0) + 'K'; | |
| else display = '$' + current.toFixed(0); | |
| } else { | |
| display = current.toFixed(1) + suffix; | |
| } | |
| el.textContent = display; | |
| if (progress < 1) requestAnimationFrame(update); | |
| else { | |
| if (isCurrency) { | |
| if (end >= 1000000) el.textContent = '$' + (end / 1000000).toFixed(2) + 'M'; | |
| else if (end >= 1000) el.textContent = '$' + (end / 1000).toFixed(0) + 'K'; | |
| } else { | |
| el.textContent = end.toFixed(1) + suffix; | |
| } | |
| } | |
| } | |
| requestAnimationFrame(update); | |
| } | |
| function renderChart() { | |
| const ctx = document.getElementById('financialChart').getContext('2d'); | |
| if (state.chart) state.chart.destroy(); | |
| const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; | |
| const revenueData = [320, 345, 368, 382, 395, 410, 425, 438, 452, 468, 485, 510].map(v => v * 1000); | |
| const expensesData = [210, 225, 238, 245, 252, 260, 268, 275, 282, 290, 298, 310].map(v => v * 1000); | |
| state.chart = new Chart(ctx, { | |
| type: 'line', | |
| data: { | |
| labels: months, | |
| datasets: [ | |
| { | |
| label: 'Revenue', | |
| data: revenueData, | |
| borderColor: '#10b981', | |
| backgroundColor: 'rgba(16, 185, 129, 0.08)', | |
| borderWidth: 2.5, tension: 0.4, fill: true, | |
| pointBackgroundColor: '#10b981', pointRadius: 3, pointHoverRadius: 5 | |
| }, | |
| { | |
| label: 'Expenses', | |
| data: expensesData, | |
| borderColor: '#f43f5e', | |
| backgroundColor: 'rgba(244, 63, 94, 0.06)', | |
| borderWidth: 2.5, tension: 0.4, fill: true, | |
| pointBackgroundColor: '#f43f5e', pointRadius: 3, pointHoverRadius: 5 | |
| } | |
| ] | |
| }, | |
| options: { | |
| responsive: true, maintainAspectRatio: false, | |
| interaction: { intersect: false, mode: 'index' }, | |
| plugins: { | |
| legend: { | |
| position: 'top', align: 'end', | |
| labels: { | |
| color: '#94a3b8', | |
| font: { family: 'Inter', size: 11, weight: '500' }, | |
| boxWidth: 10, boxHeight: 10, | |
| usePointStyle: true, pointStyle: 'circle', padding: 16 | |
| } | |
| }, | |
| tooltip: { | |
| backgroundColor: '#1e2a3d', | |
| titleColor: '#f1f5f9', bodyColor: '#94a3b8', | |
| borderColor: '#2d4260', borderWidth: 1, | |
| padding: 12, cornerRadius: 10, | |
| callbacks: { label: ctx => ctx.dataset.label + ': $' + ctx.parsed.y.toLocaleString() } | |
| } | |
| }, | |
| scales: { | |
| x: { | |
| grid: { color: 'rgba(31, 46, 69, 0.5)', drawBorder: false }, | |
| ticks: { color: '#64748b', font: { family: 'Inter', size: 10.5 } } | |
| }, | |
| y: { | |
| grid: { color: 'rgba(31, 46, 69, 0.5)', drawBorder: false }, | |
| ticks: { | |
| color: '#64748b', font: { family: 'Inter', size: 10.5 }, | |
| callback: v => '$' + (v / 1000).toFixed(0) + 'K' | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| function renderExpenseChart() { | |
| const ctx = document.getElementById('expenseChart').getContext('2d'); | |
| if (state.expenseChart) state.expenseChart.destroy(); | |
| const categories = ['Payroll', 'Marketing', 'Operations', 'Software', 'Travel', 'Other']; | |
| const values = [1450, 420, 380, 280, 195, 215]; | |
| const colors = ['#10b981', '#3b82f6', '#8b5cf6', '#f59e0b', '#06b6d4', '#64748b']; | |
| state.expenseChart = new Chart(ctx, { | |
| type: 'doughnut', | |
| data: { | |
| labels: categories, | |
| datasets: [{ | |
| data: values, backgroundColor: colors, | |
| borderColor: '#111827', borderWidth: 3, hoverOffset: 8 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, maintainAspectRatio: false, | |
| cutout: '65%', | |
| plugins: { | |
| legend: { display: false }, | |
| tooltip: { | |
| backgroundColor: '#1e2a3d', | |
| titleColor: '#f1f5f9', bodyColor: '#94a3b8', | |
| borderColor: '#2d4260', borderWidth: 1, | |
| padding: 10, cornerRadius: 8, | |
| callbacks: { label: ctx => `${ctx.label}: $${ctx.parsed}K` } | |
| } | |
| } | |
| } | |
| }); | |
| document.getElementById('expenseLegend').innerHTML = categories.map((cat, i) => ` | |
| <div style="display: flex; justify-content: space-between; align-items: center; padding: 4px 0; font-size: 11.5px;"> | |
| <span style="display: flex; align-items: center; gap: 8px;"> | |
| <span style="width: 8px; height: 8px; border-radius: 50%; background: ${colors[i]};"></span> | |
| <span style="color: var(--text-secondary);">${cat}</span> | |
| </span> | |
| <span style="color: var(--text-primary); font-weight: 600;">$${values[i]}K</span> | |
| </div> | |
| `).join(''); | |
| } | |
| function renderFindings() { | |
| const findings = [ | |
| { | |
| type: 'critical', severity: 'critical', icon: 'fa-triangle-exclamation', | |
| title: 'Marketing Spend Efficiency Declining', | |
| desc: 'Marketing expenses increased 22% YoY but CAC rose 18%. Consider reallocating budget.', | |
| meta: ['ROI: -4.2%', 'Action: Review Q3'] | |
| }, | |
| { | |
| type: 'success', severity: 'success', icon: 'fa-circle-check', | |
| title: 'Gross Margin Improvement', | |
| desc: 'Gross margin expanded from 28.5% to 31.3% driven by cost optimization.', | |
| meta: ['+2.8pp', 'Sustain'] | |
| }, | |
| { | |
| type: 'warning', severity: 'warning', icon: 'fa-clock', | |
| title: 'Accounts Receivable Aging', | |
| desc: 'DSO increased from 42 to 51 days. $340K is over 60 days past due.', | |
| meta: ['DSO: 51 days', 'Overdue: $340K'] | |
| }, | |
| { | |
| type: 'info', severity: 'info', icon: 'fa-lightbulb', | |
| title: 'Cash Reserve Optimization', | |
| desc: 'Operating cash flow exceeds 3-month runway. Consider deploying excess.', | |
| meta: ['Excess: $420K', 'Treasury'] | |
| } | |
| ]; | |
| document.getElementById('findingsList').innerHTML = findings.map(f => ` | |
| <div class="finding-item ${f.type}"> | |
| <div class="finding-header"> | |
| <div class="finding-title"> | |
| <i class="fas ${f.icon}" style="color: var(--accent-${f.type === 'critical' ? 'rose' : f.type === 'success' ? 'emerald' : f.type === 'warning' ? 'amber' : 'blue'});"></i> | |
| ${f.title} | |
| </div> | |
| <span class="finding-severity severity-${f.severity}">${f.severity}</span> | |
| </div> | |
| <div class="finding-desc">${f.desc}</div> | |
| <div class="finding-meta"> | |
| ${f.meta.map(m => `<span><i class="fas fa-circle" style="font-size: 4px; margin-right: 4px;"></i>${m}</span>`).join('')} | |
| </div> | |
| </div> | |
| `).join(''); | |
| document.getElementById('findingsCount').textContent = findings.length + ' findings'; | |
| } | |
| function renderDataTable() { | |
| const rows = [ | |
| { account: 'Revenue - Product Sales', category: 'Revenue', amount: 2840000, variance: '+14.2%', status: 'positive' }, | |
| { account: 'Revenue - Services', category: 'Revenue', amount: 1440000, variance: '+9.8%', status: 'positive' }, | |
| { account: 'Payroll & Benefits', category: 'Operating', amount: 1450000, variance: '+6.1%', status: 'warning' }, | |
| { account: 'Marketing & Advertising', category: 'Operating', amount: 420000, variance: '+22.4%', status: 'negative' }, | |
| { account: 'Software & Subscriptions', category: 'Operating', amount: 280000, variance: '-8.3%', status: 'positive' }, | |
| { account: 'Travel & Entertainment', category: 'Discretionary', amount: 195000, variance: '+31.2%', status: 'negative' }, | |
| { account: 'Professional Services', category: 'Operating', amount: 145000, variance: '+4.5%', status: 'warning' }, | |
| { account: 'Office & Facilities', category: 'Operating', amount: 230000, variance: '+2.1%', status: 'neutral' } | |
| ]; | |
| document.getElementById('dataTableBody').innerHTML = rows.map(r => ` | |
| <tr> | |
| <td style="font-weight: 500;">${r.account}</td> | |
| <td><span class="text-muted">${r.category}</span></td> | |
| <td class="text-mono">$${r.amount.toLocaleString()}</td> | |
| <td class="text-${r.status === 'positive' ? 'positive' : r.status === 'negative' ? 'negative' : r.status === 'warning' ? 'warning' : 'muted'}">${r.variance}</td> | |
| <td> | |
| <span style="width: 6px; height: 6px; display: inline-block; border-radius: 50%; background: var(--accent-${r.status === 'positive' ? 'emerald' : r.status === 'negative' ? 'rose' : r.status === 'warning' ? 'amber' : 'text-muted'});"></span> | |
| <span class="text-muted" style="font-size: 11px; margin-left: 6px;">${r.status === 'positive' ? 'Good' : r.status === 'negative' ? 'Review' : 'Monitor'}</span> | |
| </td> | |
| </tr> | |
| `).join(''); | |
| document.getElementById('dataRowsBadge').textContent = rows.length + ' rows'; | |
| } | |
| // ============================================================ | |
| // ANALYSIS FLOW | |
| // ============================================================ | |
| function runAnalysis() { | |
| if (state.files.length === 0) { | |
| addLog('warn', 'Analysis', 'No files uploaded. Please add documents first.'); | |
| uploadZone.style.borderColor = 'var(--accent-amber)'; | |
| uploadZone.style.animation = 'shake 0.4s ease'; | |
| setTimeout(() => { | |
| uploadZone.style.borderColor = ''; | |
| uploadZone.style.animation = ''; | |
| }, 800); | |
| showToast('warn', 'Please upload at least one document first'); | |
| return; | |
| } | |
| const totalFiles = state.files.length; | |
| addLog('info', 'Pipeline', `Starting analysis for ${totalFiles} document${totalFiles > 1 ? 's' : ''}...`); | |
| const agents = [ | |
| { id: 'agentIngestion', name: 'Document Ingestion', delay: 0 }, | |
| { id: 'agentExtraction', name: 'Financial Extractor', delay: 800 }, | |
| { id: 'agentAnalysis', name: 'Financial Analyst', delay: 1600 }, | |
| { id: 'agentAdvisor', name: 'Strategy Advisor', delay: 2400 } | |
| ]; | |
| agents.forEach((agent, idx) => { | |
| setTimeout(() => { | |
| const el = document.getElementById(agent.id); | |
| if (el) { | |
| el.className = 'agent-status status-processing'; | |
| el.textContent = 'Processing'; | |
| } | |
| addLog('info', agent.name, `Processing... stage ${idx + 1}/${agents.length}`); | |
| setTimeout(() => { | |
| if (el) { | |
| el.className = 'agent-status status-active'; | |
| el.textContent = 'Complete'; | |
| } | |
| }, 700); | |
| }, agent.delay); | |
| }); | |
| setTimeout(() => { | |
| addLog('success', 'Pipeline', 'Analysis complete. Extracting metrics...'); | |
| animateValue('kpiRevenue', 0, 4280000, 800, true); | |
| animateValue('kpiExpenses', 0, 2940000, 800, true); | |
| animateValue('kpiProfit', 0, 1340000, 800, true); | |
| animateValue('kpiMargin', 0, 31.3, 800, false, '%'); | |
| animateValue('kpiCashflow', 0, 1820000, 800, true); | |
| renderChart(); | |
| renderExpenseChart(); | |
| renderFindings(); | |
| renderDataTable(); | |
| addLog('success', 'Analysis', `Generated insights · 4 findings · dashboard updated`); | |
| document.getElementById('agentSummary').textContent = `${totalFiles} document${totalFiles > 1 ? 's' : ''} analyzed`; | |
| document.getElementById('chartStatusBadge').textContent = 'Updated'; | |
| state.processed = true; | |
| showToast('success', 'Analysis complete'); | |
| document.getElementById('findingsList').scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
| }, 3400); | |
| } | |
| // ============================================================ | |
| // BUTTON HANDLERS | |
| // ============================================================ | |
| analyzeBtn.addEventListener('click', runAnalysis); | |
| document.getElementById('clearAllBtn').addEventListener('click', clearAllData); | |
| document.getElementById('exportBtn').addEventListener('click', function() { | |
| if (!state.processed) { | |
| addLog('warn', 'Export', 'Run analysis first.'); | |
| showToast('warn', 'Run analysis before exporting'); | |
| return; | |
| } | |
| addLog('success', 'Export', 'Generating report...'); | |
| this.innerHTML = '<i class="fas fa-spinner fa-pulse"></i> Generating...'; | |
| setTimeout(() => { | |
| this.innerHTML = '<i class="fas fa-download"></i> Export'; | |
| addLog('success', 'Export', 'Report downloaded'); | |
| showToast('success', 'Report exported'); | |
| }, 1500); | |
| }); | |
| document.querySelectorAll('.kpi-card').forEach(card => { | |
| card.addEventListener('click', function() { | |
| addLog('info', 'Dashboard', `Drilling into "${this.dataset.kpi}" metrics...`); | |
| this.style.borderColor = 'var(--accent-emerald)'; | |
| setTimeout(() => { this.style.borderColor = ''; }, 300); | |
| }); | |
| }); | |
| // ============================================================ | |
| // INIT ON LOAD | |
| // ============================================================ | |
| window.addEventListener('load', () => { | |
| init(); | |
| }); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |