feature: Digitization Workstation UI
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<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">Verification Review</h2>
|
||||
<span class="bg-orange-100 text-orange-800 status-badge">
|
||||
Pending Verification
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="batch?.rejectionReason" class="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<p class="text-sm font-medium text-red-800">Previous Rejection Reason:</p>
|
||||
<p class="text-sm text-red-700">{{ batch.rejectionReason }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Patient review -->
|
||||
<fieldset 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-4">
|
||||
<div v-for="field in patientFields" :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>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div v-for="field in encounterFields" :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>
|
||||
|
||||
<!-- Observations review -->
|
||||
<fieldset class="border border-gray-200 rounded-md p-4">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">
|
||||
Observations ({{ observations.length }})
|
||||
</legend>
|
||||
<div class="space-y-2">
|
||||
<ObservationRow
|
||||
v-for="(obs, index) in observations"
|
||||
:key="obs.id"
|
||||
:observation="obs"
|
||||
:readonly="true"
|
||||
:show-verified="true"
|
||||
:verified="fieldChecks[`observations[${index}].value`] ?? false"
|
||||
@verify="(_obsId, passed) => toggleCheck(`observations[${index}].value`, passed)"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Verification progress -->
|
||||
<div class="bg-gray-50 rounded-md p-4">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span>Fields verified:</span>
|
||||
<span :class="allChecked ? 'text-clinical-safe font-bold' : 'text-gray-600'">
|
||||
{{ checkedCount }} / {{ totalFields }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2 mt-2">
|
||||
<div
|
||||
class="bg-clinical-safe h-2 rounded-full transition-all"
|
||||
:style="{ width: `${(checkedCount / Math.max(totalFields, 1)) * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 pt-4 border-t">
|
||||
<button
|
||||
@click="approveVerification"
|
||||
class="btn-primary"
|
||||
:disabled="!allChecked || processing"
|
||||
>
|
||||
{{ processing ? 'Processing...' : 'Approve - Verified' }}
|
||||
</button>
|
||||
<button
|
||||
@click="showRejectDialog = true"
|
||||
class="btn-danger"
|
||||
:disabled="processing"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</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="rejectVerification"
|
||||
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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useBatchStore } from '../stores/batches'
|
||||
import ObservationRow from '../components/ObservationRow.vue'
|
||||
import type { BatchDetailResponse, DraftObservation } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
batch: BatchDetailResponse | null
|
||||
batchId: string
|
||||
}>()
|
||||
|
||||
const batchStore = useBatchStore()
|
||||
const router = useRouter()
|
||||
const processing = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const showRejectDialog = ref(false)
|
||||
const rejectionReason = ref('')
|
||||
|
||||
const fieldChecks = ref<Record<string, boolean>>({})
|
||||
const observations = ref<DraftObservation[]>([])
|
||||
|
||||
interface FieldInfo {
|
||||
path: string
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const patientFields = ref<FieldInfo[]>([])
|
||||
const encounterFields = ref<FieldInfo[]>([])
|
||||
|
||||
watch(
|
||||
() => batchStore.currentDraft,
|
||||
(draft) => {
|
||||
if (!draft) return
|
||||
|
||||
observations.value = draft.observations ?? []
|
||||
|
||||
// Build patient field list
|
||||
if (draft.patient) {
|
||||
patientFields.value = [
|
||||
{ path: 'patient.fullName', label: 'Full Name', value: draft.patient.fullName ?? '' },
|
||||
{ path: 'patient.dateOfBirth', label: 'Date of Birth', value: draft.patient.dateOfBirth ?? '' },
|
||||
{ path: 'patient.sex', label: 'Sex', value: draft.patient.sex ?? '' },
|
||||
{ path: 'patient.bloodType', label: 'Blood Type', value: draft.patient.bloodType ?? '' },
|
||||
{ path: 'patient.emergencyContact', label: 'Emergency Contact', value: draft.patient.emergencyContact ?? '' },
|
||||
]
|
||||
}
|
||||
|
||||
// Build encounter field list
|
||||
if (draft.encounter) {
|
||||
encounterFields.value = [
|
||||
{ path: 'encounter.admissionDate', label: 'Admission Date', value: draft.encounter.admissionDate ?? '' },
|
||||
{ path: 'encounter.department', label: 'Department', value: draft.encounter.department ?? '' },
|
||||
{ path: 'encounter.roomBed', label: 'Room / Bed', value: draft.encounter.roomBed ?? '' },
|
||||
{ path: 'encounter.admissionReason', label: 'Admission Reason', value: draft.encounter.admissionReason ?? '' },
|
||||
]
|
||||
}
|
||||
|
||||
// Initialize all checks to false
|
||||
fieldChecks.value = {}
|
||||
patientFields.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 })
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const totalFields = computed(
|
||||
() => patientFields.value.length + encounterFields.value.length + observations.value.length
|
||||
)
|
||||
const checkedCount = computed(
|
||||
() => Object.values(fieldChecks.value).filter(Boolean).length
|
||||
)
|
||||
const allChecked = computed(() => checkedCount.value === totalFields.value && totalFields.value > 0)
|
||||
|
||||
function toggleCheck(path: string, value?: boolean) {
|
||||
fieldChecks.value[path] = value ?? !fieldChecks.value[path]
|
||||
}
|
||||
|
||||
async function approveVerification() {
|
||||
processing.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const checks = Object.entries(fieldChecks.value).map(([fieldPath, passed]) => ({
|
||||
fieldPath,
|
||||
passed,
|
||||
}))
|
||||
await batchStore.verifyBatch(props.batchId, checks, true)
|
||||
router.push('/verification')
|
||||
} catch (e: unknown) {
|
||||
errorMessage.value = e instanceof Error ? e.message : 'Verification failed'
|
||||
} finally {
|
||||
processing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectVerification() {
|
||||
processing.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
await batchStore.rejectBatch(props.batchId, rejectionReason.value)
|
||||
showRejectDialog.value = false
|
||||
router.push('/verification')
|
||||
} catch (e: unknown) {
|
||||
errorMessage.value = e instanceof Error ? e.message : 'Rejection failed'
|
||||
} finally {
|
||||
processing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user