
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
HIPAA-compliant telehealth video consultation room with live telemetry vitals, clinical SOAP notes, in-call encrypted chat, and prescription generator drawer.
Also available for React ->$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.jsonnpx shadcn-vue@latest add @uipkge/telehealth-video-roomInstalls to:app/components/blocks/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'
}
}<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