feature: Core Digitization Workstation (Entry, Verification, Approval)
This commit is contained in:
@@ -2,300 +2,95 @@
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<AppHeader title="Clinical Approval">
|
||||
<template #subtitle>
|
||||
<span v-if="currentBatch" class="text-sm text-gray-500">
|
||||
<span v-if="currentBatch" class="text-sm text-ink-secondary">
|
||||
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"
|
||||
:error="batchStore.error"
|
||||
empty-title="No batches are waiting for clinical approval."
|
||||
empty-description="Verified batches appear here after verification is complete."
|
||||
@select="openBatch"
|
||||
@retry="loadQueue"
|
||||
/>
|
||||
</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">{{ documentError ?? '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>
|
||||
<StatusBadge :status="currentBatch?.status ?? 'AWAITING_CLINICAL_APPROVAL'" />
|
||||
</div>
|
||||
|
||||
<!-- Supersession info -->
|
||||
<div
|
||||
v-if="currentBatch?.supersedesBatchId"
|
||||
class="bg-blue-50 border border-blue-200 rounded-md p-4 text-sm"
|
||||
>
|
||||
<p class="font-medium text-blue-800">Correction Batch</p>
|
||||
<p class="text-blue-700 mt-1">
|
||||
This batch corrects and will supersede batch
|
||||
<span class="font-mono">{{ currentBatch.supersedesBatchId.substring(0, 8) }}...</span>
|
||||
</p>
|
||||
<button
|
||||
v-if="currentBatch.patientId"
|
||||
@click="router.push(`/patients/${currentBatch.patientId}/history`)"
|
||||
class="text-xs text-blue-600 hover:text-blue-800 mt-2"
|
||||
>
|
||||
View patient history
|
||||
</button>
|
||||
</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="showApproveConfirm = true"
|
||||
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 class="flex gap-4 mt-3 pt-3 border-t border-green-200">
|
||||
<button
|
||||
@click="createCorrection"
|
||||
class="text-sm text-primary-600 hover:text-primary-800 font-medium"
|
||||
>
|
||||
Create Correction
|
||||
</button>
|
||||
<button
|
||||
v-if="currentBatch?.patientId"
|
||||
@click="router.push(`/patients/${currentBatch!.patientId}/history`)"
|
||||
class="text-sm text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
View Patient History
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="showApproveConfirm"
|
||||
title="Approve & Promote"
|
||||
body="Approval will promote the verified records into live clinical tables."
|
||||
confirm-label="Approve & Promote"
|
||||
variant="primary"
|
||||
:confirm-disabled="processing"
|
||||
@confirm="confirmApprove"
|
||||
@cancel="showApproveConfirm = false"
|
||||
<WorkstationLayout :has-batch="!!batchId">
|
||||
<template #queue>
|
||||
<h2 class="text-xl font-semibold mb-4 text-ink-strong">Clinical Approval Queue</h2>
|
||||
<p class="text-sm text-ink-secondary mb-4">
|
||||
Verified batches awaiting clinical sign-off before promotion to live tables.
|
||||
</p>
|
||||
<BatchList
|
||||
:batches="batchStore.batches"
|
||||
:loading="batchStore.loading"
|
||||
:error="batchStore.error"
|
||||
empty-title="No batches are waiting for clinical approval."
|
||||
empty-description="Verified batches appear here after verification is complete."
|
||||
@select="openBatch"
|
||||
@retry="loadQueue"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="showRejectDialog"
|
||||
title="Reject Batch"
|
||||
body="This returns the batch for rework. A reason is required."
|
||||
confirm-label="Confirm Rejection"
|
||||
variant="danger"
|
||||
:confirm-disabled="!rejectionReason.trim() || processing"
|
||||
@confirm="reject"
|
||||
@cancel="closeRejectDialog"
|
||||
>
|
||||
<textarea
|
||||
v-model="rejectionReason"
|
||||
class="form-input"
|
||||
rows="4"
|
||||
placeholder="Reason for rejection (required)..."
|
||||
/>
|
||||
</ConfirmDialog>
|
||||
<template #rail>
|
||||
<WorkstationQueueRail
|
||||
title="Approval queue"
|
||||
:batches="batchStore.batches"
|
||||
:selected-id="batchId"
|
||||
:loading="batchStore.loading"
|
||||
@select="openBatch"
|
||||
@back="router.push('/approval')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-if="errorMessage" class="text-clinical-danger text-sm">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #scan>
|
||||
<ScanViewer
|
||||
:url="documentUrl"
|
||||
:loading="documentLoading"
|
||||
:error="documentError"
|
||||
@retry="refreshUrl"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #form>
|
||||
<ApprovalForm
|
||||
v-if="batchId"
|
||||
:batch="currentBatch"
|
||||
:batch-id="batchId"
|
||||
:draft="draft"
|
||||
@back="router.push('/approval')"
|
||||
@view-history="(patientId) => router.push(`/patients/${patientId}/history`)"
|
||||
@create-correction="createCorrection"
|
||||
@rejected="router.push('/approval')"
|
||||
/>
|
||||
</template>
|
||||
</WorkstationLayout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useBatchStore } from '../stores/batches'
|
||||
import { usePresignedUrl } from '../composables/usePresignedUrl'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import ScanViewer from '../components/ScanViewer.vue'
|
||||
import BatchList from '../components/BatchList.vue'
|
||||
import ObservationRow from '../components/ObservationRow.vue'
|
||||
import StatusBadge from '../components/StatusBadge.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import WorkstationLayout from '../components/WorkstationLayout.vue'
|
||||
import WorkstationQueueRail from '../components/WorkstationQueueRail.vue'
|
||||
import ApprovalForm from '../components/ApprovalForm.vue'
|
||||
|
||||
const props = defineProps<{ batchId?: string }>()
|
||||
|
||||
const batchStore = useBatchStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined))
|
||||
const currentBatch = computed(() => batchStore.currentBatch)
|
||||
const draft = computed(() => batchStore.currentDraft)
|
||||
const { documentUrl, documentError } = usePresignedUrl(batchId)
|
||||
|
||||
const processing = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const enableRetroactiveAlerts = ref(false)
|
||||
const showApproveConfirm = ref(false)
|
||||
const showRejectDialog = ref(false)
|
||||
const rejectionReason = ref('')
|
||||
const promotionResult = ref<{ mrn: string; encounterId: string; observationIds: string[] } | null>(null)
|
||||
const deferred = ref(false)
|
||||
const { documentUrl, documentError, documentLoading, refreshUrl } = usePresignedUrl(batchId)
|
||||
|
||||
function openBatch(id: string) {
|
||||
router.push(`/approval/${id}`)
|
||||
}
|
||||
|
||||
function formatBatchType(type: string): string {
|
||||
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
}
|
||||
|
||||
function closeRejectDialog() {
|
||||
showRejectDialog.value = false
|
||||
}
|
||||
|
||||
async function confirmApprove() {
|
||||
showApproveConfirm.value = false
|
||||
await approve()
|
||||
}
|
||||
|
||||
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
|
||||
toast.info('Approved. Promotion will be retried automatically.')
|
||||
} else if (response?.data) {
|
||||
promotionResult.value = response.data
|
||||
toast.success('Batch approved and promoted successfully')
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Approval failed'
|
||||
if (msg.includes('PROMOTION_DEFERRED')) {
|
||||
deferred.value = true
|
||||
toast.info('Approved. Promotion will be retried automatically.')
|
||||
} else {
|
||||
errorMessage.value = msg
|
||||
toast.error(msg)
|
||||
}
|
||||
} finally {
|
||||
processing.value = false
|
||||
}
|
||||
return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function createCorrection() {
|
||||
@@ -310,24 +105,6 @@ function createCorrection() {
|
||||
})
|
||||
}
|
||||
|
||||
async function reject() {
|
||||
if (!batchId.value) return
|
||||
processing.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
await batchStore.rejectBatch(batchId.value, rejectionReason.value)
|
||||
showRejectDialog.value = false
|
||||
toast.warning('Batch rejected')
|
||||
router.push('/approval')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Rejection failed'
|
||||
errorMessage.value = msg
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
processing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
batchId,
|
||||
async (id) => {
|
||||
@@ -340,9 +117,7 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!batchId.value) {
|
||||
await loadQueue()
|
||||
}
|
||||
await loadQueue()
|
||||
})
|
||||
|
||||
async function loadQueue() {
|
||||
|
||||
Reference in New Issue
Block a user