fix: Add No clinical approval view + No live capture view for bedside data entry + EntryForm missing allergy, medication, and discharge fields
This commit is contained in:
@@ -38,6 +38,12 @@
|
||||
@apply grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-8
|
||||
h-auto lg:h-[calc(100vh-4rem)] min-h-0;
|
||||
}
|
||||
.nav-link {
|
||||
@apply text-sm text-gray-500 hover:text-gray-800 transition-colors;
|
||||
}
|
||||
.nav-link.router-link-active {
|
||||
@apply text-gray-900 font-medium;
|
||||
}
|
||||
.status-badge {
|
||||
@apply px-2 py-1 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
<div class="app-header">
|
||||
<div class="app-header-title">
|
||||
<h1 class="text-lg font-semibold">{{ title }}</h1>
|
||||
<nav class="flex gap-3 ml-4">
|
||||
<router-link v-if="auth.canIntake" to="/intake" class="nav-link">Intake</router-link>
|
||||
<router-link v-if="auth.canEntry" to="/entry" class="nav-link">Entry</router-link>
|
||||
<router-link v-if="auth.canVerify" to="/verification" class="nav-link">Verification</router-link>
|
||||
<router-link v-if="auth.canApprove" to="/approval" class="nav-link">Approval</router-link>
|
||||
<router-link v-if="auth.canLiveCapture" to="/live-capture" class="nav-link">Live Capture</router-link>
|
||||
<router-link v-if="auth.canSupervise" to="/dashboard" class="nav-link">Dashboard</router-link>
|
||||
</nav>
|
||||
<slot name="subtitle" />
|
||||
</div>
|
||||
<div class="app-header-actions">
|
||||
|
||||
@@ -60,6 +60,82 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Allergies section (ALLERGY_UPDATE or MIXED) -->
|
||||
<fieldset v-if="showAllergies" class="border border-gray-200 rounded-md p-4">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">Allergies</legend>
|
||||
<label class="flex items-center gap-2 mb-3 cursor-pointer">
|
||||
<input
|
||||
v-model="patient.noKnownAllergies"
|
||||
@change="onNoKnownAllergiesChange"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 text-clinical-safe rounded"
|
||||
/>
|
||||
<span class="text-sm">No known allergies (NKA)</span>
|
||||
</label>
|
||||
<div v-if="!patient.noKnownAllergies" class="space-y-2">
|
||||
<div
|
||||
v-for="(allergy, idx) in allergies"
|
||||
:key="idx"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
v-model="allergies[idx]"
|
||||
@blur="saveAllergies"
|
||||
type="text"
|
||||
class="form-input text-sm flex-1"
|
||||
placeholder="Allergy (e.g. Penicillin)"
|
||||
/>
|
||||
<button
|
||||
@click="removeAllergy(idx)"
|
||||
class="text-clinical-danger hover:text-red-800 text-sm"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<button @click="addAllergy" class="text-sm text-primary-600 hover:text-primary-800">
|
||||
+ Add Allergy
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Medications section (MEDICATION_LIST or MIXED) -->
|
||||
<fieldset v-if="showMedications" class="border border-gray-200 rounded-md p-4">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">Medications</legend>
|
||||
<label class="flex items-center gap-2 mb-3 cursor-pointer">
|
||||
<input
|
||||
v-model="patient.noActiveMedications"
|
||||
@change="onNoActiveMedicationsChange"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 text-clinical-safe rounded"
|
||||
/>
|
||||
<span class="text-sm">No active medications</span>
|
||||
</label>
|
||||
<div v-if="!patient.noActiveMedications" class="space-y-2">
|
||||
<div
|
||||
v-for="(med, idx) in medications"
|
||||
:key="idx"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
v-model="medications[idx]"
|
||||
@blur="saveMedications"
|
||||
type="text"
|
||||
class="form-input text-sm flex-1"
|
||||
placeholder="Medication (e.g. Metoprolol 50mg BID)"
|
||||
/>
|
||||
<button
|
||||
@click="removeMedication(idx)"
|
||||
class="text-clinical-danger hover:text-red-800 text-sm"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<button @click="addMedication" class="text-sm text-primary-600 hover:text-primary-800">
|
||||
+ Add Medication
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Encounter section -->
|
||||
<fieldset class="border border-gray-200 rounded-md p-4">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">Encounter Context</legend>
|
||||
@@ -98,6 +174,24 @@
|
||||
class="form-input text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showEncounterSummary">
|
||||
<label class="block text-xs text-gray-500">Encounter Status</label>
|
||||
<select v-model="encounter.status" @change="saveEncounter" class="form-input text-sm">
|
||||
<option value="">—</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="discharged">Discharged</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="showEncounterSummary" class="col-span-2">
|
||||
<label class="block text-xs text-gray-500">Discharge Diagnosis</label>
|
||||
<textarea
|
||||
v-model="encounter.dischargeDiagnosis"
|
||||
@blur="saveEncounter"
|
||||
class="form-input text-sm"
|
||||
rows="2"
|
||||
placeholder="Discharge diagnosis..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -136,7 +230,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { useBatchStore } from '../stores/batches'
|
||||
import ObservationRow from '../components/ObservationRow.vue'
|
||||
import type { BatchDetailResponse, DraftObservation } from '../types'
|
||||
@@ -174,25 +268,53 @@ const departments = [
|
||||
'Anesthesiology',
|
||||
]
|
||||
|
||||
const batchType = computed(() => props.batch?.batchType ?? '')
|
||||
const showAllergies = computed(() =>
|
||||
['ALLERGY_UPDATE', 'MIXED'].includes(batchType.value)
|
||||
)
|
||||
const showMedications = computed(() =>
|
||||
['MEDICATION_LIST', 'MIXED'].includes(batchType.value)
|
||||
)
|
||||
const showEncounterSummary = computed(() =>
|
||||
['ENCOUNTER_SUMMARY', 'MIXED'].includes(batchType.value)
|
||||
)
|
||||
|
||||
const patient = reactive({
|
||||
fullName: '',
|
||||
dateOfBirth: '',
|
||||
sex: '',
|
||||
bloodType: '',
|
||||
emergencyContact: '',
|
||||
noKnownAllergies: false,
|
||||
noActiveMedications: false,
|
||||
})
|
||||
|
||||
const allergies = ref<string[]>([])
|
||||
const medications = ref<string[]>([])
|
||||
|
||||
const encounter = reactive({
|
||||
admissionDate: '',
|
||||
department: '',
|
||||
roomBed: '',
|
||||
admissionReason: '',
|
||||
dischargeDiagnosis: '',
|
||||
status: '',
|
||||
})
|
||||
|
||||
const observations = ref<DraftObservation[]>([])
|
||||
|
||||
const statusColor = ref('bg-gray-100 text-gray-800')
|
||||
|
||||
function parseJsonList(json: string | null): string[] {
|
||||
if (!json) return []
|
||||
try {
|
||||
const parsed = JSON.parse(json)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Load draft data when batch changes
|
||||
watch(
|
||||
() => batchStore.currentDraft,
|
||||
@@ -205,7 +327,11 @@ watch(
|
||||
sex: draft.patient.sex ?? '',
|
||||
bloodType: draft.patient.bloodType ?? '',
|
||||
emergencyContact: draft.patient.emergencyContact ?? '',
|
||||
noKnownAllergies: draft.patient.noKnownAllergies ?? false,
|
||||
noActiveMedications: draft.patient.noActiveMedications ?? false,
|
||||
})
|
||||
allergies.value = parseJsonList(draft.patient.allergiesJson)
|
||||
medications.value = parseJsonList(draft.patient.medicationsJson)
|
||||
}
|
||||
if (draft.encounter) {
|
||||
Object.assign(encounter, {
|
||||
@@ -213,6 +339,8 @@ watch(
|
||||
department: draft.encounter.department ?? '',
|
||||
roomBed: draft.encounter.roomBed ?? '',
|
||||
admissionReason: draft.encounter.admissionReason ?? '',
|
||||
dischargeDiagnosis: draft.encounter.dischargeDiagnosis ?? '',
|
||||
status: draft.encounter.status ?? '',
|
||||
})
|
||||
}
|
||||
observations.value = draft.observations ?? []
|
||||
@@ -220,9 +348,67 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function addAllergy() {
|
||||
allergies.value.push('')
|
||||
}
|
||||
|
||||
function removeAllergy(idx: number) {
|
||||
allergies.value.splice(idx, 1)
|
||||
saveAllergies()
|
||||
}
|
||||
|
||||
async function saveAllergies() {
|
||||
try {
|
||||
await batchStore.saveDraftPatient(props.batchId, {
|
||||
...patient,
|
||||
allergiesJson: JSON.stringify(allergies.value.filter(a => a.trim())),
|
||||
})
|
||||
} catch (e: unknown) {
|
||||
errorMessage.value = e instanceof Error ? e.message : 'Failed to save allergies'
|
||||
}
|
||||
}
|
||||
|
||||
function onNoKnownAllergiesChange() {
|
||||
if (patient.noKnownAllergies) {
|
||||
allergies.value = []
|
||||
}
|
||||
savePatient()
|
||||
}
|
||||
|
||||
function addMedication() {
|
||||
medications.value.push('')
|
||||
}
|
||||
|
||||
function removeMedication(idx: number) {
|
||||
medications.value.splice(idx, 1)
|
||||
saveMedications()
|
||||
}
|
||||
|
||||
async function saveMedications() {
|
||||
try {
|
||||
await batchStore.saveDraftPatient(props.batchId, {
|
||||
...patient,
|
||||
medicationsJson: JSON.stringify(medications.value.filter(m => m.trim())),
|
||||
})
|
||||
} catch (e: unknown) {
|
||||
errorMessage.value = e instanceof Error ? e.message : 'Failed to save medications'
|
||||
}
|
||||
}
|
||||
|
||||
function onNoActiveMedicationsChange() {
|
||||
if (patient.noActiveMedications) {
|
||||
medications.value = []
|
||||
}
|
||||
savePatient()
|
||||
}
|
||||
|
||||
async function savePatient() {
|
||||
try {
|
||||
await batchStore.saveDraftPatient(props.batchId, patient)
|
||||
await batchStore.saveDraftPatient(props.batchId, {
|
||||
...patient,
|
||||
allergiesJson: patient.noKnownAllergies ? null : JSON.stringify(allergies.value.filter(a => a.trim())),
|
||||
medicationsJson: patient.noActiveMedications ? null : JSON.stringify(medications.value.filter(m => m.trim())),
|
||||
})
|
||||
} catch (e: unknown) {
|
||||
errorMessage.value = e instanceof Error ? e.message : 'Failed to save patient'
|
||||
}
|
||||
|
||||
@@ -33,6 +33,48 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Allergies review (ALLERGY_UPDATE or MIXED) -->
|
||||
<fieldset v-if="allergyFields.length > 0" class="border border-gray-200 rounded-md p-4">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">Allergies</legend>
|
||||
<div class="space-y-3">
|
||||
<div v-for="field in allergyFields" :key="field.path">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="fieldChecks[field.path]"
|
||||
@change="toggleCheck(field.path)"
|
||||
class="w-4 h-4 text-clinical-safe rounded"
|
||||
/>
|
||||
<label class="text-xs text-gray-500">{{ field.label }}</label>
|
||||
</div>
|
||||
<p class="text-sm mt-2 pl-8 font-medium">
|
||||
{{ field.value || '(empty)' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Medications review (MEDICATION_LIST or MIXED) -->
|
||||
<fieldset v-if="medicationFields.length > 0" class="border border-gray-200 rounded-md p-4">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">Medications</legend>
|
||||
<div class="space-y-3">
|
||||
<div v-for="field in medicationFields" :key="field.path">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="fieldChecks[field.path]"
|
||||
@change="toggleCheck(field.path)"
|
||||
class="w-4 h-4 text-clinical-safe rounded"
|
||||
/>
|
||||
<label class="text-xs text-gray-500">{{ field.label }}</label>
|
||||
</div>
|
||||
<p class="text-sm mt-2 pl-8 font-medium">
|
||||
{{ field.value || '(empty)' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Encounter review -->
|
||||
<fieldset class="border border-gray-200 rounded-md p-4">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">Encounter Context</legend>
|
||||
@@ -172,8 +214,25 @@ interface FieldInfo {
|
||||
}
|
||||
|
||||
const patientFields = ref<FieldInfo[]>([])
|
||||
const allergyFields = ref<FieldInfo[]>([])
|
||||
const medicationFields = ref<FieldInfo[]>([])
|
||||
const encounterFields = ref<FieldInfo[]>([])
|
||||
|
||||
const batchType = computed(() => props.batch?.batchType ?? '')
|
||||
const showAllergies = computed(() => ['ALLERGY_UPDATE', 'MIXED'].includes(batchType.value))
|
||||
const showMedications = computed(() => ['MEDICATION_LIST', 'MIXED'].includes(batchType.value))
|
||||
const showEncounterSummary = computed(() => ['ENCOUNTER_SUMMARY', 'MIXED'].includes(batchType.value))
|
||||
|
||||
function parseJsonList(json: string | null): string[] {
|
||||
if (!json) return []
|
||||
try {
|
||||
const parsed = JSON.parse(json)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => batchStore.currentDraft,
|
||||
(draft) => {
|
||||
@@ -190,6 +249,50 @@ watch(
|
||||
{ path: 'patient.bloodType', label: 'Blood Type', value: draft.patient.bloodType ?? '' },
|
||||
{ path: 'patient.emergencyContact', label: 'Emergency Contact', value: draft.patient.emergencyContact ?? '' },
|
||||
]
|
||||
|
||||
// Build allergy fields
|
||||
allergyFields.value = []
|
||||
if (showAllergies.value) {
|
||||
if (draft.patient.noKnownAllergies) {
|
||||
allergyFields.value = [
|
||||
{ path: 'patient.noKnownAllergies', label: 'No Known Allergies', value: 'Yes (NKA)' },
|
||||
]
|
||||
} else {
|
||||
const items = parseJsonList(draft.patient.allergiesJson)
|
||||
allergyFields.value = items.map((item, i) => ({
|
||||
path: `patient.allergies[${i}]`,
|
||||
label: `Allergy ${i + 1}`,
|
||||
value: item,
|
||||
}))
|
||||
if (items.length === 0) {
|
||||
allergyFields.value = [
|
||||
{ path: 'patient.allergiesJson', label: 'Allergies', value: '(none entered)' },
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build medication fields
|
||||
medicationFields.value = []
|
||||
if (showMedications.value) {
|
||||
if (draft.patient.noActiveMedications) {
|
||||
medicationFields.value = [
|
||||
{ path: 'patient.noActiveMedications', label: 'No Active Medications', value: 'Yes' },
|
||||
]
|
||||
} else {
|
||||
const items = parseJsonList(draft.patient.medicationsJson)
|
||||
medicationFields.value = items.map((item, i) => ({
|
||||
path: `patient.medications[${i}]`,
|
||||
label: `Medication ${i + 1}`,
|
||||
value: item,
|
||||
}))
|
||||
if (items.length === 0) {
|
||||
medicationFields.value = [
|
||||
{ path: 'patient.medicationsJson', label: 'Medications', value: '(none entered)' },
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build encounter field list
|
||||
@@ -200,11 +303,19 @@ watch(
|
||||
{ path: 'encounter.roomBed', label: 'Room / Bed', value: draft.encounter.roomBed ?? '' },
|
||||
{ path: 'encounter.admissionReason', label: 'Admission Reason', value: draft.encounter.admissionReason ?? '' },
|
||||
]
|
||||
if (showEncounterSummary.value) {
|
||||
encounterFields.value.push(
|
||||
{ path: 'encounter.status', label: 'Encounter Status', value: draft.encounter.status ?? '' },
|
||||
{ path: 'encounter.dischargeDiagnosis', label: 'Discharge Diagnosis', value: draft.encounter.dischargeDiagnosis ?? '' },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize all checks to false
|
||||
fieldChecks.value = {}
|
||||
patientFields.value.forEach((f) => { fieldChecks.value[f.path] = false })
|
||||
allergyFields.value.forEach((f) => { fieldChecks.value[f.path] = false })
|
||||
medicationFields.value.forEach((f) => { fieldChecks.value[f.path] = false })
|
||||
encounterFields.value.forEach((f) => { fieldChecks.value[f.path] = false })
|
||||
observations.value.forEach((_, i) => { fieldChecks.value[`observations[${i}].value`] = false })
|
||||
},
|
||||
@@ -212,7 +323,8 @@ watch(
|
||||
)
|
||||
|
||||
const totalFields = computed(
|
||||
() => patientFields.value.length + encounterFields.value.length + observations.value.length
|
||||
() => patientFields.value.length + allergyFields.value.length + medicationFields.value.length
|
||||
+ encounterFields.value.length + observations.value.length
|
||||
)
|
||||
const checkedCount = computed(
|
||||
() => Object.values(fieldChecks.value).filter(Boolean).length
|
||||
|
||||
@@ -46,6 +46,31 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/approval',
|
||||
name: 'ApprovalQueue',
|
||||
component: () => import('../views/ApprovalView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
roles: ['CLINICAL_APPROVER', 'ADMINISTRATOR'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/approval/:batchId',
|
||||
name: 'ApprovalBatch',
|
||||
component: () => import('../views/ApprovalView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
roles: ['CLINICAL_APPROVER', 'ADMINISTRATOR'],
|
||||
},
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/live-capture',
|
||||
name: 'LiveCapture',
|
||||
component: () => import('../views/LiveCaptureView.vue'),
|
||||
meta: { requiresAuth: true, roles: ['CLINICIAN', 'ADMINISTRATOR'] },
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
|
||||
@@ -11,8 +11,11 @@ export function getDefaultRouteForRole(role: string): string {
|
||||
case 'DATA_ENTRY_CLERK':
|
||||
return '/entry'
|
||||
case 'VERIFIER':
|
||||
case 'CLINICAL_APPROVER':
|
||||
return '/verification'
|
||||
case 'CLINICAL_APPROVER':
|
||||
return '/approval'
|
||||
case 'CLINICIAN':
|
||||
return '/live-capture'
|
||||
case 'ADMINISTRATOR':
|
||||
return '/dashboard'
|
||||
default:
|
||||
@@ -42,6 +45,12 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const canVerify = computed(() =>
|
||||
['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'].includes(userRole.value)
|
||||
)
|
||||
const canApprove = computed(() =>
|
||||
['CLINICAL_APPROVER', 'ADMINISTRATOR'].includes(userRole.value)
|
||||
)
|
||||
const canLiveCapture = computed(() =>
|
||||
['CLINICIAN', 'ADMINISTRATOR'].includes(userRole.value)
|
||||
)
|
||||
const canSupervise = computed(() =>
|
||||
['ADMINISTRATOR'].includes(userRole.value)
|
||||
)
|
||||
@@ -113,6 +122,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
canIntake,
|
||||
canEntry,
|
||||
canVerify,
|
||||
canApprove,
|
||||
canLiveCapture,
|
||||
canSupervise,
|
||||
login,
|
||||
fetchCurrentUser,
|
||||
|
||||
@@ -182,10 +182,16 @@ async function assignBatch(batchId: string, entryClerkUserId: string): Promise<v
|
||||
await post<void>(`digitization-batches/${batchId}/reject`, { reason })
|
||||
}
|
||||
|
||||
async function approveBatch(batchId: string): Promise<void> {
|
||||
await post<void>(`digitization-batches/${batchId}/approve`, null, {
|
||||
'Idempotency-Key': crypto.randomUUID(),
|
||||
})
|
||||
async function approveBatch(
|
||||
batchId: string,
|
||||
enableRetroactiveAlerts: boolean = false
|
||||
): Promise<{ status?: number; data?: { mrn: string; encounterId: string; observationIds: string[] } }> {
|
||||
const response = await post<{ mrn: string; encounterId: string; observationIds: string[] }>(
|
||||
`digitization-batches/${batchId}/approve`,
|
||||
{ enableRetroactiveAlerts },
|
||||
{ 'Idempotency-Key': crypto.randomUUID() }
|
||||
)
|
||||
return { data: response.data ?? undefined }
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { post } from '../api/client'
|
||||
import type {
|
||||
LiveCaptureObservationInput,
|
||||
LiveCaptureResponse,
|
||||
} from '../types'
|
||||
|
||||
export const useLiveCaptureStore = defineStore('liveCapture', () => {
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const lastResult = ref<LiveCaptureResponse | null>(null)
|
||||
|
||||
async function recordObservations(
|
||||
encounterId: string,
|
||||
observations: LiveCaptureObservationInput[],
|
||||
clinicianAttestation: boolean,
|
||||
passwordConfirm: string,
|
||||
): Promise<LiveCaptureResponse> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
lastResult.value = null
|
||||
try {
|
||||
const response = await post<LiveCaptureResponse>(
|
||||
`live-capture/encounters/${encounterId}/observations`,
|
||||
{
|
||||
observations: observations.map(o => ({
|
||||
observationCode: o.observationCode,
|
||||
value: o.value,
|
||||
unit: o.unit,
|
||||
recordedAt: o.recordedAt,
|
||||
note: o.note || null,
|
||||
})),
|
||||
clinicianAttestation,
|
||||
passwordConfirm,
|
||||
},
|
||||
)
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.error?.message ?? 'Failed to record observations')
|
||||
}
|
||||
lastResult.value = response.data
|
||||
return response.data
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to record observations'
|
||||
error.value = msg
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openEncounterWithVitals(
|
||||
patientId: string,
|
||||
department: string,
|
||||
roomBed: string,
|
||||
admissionReason: string,
|
||||
observations: LiveCaptureObservationInput[],
|
||||
clinicianAttestation: boolean,
|
||||
passwordConfirm: string,
|
||||
): Promise<LiveCaptureResponse> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
lastResult.value = null
|
||||
try {
|
||||
const response = await post<LiveCaptureResponse>(
|
||||
'live-capture/encounters',
|
||||
{
|
||||
patientId,
|
||||
department,
|
||||
roomBed: roomBed || null,
|
||||
admissionReason,
|
||||
observations: observations.map(o => ({
|
||||
observationCode: o.observationCode,
|
||||
value: o.value,
|
||||
unit: o.unit,
|
||||
recordedAt: o.recordedAt,
|
||||
note: o.note || null,
|
||||
})),
|
||||
clinicianAttestation,
|
||||
passwordConfirm,
|
||||
},
|
||||
)
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.error?.message ?? 'Failed to open encounter')
|
||||
}
|
||||
lastResult.value = response.data
|
||||
return response.data
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to open encounter'
|
||||
error.value = msg
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
lastResult.value = null
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
lastResult,
|
||||
recordObservations,
|
||||
openEncounterWithVitals,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
@@ -82,6 +82,8 @@ export interface DraftPatient {
|
||||
emergencyContact: string | null
|
||||
allergiesJson: string | null
|
||||
noKnownAllergies: boolean
|
||||
medicationsJson: string | null
|
||||
noActiveMedications: boolean
|
||||
}
|
||||
|
||||
export interface DraftEncounter {
|
||||
@@ -142,4 +144,38 @@ export interface UserSummary {
|
||||
username: string
|
||||
fullName: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export interface LiveCaptureObservationInput {
|
||||
observationCode: string
|
||||
value: number | null
|
||||
unit: string
|
||||
recordedAt: string
|
||||
note: string
|
||||
}
|
||||
|
||||
export interface LiveCaptureCriticalAlert {
|
||||
alertId: string
|
||||
severity: string
|
||||
message: string
|
||||
thresholdValue: number
|
||||
thresholdBound: string
|
||||
}
|
||||
|
||||
export interface LiveCaptureObservationResponse {
|
||||
draftObservationId: string
|
||||
liveObservationId: string
|
||||
observationCode: string
|
||||
value: number
|
||||
unit: string
|
||||
recordedAt: string
|
||||
criticalAlert: LiveCaptureCriticalAlert | null
|
||||
}
|
||||
|
||||
export interface LiveCaptureResponse {
|
||||
batchId: string
|
||||
encounterId: string
|
||||
observations: LiveCaptureObservationResponse[]
|
||||
criticalAlertCount: number
|
||||
promotedAt: string
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<div class="min-h-screen lg:h-screen flex flex-col">
|
||||
<AppHeader title="Clinical Approval">
|
||||
<template #subtitle>
|
||||
<span v-if="currentBatch" class="text-sm text-gray-500">
|
||||
Batch: {{ currentBatch.id.substring(0, 8) }}...
|
||||
| Type: {{ formatBatchType(currentBatch.batchType) }}
|
||||
</span>
|
||||
</template>
|
||||
</AppHeader>
|
||||
|
||||
<!-- Queue view (no batch selected) -->
|
||||
<div v-if="!batchId" class="flex-1 p-4 sm:p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Clinical Approval Queue</h2>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
Verified batches awaiting clinical sign-off before promotion to live tables.
|
||||
</p>
|
||||
<BatchList
|
||||
:batches="batchStore.batches"
|
||||
:loading="batchStore.loading"
|
||||
@select="openBatch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Split pane (batch selected) -->
|
||||
<div v-else class="flex-1 split-pane">
|
||||
<ScanViewer
|
||||
v-if="documentUrl"
|
||||
:url="documentUrl"
|
||||
/>
|
||||
<div v-else class="flex items-center justify-center bg-gray-100 rounded-lg">
|
||||
<p class="text-gray-500">Loading document...</p>
|
||||
</div>
|
||||
|
||||
<!-- Approval panel -->
|
||||
<div class="h-full overflow-y-auto p-4 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">Clinical Review</h2>
|
||||
<span class="bg-purple-100 text-purple-800 status-badge">
|
||||
Awaiting Clinical Approval
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Patient summary (read-only) -->
|
||||
<fieldset v-if="draft?.patient" class="border border-gray-200 rounded-md p-4">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">Patient Demographics</legend>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<div><span class="text-gray-500">Full Name:</span> <span class="font-medium ml-1">{{ draft.patient.fullName }}</span></div>
|
||||
<div><span class="text-gray-500">DOB:</span> <span class="font-medium ml-1">{{ draft.patient.dateOfBirth ?? 'N/A' }}</span></div>
|
||||
<div><span class="text-gray-500">Sex:</span> <span class="font-medium ml-1">{{ draft.patient.sex ?? 'N/A' }}</span></div>
|
||||
<div><span class="text-gray-500">Blood Type:</span> <span class="font-medium ml-1">{{ draft.patient.bloodType ?? 'N/A' }}</span></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Encounter context (read-only) -->
|
||||
<fieldset v-if="draft?.encounter" class="border border-gray-200 rounded-md p-4">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">Encounter Context</legend>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
<div><span class="text-gray-500">Admission:</span> <span class="font-medium ml-1">{{ draft.encounter.admissionDate ?? 'N/A' }}</span></div>
|
||||
<div><span class="text-gray-500">Department:</span> <span class="font-medium ml-1">{{ draft.encounter.department ?? 'N/A' }}</span></div>
|
||||
<div><span class="text-gray-500">Room/Bed:</span> <span class="font-medium ml-1">{{ draft.encounter.roomBed ?? 'N/A' }}</span></div>
|
||||
<div><span class="text-gray-500">Reason:</span> <span class="font-medium ml-1">{{ draft.encounter.admissionReason ?? 'N/A' }}</span></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Observations (read-only) -->
|
||||
<fieldset class="border border-gray-200 rounded-md p-4">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">
|
||||
Observations ({{ draft?.observations?.length ?? 0 }})
|
||||
</legend>
|
||||
<div class="space-y-2">
|
||||
<ObservationRow
|
||||
v-for="obs in (draft?.observations ?? [])"
|
||||
:key="obs.id"
|
||||
:observation="obs"
|
||||
:readonly="true"
|
||||
/>
|
||||
<p v-if="!draft?.observations?.length" class="text-sm text-gray-500">
|
||||
No observations recorded.
|
||||
</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Verification info -->
|
||||
<div v-if="currentBatch?.verifiedByUserId" class="bg-blue-50 border border-blue-200 rounded-md p-4 text-sm">
|
||||
<p class="font-medium text-blue-800">Verified by: {{ currentBatch.verifiedByUserId.substring(0, 8) }}...</p>
|
||||
</div>
|
||||
|
||||
<!-- Enable retroactive alerts toggle -->
|
||||
<div class="bg-gray-50 rounded-md p-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="enableRetroactiveAlerts"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 text-clinical-safe rounded"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-sm font-medium">Enable retroactive alerts</span>
|
||||
<p class="text-xs text-gray-500 mt-0.5">
|
||||
If checked, backfill observations will be evaluated by the alert engine.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 pt-4 border-t">
|
||||
<button
|
||||
@click="approve"
|
||||
class="btn-primary"
|
||||
:disabled="processing"
|
||||
>
|
||||
{{ processing ? 'Promoting...' : 'Approve & Promote' }}
|
||||
</button>
|
||||
<button
|
||||
@click="showRejectDialog = true"
|
||||
class="btn-danger"
|
||||
:disabled="processing"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
@click="router.push('/approval')"
|
||||
class="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
:disabled="processing"
|
||||
>
|
||||
Back to Queue
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Promotion result banner -->
|
||||
<div v-if="promotionResult" class="bg-green-50 border border-green-200 rounded-md p-4 text-sm">
|
||||
<p class="font-medium text-green-800">Promotion successful</p>
|
||||
<p class="text-green-700 mt-1">Patient MRN: {{ promotionResult.mrn }}</p>
|
||||
<p class="text-green-700">Encounter: {{ promotionResult.encounterId?.substring(0, 8) }}...</p>
|
||||
<p class="text-green-700">Observations promoted: {{ promotionResult.observationIds?.length }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Deferred banner -->
|
||||
<div v-if="deferred" class="bg-yellow-50 border border-yellow-200 rounded-md p-4 text-sm">
|
||||
<p class="font-medium text-yellow-800">Approved - Promotion Deferred</p>
|
||||
<p class="text-yellow-700 mt-1">
|
||||
Promotion will be retried automatically due to a temporary infrastructure issue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Reject dialog -->
|
||||
<div
|
||||
v-if="showRejectDialog"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||
>
|
||||
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-lg font-semibold mb-4">Reject Batch</h3>
|
||||
<textarea
|
||||
v-model="rejectionReason"
|
||||
class="form-input"
|
||||
rows="4"
|
||||
placeholder="Reason for rejection (required)..."
|
||||
/>
|
||||
<div class="flex flex-col-reverse sm:flex-row sm:justify-end gap-4 mt-4">
|
||||
<button
|
||||
@click="showRejectDialog = false"
|
||||
class="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="reject"
|
||||
class="btn-danger"
|
||||
:disabled="!rejectionReason.trim()"
|
||||
>
|
||||
Confirm Rejection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="text-clinical-danger text-sm">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useBatchStore } from '../stores/batches'
|
||||
import { usePresignedUrl } from '../composables/usePresignedUrl'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import ScanViewer from '../components/ScanViewer.vue'
|
||||
import BatchList from '../components/BatchList.vue'
|
||||
import ObservationRow from '../components/ObservationRow.vue'
|
||||
|
||||
const props = defineProps<{ batchId?: string }>()
|
||||
|
||||
const batchStore = useBatchStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined))
|
||||
const currentBatch = computed(() => batchStore.currentBatch)
|
||||
const draft = computed(() => batchStore.currentDraft)
|
||||
const { documentUrl } = usePresignedUrl(batchId)
|
||||
|
||||
const processing = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const enableRetroactiveAlerts = ref(false)
|
||||
const showRejectDialog = ref(false)
|
||||
const rejectionReason = ref('')
|
||||
const promotionResult = ref<{ mrn: string; encounterId: string; observationIds: string[] } | null>(null)
|
||||
const deferred = ref(false)
|
||||
|
||||
function openBatch(id: string) {
|
||||
router.push(`/approval/${id}`)
|
||||
}
|
||||
|
||||
function formatBatchType(type: string): string {
|
||||
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
}
|
||||
|
||||
async function approve() {
|
||||
if (!batchId.value) return
|
||||
processing.value = true
|
||||
errorMessage.value = ''
|
||||
promotionResult.value = null
|
||||
deferred.value = false
|
||||
|
||||
try {
|
||||
const response = await batchStore.approveBatch(batchId.value, enableRetroactiveAlerts.value)
|
||||
if (response?.status === 202) {
|
||||
deferred.value = true
|
||||
} else if (response?.data) {
|
||||
promotionResult.value = response.data
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Approval failed'
|
||||
if (msg.includes('PROMOTION_DEFERRED')) {
|
||||
deferred.value = true
|
||||
} else {
|
||||
errorMessage.value = msg
|
||||
}
|
||||
} finally {
|
||||
processing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reject() {
|
||||
if (!batchId.value) return
|
||||
processing.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
await batchStore.rejectBatch(batchId.value, rejectionReason.value)
|
||||
showRejectDialog.value = false
|
||||
router.push('/approval')
|
||||
} catch (e: unknown) {
|
||||
errorMessage.value = e instanceof Error ? e.message : 'Rejection failed'
|
||||
} finally {
|
||||
processing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
batchId,
|
||||
async (id) => {
|
||||
if (id) {
|
||||
await batchStore.getBatch(id)
|
||||
await batchStore.getDraft(id)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!batchId.value) {
|
||||
await batchStore.listBatches({
|
||||
status: 'AWAITING_CLINICAL_APPROVAL',
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,388 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<AppHeader title="Live Capture" />
|
||||
|
||||
<div class="flex-1 p-4 sm:p-6 lg:p-8 max-w-5xl mx-auto w-full">
|
||||
<!-- Mode selector tabs -->
|
||||
<div class="flex border-b mb-6">
|
||||
<button
|
||||
@click="mode = 'new'"
|
||||
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors"
|
||||
:class="mode === 'new'
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'"
|
||||
>
|
||||
New Encounter
|
||||
</button>
|
||||
<button
|
||||
@click="mode = 'existing'"
|
||||
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors"
|
||||
:class="mode === 'existing'
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'"
|
||||
>
|
||||
Existing Encounter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- New encounter mode -->
|
||||
<div v-if="mode === 'new'" class="space-y-6">
|
||||
<fieldset class="card">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">Patient</legend>
|
||||
<PatientSearch v-model="patientId" />
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="card">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">Encounter Details</legend>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Department</label>
|
||||
<select v-model="department" class="form-input">
|
||||
<option value="">Select department...</option>
|
||||
<option v-for="dept in departments" :key="dept.value" :value="dept.value">
|
||||
{{ dept.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Room / Bed</label>
|
||||
<input v-model="roomBed" type="text" class="form-input" placeholder="e.g. 4B-12" />
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-xs text-gray-500 mb-1">Admission Reason</label>
|
||||
<input v-model="admissionReason" type="text" class="form-input" placeholder="Chief complaint or reason" />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<!-- Existing encounter mode -->
|
||||
<div v-if="mode === 'existing'" class="space-y-6">
|
||||
<fieldset class="card">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">Encounter</legend>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Encounter ID</label>
|
||||
<input
|
||||
v-model="encounterId"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="Enter existing encounter UUID"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<!-- Vitals entry -->
|
||||
<fieldset class="card mt-6">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">
|
||||
Vitals ({{ observations.length }})
|
||||
</legend>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(obs, idx) in observations"
|
||||
:key="idx"
|
||||
class="p-4 bg-gray-50 rounded-md"
|
||||
>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
<div class="col-span-2 sm:col-span-1">
|
||||
<label class="block text-xs text-gray-500 mb-1">Code</label>
|
||||
<select v-model="obs.observationCode" class="form-input text-sm" @change="autoFillUnit(obs)">
|
||||
<option value="">Select...</option>
|
||||
<option v-for="code in vitalCodes" :key="code.value" :value="code.value">
|
||||
{{ code.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Value</label>
|
||||
<input
|
||||
v-model.number="obs.value"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="form-input text-sm"
|
||||
inputmode="decimal"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Unit</label>
|
||||
<input v-model="obs.unit" type="text" class="form-input text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Recorded At</label>
|
||||
<input
|
||||
v-model="obs.recordedAt"
|
||||
type="datetime-local"
|
||||
class="form-input text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs text-gray-500 mb-1">Note</label>
|
||||
<input v-model="obs.note" type="text" class="form-input text-sm" placeholder="Optional" />
|
||||
</div>
|
||||
<button
|
||||
@click="removeObservation(idx)"
|
||||
class="mb-0.5 text-clinical-danger hover:text-red-800 text-sm px-2 py-2"
|
||||
:disabled="observations.length <= 1"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="addObservation"
|
||||
class="mt-4 text-sm text-primary-600 hover:text-primary-800"
|
||||
:disabled="observations.length >= 10"
|
||||
>
|
||||
+ Add Observation
|
||||
</button>
|
||||
<p v-if="observations.length >= 10" class="text-xs text-gray-400 mt-1">
|
||||
Maximum 10 observations per submission.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<!-- Attestation & password -->
|
||||
<fieldset class="card mt-6">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">Clinician Attestation</legend>
|
||||
<div class="space-y-4">
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="clinicianAttestation"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 mt-0.5 text-clinical-safe rounded"
|
||||
/>
|
||||
<span class="text-sm">
|
||||
I attest that I have directly observed or performed these measurements and they are accurate to the best of my clinical judgment.
|
||||
</span>
|
||||
</label>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Password Confirmation</label>
|
||||
<input
|
||||
v-model="passwordConfirm"
|
||||
type="password"
|
||||
class="form-input max-w-sm"
|
||||
placeholder="Re-enter your password to confirm"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="mt-6 flex flex-col sm:flex-row gap-4">
|
||||
<button
|
||||
@click="submit"
|
||||
class="btn-primary text-base px-8 py-3"
|
||||
:disabled="!canSubmit || store.loading"
|
||||
>
|
||||
{{ store.loading ? 'Submitting...' : 'Record Vitals' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="lastResult"
|
||||
@click="resetForm"
|
||||
class="px-4 py-3 text-gray-600 hover:text-gray-800 text-sm"
|
||||
>
|
||||
Record More Vitals
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="errorMessage" class="mt-4 bg-red-50 border border-red-200 rounded-md p-4 text-sm text-clinical-danger">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- Success result -->
|
||||
<div v-if="lastResult" class="mt-6 space-y-4">
|
||||
<div class="bg-green-50 border border-green-200 rounded-md p-4">
|
||||
<p class="font-medium text-green-800">Vitals recorded and promoted successfully</p>
|
||||
<div class="text-sm text-green-700 mt-2 space-y-1">
|
||||
<p>Batch: {{ lastResult.batchId.substring(0, 8) }}...</p>
|
||||
<p>Encounter: {{ lastResult.encounterId.substring(0, 8) }}...</p>
|
||||
<p>Observations promoted: {{ lastResult.observations.length }}</p>
|
||||
<p>Promoted at: {{ new Date(lastResult.promotedAt).toLocaleString() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Critical alerts -->
|
||||
<div
|
||||
v-if="lastResult.criticalAlertCount > 0"
|
||||
class="bg-red-50 border-2 border-red-400 rounded-md p-4"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-red-700 font-bold text-lg">CRITICAL ALERTS ({{ lastResult.criticalAlertCount }})</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="obs in lastResult.observations.filter(o => o.criticalAlert)"
|
||||
:key="obs.liveObservationId"
|
||||
class="bg-white border border-red-300 rounded p-3 mb-2 last:mb-0"
|
||||
>
|
||||
<p class="font-medium text-red-800">
|
||||
{{ formatCode(obs.observationCode) }}: {{ obs.value }} {{ obs.unit }}
|
||||
</p>
|
||||
<p class="text-sm text-red-700 mt-1">{{ obs.criticalAlert!.message }}</p>
|
||||
<div class="text-xs text-red-600 mt-1">
|
||||
<span>Severity: {{ obs.criticalAlert!.severity }}</span>
|
||||
<span class="ml-4">
|
||||
Threshold ({{ obs.criticalAlert!.thresholdBound.replace('_', ' ') }}):
|
||||
{{ obs.criticalAlert!.thresholdValue }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useLiveCaptureStore } from '../stores/liveCapture'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import PatientSearch from '../components/PatientSearch.vue'
|
||||
import type { LiveCaptureObservationInput } from '../types'
|
||||
|
||||
const store = useLiveCaptureStore()
|
||||
|
||||
const mode = ref<'new' | 'existing'>('new')
|
||||
const patientId = ref<string | undefined>()
|
||||
const department = ref('')
|
||||
const roomBed = ref('')
|
||||
const admissionReason = ref('')
|
||||
const encounterId = ref('')
|
||||
const clinicianAttestation = ref(false)
|
||||
const passwordConfirm = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
const lastResult = computed(() => store.lastResult)
|
||||
|
||||
const departments = [
|
||||
{ value: 'Emergency Department', label: 'Emergency Department' },
|
||||
{ value: 'Internal Medicine', label: 'Internal Medicine' },
|
||||
{ value: 'General Medicine', label: 'General Medicine' },
|
||||
{ value: 'Surgery', label: 'Surgery' },
|
||||
{ value: 'ICU', label: 'ICU' },
|
||||
{ value: 'NICU', label: 'NICU' },
|
||||
{ value: 'Medical-Surgical', label: 'Medical-Surgical' },
|
||||
{ value: 'Outpatient Clinic', label: 'Outpatient Clinic' },
|
||||
{ value: 'Pediatrics', label: 'Pediatrics' },
|
||||
{ value: 'Obstetrics & Gynecology', label: 'Obstetrics & Gynecology' },
|
||||
{ value: 'Labor & Delivery', label: 'Labor & Delivery' },
|
||||
{ value: 'Cardiology', label: 'Cardiology' },
|
||||
{ value: 'Orthopedics', label: 'Orthopedics' },
|
||||
{ value: 'Neurology', label: 'Neurology' },
|
||||
{ value: 'Oncology', label: 'Oncology' },
|
||||
{ value: 'Radiology', label: 'Radiology' },
|
||||
{ value: 'Laboratory', label: 'Laboratory' },
|
||||
{ value: 'Psychiatry', label: 'Psychiatry' },
|
||||
{ value: 'Physical Therapy', label: 'Physical Therapy' },
|
||||
{ value: 'Anesthesiology', label: 'Anesthesiology' },
|
||||
]
|
||||
|
||||
const vitalCodes = [
|
||||
{ value: 'HEART_RATE', label: 'Heart Rate', unit: 'bpm' },
|
||||
{ value: 'TEMP_C', label: 'Temperature', unit: '°C' },
|
||||
{ value: 'BP_SYSTOLIC', label: 'BP Systolic', unit: 'mmHg' },
|
||||
{ value: 'BP_DIASTOLIC', label: 'BP Diastolic', unit: 'mmHg' },
|
||||
{ value: 'RESP_RATE', label: 'Respiratory Rate', unit: 'breaths/min' },
|
||||
{ value: 'SPO2', label: 'SpO2', unit: '%' },
|
||||
{ value: 'POTASSIUM_MEQ_L', label: 'Potassium', unit: 'mEq/L' },
|
||||
{ value: 'GLUCOSE_MG_DL', label: 'Glucose', unit: 'mg/dL' },
|
||||
{ value: 'WBC_K_UL', label: 'WBC', unit: '×10³/µL' },
|
||||
{ value: 'LACTATE_MMOL_L', label: 'Lactate', unit: 'mmol/L' },
|
||||
]
|
||||
|
||||
function nowLocal(): string {
|
||||
const d = new Date()
|
||||
d.setMinutes(d.getMinutes() - d.getTimezoneOffset())
|
||||
return d.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
function makeObservation(): LiveCaptureObservationInput {
|
||||
return { observationCode: '', value: null, unit: '', recordedAt: nowLocal(), note: '' }
|
||||
}
|
||||
|
||||
const observations = ref<LiveCaptureObservationInput[]>([makeObservation()])
|
||||
|
||||
function addObservation() {
|
||||
if (observations.value.length < 10) {
|
||||
observations.value.push(makeObservation())
|
||||
}
|
||||
}
|
||||
|
||||
function removeObservation(idx: number) {
|
||||
if (observations.value.length > 1) {
|
||||
observations.value.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function autoFillUnit(obs: LiveCaptureObservationInput) {
|
||||
const match = vitalCodes.find(c => c.value === obs.observationCode)
|
||||
if (match) obs.unit = match.unit
|
||||
}
|
||||
|
||||
function formatCode(code: string): string {
|
||||
const match = vitalCodes.find(c => c.value === code)
|
||||
return match?.label ?? code
|
||||
}
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
const hasValidObs = observations.value.every(
|
||||
o => o.observationCode && o.value !== null && o.unit && o.recordedAt,
|
||||
)
|
||||
if (!hasValidObs || !clinicianAttestation.value || !passwordConfirm.value) return false
|
||||
|
||||
if (mode.value === 'new') {
|
||||
return !!patientId.value && !!department.value && !!admissionReason.value
|
||||
}
|
||||
return !!encounterId.value.trim()
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
errorMessage.value = ''
|
||||
store.reset()
|
||||
|
||||
const obs = observations.value.map(o => ({
|
||||
...o,
|
||||
recordedAt: new Date(o.recordedAt).toISOString(),
|
||||
}))
|
||||
|
||||
try {
|
||||
if (mode.value === 'new') {
|
||||
await store.openEncounterWithVitals(
|
||||
patientId.value!,
|
||||
department.value,
|
||||
roomBed.value,
|
||||
admissionReason.value,
|
||||
obs,
|
||||
clinicianAttestation.value,
|
||||
passwordConfirm.value,
|
||||
)
|
||||
} else {
|
||||
await store.recordObservations(
|
||||
encounterId.value.trim(),
|
||||
obs,
|
||||
clinicianAttestation.value,
|
||||
passwordConfirm.value,
|
||||
)
|
||||
}
|
||||
passwordConfirm.value = ''
|
||||
} catch (e: unknown) {
|
||||
errorMessage.value = e instanceof Error ? e.message : 'Submission failed'
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
store.reset()
|
||||
errorMessage.value = ''
|
||||
observations.value = [makeObservation()]
|
||||
clinicianAttestation.value = false
|
||||
passwordConfirm.value = ''
|
||||
if (mode.value === 'existing') {
|
||||
encounterId.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user