feature: Core Digitization Workstation (Entry, Verification, Approval)
CI / backend (push) Successful in 6m11s
CI / frontend (push) Failing after 1m7s

This commit is contained in:
2026-08-12 04:22:25 +08:00
parent 9811f2a2ed
commit 5caf928787
16 changed files with 2077 additions and 905 deletions
+55 -280
View File
@@ -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() {