Spaces:
Sleeping
Sleeping
| import { Component, inject, OnInit, signal } from '@angular/core'; | |
| import { CommonModule } from '@angular/common'; | |
| import { FormsModule } from '@angular/forms'; | |
| import { ReportsService } from '../../core/services/reports.service'; | |
| ({ | |
| selector: 'app-reports', | |
| standalone: true, | |
| imports: [CommonModule, FormsModule], | |
| template: ` | |
| <div class="reports-page"> | |
| <header class="page-header"> | |
| <div> | |
| <h1>Reports & Analytics</h1> | |
| <p>Export and analyze your medical coding data</p> | |
| </div> | |
| <div class="export-btns"> | |
| <button class="btn-export pdf" (click)="exportPDF()" [disabled]="exporting()"> | |
| @if (exporting() === 'pdf') { <span class="spinner-sm"></span> } @else { 📄 } | |
| Export PDF | |
| </button> | |
| <button class="btn-export excel" (click)="exportExcel()" [disabled]="exporting()"> | |
| @if (exporting() === 'excel') { <span class="spinner-sm"></span> } @else { 📊 } | |
| Export Excel | |
| </button> | |
| </div> | |
| </header> | |
| <!-- Status breakdown --> | |
| <div class="report-grid"> | |
| <div class="report-card full"> | |
| <h3>Encounter Status Breakdown</h3> | |
| <div class="status-breakdown"> | |
| @for (s of statusBreakdown(); track s._id) { | |
| <div class="status-item"> | |
| <div class="status-header"> | |
| <span class="status-pill" [class]="s._id">{{ s._id | titlecase }}</span> | |
| <span class="status-pct">{{ getPct(s.count) }}%</span> | |
| </div> | |
| <div class="progress-bar"> | |
| <div class="progress-fill" [class]="s._id" [style.width.%]="getPct(s.count)"></div> | |
| </div> | |
| <span class="status-count">{{ s.count }} encounters</span> | |
| </div> | |
| } | |
| </div> | |
| </div> | |
| <!-- Top diagnoses table --> | |
| <div class="report-card"> | |
| <h3>Top ICD-10 Diagnoses</h3> | |
| <table class="report-table"> | |
| <thead> | |
| <tr><th>#</th><th>Code</th><th>Description</th><th>Count</th></tr> | |
| </thead> | |
| <tbody> | |
| @for (d of topDiagnoses(); track d._id; let i = $index) { | |
| <tr> | |
| <td class="rank">{{ i + 1 }}</td> | |
| <td><span class="code-badge icd10">{{ d._id }}</span></td> | |
| <td class="desc">{{ d.description }}</td> | |
| <td class="count">{{ d.count }}</td> | |
| </tr> | |
| } | |
| @if (!topDiagnoses().length) { | |
| <tr><td colspan="4" class="empty">No data</td></tr> | |
| } | |
| </tbody> | |
| </table> | |
| </div> | |
| <!-- Top procedures table --> | |
| <div class="report-card"> | |
| <h3>Top CPT Procedures</h3> | |
| <table class="report-table"> | |
| <thead> | |
| <tr><th>#</th><th>Code</th><th>Description</th><th>Count</th></tr> | |
| </thead> | |
| <tbody> | |
| @for (p of topProcedures(); track p._id; let i = $index) { | |
| <tr> | |
| <td class="rank">{{ i + 1 }}</td> | |
| <td><span class="code-badge cpt">{{ p._id }}</span></td> | |
| <td class="desc">{{ p.description }}</td> | |
| <td class="count">{{ p.count }}</td> | |
| </tr> | |
| } | |
| @if (!topProcedures().length) { | |
| <tr><td colspan="4" class="empty">No data</td></tr> | |
| } | |
| </tbody> | |
| </table> | |
| </div> | |
| <!-- Encounters by month --> | |
| <div class="report-card full"> | |
| <h3>Encounter Volume by Month</h3> | |
| <div class="month-chart"> | |
| @for (m of encountersByMonth(); track m._id) { | |
| <div class="month-col"> | |
| <span class="month-count">{{ m.count }}</span> | |
| <div class="month-bar" [style.height.px]="barHeight(m.count)"></div> | |
| <span class="month-label">{{ monthLabel(m._id) }}</span> | |
| </div> | |
| } | |
| @if (!encountersByMonth().length) { | |
| <div class="empty">No data available</div> | |
| } | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| `, | |
| styles: [` | |
| .reports-page { padding:2rem; font-family:'Inter',system-ui,sans-serif; max-width:1200px; } | |
| .page-header { display:flex; align-items:flex-start; justify-content:space-between; margin-bottom:2rem; flex-wrap:wrap; gap:1rem; } | |
| .page-header h1 { font-size:1.75rem; font-weight:700; color:#0f172a; margin:0 0 0.25rem; } | |
| .page-header p { color:#64748b; margin:0; } | |
| .export-btns { display:flex; gap:0.75rem; } | |
| .btn-export { padding:0.65rem 1.25rem; border:none; border-radius:8px; font-weight:600; font-size:0.9rem; cursor:pointer; display:flex; align-items:center; gap:0.5rem; } | |
| .btn-export:disabled { opacity:0.6; cursor:not-allowed; } | |
| .btn-export.pdf { background:#ef4444; color:white; } | |
| .btn-export.pdf:hover:not(:disabled) { background:#dc2626; } | |
| .btn-export.excel { background:#16a34a; color:white; } | |
| .btn-export.excel:hover:not(:disabled) { background:#15803d; } | |
| .spinner-sm { width:14px; height:14px; border:2px solid rgba(255,255,255,0.4); border-top-color:white; border-radius:50%; animation:spin 0.6s linear infinite; display:inline-block; } | |
| @keyframes spin { to { transform:rotate(360deg); } } | |
| .report-grid { display:grid; grid-template-columns:1fr 1fr; gap:1.25rem; } | |
| .report-card { background:white; border-radius:12px; padding:1.5rem; border:1px solid #e2e8f0; } | |
| .report-card.full { grid-column:1/-1; } | |
| .report-card h3 { font-size:0.9rem; font-weight:600; color:#374151; margin:0 0 1.25rem; } | |
| /* Status breakdown */ | |
| .status-breakdown { display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:1rem; } | |
| .status-item { display:flex; flex-direction:column; gap:0.4rem; } | |
| .status-header { display:flex; align-items:center; justify-content:space-between; } | |
| .status-pill { padding:0.2rem 0.7rem; border-radius:999px; font-size:0.75rem; font-weight:700; text-transform:uppercase; } | |
| .status-pill.draft { background:#fef3c7; color:#b45309; } | |
| .status-pill.coded { background:#eff6ff; color:#1d4ed8; } | |
| .status-pill.billed { background:#f3e8ff; color:#7c3aed; } | |
| .status-pill.paid { background:#ecfdf5; color:#065f46; } | |
| .status-pct { font-size:0.85rem; font-weight:700; color:#0f172a; } | |
| .progress-bar { height:8px; background:#f1f5f9; border-radius:4px; overflow:hidden; } | |
| .progress-fill { height:100%; border-radius:4px; transition:width 0.6s; } | |
| .progress-fill.draft { background:#f59e0b; } | |
| .progress-fill.coded { background:#3b82f6; } | |
| .progress-fill.billed { background:#8b5cf6; } | |
| .progress-fill.paid { background:#10b981; } | |
| .status-count { font-size:0.78rem; color:#64748b; } | |
| /* Tables */ | |
| .report-table { width:100%; border-collapse:collapse; font-size:0.85rem; } | |
| .report-table th { padding:0.6rem 0.75rem; text-align:left; font-weight:600; color:#374151; border-bottom:2px solid #e2e8f0; font-size:0.78rem; text-transform:uppercase; letter-spacing:0.04em; } | |
| .report-table td { padding:0.6rem 0.75rem; border-bottom:1px solid #f8fafc; color:#1e293b; } | |
| .report-table tr:last-child td { border-bottom:none; } | |
| .report-table tr:hover td { background:#f8fafc; } | |
| .rank { color:#94a3b8; font-weight:600; width:30px; } | |
| .code-badge { padding:0.15rem 0.5rem; border-radius:4px; font-weight:700; font-size:0.78rem; white-space:nowrap; font-family:'JetBrains Mono',monospace; } | |
| .code-badge.icd10 { background:#eff6ff; color:#1d4ed8; } | |
| .code-badge.cpt { background:#f0fdf4; color:#15803d; } | |
| .desc { color:#64748b; max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } | |
| .count { font-weight:700; text-align:right; } | |
| .empty { text-align:center; color:#94a3b8; padding:2rem; } | |
| /* Month chart */ | |
| .month-chart { display:flex; align-items:flex-end; gap:1rem; height:160px; padding:0 0 1.5rem; } | |
| .month-col { display:flex; flex-direction:column; align-items:center; flex:1; height:100%; } | |
| .month-count { font-size:0.72rem; font-weight:700; color:#374151; margin-bottom:0.25rem; } | |
| .month-bar { width:100%; background:linear-gradient(to top,#0EA5E9,#38bdf8); border-radius:4px 4px 0 0; min-height:4px; } | |
| .month-label { font-size:0.7rem; color:#94a3b8; margin-top:0.4rem; } | |
| @media (max-width:768px) { .report-grid { grid-template-columns:1fr; } } | |
| `], | |
| }) | |
| export class ReportsComponent implements OnInit { | |
| private reportsSvc = inject(ReportsService); | |
| statusBreakdown = signal<any[]>([]); | |
| topDiagnoses = signal<any[]>([]); | |
| topProcedures = signal<any[]>([]); | |
| encountersByMonth = signal<any[]>([]); | |
| exporting = signal<string | false>(false); | |
| get totalEncounters() { | |
| return this.statusBreakdown().reduce((sum, s) => sum + s.count, 0); | |
| } | |
| ngOnInit() { | |
| this.reportsSvc.getStatusBreakdown().subscribe(d => this.statusBreakdown.set(d || [])); | |
| this.reportsSvc.getTopDiagnoses(10).subscribe(d => this.topDiagnoses.set(d || [])); | |
| this.reportsSvc.getTopProcedures(10).subscribe(d => this.topProcedures.set(d || [])); | |
| this.reportsSvc.getEncountersByMonth().subscribe(d => this.encountersByMonth.set(d || [])); | |
| } | |
| getPct(count: number): number { | |
| const total = this.totalEncounters; | |
| return total ? Math.round((count / total) * 100) : 0; | |
| } | |
| barHeight(count: number): number { | |
| const max = Math.max(...this.encountersByMonth().map(m => m.count), 1); | |
| return Math.max((count / max) * 120, 4); | |
| } | |
| monthLabel(id: string): string { | |
| if (!id) return ''; | |
| const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; | |
| const parts = id.split('-'); | |
| return months[parseInt(parts[1]) - 1] || id; | |
| } | |
| exportPDF() { | |
| this.exporting.set('pdf'); | |
| this.reportsSvc.exportEncountersPDF().subscribe({ | |
| next: (blob) => { | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `medicode-report-${new Date().toISOString().substring(0,10)}.pdf`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| this.exporting.set(false); | |
| }, | |
| error: () => this.exporting.set(false), | |
| }); | |
| } | |
| exportExcel() { | |
| this.exporting.set('excel'); | |
| this.reportsSvc.exportEncountersExcel().subscribe({ | |
| next: (blob) => { | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `medicode-report-${new Date().toISOString().substring(0,10)}.xlsx`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| this.exporting.set(false); | |
| }, | |
| error: () => this.exporting.set(false), | |
| }); | |
| } | |
| } |