medicodeapp / frontend /src /app /features /reports /reports.component.ts
Denisijcu's picture
upload files
c98875e
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';
@Component({
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),
});
}
}