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 shadcn-vue@latest add https://uipkge.dev/r/vue/telehealth-video-room.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/telehealth-video-room.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/telehealth-video-room.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/telehealth-video-room.jsonNamed registry:
npx shadcn-vue@latest add @uipkge/telehealth-video-roomInstalls to:app/components/blocks/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'
}
}Files installed (1)
app/components/blocks/TelehealthVideoRoom.vue46.3 kB
<script setup lang="ts"> import { computed, onMounted, onUnmounted, ref } from 'vue' import { AlertCircle, CheckCircle2, Clock, Download, FileCheck, FileText, Heart, HeartPulse, Lock, Mic, MicOff, PanelRightClose, PanelRightOpen, Paperclip, PhoneOff, Pill, RefreshCw, Send, Share2, ShieldCheck, Thermometer, Video, VideoOff, Wifi, X, MessageSquare, Settings, } from 'lucide-vue-next' import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Separator } from '@/components/ui/separator' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Textarea } from '@/components/ui/textarea' // Interactive State const isMuted = ref(false) const isCameraOff = ref(false) const isScreenSharing = ref(false) const isSidebarOpen = ref(true) const activeTab = ref<'notes' | 'chat' | 'vitals'>('notes') const isCallEnded = ref(false) const showSettingsModal = ref(false) const callDurationSeconds = ref(868) // 14:28 const unreadChatCount = ref(0) const lastVitalsUpdate = ref('Just now') const isRefreshingVitals = ref(false) // Timer logic let timerInterval: ReturnType<typeof setInterval> | null = null onMounted(() => { timerInterval = setInterval(() => { if (!isCallEnded.value) { callDurationSeconds.value += 1 } }, 1000) }) onUnmounted(() => { if (timerInterval) clearInterval(timerInterval) }) const formattedDuration = computed(() => { const mins = Math.floor(callDurationSeconds.value / 60) const secs = callDurationSeconds.value % 60 return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` }) // SOAP Notes State const chiefComplaint = ref( 'Recurrent palpitations, sudden resting tachycardia (120-140 bpm), and lightheadedness over the past 2 weeks following morning coffee.', ) const soapSubjective = ref( '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.', ) const soapObjective = ref( '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.', ) const soapAssessment = ref( '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.', ) const soapPlan = ref( '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 interface PrescriptionItem { id: string drugName: string dosage: string sig: string quantity: string refills: number pharmacy: string timestamp: string status: 'transmitted' | 'pending' rxNumber: string } const selectedDrugPreset = ref('metoprolol') const customDosage = ref('25mg PO Daily in Morning') const customQuantity = ref('30 tablets') const customRefills = ref('2') const rxSuccessMessage = ref('') const drugOptions = [ { 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' }, ] const prescriptions = ref<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', }, ]) function handleSelectDrugPreset(drugId: string) { selectedDrugPreset.value = drugId const found = drugOptions.find((d) => d.id === drugId) if (found) { customDosage.value = found.defaultDosage customQuantity.value = found.defaultQty customRefills.value = found.refills } } function handleGeneratePrescription() { const current = drugOptions.find((d) => d.id === selectedDrugPreset.value) const drugName = current ? current.name : 'Custom Medication' const newRx: PrescriptionItem = { id: `rx-${Date.now()}`, drugName: `${drugName} (${customDosage.value})`, dosage: customDosage.value, sig: `Take as directed: ${customDosage.value}`, quantity: customQuantity.value, refills: parseInt(customRefills.value) || 1, pharmacy: 'CVS Pharmacy #4021 (Austin, TX)', timestamp: 'Just now', status: 'transmitted', rxNumber: `RX-${Math.floor(100000 + Math.random() * 900000)}`, } prescriptions.value.unshift(newRx) rxSuccessMessage.value = `e-Rx signed & securely transmitted to CVS Pharmacy #4021 (${newRx.rxNumber})` setTimeout(() => { rxSuccessMessage.value = '' }, 4500) } // Chat Thread State interface ChatMessage { id: string sender: 'doctor' | 'patient' senderName: string text: string timestamp: string attachment?: { name: string size: string type: 'pdf' | 'image' } } const chatInput = ref('') const chatMessages = ref<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', }, ]) function handleSendMessage() { const text = chatInput.value.trim() if (!text) return chatMessages.value.push({ id: `msg-${Date.now()}`, sender: 'patient', senderName: 'David Chen (You)', text, timestamp: 'Just now', }) chatInput.value = '' // Simulated provider automated acknowledgment if consultation active setTimeout(() => { if (!isCallEnded.value) { chatMessages.value.push({ 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) } function handleOpenTab(tab: 'notes' | 'chat' | 'vitals') { activeTab.value = tab isSidebarOpen.value = true } function handleEndCall() { isCallEnded.value = true } function handleRestartCall() { isCallEnded.value = false callDurationSeconds.value = 0 } function handleRefreshVitals() { isRefreshingVitals.value = true setTimeout(() => { isRefreshingVitals.value = false lastVitalsUpdate.value = 'Just now' }, 800) } </script> <template> <div data-slot="telehealth-video-room" class="bg-background text-foreground w-full space-y-4"> <!-- 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"> <span v-if="!isCallEnded" class="bg-success absolute inline-flex h-full w-full rounded-full opacity-75" /> <span :class="['relative inline-flex size-3 rounded-full', isCallEnded ? 'bg-zinc-400' : 'bg-success']" /> </span> <div class="flex items-center gap-1.5 font-mono text-sm font-semibold tracking-tight"> <Clock class="text-muted-foreground size-3.5" /> <span>{{ isCallEnded ? 'Call Ended' : formattedDuration }}</span> </div> </div> <Separator orientation="vertical" class="hidden h-5 sm:block" /> <!-- HIPAA Encrypted Connection Badge --> <Badge variant="outline" class="border-success/30 bg-success/10 text-success gap-1.5 text-xs font-medium"> <Lock class="text-success size-3" /> <span>HIPAA Encrypted · AES-256</span> </Badge> <Separator orientation="vertical" class="hidden h-5 md:block" /> <!-- 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"> <Wifi class="text-success size-3.5" /> <span>HD Connection (18ms)</span> </div> <Button v-if="!isCallEnded" variant="destructive" size="sm" class="h-8 gap-1.5 px-3 text-xs font-semibold shadow-xs" @click="handleEndCall" > <PhoneOff class="size-3.5" /> <span>End Consultation</span> </Button> <Button v-else variant="default" size="sm" class="h-8 gap-1.5 px-3 text-xs font-semibold" @click="handleRestartCall" > <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 (Center 8 cols when open, full 12 cols when collapsed) --> <section :class="[ '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', isSidebarOpen ? 'min-h-[560px] lg:col-span-8 xl:col-span-8' : 'min-h-[600px] lg:col-span-12', ]" > <!-- Overlay Top: Provider Status & Watermark --> <div class="z-10 flex flex-wrap items-start justify-between gap-3"> <!-- Doctor Name & Active Speaking Indicator --> <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" > <Avatar class="border-success/50 size-8 border"> <AvatarFallback class="bg-success/15 text-success text-xs font-semibold">SJ</AvatarFallback> </Avatar> <div> <div class="flex items-center gap-1.5"> <span class="text-xs font-semibold text-zinc-100">Dr. Sarah Jenkins, MD</span> <Badge variant="outline" class="border-success/40 bg-success/20 text-success px-1 py-0 text-xs font-normal" > Attending </Badge> </div> <div class="text-muted-foreground flex items-center gap-2 text-xs"> <div class="flex items-center gap-1"> <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 class="bg-success h-2.5 w-0.5 animate-[pulse_0.8s_ease-in-out_infinite] rounded-full" /> <span class="bg-success h-3.5 w-0.5 animate-[pulse_1.2s_ease-in-out_infinite] rounded-full" /> <span class="bg-success h-1.5 w-0.5 animate-[pulse_0.6s_ease-in-out_infinite] rounded-full" /> </div> <span class="text-success text-xs font-medium">Speaking</span> </div> <span>·</span> <span>St. Jude Telehealth Clinic</span> </div> </div> </div> <!-- Feed Encryption & Signal Badge --> <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" > <ShieldCheck class="text-success size-3.5" /> <span class="font-mono text-xs">1080p 60fps · BAA Protected</span> </div> </div> <!-- Main Video Placeholder Canvas (Simulated high-res clinical video stream) --> <div class="relative my-auto flex flex-col items-center justify-center pt-12 pb-28 text-center"> <!-- Background radial glow --> <div class="pointer-events-none absolute inset-0 bg-radial from-emerald-950/20 via-zinc-950/80 to-zinc-950" /> <div class="relative z-10 space-y-4"> <!-- Simulated Video Avatar Portrait with active ring --> <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 class="border-success/80 relative flex size-full items-center justify-center overflow-hidden rounded-full border-2 bg-zinc-900 shadow-xl" > <Avatar class="size-full"> <AvatarFallback class="text-success bg-zinc-900 text-xl font-bold sm:text-2xl"> SJ </AvatarFallback> </Avatar> </div> <span class="bg-success absolute right-2 bottom-2 size-4 rounded-full ring-2 ring-zinc-950" /> </div> <!-- Provider Live Status Title --> <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> <!-- Video Stream Diagnostic Watermark --> <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 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 (Patient View - Bottom Right) --> <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" > <div v-if="!isCameraOff" 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"> <Badge variant="outline" class="border-zinc-700 bg-zinc-900/80 px-1 py-0 text-xs text-zinc-300"> You (720p) </Badge> <div :class="[ 'flex size-4 items-center justify-center rounded-full', isMuted ? 'bg-destructive text-white' : 'bg-success/80 text-white', ]" > <MicOff v-if="isMuted" class="size-2.5" /> <Mic v-else class="size-2.5" /> </div> </div> <div class="flex items-center gap-1.5"> <Avatar class="size-5"> <AvatarFallback class="bg-zinc-700 text-xs font-bold text-zinc-100">DC</AvatarFallback> </Avatar> <span class="truncate text-xs font-medium text-zinc-200">David Chen</span> </div> </div> <!-- Camera Off Placeholder --> <div v-else class="text-muted-foreground flex size-full flex-col items-center justify-center gap-1 bg-zinc-950 p-2 text-center" > <VideoOff class="text-muted-foreground size-4" /> <span class="text-xs font-medium">Camera Off</span> </div> </div> <!-- Floating Call Controls Bar (Bottom Center) --> <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" > <!-- Mute / Unmute Button --> <Button :variant="isMuted ? 'destructive' : 'secondary'" size="icon" class="size-9 rounded-full sm:size-10" :aria-label="isMuted ? 'Unmute microphone' : 'Mute microphone'" @click="isMuted = !isMuted" > <MicOff v-if="isMuted" class="size-4" /> <Mic v-else class="text-success size-4" /> </Button> <!-- Video On / Off Button --> <Button :variant="isCameraOff ? 'destructive' : 'secondary'" size="icon" class="size-9 rounded-full sm:size-10" :aria-label="isCameraOff ? 'Turn on camera' : 'Turn off camera'" @click="isCameraOff = !isCameraOff" > <VideoOff v-if="isCameraOff" class="size-4" /> <Video v-else class="text-success size-4" /> </Button> <!-- Screen Share Button --> <Button :variant="isScreenSharing ? 'default' : 'secondary'" size="icon" class="size-9 rounded-full sm:size-10" :aria-label="isScreenSharing ? 'Stop sharing screen' : 'Share screen'" @click="isScreenSharing = !isScreenSharing" > <Share2 class="size-4" /> </Button> <Separator orientation="vertical" class="mx-1 h-6 bg-zinc-700" /> <!-- Tab Quick Toggle Buttons --> <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')" > <FileText class="size-3.5" /> <span class="hidden sm:inline">SOAP Notes</span> </Button> <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')" > <MessageSquare class="size-3.5" /> <span class="hidden sm:inline">In-Call Chat</span> <span v-if="!isSidebarOpen && unreadChatCount > 0" class="bg-destructive text-destructive-foreground absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full text-xs font-bold" > {{ unreadChatCount }} </span> </Button> <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')" > <HeartPulse class="text-destructive size-3.5" /> <span class="hidden sm:inline">Vitals</span> </Button> <Separator orientation="vertical" class="mx-1 h-6 bg-zinc-700" /> <!-- Device Settings Toggle --> <Button variant="secondary" size="icon" class="size-9 rounded-full sm:size-10" aria-label="Audio and Video Settings" @click="showSettingsModal = !showSettingsModal" > <Settings class="size-4 text-zinc-300" /> </Button> <!-- Expand / Collapse Sidebar Toggle --> <Button variant="secondary" size="icon" class="size-9 rounded-full sm:size-10" :aria-label="isSidebarOpen ? 'Collapse side workspace' : 'Open side workspace'" @click="isSidebarOpen = !isSidebarOpen" > <PanelRightClose v-if="isSidebarOpen" class="size-4 text-zinc-300" /> <PanelRightOpen v-else class="size-4 text-zinc-300" /> </Button> </div> </div> <!-- Quick Settings Flyout / Drawer Banner (when clicked) --> <div v-if="showSettingsModal" 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 variant="ghost" size="icon" class="size-6" aria-label="Close settings modal" @click="showSettingsModal = false" > <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 (Right 1/3 when open) --> <aside v-if="isSidebarOpen" class="bg-card flex flex-col overflow-hidden rounded-xl border shadow-xs lg:col-span-4 xl:col-span-4" > <Tabs v-model="activeTab" class="flex h-full flex-col"> <!-- Workspace Tab Navigation Header --> <div class="bg-muted/30 flex items-center justify-between border-b px-3 py-2.5"> <TabsList class="grid h-8 w-full max-w-[320px] grid-cols-3"> <TabsTrigger value="notes" class="gap-1 text-xs font-medium"> <FileText class="size-3" /> <span>Notes & Rx</span> </TabsTrigger> <TabsTrigger value="chat" class="gap-1 text-xs font-medium"> <MessageSquare class="size-3" /> <span>Chat</span> </TabsTrigger> <TabsTrigger value="vitals" class="gap-1 text-xs font-medium"> <HeartPulse class="text-destructive size-3" /> <span>Vitals</span> </TabsTrigger> </TabsList> <Button variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground ml-1 size-8" aria-label="Close sidebar" @click="isSidebarOpen = false" > <PanelRightClose class="size-4" /> </Button> </div> <!-- TAB 1: Clinical Notes & Rx --> <TabsContent value="notes" class="flex-1 space-y-4 overflow-y-auto p-4 focus-visible:outline-none"> <!-- Patient Demographics Pill Strip --> <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"> <Avatar class="size-7"> <AvatarFallback class="bg-primary/10 text-primary text-xs font-bold">DC</AvatarFallback> </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> <Badge variant="destructive" class="text-xs font-normal"> Allergy: Penicillin </Badge> </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> <!-- Chief Complaint --> <div class="space-y-1.5"> <label class="text-muted-foreground flex items-center gap-1.5 text-xs font-bold tracking-wider uppercase"> <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> <!-- SOAP Documentation Cards --> <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> <!-- S: Subjective --> <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> <!-- O: Objective --> <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> <!-- A: Assessment --> <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> <!-- P: Plan (Editable) --> <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> <Textarea v-model="soapPlan" rows="3" class="resize-y text-xs leading-relaxed" placeholder="Enter medical orders, follow-up recommendations..." /> </div> </div> <!-- Quick e-Prescription Generator --> <Card class="border-primary/30 shadow-xs"> <CardHeader class="px-3.5 pt-3 pb-2"> <div class="flex items-center justify-between"> <CardTitle class="text-foreground flex items-center gap-1.5 text-xs font-bold"> <Pill class="text-primary size-3.5" /> Electronic Prescription (e-Rx) </CardTitle> <Badge variant="outline" class="font-mono text-xs">NPI #1894021</Badge> </div> <CardDescription class="text-xs"> Generate and electronically sign DEA-compliant prescription </CardDescription> </CardHeader> <CardContent 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"> <Button v-for="drug in drugOptions" :key="drug.id" 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> <Input v-model="customDosage" class="h-7 text-xs" /> </div> <div class="space-y-1"> <label class="text-muted-foreground text-xs">Quantity & Refills</label> <Input v-model="customQuantity" class="h-7 text-xs" /> </div> </div> <div v-if="rxSuccessMessage" class="border-success/30 bg-success/10 text-success flex items-center gap-1.5 rounded-md border p-2 text-xs font-medium" > <CheckCircle2 class="size-3.5 shrink-0" /> <span>{{ rxSuccessMessage }}</span> </div> <Button size="sm" class="w-full gap-1.5 text-xs font-semibold" @click="handleGeneratePrescription"> <FileCheck class="size-3.5" /> <span>Sign & Transmit e-Rx to Pharmacy</span> </Button> <!-- Encounter Prescriptions History --> <div v-if="prescriptions.length > 0" class="space-y-1.5 border-t pt-1"> <span class="text-muted-foreground text-xs font-medium">Active Prescriptions this Visit:</span> <div v-for="rx in prescriptions" :key="rx.id" 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> <Badge variant="outline" class="border-success/40 bg-success/10 text-success text-xs"> {{ rx.status === 'transmitted' ? 'Transmitted' : 'Pending' }} </Badge> </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> </CardContent> </Card> </TabsContent> <!-- TAB 2: In-Call Chat --> <TabsContent value="chat" class="flex h-[460px] flex-col focus-visible:outline-none"> <!-- HIPAA Banner --> <div class="bg-success/10 text-success flex items-center gap-2 border-b p-2.5 text-xs"> <ShieldCheck class="text-success size-4 shrink-0" /> <span>Encrypted Consultation Chat · Archived in EHR Audit Log</span> </div> <!-- Messages List --> <div class="flex-1 space-y-3.5 overflow-y-auto p-4"> <div v-for="msg in chatMessages" :key="msg.id" :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> <!-- Message bubble --> <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> <!-- Attachment preview if present --> <div v-if="msg.attachment" 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"> <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 variant="ghost" size="icon" class="size-6 shrink-0 text-current hover:bg-white/10" aria-label="Download attachment" > <Download class="size-3.5" /> </Button> </div> </div> </div> </div> <!-- Chat Composer --> <div class="bg-muted/20 space-y-2 border-t p-3"> <div class="flex items-center gap-1.5"> <Input v-model="chatInput" placeholder="Type encrypted message to Dr. Jenkins..." class="h-8 text-xs" @keydown.enter.prevent="handleSendMessage" /> <Button variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground size-8 shrink-0" aria-label="Attach clinical file" > <Paperclip class="size-4" /> </Button> <Button size="sm" class="h-8 shrink-0 gap-1 px-3 text-xs" :disabled="!chatInput.trim()" @click="handleSendMessage" > <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> </TabsContent> <!-- TAB 3: Patient Vitals & Telemetry --> <TabsContent value="vitals" class="flex-1 space-y-4 overflow-y-auto p-4 focus-visible:outline-none"> <!-- Telemetry Stream Banner --> <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 class="bg-success relative inline-flex size-2.5 rounded-full" /> </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 variant="outline" size="sm" class="h-7 gap-1.5 text-xs" :disabled="isRefreshingVitals" @click="handleRefreshVitals" > <RefreshCw :class="['size-3', isRefreshingVitals && 'animate-spin']" /> <span>Refresh</span> </Button> </div> <!-- Vitals Telemetry Grid --> <div class="grid grid-cols-2 gap-3"> <!-- Blood Pressure --> <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> <Badge variant="outline" class="border-success/40 bg-success/10 text-success text-xs"> Normal </Badge> </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> </Card> <!-- Heart Rate --> <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"> <Heart class="text-destructive size-3" /> Heart Rate </span> <Badge variant="outline" class="border-success/40 bg-success/10 text-success text-xs"> NSR </Badge> </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> </Card> <!-- Oxygen Saturation (SpO2) --> <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> <Badge variant="outline" class="border-success/40 bg-success/10 text-success text-xs"> Optimal </Badge> </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> </Card> <!-- Body Temperature --> <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"> <Thermometer class="text-warning size-3" /> Temperature </span> <Badge variant="secondary" class="text-xs"> Afebrile </Badge> </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> </Card> <!-- Respiration Rate --> <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> <Badge variant="secondary" class="text-xs"> Eupneic </Badge> </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> </Card> <!-- Fasting Blood Glucose --> <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> <Badge variant="outline" class="border-success/40 bg-success/10 text-success text-xs"> Normal </Badge> </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> </Card> </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"> <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> </TabsContent> </Tabs> </aside> </div> </div> </template>
Raw manifest:https://uipkge.dev/r/vue/telehealth-video-room.json