medicodeapp / frontend /src /app /features /codes /codes-search.component.ts
Denisijcu's picture
upload files
c98875e
import { Component, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { debounceTime, distinctUntilChanged, Subject, switchMap, of } from 'rxjs';
import { CodesService, CodeEntry, PagedResult } from '../../core/services/codes.service';
type Tab = 'all' | 'icd10' | 'cpt';
@Component({
selector: 'app-codes-search',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="codes-page">
<header class="page-header">
<div>
<h1>Code Search</h1>
<p>Search ICD-10 diagnostic codes and CPT procedure codes</p>
</div>
</header>
<!-- Search bar -->
<div class="search-section">
<div class="search-box">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<input
class="search-input"
type="text"
[(ngModel)]="query"
(ngModelChange)="onSearch($event)"
placeholder="Search by code or description (e.g. J18.9, pneumonia, 99213...)"
/>
@if (query) {
<button class="clear-btn" (click)="clear()">✕</button>
}
</div>
<div class="tabs">
@for (tab of tabs; track tab.id) {
<button class="tab" [class.active]="activeTab() === tab.id" (click)="switchTab(tab.id)">
{{ tab.label }}
@if (tab.id === 'icd10' && icd10Results()?.total) {
<span class="badge">{{ icd10Results()!.total }}</span>
}
@if (tab.id === 'cpt' && cptResults()?.total) {
<span class="badge">{{ cptResults()!.total }}</span>
}
</button>
}
</div>
</div>
<!-- Loading -->
@if (loading()) {
<div class="loading-state">
<div class="spinner"></div>
<span>Searching codes...</span>
</div>
}
<!-- Global results (all tab) -->
@if (!loading() && activeTab() === 'all' && query) {
<div class="results-split">
<div class="results-col">
<div class="col-header">
<span class="col-badge icd10">ICD-10</span>
<span class="col-count">{{ globalResults()?.icd10?.length || 0 }} results</span>
</div>
@for (code of globalResults()?.icd10; track code._id) {
<div class="code-card" (click)="selectCode(code)">
<div class="code-tag icd10">{{ code.code }}</div>
<div class="code-body">
<p class="code-desc">{{ code.description }}</p>
@if (code.category) { <p class="code-cat">{{ code.category }}</p> }
</div>
<div class="code-badge" [class.billable]="code.billable">
{{ code.billable ? 'Billable' : 'Non-billable' }}
</div>
</div>
}
@if (!globalResults()?.icd10?.length) {
<div class="empty-col">No ICD-10 results</div>
}
</div>
<div class="results-col">
<div class="col-header">
<span class="col-badge cpt">CPT</span>
<span class="col-count">{{ globalResults()?.cpt?.length || 0 }} results</span>
</div>
@for (code of globalResults()?.cpt; track code._id) {
<div class="code-card" (click)="selectCode(code)">
<div class="code-tag cpt">{{ code.code }}</div>
<div class="code-body">
<p class="code-desc">{{ code.description }}</p>
@if (code.category) { <p class="code-cat">{{ code.category }}</p> }
</div>
<div class="code-badge" [class.billable]="code.billable">
{{ code.billable ? 'Billable' : 'Non-billable' }}
</div>
</div>
}
@if (!globalResults()?.cpt?.length) {
<div class="empty-col">No CPT results</div>
}
</div>
</div>
}
<!-- ICD-10 tab -->
@if (!loading() && activeTab() === 'icd10') {
<div class="results-list">
@for (code of icd10Results()?.data; track code._id) {
<div class="code-card" (click)="selectCode(code)">
<div class="code-tag icd10">{{ code.code }}</div>
<div class="code-body">
<p class="code-desc">{{ code.description }}</p>
@if (code.category) { <p class="code-cat">{{ code.category }}</p> }
</div>
<div class="code-badge" [class.billable]="code.billable">
{{ code.billable ? 'Billable' : 'Non-billable' }}
</div>
</div>
}
@if (icd10Results()?.pages && icd10Results()!.pages > 1) {
<div class="pagination">
<button [disabled]="icd10Page() === 1" (click)="changePage('icd10', -1)">← Prev</button>
<span>Page {{ icd10Page() }} of {{ icd10Results()?.pages }}</span>
<button [disabled]="icd10Page() === icd10Results()?.pages" (click)="changePage('icd10', 1)">Next →</button>
</div>
}
</div>
}
<!-- CPT tab -->
@if (!loading() && activeTab() === 'cpt') {
<div class="results-list">
@for (code of cptResults()?.data; track code._id) {
<div class="code-card" (click)="selectCode(code)">
<div class="code-tag cpt">{{ code.code }}</div>
<div class="code-body">
<p class="code-desc">{{ code.description }}</p>
@if (code.category) { <p class="code-cat">{{ code.category }}</p> }
</div>
<div class="code-badge" [class.billable]="code.billable">
{{ code.billable ? 'Billable' : 'Non-billable' }}
</div>
</div>
}
@if (cptResults()?.pages && cptResults()!.pages > 1) {
<div class="pagination">
<button [disabled]="cptPage() === 1" (click)="changePage('cpt', -1)">← Prev</button>
<span>Page {{ cptPage() }} of {{ cptResults()?.pages }}</span>
<button [disabled]="cptPage() === cptResults()?.pages" (click)="changePage('cpt', 1)">Next →</button>
</div>
}
</div>
}
<!-- Empty state -->
@if (!loading() && !query) {
<div class="empty-state">
<svg viewBox="0 0 64 64" fill="none">
<circle cx="28" cy="28" r="20" stroke="#cbd5e1" stroke-width="3"/>
<path d="m44 44 12 12" stroke="#cbd5e1" stroke-width="3" stroke-linecap="round"/>
<path d="M20 28h16M28 20v16" stroke="#94a3b8" stroke-width="2.5" stroke-linecap="round"/>
</svg>
<h3>Start searching</h3>
<p>Enter a code like <strong>J18.9</strong> or keyword like <strong>pneumonia</strong></p>
</div>
}
<!-- Code detail modal -->
@if (selectedCode()) {
<div class="modal-overlay" (click)="selectedCode.set(null)">
<div class="modal" (click)="$event.stopPropagation()">
<div class="modal-header">
<div class="code-tag" [class.icd10]="selectedCode()!.type === 'ICD-10'" [class.cpt]="selectedCode()!.type === 'CPT' || !selectedCode()!.type">
{{ selectedCode()?.code }}
</div>
<button class="modal-close" (click)="selectedCode.set(null)">✕</button>
</div>
<div class="modal-body">
<h2>{{ selectedCode()?.description }}</h2>
@if (selectedCode()?.category) {
<div class="detail-row"><span>Category</span><strong>{{ selectedCode()?.category }}</strong></div>
}
<div class="detail-row">
<span>Billable</span>
<strong [style.color]="selectedCode()?.billable ? '#16a34a' : '#dc2626'">
{{ selectedCode()?.billable ? 'Yes' : 'No' }}
</strong>
</div>
<div class="detail-row"><span>Type</span><strong>{{ selectedCode()?.type || 'ICD-10' }}</strong></div>
</div>
<div class="modal-footer">
<button class="btn-copy" (click)="copyCode(selectedCode()!.code)">
{{ copied() ? '✓ Copied!' : 'Copy code' }}
</button>
</div>
</div>
</div>
}
</div>
`,
styles: [`
.codes-page { padding:2rem; max-width:1200px; font-family:'Inter',system-ui,sans-serif; }
.page-header { margin-bottom:1.5rem; }
.page-header h1 { font-size:1.75rem; font-weight:700; color:#0f172a; margin:0 0 0.25rem; }
.page-header p { color:#64748b; margin:0; }
.search-section { margin-bottom:1.5rem; }
.search-box { position:relative; margin-bottom:1rem; }
.search-icon { position:absolute; left:1rem; top:50%; transform:translateY(-50%); width:20px; height:20px; color:#94a3b8; }
.search-input {
width:100%; padding:0.9rem 3rem; border:1px solid #e2e8f0; border-radius:12px;
font-size:1rem; background:white; box-sizing:border-box; outline:none;
transition:border-color 0.15s, box-shadow 0.15s; box-shadow:0 1px 3px rgba(0,0,0,0.06);
}
.search-input:focus { border-color:#0EA5E9; box-shadow:0 0 0 3px rgba(14,165,233,0.15); }
.clear-btn { position:absolute; right:1rem; top:50%; transform:translateY(-50%); background:none; border:none; cursor:pointer; color:#94a3b8; font-size:1rem; }
.tabs { display:flex; gap:0.5rem; }
.tab {
padding:0.5rem 1.25rem; border:1px solid #e2e8f0; border-radius:8px; background:white;
font-size:0.875rem; font-weight:500; color:#64748b; cursor:pointer; display:flex; align-items:center; gap:0.5rem;
transition:all 0.15s;
}
.tab.active { background:#0EA5E9; color:white; border-color:#0EA5E9; }
.badge { background:rgba(255,255,255,0.3); border-radius:999px; padding:0.1rem 0.5rem; font-size:0.75rem; }
.tab:not(.active) .badge { background:#e0f2fe; color:#0369a1; }
.loading-state { display:flex; align-items:center; gap:0.75rem; padding:3rem; color:#64748b; justify-content:center; }
.spinner { width:24px; height:24px; border:2px solid #e2e8f0; border-top-color:#0EA5E9; border-radius:50%; animation:spin 0.6s linear infinite; }
@keyframes spin { to { transform:rotate(360deg); } }
.results-split { display:grid; grid-template-columns:1fr 1fr; gap:1.5rem; }
.results-col { display:flex; flex-direction:column; gap:0.5rem; }
.col-header { display:flex; align-items:center; gap:0.75rem; margin-bottom:0.5rem; }
.col-count { font-size:0.8rem; color:#94a3b8; }
.col-badge { padding:0.25rem 0.75rem; border-radius:999px; font-size:0.75rem; font-weight:700; }
.col-badge.icd10 { background:#eff6ff; color:#1d4ed8; }
.col-badge.cpt { background:#f0fdf4; color:#15803d; }
.empty-col { color:#94a3b8; font-size:0.875rem; padding:1rem; text-align:center; background:white; border-radius:8px; border:1px dashed #e2e8f0; }
.results-list { display:flex; flex-direction:column; gap:0.5rem; }
.code-card {
background:white; border:1px solid #e2e8f0; border-radius:10px; padding:1rem;
display:flex; align-items:center; gap:1rem; cursor:pointer; transition:border-color 0.15s, box-shadow 0.15s;
}
.code-card:hover { border-color:#0EA5E9; box-shadow:0 0 0 3px rgba(14,165,233,0.08); }
.code-tag {
padding:0.35rem 0.75rem; border-radius:6px; font-size:0.85rem; font-weight:700;
white-space:nowrap; font-family:'JetBrains Mono',monospace;
}
.code-tag.icd10 { background:#eff6ff; color:#1d4ed8; }
.code-tag.cpt { background:#f0fdf4; color:#15803d; }
.code-body { flex:1; min-width:0; }
.code-desc { margin:0; font-size:0.9rem; color:#1e293b; font-weight:500; }
.code-cat { margin:0.2rem 0 0; font-size:0.775rem; color:#94a3b8; }
.code-badge { padding:0.2rem 0.6rem; border-radius:999px; font-size:0.7rem; font-weight:600; background:#f1f5f9; color:#64748b; white-space:nowrap; }
.code-badge.billable { background:#f0fdf4; color:#16a34a; }
.pagination { display:flex; align-items:center; justify-content:center; gap:1rem; padding:1rem; }
.pagination button { padding:0.5rem 1rem; border:1px solid #e2e8f0; border-radius:6px; background:white; cursor:pointer; font-size:0.875rem; }
.pagination button:disabled { opacity:0.4; cursor:not-allowed; }
.pagination span { color:#64748b; font-size:0.875rem; }
.empty-state { display:flex; flex-direction:column; align-items:center; padding:5rem 2rem; color:#94a3b8; }
.empty-state svg { width:80px; height:80px; margin-bottom:1.5rem; }
.empty-state h3 { font-size:1.1rem; color:#475569; margin:0 0 0.5rem; }
.empty-state p { margin:0; font-size:0.9rem; text-align:center; }
.empty-state strong { color:#475569; }
.modal-overlay { position:fixed; inset:0; background:rgba(15,23,42,0.5); display:flex; align-items:center; justify-content:center; z-index:100; }
.modal { background:white; border-radius:16px; width:100%; max-width:480px; box-shadow:0 25px 50px rgba(0,0,0,0.25); overflow:hidden; }
.modal-header { display:flex; align-items:center; justify-content:space-between; padding:1.25rem 1.5rem; border-bottom:1px solid #f1f5f9; }
.modal-close { background:none; border:none; cursor:pointer; color:#94a3b8; font-size:1.2rem; }
.modal-body { padding:1.5rem; }
.modal-body h2 { font-size:1.1rem; font-weight:600; color:#0f172a; margin:0 0 1.25rem; }
.detail-row { display:flex; justify-content:space-between; padding:0.6rem 0; border-bottom:1px solid #f8fafc; font-size:0.875rem; }
.detail-row span { color:#64748b; }
.modal-footer { padding:1rem 1.5rem; border-top:1px solid #f1f5f9; display:flex; justify-content:flex-end; }
.btn-copy { padding:0.6rem 1.5rem; background:#0EA5E9; color:white; border:none; border-radius:8px; font-weight:600; cursor:pointer; font-size:0.9rem; }
.btn-copy:hover { background:#0284c7; }
@media (max-width: 768px) { .results-split { grid-template-columns:1fr; } }
`],
})
export class CodesSearchComponent implements OnInit {
private codesService = inject(CodesService);
private search$ = new Subject<string>();
query = '';
activeTab = signal<Tab>('all');
loading = signal(false);
globalResults = signal<any>(null);
icd10Results = signal<PagedResult<CodeEntry> | null>(null);
cptResults = signal<PagedResult<CodeEntry> | null>(null);
icd10Page = signal(1);
cptPage = signal(1);
selectedCode = signal<CodeEntry | null>(null);
copied = signal(false);
tabs = [
{ id: 'all' as Tab, label: 'All' },
{ id: 'icd10' as Tab, label: 'ICD-10' },
{ id: 'cpt' as Tab, label: 'CPT' },
];
ngOnInit() {
this.search$.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q => {
if (!q.trim()) return of(null);
this.loading.set(true);
if (this.activeTab() === 'all') return this.codesService.globalSearch(q);
if (this.activeTab() === 'icd10') return this.codesService.searchICD10(q, this.icd10Page());
return this.codesService.searchCPT(q, this.cptPage());
}),
).subscribe(res => {
this.loading.set(false);
if (!res) return;
if (this.activeTab() === 'all') this.globalResults.set(res);
else if (this.activeTab() === 'icd10') this.icd10Results.set(res as any);
else this.cptResults.set(res as any);
});
// Load all ICD-10 and CPT on startup for browsing
this.codesService.searchICD10('', 1).subscribe(r => this.icd10Results.set(r));
this.codesService.searchCPT('', 1).subscribe(r => this.cptResults.set(r));
}
onSearch(q: string) { this.search$.next(q); }
switchTab(tab: Tab) {
this.activeTab.set(tab);
if (this.query) this.search$.next(this.query);
}
changePage(type: 'icd10' | 'cpt', delta: number) {
if (type === 'icd10') {
this.icd10Page.update(p => p + delta);
this.codesService.searchICD10(this.query, this.icd10Page()).subscribe(r => this.icd10Results.set(r));
} else {
this.cptPage.update(p => p + delta);
this.codesService.searchCPT(this.query, this.cptPage()).subscribe(r => this.cptResults.set(r));
}
}
selectCode(code: CodeEntry) { this.selectedCode.set(code); }
clear() {
this.query = '';
this.globalResults.set(null);
this.icd10Results.set(null);
this.cptResults.set(null);
}
copyCode(code: string) {
navigator.clipboard.writeText(code);
this.copied.set(true);
setTimeout(() => this.copied.set(false), 2000);
}
}