Menu

Change language

GitHub repo

Triage Queue Dashboard

blockhealthcare

Emergency Department (ED) and Urgent Care patient acuity triage queue and room allocation board: live unit header, 4 ESI overview cards with target wait thresholds, searchable patient queue table with vitals snapshot, assigned room, attending care team, and quick clinical actions.

Installation

$npx uipkge-ng@latest add triage-queue-dashboard
Manifest:https://uipkge.dev/r/angular/triage-queue-dashboard.jsonInstalls to:src/app/components/blocks/triage-queue-dashboard/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

PatientVitals
interface PatientVitals {
  bp: string
  hr: number
  spo2: number
  temp: string
  respRate: number
}
Caregiver
interface Caregiver {
  name: string
  role: 'RN' | 'MD' | 'PA' | 'NP'
  initials: string
  avatar?: string
}
TriagePatient
interface TriagePatient {
  id: string
  mrn: string
  name: string
  age: number
  gender: 'M' | 'F' | 'Other'
  acuity: EsiLevel
  acuityCode: string
  acuityLabel: string
  chiefComplaint: string
  symptoms: string
  waitTime: string
  waitMinutes: number
  vitals: PatientVitals
  assignedRoom: string
  roomType: TriageRoomType
  primaryNurse: Caregiver
  attendingPhysician: Caregiver
  clinicalStatus: string
}
AcuitySummaryCard
interface AcuitySummaryCard {
  level: EsiLevel
  title: string
  subtitle: string
  activeCount: number
  targetWait: string
  targetWindow: string
  badgeVariantClass: string
  indicatorDot?: string
  pulse: boolean
  description: string
}

Files installed (2)

  • src/app/components/blocks/triage-queue-dashboard/triage-queue-dashboard.component.ts34.6 kB
    // Emergency Department (ED) triage queue and room allocation board: live unit header,
    // 4 clickable ESI overview cards with target wait thresholds, searchable patient queue
    // table with vitals snapshot, assigned room, attending care team, and quick clinical
    // actions. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import {
      Bed,
      Clock,
      DoorOpen,
      HeartPulse,
      LucideAngularModule,
      MoreHorizontal,
      Plus,
      RefreshCw,
      Search,
      Zap,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiAvatarComponent, UiAvatarFallbackComponent, UiAvatarImageComponent } from '@/ui/avatar/avatar.component'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardDescriptionComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import {
      UiDropdownMenuComponent,
      UiDropdownMenuContentComponent,
      UiDropdownMenuItemComponent,
      UiDropdownMenuLabelComponent,
      UiDropdownMenuSeparatorComponent,
      UiDropdownMenuTriggerComponent,
    } from '@/ui/dropdown-menu/dropdown-menu.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import {
      UiSelectComponent,
      UiSelectContentComponent,
      UiSelectItemComponent,
      UiSelectTriggerComponent,
      UiSelectValueComponent,
    } from '@/ui/select/select.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import {
      UiTableBodyComponent,
      UiTableCellComponent,
      UiTableComponent,
      UiTableHeadComponent,
      UiTableHeaderComponent,
      UiTableRowComponent,
    } from '@/ui/table/table.component'
    
    export type EsiLevel = 'esi-1' | 'esi-2' | 'esi-3' | 'esi-4'
    export type TriageRoomType = 'trauma' | 'acute' | 'fast_track' | 'waiting'
    export type TriageSortBy = 'wait-desc' | 'wait-asc' | 'acuity'
    
    export interface PatientVitals {
      bp: string
      hr: number
      spo2: number
      temp: string
      respRate: number
    }
    
    export interface Caregiver {
      name: string
      role: 'RN' | 'MD' | 'PA' | 'NP'
      initials: string
      avatar?: string
    }
    
    export interface TriagePatient {
      id: string
      mrn: string
      name: string
      age: number
      gender: 'M' | 'F' | 'Other'
      acuity: EsiLevel
      acuityCode: string
      acuityLabel: string
      chiefComplaint: string
      symptoms: string
      waitTime: string
      waitMinutes: number
      vitals: PatientVitals
      assignedRoom: string
      roomType: TriageRoomType
      primaryNurse: Caregiver
      attendingPhysician: Caregiver
      clinicalStatus: string
    }
    
    export interface AcuitySummaryCard {
      level: EsiLevel
      title: string
      subtitle: string
      activeCount: number
      targetWait: string
      targetWindow: string
      badgeVariantClass: string
      indicatorDot?: string
      pulse: boolean
      description: string
    }
    
    const acuityCards: AcuitySummaryCard[] = [
      {
        level: 'esi-1',
        title: 'ESI 1 · Resuscitation',
        subtitle: 'Immediate Life Threat',
        activeCount: 1,
        targetWait: 'Immediate',
        targetWindow: '0 min wait',
        badgeVariantClass: 'border-destructive/30 bg-destructive/10 text-destructive',
        indicatorDot: 'bg-destructive',
        pulse: true,
        description: 'Hemodynamic instability, active airway compromise',
      },
      {
        level: 'esi-2',
        title: 'ESI 2 · Emergent',
        subtitle: 'High Risk / Severe Pain',
        activeCount: 3,
        targetWait: '< 15m wait',
        targetWindow: 'Max 15 min',
        badgeVariantClass: 'border-orange-500/30 bg-orange-500/10 text-orange-700 dark:text-orange-400',
        indicatorDot: 'bg-orange-500',
        pulse: false,
        description: 'Confused, lethargic, severe respiratory distress',
      },
      {
        level: 'esi-3',
        title: 'ESI 3 · Urgent',
        subtitle: 'Multiple Resources Needed',
        activeCount: 8,
        targetWait: '< 30m wait',
        targetWindow: 'Max 30 min',
        badgeVariantClass: 'border-warning/30 bg-warning/10 text-warning',
        indicatorDot: 'bg-warning',
        pulse: false,
        description: 'Stable vitals, 2+ diagnostic or lab orders needed',
      },
      {
        level: 'esi-4',
        title: 'ESI 4/5 · Non-Urgent',
        subtitle: 'Low Resource / Fast Track',
        activeCount: 6,
        targetWait: '< 60m wait',
        targetWindow: 'Max 60 min',
        badgeVariantClass: 'border-success/30 bg-success/10 text-success',
        indicatorDot: 'bg-success',
        pulse: false,
        description: 'Simple lacerations, routine medication re-eval',
      },
    ]
    
    const defaultPatients: TriagePatient[] = [
      {
        id: 'pt-1',
        mrn: 'MRN-94021',
        name: 'James Wilson',
        age: 62,
        gender: 'M',
        acuity: 'esi-1',
        acuityCode: 'ESI 1',
        acuityLabel: 'ESI 1 - Resuscitation',
        chiefComplaint: 'Severe Crushing Chest Pain',
        symptoms: 'Diaphoresis, radiating left jaw pain, dyspnea. STAT STEMI protocol initiated.',
        waitTime: '2 min',
        waitMinutes: 2,
        vitals: { bp: '88/54', hr: 124, spo2: 91, temp: '98.4°F', respRate: 28 },
        assignedRoom: 'Trauma Bay 2',
        roomType: 'trauma',
        primaryNurse: {
          name: 'Sarah Jenkins, RN',
          role: 'RN',
          initials: 'SJ',
          avatar: 'https://images.unsplash.com/photo-1559839734-2b71ea197ec2?w=120&auto=format&fit=crop&q=80',
        },
        attendingPhysician: {
          name: 'Dr. Marcus Chen, MD',
          role: 'MD',
          initials: 'MC',
          avatar: 'https://images.unsplash.com/photo-1622253692010-333f2da6031d?w=120&auto=format&fit=crop&q=80',
        },
        clinicalStatus: 'Cath Lab Alert · In Bay',
      },
      {
        id: 'pt-2',
        mrn: 'MRN-88319',
        name: 'Maria Garcia',
        age: 34,
        gender: 'F',
        acuity: 'esi-2',
        acuityCode: 'ESI 2',
        acuityLabel: 'ESI 2 - Emergent',
        chiefComplaint: 'Compound Tibia Fracture',
        symptoms: 'Post-motorcycle collision, visible bone deformity, uncontrolled bleeding controlled by tourniquet.',
        waitTime: '9 min',
        waitMinutes: 9,
        vitals: { bp: '138/86', hr: 106, spo2: 97, temp: '99.1°F', respRate: 22 },
        assignedRoom: 'Trauma Bay 4',
        roomType: 'trauma',
        primaryNurse: {
          name: 'Kevin Vance, RN',
          role: 'RN',
          initials: 'KV',
          avatar: 'https://images.unsplash.com/photo-1537368910025-700350fe46c7?w=120&auto=format&fit=crop&q=80',
        },
        attendingPhysician: {
          name: 'Dr. Lisa Thorne, MD',
          role: 'MD',
          initials: 'LT',
          avatar: 'https://images.unsplash.com/photo-1594824813637-e54e44e26210?w=120&auto=format&fit=crop&q=80',
        },
        clinicalStatus: 'Ortho Paged · IV Analgesia',
      },
      {
        id: 'pt-3',
        mrn: 'MRN-72915',
        name: 'Noah Miller',
        age: 8,
        gender: 'M',
        acuity: 'esi-2',
        acuityCode: 'ESI 2',
        acuityLabel: 'ESI 2 - Emergent',
        chiefComplaint: 'Acute Asthma Exacerbation',
        symptoms: 'Inspiratory and expiratory stridor, intercostal retractions, refractory to home albuterol.',
        waitTime: '14 min',
        waitMinutes: 14,
        vitals: { bp: '104/68', hr: 132, spo2: 92, temp: '99.8°F', respRate: 34 },
        assignedRoom: 'Bed 06 (Peds Acute)',
        roomType: 'acute',
        primaryNurse: {
          name: 'Angela Ray, RN',
          role: 'RN',
          initials: 'AR',
          avatar: 'https://images.unsplash.com/photo-1582750433449-648ed127bb54?w=120&auto=format&fit=crop&q=80',
        },
        attendingPhysician: {
          name: 'Dr. Marcus Chen, MD',
          role: 'MD',
          initials: 'MC',
          avatar: 'https://images.unsplash.com/photo-1622253692010-333f2da6031d?w=120&auto=format&fit=crop&q=80',
        },
        clinicalStatus: 'Duoneb #2 · Continuous O2',
      },
      {
        id: 'pt-4',
        mrn: 'MRN-51829',
        name: 'Sarah Lee',
        age: 45,
        gender: 'F',
        acuity: 'esi-3',
        acuityCode: 'ESI 3',
        acuityLabel: 'ESI 3 - Urgent',
        chiefComplaint: 'Right Lower Quadrant Abdominal Pain',
        symptoms: 'Severe pain x 14h, focal McBurney point tenderness, nausea, low-grade fever.',
        waitTime: '28 min',
        waitMinutes: 28,
        vitals: { bp: '126/82', hr: 92, spo2: 98, temp: '101.4°F', respRate: 18 },
        assignedRoom: 'Bed 14',
        roomType: 'acute',
        primaryNurse: {
          name: 'David Park, RN',
          role: 'RN',
          initials: 'DP',
          avatar: 'https://images.unsplash.com/photo-1612349317150-e413f6a5b16d?w=120&auto=format&fit=crop&q=80',
        },
        attendingPhysician: {
          name: 'Dr. Sophia Adams, MD',
          role: 'MD',
          initials: 'SA',
          avatar: 'https://images.unsplash.com/photo-1559839734-2b71ea197ec2?w=120&auto=format&fit=crop&q=80',
        },
        clinicalStatus: 'CT Scan Queued · NPO',
      },
      {
        id: 'pt-5',
        mrn: 'MRN-63910',
        name: 'Robert Chen',
        age: 58,
        gender: 'M',
        acuity: 'esi-3',
        acuityCode: 'ESI 3',
        acuityLabel: 'ESI 3 - Urgent',
        chiefComplaint: 'Suspected Renal Colic & Flank Pain',
        symptoms: 'Sudden onset severe right flank pain radiating to groin, gross hematuria, pain score 8/10.',
        waitTime: '42 min',
        waitMinutes: 42,
        vitals: { bp: '146/94', hr: 88, spo2: 99, temp: '98.4°F', respRate: 16 },
        assignedRoom: 'Bed 18',
        roomType: 'acute',
        primaryNurse: {
          name: 'Rachel Torres, RN',
          role: 'RN',
          initials: 'RT',
          avatar: 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=120&auto=format&fit=crop&q=80',
        },
        attendingPhysician: {
          name: 'Dr. Sophia Adams, MD',
          role: 'MD',
          initials: 'SA',
          avatar: 'https://images.unsplash.com/photo-1559839734-2b71ea197ec2?w=120&auto=format&fit=crop&q=80',
        },
        clinicalStatus: 'Ultrasound Ordered · IV Fluids',
      },
      {
        id: 'pt-6',
        mrn: 'MRN-90423',
        name: 'Marcus Vance',
        age: 22,
        gender: 'M',
        acuity: 'esi-4',
        acuityCode: 'ESI 4',
        acuityLabel: 'ESI 4 - Non-Urgent',
        chiefComplaint: 'Forearm Deep Glass Laceration',
        symptoms: '4cm clean linear laceration, hemostasis achieved, neurovascular sensation intact distally.',
        waitTime: '54 min',
        waitMinutes: 54,
        vitals: { bp: '120/78', hr: 74, spo2: 100, temp: '98.6°F', respRate: 14 },
        assignedRoom: 'Fast Track 02',
        roomType: 'fast_track',
        primaryNurse: {
          name: 'Kevin Vance, RN',
          role: 'RN',
          initials: 'KV',
          avatar: 'https://images.unsplash.com/photo-1537368910025-700350fe46c7?w=120&auto=format&fit=crop&q=80',
        },
        attendingPhysician: {
          name: 'Dr. Emily Watson, MD',
          role: 'MD',
          initials: 'EW',
          avatar: 'https://images.unsplash.com/photo-1594824813637-e54e44e26210?w=120&auto=format&fit=crop&q=80',
        },
        clinicalStatus: 'Wound Irrigated · Suture Tray Ready',
      },
    ]
    
    const acuityOrder: Record<EsiLevel, number> = { 'esi-1': 1, 'esi-2': 2, 'esi-3': 3, 'esi-4': 4 }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-triage-queue-dashboard, [ui-triage-queue-dashboard]',
      standalone: true,
      // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiAvatarComponent,
        UiAvatarFallbackComponent,
        UiAvatarImageComponent,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiDropdownMenuComponent,
        UiDropdownMenuContentComponent,
        UiDropdownMenuItemComponent,
        UiDropdownMenuLabelComponent,
        UiDropdownMenuSeparatorComponent,
        UiDropdownMenuTriggerComponent,
        UiInputComponent,
        UiSelectComponent,
        UiSelectContentComponent,
        UiSelectItemComponent,
        UiSelectTriggerComponent,
        UiSelectValueComponent,
        UiSeparatorComponent,
        UiTableBodyComponent,
        UiTableCellComponent,
        UiTableComponent,
        UiTableHeadComponent,
        UiTableHeaderComponent,
        UiTableRowComponent,
      ],
      template: `
        <div data-slot="triage-queue-dashboard" [class]="rootClass">
          <!-- Live ED Header -->
          <header class="bg-card border-border rounded-xl border p-5 shadow-xs sm:p-6">
            <div class="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
              <div class="space-y-1.5">
                <div class="flex flex-wrap items-center gap-2.5">
                  <div class="bg-primary/10 text-primary flex size-9 items-center justify-center rounded-lg">
                    <lucide-icon [img]="HeartPulse" class="size-5" />
                  </div>
                  <div>
                    <h1 class="text-foreground text-xl font-bold tracking-tight sm:text-2xl">
                      Emergency Department Triage Queue
                    </h1>
                    <p class="text-muted-foreground text-xs font-medium sm:text-sm">{{ facilityName }}</p>
                  </div>
                </div>
    
                <div class="flex flex-wrap items-center gap-2 pt-1 text-xs">
                  <span ui-badge variant="outline" class="border-border bg-muted/50 text-foreground gap-1.5 font-medium">
                    <lucide-icon [img]="Bed" class="text-primary size-3.5" />
                    {{ unitName }}
                  </span>
    
                  <span ui-badge variant="outline" class="border-success/30 bg-success/10 text-success gap-1.5 font-medium">
                    <span class="bg-success size-1.5 animate-pulse rounded-full"></span>
                    18 In Unit · 4 Awaiting Triage
                  </span>
    
                  <span class="text-muted-foreground hidden items-center gap-1 sm:inline-flex">
                    <lucide-icon [img]="Clock" class="size-3" />
                    Live Sync · Updated seconds ago
                  </span>
                </div>
              </div>
    
              <div class="flex flex-wrap items-center gap-2.5">
                <button ui-button variant="outline" size="sm" class="gap-1.5 text-xs font-medium">
                  <lucide-icon [img]="RefreshCw" class="text-muted-foreground size-3.5" />
                  Refresh Queue
                </button>
    
                <button ui-button size="sm" class="gap-1.5 text-xs font-semibold shadow-xs">
                  <lucide-icon [img]="Plus" class="size-4" />
                  Admit New Patient
                </button>
              </div>
            </div>
          </header>
    
          <!-- 4 Acuity Overview Cards (ESI 1 to ESI 4/5) -->
          <section aria-label="Acuity Overview Cards" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
            @for (card of acuityCards; track card.level) {
              <div ui-card [class]="acuityCardClass(card)" (click)="handleAcuityCardClick(card.level)">
                <div ui-card-content class="p-4 sm:p-5">
                  <div class="flex items-start justify-between gap-2">
                    <div class="space-y-1">
                      <div class="flex items-center gap-1.5">
                        <span [class]="acuityDotClass(card)"></span>
                        <span class="text-muted-foreground text-xs font-medium tracking-wider uppercase">
                          {{ card.subtitle }}
                        </span>
                      </div>
                      <h2 class="text-foreground text-sm font-semibold sm:text-base">{{ card.title }}</h2>
                    </div>
    
                    <span
                      ui-badge
                      variant="outline"
                      [class]="'gap-1 px-2 py-0.5 text-xs font-semibold ' + card.badgeVariantClass"
                    >
                      @if (card.pulse) {
                        <span class="bg-destructive size-1.5 animate-pulse rounded-full"></span>
                      }
                      {{ card.targetWait }}
                    </span>
                  </div>
    
                  <div class="mt-4 flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5">
                    <div class="flex items-baseline gap-1.5">
                      <span class="text-foreground text-2xl font-bold tracking-tight tabular-nums sm:text-3xl">
                        {{ card.activeCount }}
                      </span>
                      <span class="text-muted-foreground text-xs font-medium">active in queue</span>
                    </div>
    
                    <span class="text-muted-foreground text-xs tabular-nums">{{ card.targetWindow }}</span>
                  </div>
    
                  <p class="text-muted-foreground mt-2 text-xs leading-relaxed">{{ card.description }}</p>
                </div>
              </div>
            }
          </section>
    
          <!-- Patient Queue Table Card -->
          <div ui-card class="border-border shadow-xs">
            <div ui-card-header class="border-border border-b p-4 sm:p-5">
              <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
                <div>
                  <div class="flex items-center gap-2">
                    <h3 ui-card-title class="text-foreground text-base font-semibold sm:text-lg">
                      Patient Triage & Allocation Board
                    </h3>
                    <span ui-badge variant="secondary" class="font-mono text-xs tabular-nums">
                      {{ filteredPatients().length }} Patients
                    </span>
                  </div>
                  <p ui-card-description class="text-muted-foreground mt-0.5 text-xs">
                    Continuous monitoring of emergency severity index, vital signs, and room throughput.
                  </p>
                </div>
    
                <div class="flex flex-wrap items-center gap-2.5">
                  <div class="relative w-full min-w-[200px] sm:w-60">
                    <lucide-icon
                      [img]="Search"
                      class="text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2"
                    />
                    <ui-input
                      [value]="search()"
                      (valueChange)="search.set($event)"
                      type="text"
                      placeholder="Search patient, MRN, bay..."
                      class="h-8 pl-8 text-xs sm:text-sm"
                    />
                  </div>
    
                  <div class="w-36 sm:w-40">
                    <ui-select [value]="acuityFilter()" (valueChange)="setAcuityFilter($event)">
                      <button ui-select-trigger class="h-8 text-xs">
                        <ui-select-value placeholder="All Acuities" />
                      </button>
                      <ui-select-content>
                        <ui-select-item value="all">All Acuities</ui-select-item>
                        <ui-select-item value="esi-1">ESI 1 - Resuscitation</ui-select-item>
                        <ui-select-item value="esi-2">ESI 2 - Emergent</ui-select-item>
                        <ui-select-item value="esi-3">ESI 3 - Urgent</ui-select-item>
                        <ui-select-item value="esi-4">ESI 4/5 - Non-Urgent</ui-select-item>
                      </ui-select-content>
                    </ui-select>
                  </div>
    
                  <div class="w-32 sm:w-36">
                    <ui-select [value]="locationFilter()" (valueChange)="setLocationFilter($event)">
                      <button ui-select-trigger class="h-8 text-xs">
                        <ui-select-value placeholder="All Locations" />
                      </button>
                      <ui-select-content>
                        <ui-select-item value="all">All Locations</ui-select-item>
                        <ui-select-item value="trauma">Trauma Bays</ui-select-item>
                        <ui-select-item value="acute">Acute Beds</ui-select-item>
                        <ui-select-item value="fast_track">Fast Track</ui-select-item>
                      </ui-select-content>
                    </ui-select>
                  </div>
    
                  @if (isFiltered()) {
                    <button ui-button variant="ghost" size="sm" class="h-8 px-2 text-xs" (click)="resetFilters()">
                      Clear
                    </button>
                  }
                </div>
              </div>
            </div>
    
            <div ui-card-content class="p-0">
              <div class="overflow-x-auto">
                <table ui-table>
                  <thead ui-table-header>
                    <tr ui-table-row class="hover:bg-transparent">
                      <th ui-table-head class="w-[140px] text-xs font-semibold">Acuity (ESI)</th>
                      <th ui-table-head class="min-w-[200px] text-xs font-semibold">Patient Demographics</th>
                      <th ui-table-head class="min-w-[240px] text-xs font-semibold">Chief Complaint & Symptoms</th>
                      <th ui-table-head class="w-[110px] text-xs font-semibold">Wait Time</th>
                      <th ui-table-head class="min-w-[190px] text-xs font-semibold">Vitals Snapshot</th>
                      <th ui-table-head class="w-[150px] text-xs font-semibold">Bed / Room</th>
                      <th ui-table-head class="min-w-[160px] text-xs font-semibold">Attending Staff</th>
                      <th ui-table-head class="w-[60px] text-right text-xs font-semibold">
                        <span class="sr-only">Actions</span>
                      </th>
                    </tr>
                  </thead>
    
                  <tbody ui-table-body>
                    @if (filteredPatients().length === 0) {
                      <tr ui-table-row>
                        <td ui-table-cell [attr.colspan]="8" class="h-48 text-center">
                          <div class="flex flex-col items-center justify-center gap-2">
                            <div class="bg-muted flex size-10 items-center justify-center rounded-full">
                              <lucide-icon [img]="Search" class="text-muted-foreground size-5" />
                            </div>
                            <p class="text-foreground text-sm font-semibold">No patients match current filters</p>
                            <p class="text-muted-foreground text-xs">
                              Try clearing your search query or selecting "All Acuities".
                            </p>
                            <button ui-button variant="outline" size="sm" class="mt-2 text-xs" (click)="resetFilters()">
                              Reset Filters
                            </button>
                          </div>
                        </td>
                      </tr>
                    }
    
                    @for (pt of filteredPatients(); track pt.id) {
                      <tr ui-table-row [class]="patientRowClass(pt)">
                        <td ui-table-cell class="align-top font-medium">
                          <div class="flex flex-col items-start gap-1">
                            <span ui-badge variant="outline" [class]="acuityBadgeClass(pt)">
                              @if (pt.acuity === 'esi-1') {
                                <span class="bg-destructive size-1.5 animate-pulse rounded-full"></span>
                              }
                              {{ pt.acuityCode }}
                            </span>
                            <span class="text-muted-foreground text-xs font-medium">{{ acuityLabel(pt.acuity) }}</span>
                          </div>
                        </td>
    
                        <td ui-table-cell class="align-top">
                          <div class="space-y-1">
                            <div class="flex items-center gap-1.5">
                              <span class="text-foreground text-sm font-semibold tracking-tight">{{ pt.name }}</span>
                              <span class="text-muted-foreground text-xs font-medium">({{ pt.age }}{{ pt.gender }})</span>
                            </div>
                            <div class="text-muted-foreground flex items-center gap-2 font-mono text-xs">
                              <span>{{ pt.mrn }}</span>
                            </div>
                          </div>
                        </td>
    
                        <td ui-table-cell class="align-top">
                          <div class="space-y-1">
                            <p class="text-foreground text-xs font-semibold">{{ pt.chiefComplaint }}</p>
                            <p class="text-muted-foreground text-xs leading-relaxed">{{ pt.symptoms }}</p>
                          </div>
                        </td>
    
                        <td ui-table-cell class="align-top">
                          <div class="flex items-center gap-1.5 pt-0.5">
                            <lucide-icon [img]="Clock" [class]="waitIconClass(pt)" />
                            <span [class]="waitTextClass(pt)">{{ pt.waitTime }}</span>
                          </div>
                        </td>
    
                        <td ui-table-cell class="align-top">
                          <div class="grid grid-cols-2 gap-x-3 gap-y-1 text-xs">
                            <div class="flex items-center justify-between gap-1 font-mono">
                              <span class="text-muted-foreground">BP:</span>
                              <span [class]="bpClass(pt)">{{ pt.vitals.bp }}</span>
                            </div>
                            <div class="flex items-center justify-between gap-1 font-mono">
                              <span class="text-muted-foreground">HR:</span>
                              <span [class]="hrClass(pt)">{{ pt.vitals.hr }} bpm</span>
                            </div>
                            <div class="flex items-center justify-between gap-1 font-mono">
                              <span class="text-muted-foreground">SpO2:</span>
                              <span [class]="spo2Class(pt)">{{ pt.vitals.spo2 }}%</span>
                            </div>
                            <div class="flex items-center justify-between gap-1 font-mono">
                              <span class="text-muted-foreground">Temp:</span>
                              <span class="text-foreground font-semibold tabular-nums">{{ pt.vitals.temp }}</span>
                            </div>
                          </div>
                        </td>
    
                        <td ui-table-cell class="align-top">
                          <div class="space-y-1">
                            <span
                              ui-badge
                              variant="outline"
                              class="border-border bg-muted/40 text-foreground gap-1 text-xs font-medium"
                            >
                              <lucide-icon [img]="Bed" class="text-primary size-3" />
                              {{ pt.assignedRoom }}
                            </span>
                            <p class="text-muted-foreground text-xs">{{ pt.clinicalStatus }}</p>
                          </div>
                        </td>
    
                        <td ui-table-cell class="align-top">
                          <div class="space-y-1.5">
                            <div class="flex items-center gap-2">
                              <ui-avatar class="size-6">
                                <ui-avatar-image [src]="pt.primaryNurse.avatar" [alt]="pt.primaryNurse.name" />
                                <ui-avatar-fallback class="bg-primary/10 text-primary text-xs font-medium">
                                  {{ pt.primaryNurse.initials }}
                                </ui-avatar-fallback>
                              </ui-avatar>
                              <span
                                class="text-foreground max-w-[130px] truncate text-xs leading-none font-medium"
                                [attr.title]="pt.primaryNurse.name"
                              >
                                {{ pt.primaryNurse.name }}
                              </span>
                            </div>
                            <div class="flex items-center gap-2">
                              <ui-avatar class="size-6">
                                <ui-avatar-image [src]="pt.attendingPhysician.avatar" [alt]="pt.attendingPhysician.name" />
                                <ui-avatar-fallback class="bg-secondary text-secondary-foreground text-xs font-medium">
                                  {{ pt.attendingPhysician.initials }}
                                </ui-avatar-fallback>
                              </ui-avatar>
                              <span
                                class="text-muted-foreground max-w-[130px] truncate text-xs leading-none"
                                [attr.title]="pt.attendingPhysician.name"
                              >
                                {{ pt.attendingPhysician.name }}
                              </span>
                            </div>
                          </div>
                        </td>
    
                        <td ui-table-cell class="text-right align-top">
                          <ui-dropdown-menu>
                            <button ui-button ui-dropdown-menu-trigger variant="ghost" size="icon-sm" class="size-8">
                              <lucide-icon [img]="MoreHorizontal" class="size-4" />
                              <span class="sr-only">Patient actions</span>
                            </button>
                            <ui-dropdown-menu-content align="end" class="w-48">
                              <ui-dropdown-menu-label>Triage Actions</ui-dropdown-menu-label>
                              <ui-dropdown-menu-item>
                                <lucide-icon [img]="Bed" class="size-4" />
                                <span>Assign Room</span>
                              </ui-dropdown-menu-item>
                              <ui-dropdown-menu-item>
                                <lucide-icon [img]="HeartPulse" class="size-4" />
                                <span>Re-triage Acuity</span>
                              </ui-dropdown-menu-item>
                              <ui-dropdown-menu-item>
                                <lucide-icon [img]="Zap" class="size-4" />
                                <span>Order STAT Labs</span>
                              </ui-dropdown-menu-item>
                              <ui-dropdown-menu-separator />
                              <ui-dropdown-menu-item>
                                <lucide-icon [img]="DoorOpen" class="size-4" />
                                <span>Discharge / Transfer</span>
                              </ui-dropdown-menu-item>
                            </ui-dropdown-menu-content>
                          </ui-dropdown-menu>
                        </td>
                      </tr>
                    }
                  </tbody>
                </table>
              </div>
    
              <div
                class="border-border bg-muted/20 text-muted-foreground flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-xs"
              >
                <div class="flex items-center gap-3 font-medium">
                  <span>Level 1 Trauma Alert Protocol Active</span>
                  <div ui-separator orientation="vertical" class="h-3.5"></div>
                  <span class="tabular-nums">Average Door-to-Triage: 4.2 min</span>
                </div>
    
                <div class="flex items-center gap-2">
                  <span class="text-foreground font-medium">ED Occupancy:</span>
                  <span class="text-foreground font-semibold tabular-nums">88% (22/25 Beds)</span>
                </div>
              </div>
            </div>
          </div>
        </div>
      `,
    })
    export class UiTriageQueueDashboardComponent {
      protected readonly Bed = Bed
      protected readonly Clock = Clock
      protected readonly DoorOpen = DoorOpen
      protected readonly HeartPulse = HeartPulse
      protected readonly MoreHorizontal = MoreHorizontal
      protected readonly Plus = Plus
      protected readonly RefreshCw = RefreshCw
      protected readonly Search = Search
      protected readonly Zap = Zap
    
      protected readonly acuityCards = acuityCards
    
      @Input('class') className?: string
      @Input() unitName = 'Level 1 Trauma Center · Bay 4'
      @Input() facilityName = 'St. Jude Metropolitan Academic Medical Center'
      @Input() set initialSearch(v: string | undefined) {
        this.search.set(v ?? '')
      }
      @Input() set initialAcuity(v: string | undefined) {
        if (v) this.acuityFilter.set(v)
      }
      @Input() set initialLocation(v: string | undefined) {
        if (v) this.locationFilter.set(v)
      }
      @Input() set initialPatients(v: TriagePatient[] | undefined) {
        if (v) this.patients.set(v)
      }
    
      readonly patients = signal<TriagePatient[]>(defaultPatients.map((p) => ({ ...p })))
      readonly search = signal('')
      readonly acuityFilter = signal<string>('all')
      readonly locationFilter = signal<string>('all')
      readonly sortBy = signal<TriageSortBy>('acuity')
    
      readonly filteredPatients = computed(() => {
        const q = this.search().trim().toLowerCase()
        const acuity = this.acuityFilter()
        const location = this.locationFilter()
        const sort = this.sortBy()
        return this.patients()
          .filter((pt) => {
            if (q) {
              const matchesName = pt.name.toLowerCase().includes(q)
              const matchesMrn = pt.mrn.toLowerCase().includes(q)
              const matchesComplaint = pt.chiefComplaint.toLowerCase().includes(q)
              const matchesRoom = pt.assignedRoom.toLowerCase().includes(q)
              if (!matchesName && !matchesMrn && !matchesComplaint && !matchesRoom) return false
            }
            if (acuity !== 'all' && pt.acuity !== acuity) return false
            if (location !== 'all' && pt.roomType !== location) return false
            return true
          })
          .sort((a, b) => {
            if (sort === 'wait-desc') return b.waitMinutes - a.waitMinutes
            if (sort === 'wait-asc') return a.waitMinutes - b.waitMinutes
            return acuityOrder[a.acuity] - acuityOrder[b.acuity]
          })
      })
    
      readonly isFiltered = computed(
        () => !!this.search() || this.acuityFilter() !== 'all' || this.locationFilter() !== 'all',
      )
    
      get rootClass(): string {
        return cn('bg-background text-foreground flex w-full flex-col gap-6 font-sans', this.className)
      }
    
      acuityCardClass(card: AcuitySummaryCard): string {
        return cn(
          'border-border relative cursor-pointer transition-colors duration-150 select-none hover:shadow-sm',
          this.acuityFilter() === card.level && 'ring-primary/40 bg-accent/40 ring-2',
        )
      }
    
      acuityDotClass(card: AcuitySummaryCard): string {
        return cn('size-2 shrink-0 rounded-full', card.indicatorDot, card.pulse && 'animate-pulse')
      }
    
      acuityBadgeClass(pt: TriagePatient): string {
        return cn(
          'gap-1.5 px-2.5 py-1 text-xs font-semibold whitespace-nowrap',
          pt.acuity === 'esi-1' && 'border-destructive/30 bg-destructive/10 text-destructive',
          pt.acuity === 'esi-2' && 'border-orange-500/30 bg-orange-500/10 text-orange-700 dark:text-orange-400',
          pt.acuity === 'esi-3' && 'border-warning/30 bg-warning/10 text-warning',
          pt.acuity === 'esi-4' && 'border-success/30 bg-success/10 text-success',
        )
      }
    
      acuityLabel(level: EsiLevel): string {
        if (level === 'esi-1') return 'Resuscitation'
        if (level === 'esi-2') return 'Emergent'
        if (level === 'esi-3') return 'Urgent'
        return 'Non-Urgent'
      }
    
      patientRowClass(pt: TriagePatient): string {
        return cn('transition-colors', pt.acuity === 'esi-1' && 'bg-destructive/[0.03] dark:bg-destructive/10]')
      }
    
      waitIconClass(pt: TriagePatient): string {
        return cn('size-3.5 shrink-0', pt.waitMinutes > 30 ? 'text-warning' : 'text-muted-foreground')
      }
    
      waitTextClass(pt: TriagePatient): string {
        return cn('text-xs font-semibold tabular-nums', pt.waitMinutes > 40 ? 'text-warning' : 'text-foreground')
      }
    
      bpClass(pt: TriagePatient): string {
        return cn('font-semibold tabular-nums', pt.acuity === 'esi-1' ? 'text-destructive' : 'text-foreground')
      }
    
      hrClass(pt: TriagePatient): string {
        return cn('font-semibold tabular-nums', pt.vitals.hr > 110 ? 'text-destructive' : 'text-foreground')
      }
    
      spo2Class(pt: TriagePatient): string {
        return cn('font-semibold tabular-nums', pt.vitals.spo2 < 95 ? 'text-destructive' : 'text-foreground')
      }
    
      handleAcuityCardClick(level: EsiLevel): void {
        this.acuityFilter.set(this.acuityFilter() === level ? 'all' : level)
      }
    
      setAcuityFilter(value: string): void {
        this.acuityFilter.set(value)
      }
    
      setLocationFilter(value: string): void {
        this.locationFilter.set(value)
      }
    
      setSortBy(value: TriageSortBy): void {
        this.sortBy.set(value)
      }
    
      resetFilters(): void {
        this.search.set('')
        this.acuityFilter.set('all')
        this.locationFilter.set('all')
        this.sortBy.set('acuity')
      }
    }
    
  • src/app/components/blocks/triage-queue-dashboard/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/triage-queue-dashboard.json