Spaces:
Sleeping
Sleeping
| 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'; | |
| ({ | |
| 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); | |
| } | |
| } | |