Telehealth Video Room
blockhealthcareHIPAA-compliant telehealth video consultation room with live telemetry vitals, clinical SOAP notes, in-call encrypted chat, and prescription generator drawer.
Installation
$pnpm dlx uipkge-ng@latest add telehealth-video-room$npx uipkge-ng@latest add telehealth-video-room$yarn dlx uipkge-ng@latest add telehealth-video-room$bunx uipkge-ng@latest add telehealth-video-roomManifest:
https://uipkge.dev/r/angular/telehealth-video-room.jsonInstalls to:src/app/components/blocks/telehealth-video-room/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
PrescriptionIteminterface PrescriptionItem {
id: string
drugName: string
dosage: string
sig: string
quantity: string
refills: number
pharmacy: string
timestamp: string
status: 'transmitted' | 'pending'
rxNumber: string
}ChatMessageinterface ChatMessage {
id: string
sender: 'doctor' | 'patient'
senderName: string
text: string
timestamp: string
attachment?: {
name: string
size: string
type: 'pdf' | 'image'
}
}DrugOptioninterface DrugOption {
id: string
name: string
defaultDosage: string
defaultQty: string
refills: string
}Files installed (2)
src/app/components/blocks/telehealth-video-room/telehealth-video-room.component.ts54.3 kB
// HIPAA-compliant telehealth video consultation room with live telemetry // vitals, clinical SOAP notes, in-call encrypted chat, and an e-prescription // generator. Port of the React block 1:1. import { ChangeDetectionStrategy, Component, DestroyRef, Input, computed, inject, signal } from '@angular/core' import { AlertCircle, CheckCircle2, Clock, Download, FileCheck, FileText, Heart, HeartPulse, Lock, LucideAngularModule, MessageSquare, Mic, MicOff, PanelRightClose, PanelRightOpen, Paperclip, PhoneOff, Pill, RefreshCw, Send, Settings, Share2, ShieldCheck, Thermometer, Video, VideoOff, Wifi, X, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiAvatarComponent, UiAvatarFallbackComponent } 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 { UiInputComponent } from '@/ui/input/input.component' import { UiSeparatorComponent } from '@/ui/separator/separator.component' import { UiTabsComponent, UiTabsContentComponent, UiTabsListComponent, UiTabsTriggerComponent, } from '@/ui/tabs/tabs.component' import { UiTextareaComponent } from '@/ui/textarea/textarea.component' export type TelehealthTab = 'notes' | 'chat' | 'vitals' export interface PrescriptionItem { id: string drugName: string dosage: string sig: string quantity: string refills: number pharmacy: string timestamp: string status: 'transmitted' | 'pending' rxNumber: string } export interface ChatMessage { id: string sender: 'doctor' | 'patient' senderName: string text: string timestamp: string attachment?: { name: string size: string type: 'pdf' | 'image' } } interface DrugOption { id: string name: string defaultDosage: string defaultQty: string refills: string } const drugOptions: DrugOption[] = [ { id: 'metoprolol', name: 'Metoprolol Succinate ER', defaultDosage: '25mg PO Daily (Morning)', defaultQty: '30 tablets', refills: '2', }, { id: 'propranolol', name: 'Propranolol HCl', defaultDosage: '10mg PO BID (Twice Daily)', defaultQty: '60 tablets', refills: '1', }, { id: 'diltiazem', name: 'Diltiazem HCl Extended Release', defaultDosage: '120mg PO Daily', defaultQty: '30 capsules', refills: '3', }, { id: 'lisinopril', name: 'Lisinopril', defaultDosage: '10mg PO Daily', defaultQty: '30 tablets', refills: '3' }, ] @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-telehealth-video-room, [ui-telehealth-video-room]', 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, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardHeaderComponent, UiCardTitleComponent, UiInputComponent, UiSeparatorComponent, UiTabsComponent, UiTabsContentComponent, UiTabsListComponent, UiTabsTriggerComponent, UiTextareaComponent, ], template: ` <div data-slot="telehealth-video-room" [class]="rootClass"> <!-- Top Room Bar --> <header class="bg-card rounded-xl border p-4 shadow-xs sm:px-6 sm:py-3.5"> <div class="flex flex-wrap items-center justify-between gap-3"> <!-- Call Metadata & HIPAA Badge --> <div class="flex flex-wrap items-center gap-3"> <div class="flex items-center gap-2"> <span class="relative flex size-3"> @if (!isCallEnded()) { <span class="bg-success absolute inline-flex h-full w-full rounded-full opacity-75"></span> } <span [class]="'relative inline-flex size-3 rounded-full ' + (isCallEnded() ? 'bg-zinc-400' : 'bg-success')" ></span> </span> <div class="flex items-center gap-1.5 font-mono text-sm font-semibold tracking-tight"> <lucide-icon [img]="Clock" class="text-muted-foreground size-3.5" /> <span>{{ isCallEnded() ? 'Call Ended' : formattedDuration() }}</span> </div> </div> <div ui-separator orientation="vertical" class="hidden h-5 sm:block"></div> <!-- HIPAA Encrypted Connection Badge --> <span ui-badge variant="outline" class="border-success/30 bg-success/10 text-success gap-1.5 text-xs font-medium" > <lucide-icon [img]="Lock" class="text-success size-3" /> <span>HIPAA Encrypted · AES-256</span> </span> <div ui-separator orientation="vertical" class="hidden h-5 md:block"></div> <!-- Doctor & Patient Context Strip --> <div class="hidden items-center gap-2 lg:flex"> <div class="flex items-center gap-1.5 text-xs"> <span class="text-muted-foreground">Provider:</span> <span class="text-foreground font-semibold">Dr. Sarah Jenkins, MD</span> <span class="text-muted-foreground">(Cardiology)</span> </div> <div class="flex items-center gap-1.5 text-xs"> <span class="text-muted-foreground">Patient:</span> <span class="text-foreground font-semibold">David Chen</span> <span class="text-muted-foreground font-mono text-xs">(MRN-88412)</span> </div> </div> </div> <!-- Header Actions: End Call & Network Status --> <div class="flex items-center gap-2.5"> <div class="text-muted-foreground hidden items-center gap-1.5 text-xs sm:flex"> <lucide-icon [img]="Wifi" class="text-success size-3.5" /> <span>HD Connection (18ms)</span> </div> @if (!isCallEnded()) { <button ui-button variant="destructive" size="sm" class="h-8 gap-1.5 px-3 text-xs font-semibold shadow-xs" (click)="handleEndCall()" > <lucide-icon [img]="PhoneOff" class="size-3.5" /> <span>End Consultation</span> </button> } @else { <button ui-button variant="default" size="sm" class="h-8 gap-1.5 px-3 text-xs font-semibold" (click)="handleRestartCall()" > <lucide-icon [img]="RefreshCw" class="size-3.5" /> <span>Reconnect Call</span> </button> } </div> </div> </header> <!-- Main Video Room Grid (Video Canvas + Collapsible Workspace) --> <div class="grid grid-cols-1 gap-4 lg:grid-cols-12"> <!-- Video Call Canvas --> <section [class]="canvasClass"> <!-- Overlay Top: Provider Status & Watermark --> <div class="z-10 flex flex-wrap items-start justify-between gap-3"> <div class="flex items-center gap-2.5 rounded-lg border border-zinc-700/60 bg-zinc-900/85 p-2 shadow-md backdrop-blur-md" > <ui-avatar class="border-success/50 size-8 border"> <ui-avatar-fallback class="bg-success/15 text-success text-xs font-semibold">SJ</ui-avatar-fallback> </ui-avatar> <div> <div class="flex items-center gap-1.5"> <span class="text-xs font-semibold text-zinc-100">Dr. Sarah Jenkins, MD</span> <span ui-badge variant="outline" class="border-success/40 bg-success/20 text-success px-1 py-0 text-xs font-normal" > Attending </span> </div> <div class="text-muted-foreground flex items-center gap-2 text-xs"> <div class="flex items-center gap-1"> <lucide-icon [img]="Mic" class="text-success size-3" /> <div class="flex items-center gap-0.5"> <span class="bg-success size-1 animate-pulse rounded-full"></span> <span class="bg-success h-2.5 w-0.5 animate-[pulse_0.8s_ease-in-out_infinite] rounded-full" ></span> <span class="bg-success h-3.5 w-0.5 animate-[pulse_1.2s_ease-in-out_infinite] rounded-full" ></span> <span class="bg-success h-1.5 w-0.5 animate-[pulse_0.6s_ease-in-out_infinite] rounded-full" ></span> </div> <span class="text-success text-xs font-medium">Speaking</span> </div> <span>·</span> <span>St. Jude Telehealth Clinic</span> </div> </div> </div> <div class="flex items-center gap-1.5 rounded-lg border border-zinc-800 bg-zinc-900/80 px-2.5 py-1 text-xs text-zinc-300 backdrop-blur-sm" > <lucide-icon [img]="ShieldCheck" class="text-success size-3.5" /> <span class="font-mono text-xs">1080p 60fps · BAA Protected</span> </div> </div> <!-- Main Video Placeholder Canvas --> <div class="relative my-auto flex flex-col items-center justify-center pt-12 pb-28 text-center"> <div class="pointer-events-none absolute inset-0 bg-radial from-emerald-950/20 via-zinc-950/80 to-zinc-950" ></div> <div class="relative z-10 space-y-4"> <div class="relative mx-auto size-28 sm:size-36"> <div class="absolute -inset-1 rounded-full bg-gradient-to-tr from-emerald-500 to-teal-400 opacity-60 blur-xs" ></div> <div class="border-success/80 relative flex size-full items-center justify-center overflow-hidden rounded-full border-2 bg-zinc-900 shadow-xl" > <ui-avatar class="size-full"> <ui-avatar-fallback class="text-success bg-zinc-900 text-xl font-bold sm:text-2xl"> SJ </ui-avatar-fallback> </ui-avatar> </div> <span class="bg-success absolute right-2 bottom-2 size-4 rounded-full ring-2 ring-zinc-950"></span> </div> <div class="space-y-1"> <h3 class="text-base font-semibold tracking-tight text-zinc-100 sm:text-lg">Dr. Sarah Jenkins, MD</h3> <p class="text-muted-foreground text-xs"> Department of Cardiovascular Medicine · Clinical Telehealth Encounter </p> </div> <div class="text-muted-foreground inline-flex max-w-full items-center gap-2 rounded-full border border-zinc-800 bg-zinc-900/70 px-3 py-1 font-mono text-xs" > <span class="bg-success size-1.5 shrink-0 rounded-full"></span> <span class="truncate">Session: #TH-9021-TX</span> <span class="hidden sm:inline">·</span> <span class="hidden truncate sm:inline">Latency: 24ms (0% loss)</span> </div> </div> </div> <!-- Picture-in-Picture Self Video Tile --> <div class="absolute right-4 bottom-20 z-20 aspect-video w-36 overflow-hidden rounded-xl border-2 border-zinc-700/80 bg-zinc-900 shadow-sm transition-colors sm:right-6 sm:bottom-20 sm:w-48" > @if (!isCameraOff()) { <div class="relative flex size-full flex-col justify-between bg-gradient-to-b from-zinc-800 to-zinc-950 p-2 text-white" > <div class="flex items-center justify-between"> <span ui-badge variant="outline" class="border-zinc-700 bg-zinc-900/80 px-1 py-0 text-xs text-zinc-300" > You (720p) </span> <div [class]=" 'flex size-4 items-center justify-center rounded-full ' + (isMuted() ? 'bg-destructive text-white' : 'bg-success/80 text-white') " > @if (isMuted()) { <lucide-icon [img]="MicOff" class="size-2.5" /> } @else { <lucide-icon [img]="Mic" class="size-2.5" /> } </div> </div> <div class="flex items-center gap-1.5"> <ui-avatar class="size-5"> <ui-avatar-fallback class="bg-zinc-700 text-xs font-bold text-zinc-100">DC</ui-avatar-fallback> </ui-avatar> <span class="truncate text-xs font-medium text-zinc-200">David Chen</span> </div> </div> } @else { <div class="text-muted-foreground flex size-full flex-col items-center justify-center gap-1 bg-zinc-950 p-2 text-center" > <lucide-icon [img]="VideoOff" class="text-muted-foreground size-4" /> <span class="text-xs font-medium">Camera Off</span> </div> } </div> <!-- Floating Call Controls Bar --> <div class="z-20 flex items-center justify-center pt-4"> <div class="flex flex-wrap items-center justify-center gap-1.5 rounded-full border border-zinc-700/70 bg-zinc-900/90 p-2 shadow-sm backdrop-blur-md sm:gap-2 sm:px-4" > <button ui-button [variant]="isMuted() ? 'destructive' : 'secondary'" size="icon" class="size-9 rounded-full sm:size-10" [attr.aria-label]="isMuted() ? 'Unmute microphone' : 'Mute microphone'" (click)="isMuted.set(!isMuted())" > @if (isMuted()) { <lucide-icon [img]="MicOff" class="size-4" /> } @else { <lucide-icon [img]="Mic" class="text-success size-4" /> } </button> <button ui-button [variant]="isCameraOff() ? 'destructive' : 'secondary'" size="icon" class="size-9 rounded-full sm:size-10" [attr.aria-label]="isCameraOff() ? 'Turn on camera' : 'Turn off camera'" (click)="isCameraOff.set(!isCameraOff())" > @if (isCameraOff()) { <lucide-icon [img]="VideoOff" class="size-4" /> } @else { <lucide-icon [img]="Video" class="text-success size-4" /> } </button> <button ui-button [variant]="isScreenSharing() ? 'default' : 'secondary'" size="icon" class="size-9 rounded-full sm:size-10" [attr.aria-label]="isScreenSharing() ? 'Stop sharing screen' : 'Share screen'" (click)="isScreenSharing.set(!isScreenSharing())" > <lucide-icon [img]="Share2" class="size-4" /> </button> <div ui-separator orientation="vertical" class="mx-1 h-6 bg-zinc-700"></div> <button ui-button [variant]="isSidebarOpen() && activeTab() === 'notes' ? 'default' : 'secondary'" size="sm" class="h-9 gap-1.5 rounded-full px-3 text-xs sm:h-10 sm:px-3.5" (click)="handleOpenTab('notes')" > <lucide-icon [img]="FileText" class="size-3.5" /> <span class="hidden sm:inline">SOAP Notes</span> </button> <button ui-button [variant]="isSidebarOpen() && activeTab() === 'chat' ? 'default' : 'secondary'" size="sm" class="relative h-9 gap-1.5 rounded-full px-3 text-xs sm:h-10 sm:px-3.5" (click)="handleOpenTab('chat')" > <lucide-icon [img]="MessageSquare" class="size-3.5" /> <span class="hidden sm:inline">In-Call Chat</span> </button> <button ui-button [variant]="isSidebarOpen() && activeTab() === 'vitals' ? 'default' : 'secondary'" size="sm" class="h-9 gap-1.5 rounded-full px-3 text-xs sm:h-10 sm:px-3.5" (click)="handleOpenTab('vitals')" > <lucide-icon [img]="HeartPulse" class="text-destructive size-3.5" /> <span class="hidden sm:inline">Vitals</span> </button> <div ui-separator orientation="vertical" class="mx-1 h-6 bg-zinc-700"></div> <button ui-button variant="secondary" size="icon" class="size-9 rounded-full sm:size-10" aria-label="Audio and Video Settings" (click)="showSettingsModal.set(!showSettingsModal())" > <lucide-icon [img]="Settings" class="size-4 text-zinc-300" /> </button> <button ui-button variant="secondary" size="icon" class="size-9 rounded-full sm:size-10" [attr.aria-label]="isSidebarOpen() ? 'Collapse side workspace' : 'Open side workspace'" (click)="isSidebarOpen.set(!isSidebarOpen())" > @if (isSidebarOpen()) { <lucide-icon [img]="PanelRightClose" class="size-4 text-zinc-300" /> } @else { <lucide-icon [img]="PanelRightOpen" class="size-4 text-zinc-300" /> } </button> </div> </div> <!-- Quick Settings Flyout --> @if (showSettingsModal()) { <div class="bg-card text-card-foreground absolute top-16 right-4 z-30 w-72 rounded-xl border p-4 shadow-xl"> <div class="flex items-center justify-between pb-2"> <h4 class="text-muted-foreground text-xs font-bold tracking-wider uppercase">AV Devices & HIPAA</h4> <button ui-button variant="ghost" size="icon" class="size-6" aria-label="Close settings modal" (click)="showSettingsModal.set(false)" > <lucide-icon [img]="X" class="size-3.5" /> </button> </div> <div class="space-y-3 pt-1 text-xs"> <div> <label class="text-muted-foreground block font-medium">Microphone</label> <div class="text-foreground mt-0.5 rounded border p-1.5 font-mono text-xs"> Default - MacBook Pro Mic (CoreAudio) </div> </div> <div> <label class="text-muted-foreground block font-medium">Camera</label> <div class="text-foreground mt-0.5 rounded border p-1.5 font-mono text-xs"> FaceTime HD Camera (1080p) </div> </div> <div> <label class="text-muted-foreground block font-medium">Speaker Output</label> <div class="text-foreground mt-0.5 rounded border p-1.5 font-mono text-xs"> Studio Display Speakers </div> </div> </div> </div> } </section> <!-- Collapsible Side Workspace --> @if (isSidebarOpen()) { <aside class="bg-card flex flex-col overflow-hidden rounded-xl border shadow-xs lg:col-span-4 xl:col-span-4"> <ui-tabs [value]="activeTab()" (valueChange)="setActiveTab($event)" class="flex h-full flex-col"> <div class="bg-muted/30 flex items-center justify-between border-b px-3 py-2.5"> <ui-tabs-list class="grid h-8 w-full max-w-[320px] grid-cols-3"> <button ui-tabs-trigger value="notes" class="gap-1 text-xs font-medium"> <lucide-icon [img]="FileText" class="size-3" /> <span>Notes & Rx</span> </button> <button ui-tabs-trigger value="chat" class="gap-1 text-xs font-medium"> <lucide-icon [img]="MessageSquare" class="size-3" /> <span>Chat</span> </button> <button ui-tabs-trigger value="vitals" class="gap-1 text-xs font-medium"> <lucide-icon [img]="HeartPulse" class="text-destructive size-3" /> <span>Vitals</span> </button> </ui-tabs-list> <button ui-button variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground ml-1 size-8" aria-label="Close sidebar" (click)="isSidebarOpen.set(false)" > <lucide-icon [img]="PanelRightClose" class="size-4" /> </button> </div> <!-- TAB 1: Clinical Notes & Rx --> <ui-tabs-content value="notes" class="flex-1 space-y-4 overflow-y-auto p-4 focus-visible:outline-none"> <div class="bg-muted/40 space-y-2 rounded-lg border p-3"> <div class="flex items-center justify-between"> <div class="flex items-center gap-2"> <ui-avatar class="size-7"> <ui-avatar-fallback class="bg-primary/10 text-primary text-xs font-bold">DC</ui-avatar-fallback> </ui-avatar> <div> <span class="text-foreground text-xs font-bold">David Chen</span> <span class="text-muted-foreground ml-1 text-xs">44y · Male</span> </div> </div> <span ui-badge variant="destructive" class="text-xs font-normal"> Allergy: Penicillin </span> </div> <div class="text-muted-foreground flex flex-wrap items-center justify-between gap-1 border-t pt-2 text-xs" > <span>DOB: 05/14/1982</span> <span>Pharmacy: CVS #4021 (Austin)</span> </div> </div> <div class="space-y-1.5"> <label class="text-muted-foreground flex items-center gap-1.5 text-xs font-bold tracking-wider uppercase" > <lucide-icon [img]="AlertCircle" class="text-warning size-3.5" /> Chief Complaint </label> <div class="bg-muted/20 text-foreground rounded-lg border p-2.5 text-xs leading-relaxed"> {{ chiefComplaint() }} </div> </div> <div class="space-y-3"> <div class="flex items-center justify-between"> <span class="text-muted-foreground text-xs font-bold tracking-wider uppercase" >SOAP Encounter Note</span > <span class="text-muted-foreground font-mono text-xs">ICD-10: I47.1</span> </div> <div class="bg-card space-y-1 rounded-lg border p-3"> <div class="text-foreground flex items-center gap-1.5 text-xs font-semibold"> <span class="bg-primary text-primary-foreground flex size-4 items-center justify-center rounded-full text-xs" >S</span > <span>Subjective</span> </div> <p class="text-muted-foreground text-xs leading-relaxed">{{ soapSubjective() }}</p> </div> <div class="bg-card space-y-1 rounded-lg border p-3"> <div class="text-foreground flex items-center gap-1.5 text-xs font-semibold"> <span class="bg-primary text-primary-foreground flex size-4 items-center justify-center rounded-full text-xs" >O</span > <span>Objective (Telemetry & Exam)</span> </div> <p class="text-muted-foreground text-xs leading-relaxed">{{ soapObjective() }}</p> </div> <div class="bg-card space-y-1 rounded-lg border p-3"> <div class="text-foreground flex items-center gap-1.5 text-xs font-semibold"> <span class="bg-primary text-primary-foreground flex size-4 items-center justify-center rounded-full text-xs" >A</span > <span>Assessment & Differential</span> </div> <p class="text-muted-foreground text-xs leading-relaxed whitespace-pre-line"> {{ soapAssessment() }} </p> </div> <div class="bg-card space-y-2 rounded-lg border p-3"> <div class="flex items-center justify-between"> <div class="text-foreground flex items-center gap-1.5 text-xs font-semibold"> <span class="bg-primary text-primary-foreground flex size-4 items-center justify-center rounded-full text-xs" >P</span > <span>Clinical Plan & Orders</span> </div> <span class="text-muted-foreground text-xs">Editable</span> </div> <ui-textarea [value]="soapPlan()" (valueChange)="soapPlan.set($event)" [rows]="3" class="resize-y text-xs leading-relaxed" placeholder="Enter medical orders, follow-up recommendations..." /> </div> </div> <!-- Quick e-Prescription Generator --> <div ui-card class="border-primary/30 shadow-xs"> <div ui-card-header class="px-3.5 pt-3 pb-2"> <div class="flex items-center justify-between"> <h3 ui-card-title class="text-foreground flex items-center gap-1.5 text-xs font-bold"> <lucide-icon [img]="Pill" class="text-primary size-3.5" /> Electronic Prescription (e-Rx) </h3> <span ui-badge variant="outline" class="font-mono text-xs">NPI #1894021</span> </div> <p ui-card-description class="text-xs"> Generate and electronically sign DEA-compliant prescription </p> </div> <div ui-card-content class="space-y-3 px-3.5 pb-3"> <div class="space-y-1.5"> <label class="text-muted-foreground text-xs font-medium">Select Medication</label> <div class="grid grid-cols-2 gap-1.5"> @for (drug of drugOptions; track drug.id) { <button ui-button type="button" [variant]="selectedDrugPreset() === drug.id ? 'default' : 'outline'" size="sm" class="h-7 justify-start truncate px-2 text-xs" (click)="handleSelectDrugPreset(drug.id)" > {{ drug.name }} </button> } </div> </div> <div class="grid grid-cols-2 gap-2"> <div class="space-y-1"> <label class="text-muted-foreground text-xs">Dosage & Frequency</label> <ui-input [value]="customDosage()" (valueChange)="customDosage.set($event)" class="h-7 text-xs" /> </div> <div class="space-y-1"> <label class="text-muted-foreground text-xs">Quantity & Refills</label> <ui-input [value]="customQuantity()" (valueChange)="customQuantity.set($event)" class="h-7 text-xs" /> </div> </div> @if (rxSuccessMessage()) { <div class="border-success/30 bg-success/10 text-success flex items-center gap-1.5 rounded-md border p-2 text-xs font-medium" > <lucide-icon [img]="CheckCircle2" class="size-3.5 shrink-0" /> <span>{{ rxSuccessMessage() }}</span> </div> } <button ui-button size="sm" class="w-full gap-1.5 text-xs font-semibold" (click)="handleGeneratePrescription()" > <lucide-icon [img]="FileCheck" class="size-3.5" /> <span>Sign & Transmit e-Rx to Pharmacy</span> </button> @if (prescriptions().length > 0) { <div class="space-y-1.5 border-t pt-1"> <span class="text-muted-foreground text-xs font-medium">Active Prescriptions this Visit:</span> @for (rx of prescriptions(); track rx.id) { <div class="bg-muted/30 space-y-1 rounded border p-2 text-xs"> <div class="flex items-center justify-between"> <span class="text-foreground font-semibold">{{ rx.drugName }}</span> <span ui-badge variant="outline" class="border-success/40 bg-success/10 text-success text-xs" > {{ rx.status === 'transmitted' ? 'Transmitted' : 'Pending' }} </span> </div> <p class="text-muted-foreground text-xs"> {{ rx.dosage }} · {{ rx.quantity }} (Refills: {{ rx.refills }}) </p> <p class="text-muted-foreground font-mono text-xs">{{ rx.pharmacy }} · {{ rx.rxNumber }}</p> </div> } </div> } </div> </div> </ui-tabs-content> <!-- TAB 2: In-Call Chat --> <ui-tabs-content value="chat" class="flex h-[460px] flex-col focus-visible:outline-none"> <div class="bg-success/10 text-success flex items-center gap-2 border-b p-2.5 text-xs"> <lucide-icon [img]="ShieldCheck" class="text-success size-4 shrink-0" /> <span>Encrypted Consultation Chat · Archived in EHR Audit Log</span> </div> <div class="flex-1 space-y-3.5 overflow-y-auto p-4"> @for (msg of chatMessages(); track msg.id) { <div [class]=" 'flex flex-col gap-1 text-xs ' + (msg.sender === 'patient' ? 'items-end' : 'items-start') " > <div class="text-muted-foreground flex items-center gap-1.5 px-1 text-xs"> <span>{{ msg.senderName }}</span> <span>·</span> <span>{{ msg.timestamp }}</span> </div> <div [class]=" 'max-w-[85%] rounded-2xl px-3.5 py-2 leading-relaxed shadow-xs ' + (msg.sender === 'patient' ? 'bg-primary text-primary-foreground rounded-br-xs' : 'bg-muted text-foreground rounded-bl-xs') " > <p>{{ msg.text }}</p> @if (msg.attachment) { <div class="mt-2 flex items-center justify-between gap-2 rounded-lg border border-white/20 bg-black/10 p-2 text-xs" > <div class="flex min-w-0 items-center gap-2"> <lucide-icon [img]="FileText" class="size-4 shrink-0" /> <div class="min-w-0"> <p class="truncate font-medium">{{ msg.attachment!.name }}</p> <p class="text-xs opacity-80">{{ msg.attachment!.size }}</p> </div> </div> <button ui-button variant="ghost" size="icon" class="size-6 shrink-0 text-current hover:bg-white/10" aria-label="Download attachment" > <lucide-icon [img]="Download" class="size-3.5" /> </button> </div> } </div> </div> } </div> <div class="bg-muted/20 space-y-2 border-t p-3"> <div class="flex items-center gap-1.5"> <ui-input [value]="chatInput()" (valueChange)="chatInput.set($event)" placeholder="Type encrypted message to Dr. Jenkins..." class="h-8 text-xs" (keydown.enter)="handleSendMessage()" /> <button ui-button variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground size-8 shrink-0" aria-label="Attach clinical file" > <lucide-icon [img]="Paperclip" class="size-4" /> </button> <button ui-button size="sm" class="h-8 shrink-0 gap-1 px-3 text-xs" [disabled]="!chatInput().trim()" (click)="handleSendMessage()" > <lucide-icon [img]="Send" class="size-3.5" /> <span>Send</span> </button> </div> <div class="text-muted-foreground flex items-center justify-between px-1 text-xs"> <span>Press Enter to send</span> <span>BAA Encrypted</span> </div> </div> </ui-tabs-content> <!-- TAB 3: Patient Vitals & Telemetry --> <ui-tabs-content value="vitals" class="flex-1 space-y-4 overflow-y-auto p-4 focus-visible:outline-none"> <div class="bg-muted/30 flex items-center justify-between rounded-lg border p-3"> <div class="flex items-center gap-2"> <span class="relative flex size-2.5"> <span class="bg-success absolute inline-flex h-full w-full rounded-full opacity-75"></span> <span class="bg-success relative inline-flex size-2.5 rounded-full"></span> </span> <div> <h4 class="text-foreground text-xs font-bold">Live Telemetry Feed</h4> <p class="text-muted-foreground text-xs">BLE Medical Hub · Updated {{ lastVitalsUpdate() }}</p> </div> </div> <button ui-button variant="outline" size="sm" class="h-7 gap-1.5 text-xs" [disabled]="isRefreshingVitals()" (click)="handleRefreshVitals()" > <lucide-icon [img]="RefreshCw" [class]="'size-3' + (isRefreshingVitals() ? ' animate-spin' : '')" /> <span>Refresh</span> </button> </div> <div class="grid grid-cols-2 gap-3"> <!-- Blood Pressure --> <div ui-card class="space-y-2 p-3"> <div class="flex items-center justify-between"> <span class="text-muted-foreground text-xs font-semibold">Blood Pressure</span> <span ui-badge variant="outline" class="border-success/40 bg-success/10 text-success text-xs"> Normal </span> </div> <div> <div class="flex items-baseline gap-1"> <span class="text-foreground font-mono text-xl font-bold tracking-tight">128/82</span> <span class="text-muted-foreground text-xs">mmHg</span> </div> <p class="text-muted-foreground mt-0.5 text-xs">Target: <130/85 mmHg</p> </div> <div class="text-muted-foreground flex items-center justify-between border-t pt-1.5 text-xs"> <span>Smart Cuff</span> <span class="text-foreground font-medium">MAP: 97</span> </div> </div> <!-- Heart Rate --> <div ui-card class="space-y-2 p-3"> <div class="flex items-center justify-between"> <span class="text-muted-foreground flex items-center gap-1 text-xs font-semibold"> <lucide-icon [img]="Heart" class="text-destructive size-3" /> Heart Rate </span> <span ui-badge variant="outline" class="border-success/40 bg-success/10 text-success text-xs"> NSR </span> </div> <div> <div class="flex items-baseline gap-1"> <span class="text-foreground font-mono text-xl font-bold tracking-tight">74</span> <span class="text-muted-foreground text-xs">bpm</span> </div> <p class="text-muted-foreground mt-0.5 text-xs">Resting (Range: 68-88)</p> </div> <div class="text-muted-foreground flex items-center justify-between border-t pt-1.5 text-xs"> <span>Rhythm: Sinus</span> <span class="text-success font-medium">Regular</span> </div> </div> <!-- Oxygen Saturation (SpO2) --> <div ui-card class="space-y-2 p-3"> <div class="flex items-center justify-between"> <span class="text-muted-foreground text-xs font-semibold">Oxygen (SpO2)</span> <span ui-badge variant="outline" class="border-success/40 bg-success/10 text-success text-xs"> Optimal </span> </div> <div> <div class="flex items-baseline gap-1"> <span class="text-foreground font-mono text-xl font-bold tracking-tight">98%</span> <span class="text-muted-foreground text-xs">Ambient</span> </div> <p class="text-muted-foreground mt-0.5 text-xs">Room Air (FiO2 21%)</p> </div> <div class="text-muted-foreground flex items-center justify-between border-t pt-1.5 text-xs"> <span>PulseOx Probe</span> <span class="text-foreground font-medium">PI: 4.8%</span> </div> </div> <!-- Body Temperature --> <div ui-card class="space-y-2 p-3"> <div class="flex items-center justify-between"> <span class="text-muted-foreground flex items-center gap-1 text-xs font-semibold"> <lucide-icon [img]="Thermometer" class="text-warning size-3" /> Temperature </span> <span ui-badge variant="secondary" class="text-xs"> Afebrile </span> </div> <div> <div class="flex items-baseline gap-1"> <span class="text-foreground font-mono text-xl font-bold tracking-tight">98.4°</span> <span class="text-muted-foreground text-xs">F</span> </div> <p class="text-muted-foreground mt-0.5 text-xs">36.9°C (Temporal Scan)</p> </div> <div class="text-muted-foreground flex items-center justify-between border-t pt-1.5 text-xs"> <span>Scanner Hub</span> <span class="text-foreground font-medium">Normal</span> </div> </div> <!-- Respiration Rate --> <div ui-card class="space-y-2 p-3"> <div class="flex items-center justify-between"> <span class="text-muted-foreground text-xs font-semibold">Respiration</span> <span ui-badge variant="secondary" class="text-xs"> Eupneic </span> </div> <div> <div class="flex items-baseline gap-1"> <span class="text-foreground font-mono text-xl font-bold tracking-tight">16</span> <span class="text-muted-foreground text-xs">br/min</span> </div> <p class="text-muted-foreground mt-0.5 text-xs">Range: 12-20 normal</p> </div> <div class="text-muted-foreground flex items-center justify-between border-t pt-1.5 text-xs"> <span>Chest Sensor</span> <span class="text-foreground font-medium">Steady</span> </div> </div> <!-- Fasting Blood Glucose --> <div ui-card class="space-y-2 p-3"> <div class="flex items-center justify-between"> <span class="text-muted-foreground text-xs font-semibold">Glucose</span> <span ui-badge variant="outline" class="border-success/40 bg-success/10 text-success text-xs"> Normal </span> </div> <div> <div class="flex items-baseline gap-1"> <span class="text-foreground font-mono text-xl font-bold tracking-tight">94</span> <span class="text-muted-foreground text-xs">mg/dL</span> </div> <p class="text-muted-foreground mt-0.5 text-xs">Fasting 3.5h post-meal</p> </div> <div class="text-muted-foreground flex items-center justify-between border-t pt-1.5 text-xs"> <span>Continuous CGM</span> <span class="text-success font-medium">In Range</span> </div> </div> </div> <!-- Physician Vitals Summary --> <div class="bg-muted/20 space-y-1.5 rounded-lg border p-3 text-xs"> <div class="text-foreground flex items-center gap-1.5 font-semibold"> <lucide-icon [img]="ShieldCheck" class="text-success size-3.5" /> <span>Physician Baseline Parameter Assessment</span> </div> <p class="text-muted-foreground leading-relaxed"> All live telemetry telemetry values remain within normal hemodynamic thresholds. Patient exhibits no signs of acute decompensation or malignant arrhythmias during the video encounter. </p> </div> </ui-tabs-content> </ui-tabs> </aside> } </div> </div> `, }) export class UiTelehealthVideoRoomComponent { protected readonly AlertCircle = AlertCircle protected readonly CheckCircle2 = CheckCircle2 protected readonly Clock = Clock protected readonly Download = Download protected readonly FileCheck = FileCheck protected readonly FileText = FileText protected readonly Heart = Heart protected readonly HeartPulse = HeartPulse protected readonly Lock = Lock protected readonly MessageSquare = MessageSquare protected readonly Mic = Mic protected readonly MicOff = MicOff protected readonly PanelRightClose = PanelRightClose protected readonly PanelRightOpen = PanelRightOpen protected readonly Paperclip = Paperclip protected readonly PhoneOff = PhoneOff protected readonly Pill = Pill protected readonly RefreshCw = RefreshCw protected readonly Send = Send protected readonly Settings = Settings protected readonly Share2 = Share2 protected readonly ShieldCheck = ShieldCheck protected readonly Thermometer = Thermometer protected readonly Video = Video protected readonly VideoOff = VideoOff protected readonly Wifi = Wifi protected readonly X = X protected readonly drugOptions = drugOptions @Input('class') className?: string // Interactive State readonly isMuted = signal(false) readonly isCameraOff = signal(false) readonly isScreenSharing = signal(false) readonly isSidebarOpen = signal(true) readonly activeTab = signal<TelehealthTab>('notes') readonly isCallEnded = signal(false) readonly showSettingsModal = signal(false) readonly callDurationSeconds = signal(868) // 14:28 readonly unreadChatCount = signal(0) readonly lastVitalsUpdate = signal('Just now') readonly isRefreshingVitals = signal(false) // SOAP Notes State readonly chiefComplaint = signal( 'Recurrent palpitations, sudden resting tachycardia (120-140 bpm), and lightheadedness over the past 2 weeks following morning coffee.', ) readonly soapSubjective = signal( 'Patient reports 4 distinct episodes in past 14 days. Episodes last ~5-10 minutes with abrupt onset and offset. No syncope, mild lightheadedness. Denies chest pressure or radiating pain. Reports high work stress and 3-4 espresso shots daily.', ) readonly soapObjective = signal( 'In-call vitals: HR 74 bpm regular sinus, BP 128/82 mmHg, SpO2 98%. Telemetry strip transmitted via Smart Hub shows NSR with infrequent PACs. No ST elevation or ischemic changes.', ) readonly soapAssessment = signal( '1. Paroxysmal Supraventricular Tachycardia (PSVT) vs. Benign Premature Atrial Contractions (PACs) secondary to high caffeine consumption and sympathetic stress.\n2. Stage 1 Essential Hypertension - well controlled on current regimen.', ) readonly soapPlan = signal( '1. Ordered 14-day continuous Holter monitor patch.\n2. Titrate Metoprolol Succinate ER to 25mg PO daily.\n3. Restrict caffeine to ≤1 cup/day; hydration goal 2.5L daily.\n4. Follow-up video consultation in 3 weeks or ASAP if syncope occurs.', ) // Prescription Generator State readonly selectedDrugPreset = signal('metoprolol') readonly customDosage = signal('25mg PO Daily in Morning') readonly customQuantity = signal('30 tablets') readonly customRefills = signal('2') readonly rxSuccessMessage = signal('') readonly prescriptions = signal<PrescriptionItem[]>([ { id: 'rx-1', drugName: 'Metoprolol Succinate ER 25mg', dosage: 'Take 1 tablet by mouth daily in the morning', sig: '1 tab PO QAM', quantity: '30 tablets', refills: 2, pharmacy: 'CVS Pharmacy #4021 (Austin, TX)', timestamp: '10:24 AM', status: 'transmitted', rxNumber: 'RX-994182', }, ]) // Chat Thread State readonly chatInput = signal('') readonly chatMessages = signal<ChatMessage[]>([ { id: 'msg-1', sender: 'doctor', senderName: 'Dr. Sarah Jenkins, MD', text: 'Hello David! I have your chart open and we are receiving your continuous telemetry stream. How are you feeling right now?', timestamp: '10:15 AM', }, { id: 'msg-2', sender: 'patient', senderName: 'David Chen (You)', text: 'Good morning Dr. Jenkins. Feeling better than yesterday, but had another fluttering sensation around 8 AM after my second cup of coffee.', timestamp: '10:17 AM', }, { id: 'msg-3', sender: 'patient', senderName: 'David Chen (You)', text: 'I captured the rhythm episode on my smart watch and exported the 30-second single lead PDF strip here.', timestamp: '10:18 AM', attachment: { name: 'Apple_Watch_ECG_Lead_I_2026.pdf', size: '1.4 MB · PDF ECG Report', type: 'pdf', }, }, { id: 'msg-4', sender: 'doctor', senderName: 'Dr. Sarah Jenkins, MD', text: 'Thank you, David! Reviewing the strip now. The baseline is normal sinus rhythm with occasional isolated PACs. This matches our telemetry.', timestamp: '10:20 AM', }, ]) private rxTimer: ReturnType<typeof setTimeout> | undefined private chatTimer: ReturnType<typeof setTimeout> | undefined private vitalsTimer: ReturnType<typeof setTimeout> | undefined constructor() { const destroyRef = inject(DestroyRef) const interval = setInterval(() => { if (!this.isCallEnded()) this.callDurationSeconds.update((v) => v + 1) }, 1000) destroyRef.onDestroy(() => { clearInterval(interval) clearTimeout(this.rxTimer) clearTimeout(this.chatTimer) clearTimeout(this.vitalsTimer) }) } readonly formattedDuration = computed(() => { const total = this.callDurationSeconds() const mins = Math.floor(total / 60) const secs = total % 60 return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` }) get rootClass(): string { return cn('bg-background text-foreground w-full space-y-4', this.className) } get canvasClass(): string { return cn( 'relative flex flex-col justify-between overflow-hidden rounded-xl border border-zinc-800 bg-zinc-950 p-4 text-white shadow-lg transition-colors duration-200', this.isSidebarOpen() ? 'min-h-[560px] lg:col-span-8 xl:col-span-8' : 'min-h-[600px] lg:col-span-12', ) } setActiveTab(value: string): void { this.activeTab.set(value as TelehealthTab) } handleOpenTab(tab: TelehealthTab): void { this.activeTab.set(tab) this.isSidebarOpen.set(true) } handleEndCall(): void { this.isCallEnded.set(true) } handleRestartCall(): void { this.isCallEnded.set(false) this.callDurationSeconds.set(0) } handleRefreshVitals(): void { this.isRefreshingVitals.set(true) clearTimeout(this.vitalsTimer) this.vitalsTimer = setTimeout(() => { this.isRefreshingVitals.set(false) this.lastVitalsUpdate.set('Just now') }, 800) } handleSelectDrugPreset(drugId: string): void { this.selectedDrugPreset.set(drugId) const found = drugOptions.find((d) => d.id === drugId) if (found) { this.customDosage.set(found.defaultDosage) this.customQuantity.set(found.defaultQty) this.customRefills.set(found.refills) } } handleGeneratePrescription(): void { const current = drugOptions.find((d) => d.id === this.selectedDrugPreset()) const drugName = current ? current.name : 'Custom Medication' const dosage = this.customDosage() const newRx: PrescriptionItem = { id: `rx-${Date.now()}`, drugName: `${drugName} (${dosage})`, dosage, sig: `Take as directed: ${dosage}`, quantity: this.customQuantity(), refills: parseInt(this.customRefills(), 10) || 1, pharmacy: 'CVS Pharmacy #4021 (Austin, TX)', timestamp: 'Just now', status: 'transmitted', rxNumber: `RX-${Math.floor(100000 + Math.random() * 900000)}`, } this.prescriptions.update((prev) => [newRx, ...prev]) this.rxSuccessMessage.set(`e-Rx signed & securely transmitted to CVS Pharmacy #4021 (${newRx.rxNumber})`) clearTimeout(this.rxTimer) this.rxTimer = setTimeout(() => this.rxSuccessMessage.set(''), 4500) } handleSendMessage(): void { const text = this.chatInput().trim() if (!text) return this.chatMessages.update((prev) => [ ...prev, { id: `msg-${Date.now()}`, sender: 'patient', senderName: 'David Chen (You)', text, timestamp: 'Just now', }, ]) this.chatInput.set('') // Simulated provider automated acknowledgment if consultation active clearTimeout(this.chatTimer) this.chatTimer = setTimeout(() => { if (!this.isCallEnded()) { this.chatMessages.update((prev) => [ ...prev, { id: `msg-doc-${Date.now()}`, sender: 'doctor', senderName: 'Dr. Sarah Jenkins, MD', text: 'Noted! I have added this observation into your encounter notes and prescription schedule.', timestamp: 'Just now', }, ]) } }, 1200) } }src/app/components/blocks/telehealth-video-room/index.ts0.1 kB
export { UiTelehealthVideoRoomComponent, type ChatMessage, type PrescriptionItem, type TelehealthTab, } from './telehealth-video-room.component'
Raw manifest:https://uipkge.dev/r/angular/telehealth-video-room.json