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:
voltsrage
2026-06-27 16:27:58 +08:00
parent 1c7e7fee7d
commit efd3974d1f
13 changed files with 1184 additions and 12 deletions
@@ -25,7 +25,7 @@ public class PatientsController : ControllerBase
/// Searches live patients by MRN or full name (minimum 2 characters). /// Searches live patients by MRN or full name (minimum 2 characters).
/// </summary> /// </summary>
[HttpGet("search")] [HttpGet("search")]
[Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")] [Authorize(Roles = "INTAKE_CLERK,CLINICIAN,ADMINISTRATOR")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<PatientSearchResult>>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<IReadOnlyList<PatientSearchResult>>), StatusCodes.Status200OK)]
public async Task<IActionResult> Search([FromQuery] string q) public async Task<IActionResult> Search([FromQuery] string q)
{ {
+3 -3
View File
@@ -536,7 +536,7 @@ Clinical approvers (typically physicians) have a different workflow from verifie
--- ---
## P2 — No live capture view for bedside data entry ## ~~P2 — No live capture view for bedside data entry~~ DONE
### Problem ### Problem
@@ -570,7 +570,7 @@ Live capture is the path from paper-to-digital for current patient care. Without
--- ---
## P4 — EntryForm missing allergy, medication, and discharge fields ## ~~P4 — EntryForm missing allergy, medication, and discharge fields~~ DONE
### Problem ### Problem
@@ -811,7 +811,7 @@ A batch stuck in `APPROVED` with exhausted retries is invisible in Prometheus da
| 14 | No user management endpoints | P4 | D | Done | | 14 | No user management endpoints | P4 | D | Done |
| 15 | No batch cancel/void | P4 | D | Done | | 15 | No batch cancel/void | P4 | D | Done |
| 16 | No sort parameters on lists | P4 | D | Done | | 16 | No sort parameters on lists | P4 | D | Done |
| 17 | No clinical approval view | P2 | E | Open | | 17 | No clinical approval view | P2 | E | Done |
| 18 | No live capture view | P2 | E | Open | | 18 | No live capture view | P2 | E | Open |
| 19 | EntryForm missing allergy/med fields | P4 | E | Open | | 19 | EntryForm missing allergy/med fields | P4 | E | Open |
| 20 | No corrections/supersession UI | P4 | E | Open | | 20 | No corrections/supersession UI | P4 | E | Open |
@@ -38,6 +38,12 @@
@apply grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-8 @apply grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-8
h-auto lg:h-[calc(100vh-4rem)] min-h-0; 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 { .status-badge {
@apply px-2 py-1 rounded-full text-xs font-medium; @apply px-2 py-1 rounded-full text-xs font-medium;
} }
@@ -2,6 +2,14 @@
<div class="app-header"> <div class="app-header">
<div class="app-header-title"> <div class="app-header-title">
<h1 class="text-lg font-semibold">{{ title }}</h1> <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" /> <slot name="subtitle" />
</div> </div>
<div class="app-header-actions"> <div class="app-header-actions">
@@ -60,6 +60,82 @@
</div> </div>
</fieldset> </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 --> <!-- Encounter section -->
<fieldset class="border border-gray-200 rounded-md p-4"> <fieldset class="border border-gray-200 rounded-md p-4">
<legend class="text-sm font-medium text-gray-700 px-2">Encounter Context</legend> <legend class="text-sm font-medium text-gray-700 px-2">Encounter Context</legend>
@@ -98,6 +174,24 @@
class="form-input text-sm" class="form-input text-sm"
/> />
</div> </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> </div>
</fieldset> </fieldset>
@@ -136,7 +230,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, watch } from 'vue' import { ref, reactive, computed, watch } from 'vue'
import { useBatchStore } from '../stores/batches' import { useBatchStore } from '../stores/batches'
import ObservationRow from '../components/ObservationRow.vue' import ObservationRow from '../components/ObservationRow.vue'
import type { BatchDetailResponse, DraftObservation } from '../types' import type { BatchDetailResponse, DraftObservation } from '../types'
@@ -174,25 +268,53 @@ const departments = [
'Anesthesiology', '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({ const patient = reactive({
fullName: '', fullName: '',
dateOfBirth: '', dateOfBirth: '',
sex: '', sex: '',
bloodType: '', bloodType: '',
emergencyContact: '', emergencyContact: '',
noKnownAllergies: false,
noActiveMedications: false,
}) })
const allergies = ref<string[]>([])
const medications = ref<string[]>([])
const encounter = reactive({ const encounter = reactive({
admissionDate: '', admissionDate: '',
department: '', department: '',
roomBed: '', roomBed: '',
admissionReason: '', admissionReason: '',
dischargeDiagnosis: '',
status: '',
}) })
const observations = ref<DraftObservation[]>([]) const observations = ref<DraftObservation[]>([])
const statusColor = ref('bg-gray-100 text-gray-800') 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 // Load draft data when batch changes
watch( watch(
() => batchStore.currentDraft, () => batchStore.currentDraft,
@@ -205,7 +327,11 @@ watch(
sex: draft.patient.sex ?? '', sex: draft.patient.sex ?? '',
bloodType: draft.patient.bloodType ?? '', bloodType: draft.patient.bloodType ?? '',
emergencyContact: draft.patient.emergencyContact ?? '', 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) { if (draft.encounter) {
Object.assign(encounter, { Object.assign(encounter, {
@@ -213,6 +339,8 @@ watch(
department: draft.encounter.department ?? '', department: draft.encounter.department ?? '',
roomBed: draft.encounter.roomBed ?? '', roomBed: draft.encounter.roomBed ?? '',
admissionReason: draft.encounter.admissionReason ?? '', admissionReason: draft.encounter.admissionReason ?? '',
dischargeDiagnosis: draft.encounter.dischargeDiagnosis ?? '',
status: draft.encounter.status ?? '',
}) })
} }
observations.value = draft.observations ?? [] observations.value = draft.observations ?? []
@@ -220,9 +348,67 @@ watch(
{ immediate: true } { 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() { async function savePatient() {
try { 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) { } catch (e: unknown) {
errorMessage.value = e instanceof Error ? e.message : 'Failed to save patient' errorMessage.value = e instanceof Error ? e.message : 'Failed to save patient'
} }
@@ -33,6 +33,48 @@
</div> </div>
</fieldset> </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 --> <!-- Encounter review -->
<fieldset class="border border-gray-200 rounded-md p-4"> <fieldset class="border border-gray-200 rounded-md p-4">
<legend class="text-sm font-medium text-gray-700 px-2">Encounter Context</legend> <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 patientFields = ref<FieldInfo[]>([])
const allergyFields = ref<FieldInfo[]>([])
const medicationFields = ref<FieldInfo[]>([])
const encounterFields = 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( watch(
() => batchStore.currentDraft, () => batchStore.currentDraft,
(draft) => { (draft) => {
@@ -190,6 +249,50 @@ watch(
{ path: 'patient.bloodType', label: 'Blood Type', value: draft.patient.bloodType ?? '' }, { path: 'patient.bloodType', label: 'Blood Type', value: draft.patient.bloodType ?? '' },
{ path: 'patient.emergencyContact', label: 'Emergency Contact', value: draft.patient.emergencyContact ?? '' }, { 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 // Build encounter field list
@@ -200,11 +303,19 @@ watch(
{ path: 'encounter.roomBed', label: 'Room / Bed', value: draft.encounter.roomBed ?? '' }, { path: 'encounter.roomBed', label: 'Room / Bed', value: draft.encounter.roomBed ?? '' },
{ path: 'encounter.admissionReason', label: 'Admission Reason', value: draft.encounter.admissionReason ?? '' }, { 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 // Initialize all checks to false
fieldChecks.value = {} fieldChecks.value = {}
patientFields.value.forEach((f) => { fieldChecks.value[f.path] = false }) 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 }) encounterFields.value.forEach((f) => { fieldChecks.value[f.path] = false })
observations.value.forEach((_, i) => { fieldChecks.value[`observations[${i}].value`] = false }) observations.value.forEach((_, i) => { fieldChecks.value[`observations[${i}].value`] = false })
}, },
@@ -212,7 +323,8 @@ watch(
) )
const totalFields = computed( 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( const checkedCount = computed(
() => Object.values(fieldChecks.value).filter(Boolean).length () => Object.values(fieldChecks.value).filter(Boolean).length
+25
View File
@@ -46,6 +46,31 @@ const routes: RouteRecordRaw[] = [
}, },
props: true, 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', path: '/dashboard',
name: 'Dashboard', name: 'Dashboard',
+12 -1
View File
@@ -11,8 +11,11 @@ export function getDefaultRouteForRole(role: string): string {
case 'DATA_ENTRY_CLERK': case 'DATA_ENTRY_CLERK':
return '/entry' return '/entry'
case 'VERIFIER': case 'VERIFIER':
case 'CLINICAL_APPROVER':
return '/verification' return '/verification'
case 'CLINICAL_APPROVER':
return '/approval'
case 'CLINICIAN':
return '/live-capture'
case 'ADMINISTRATOR': case 'ADMINISTRATOR':
return '/dashboard' return '/dashboard'
default: default:
@@ -42,6 +45,12 @@ export const useAuthStore = defineStore('auth', () => {
const canVerify = computed(() => const canVerify = computed(() =>
['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'].includes(userRole.value) ['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(() => const canSupervise = computed(() =>
['ADMINISTRATOR'].includes(userRole.value) ['ADMINISTRATOR'].includes(userRole.value)
) )
@@ -113,6 +122,8 @@ export const useAuthStore = defineStore('auth', () => {
canIntake, canIntake,
canEntry, canEntry,
canVerify, canVerify,
canApprove,
canLiveCapture,
canSupervise, canSupervise,
login, login,
fetchCurrentUser, fetchCurrentUser,
+10 -4
View File
@@ -182,10 +182,16 @@ async function assignBatch(batchId: string, entryClerkUserId: string): Promise<v
await post<void>(`digitization-batches/${batchId}/reject`, { reason }) await post<void>(`digitization-batches/${batchId}/reject`, { reason })
} }
async function approveBatch(batchId: string): Promise<void> { async function approveBatch(
await post<void>(`digitization-batches/${batchId}/approve`, null, { batchId: string,
'Idempotency-Key': crypto.randomUUID(), 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 { 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,
}
})
+36
View File
@@ -82,6 +82,8 @@ export interface DraftPatient {
emergencyContact: string | null emergencyContact: string | null
allergiesJson: string | null allergiesJson: string | null
noKnownAllergies: boolean noKnownAllergies: boolean
medicationsJson: string | null
noActiveMedications: boolean
} }
export interface DraftEncounter { export interface DraftEncounter {
@@ -142,4 +144,38 @@ export interface UserSummary {
username: string username: string
fullName: string fullName: string
role: 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>