Add: No toast notification system or success feedback + No corrections/supersession UI

This commit is contained in:
voltsrage
2026-06-27 16:36:35 +08:00
parent efd3974d1f
commit 2d36d1a5dd
15 changed files with 598 additions and 29 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
<template>
<router-view />
<ToastContainer />
</template>
<script setup lang="ts">
// App shell — routing handles all layout
import ToastContainer from './components/ToastContainer.vue'
</script>
@@ -8,6 +8,7 @@
<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 to="/patients" class="nav-link">History</router-link>
<router-link v-if="auth.canSupervise" to="/dashboard" class="nav-link">Dashboard</router-link>
</nav>
<slot name="subtitle" />
@@ -232,6 +232,7 @@
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { useBatchStore } from '../stores/batches'
import { useToast } from '../composables/useToast'
import ObservationRow from '../components/ObservationRow.vue'
import type { BatchDetailResponse, DraftObservation } from '../types'
@@ -241,6 +242,7 @@ const props = defineProps<{
}>()
const batchStore = useBatchStore()
const toast = useToast()
const submitting = ref(false)
const errorMessage = ref('')
@@ -363,8 +365,11 @@ async function saveAllergies() {
...patient,
allergiesJson: JSON.stringify(allergies.value.filter(a => a.trim())),
})
toast.success('Allergies saved')
} catch (e: unknown) {
errorMessage.value = e instanceof Error ? e.message : 'Failed to save allergies'
const msg = e instanceof Error ? e.message : 'Failed to save allergies'
errorMessage.value = msg
toast.error(msg)
}
}
@@ -390,8 +395,11 @@ async function saveMedications() {
...patient,
medicationsJson: JSON.stringify(medications.value.filter(m => m.trim())),
})
toast.success('Medications saved')
} catch (e: unknown) {
errorMessage.value = e instanceof Error ? e.message : 'Failed to save medications'
const msg = e instanceof Error ? e.message : 'Failed to save medications'
errorMessage.value = msg
toast.error(msg)
}
}
@@ -409,16 +417,22 @@ async function savePatient() {
allergiesJson: patient.noKnownAllergies ? null : JSON.stringify(allergies.value.filter(a => a.trim())),
medicationsJson: patient.noActiveMedications ? null : JSON.stringify(medications.value.filter(m => m.trim())),
})
toast.success('Patient demographics saved')
} catch (e: unknown) {
errorMessage.value = e instanceof Error ? e.message : 'Failed to save patient'
const msg = e instanceof Error ? e.message : 'Failed to save patient'
errorMessage.value = msg
toast.error(msg)
}
}
async function saveEncounter() {
try {
await batchStore.saveDraftEncounter(props.batchId, encounter)
toast.success('Encounter context saved')
} catch (e: unknown) {
errorMessage.value = e instanceof Error ? e.message : 'Failed to save encounter'
const msg = e instanceof Error ? e.message : 'Failed to save encounter'
errorMessage.value = msg
toast.error(msg)
}
}
@@ -450,8 +464,11 @@ async function submitForVerification() {
errorMessage.value = ''
try {
await batchStore.submitForVerification(props.batchId)
toast.success('Batch submitted for verification')
} catch (e: unknown) {
errorMessage.value = e instanceof Error ? e.message : 'Submit failed'
const msg = e instanceof Error ? e.message : 'Submit failed'
errorMessage.value = msg
toast.error(msg)
} finally {
submitting.value = false
}
@@ -0,0 +1,52 @@
<template>
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
<transition-group name="toast">
<div
v-for="toast in toasts"
:key="toast.id"
class="pointer-events-auto rounded-md shadow-lg px-4 py-3 text-sm font-medium flex items-start gap-2"
:class="toastClasses[toast.type]"
>
<span class="flex-1">{{ toast.message }}</span>
<button
@click="dismiss(toast.id)"
class="opacity-60 hover:opacity-100 text-current ml-2 shrink-0"
>
&times;
</button>
</div>
</transition-group>
</div>
</template>
<script setup lang="ts">
import { toasts } from '../composables/useToast'
const toastClasses: Record<string, string> = {
success: 'bg-green-600 text-white',
error: 'bg-red-600 text-white',
warning: 'bg-yellow-500 text-white',
info: 'bg-blue-600 text-white',
}
function dismiss(id: number) {
toasts.value = toasts.value.filter(t => t.id !== id)
}
</script>
<style scoped>
.toast-enter-active {
transition: all 0.3s ease-out;
}
.toast-leave-active {
transition: all 0.2s ease-in;
}
.toast-enter-from {
opacity: 0;
transform: translateX(100%);
}
.toast-leave-to {
opacity: 0;
transform: translateX(100%);
}
</style>
@@ -189,6 +189,7 @@
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useBatchStore } from '../stores/batches'
import { useToast } from '../composables/useToast'
import ObservationRow from '../components/ObservationRow.vue'
import type { BatchDetailResponse, DraftObservation } from '../types'
@@ -199,6 +200,7 @@ const props = defineProps<{
const batchStore = useBatchStore()
const router = useRouter()
const toast = useToast()
const processing = ref(false)
const errorMessage = ref('')
const showRejectDialog = ref(false)
@@ -344,9 +346,12 @@ async function approveVerification() {
passed,
}))
await batchStore.verifyBatch(props.batchId, checks, true)
toast.success('Batch verified successfully')
router.push('/verification')
} catch (e: unknown) {
errorMessage.value = e instanceof Error ? e.message : 'Verification failed'
const msg = e instanceof Error ? e.message : 'Verification failed'
errorMessage.value = msg
toast.error(msg)
} finally {
processing.value = false
}
@@ -358,9 +363,12 @@ async function rejectVerification() {
try {
await batchStore.rejectBatch(props.batchId, rejectionReason.value)
showRejectDialog.value = false
toast.warning('Batch rejected')
router.push('/verification')
} catch (e: unknown) {
errorMessage.value = e instanceof Error ? e.message : 'Rejection failed'
const msg = e instanceof Error ? e.message : 'Rejection failed'
errorMessage.value = msg
toast.error(msg)
} finally {
processing.value = false
}
@@ -0,0 +1,31 @@
import { ref } from 'vue'
export type ToastType = 'success' | 'error' | 'warning' | 'info'
export interface Toast {
id: number
message: string
type: ToastType
duration: number
}
let nextId = 0
export const toasts = ref<Toast[]>([])
function addToast(message: string, type: ToastType, duration = 4000) {
const id = nextId++
toasts.value.push({ id, message, type, duration })
setTimeout(() => {
toasts.value = toasts.value.filter(t => t.id !== id)
}, duration)
}
export function useToast() {
return {
success: (message: string) => addToast(message, 'success'),
error: (message: string) => addToast(message, 'error', 6000),
warning: (message: string) => addToast(message, 'warning', 5000),
info: (message: string) => addToast(message, 'info'),
toasts,
}
}
+13
View File
@@ -65,6 +65,19 @@ const routes: RouteRecordRaw[] = [
},
props: true,
},
{
path: '/patients/:patientId/history',
name: 'PatientHistory',
component: () => import('../views/PatientHistoryView.vue'),
meta: { requiresAuth: true },
props: true,
},
{
path: '/patients',
name: 'PatientSearch',
component: () => import('../views/PatientHistoryView.vue'),
meta: { requiresAuth: true },
},
{
path: '/live-capture',
name: 'LiveCapture',
+24 -1
View File
@@ -9,6 +9,7 @@ import type {
DraftObservation,
BatchListResponse,
FieldCheck,
PatientDigitizationHistoryResponse,
} from '../types'
export const useBatchStore = defineStore('batches', () => {
@@ -68,13 +69,15 @@ export const useBatchStore = defineStore('batches', () => {
file: File,
batchType: string,
track: string,
patientId?: string
patientId?: string,
supersedesBatchId?: string,
): Promise<BatchDetailResponse | null> {
loading.value = true
error.value = null
try {
const fields: Record<string, string> = { batchType, track }
if (patientId) fields.patientId = patientId
if (supersedesBatchId) fields.supersedesBatchId = supersedesBatchId
const response = await uploadFile<BatchDetailResponse>(
'digitization-batches',
@@ -194,6 +197,25 @@ async function assignBatch(batchId: string, entryClerkUserId: string): Promise<v
return { data: response.data ?? undefined }
}
async function getPatientHistory(patientId: string): Promise<PatientDigitizationHistoryResponse | null> {
loading.value = true
error.value = null
try {
const response = await get<PatientDigitizationHistoryResponse>(
`patients/${patientId}/digitization-history`,
)
if (response.success && response.data) {
return response.data
}
return null
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to load patient history'
return null
} finally {
loading.value = false
}
}
return {
batches,
currentBatch,
@@ -216,5 +238,6 @@ async function assignBatch(batchId: string, entryClerkUserId: string): Promise<v
verifyBatch,
rejectBatch,
approveBatch,
getPatientHistory,
}
})
+41
View File
@@ -146,6 +146,47 @@ export interface UserSummary {
role: string
}
export interface DigitizationEventSummary {
eventType: string
occurredAt: string
actorUserId: string
actorName: string
metadataJson: string | null
}
export interface DigitizationHistoryEntry {
batchId: string
status: string
batchType: string
track: string
supersedesBatchId: string | null
isCorrection: boolean
hasBeenSuperseded: boolean
supersededByBatchId: string | null
draftObservationCount: number
liveObservationCount: number
supersededObservationCount: number
createdAt: string
promotedAt: string | null
promotionEncounterId: string | null
enteredByUserId: string | null
enteredByUserName: string | null
verifiedByUserId: string | null
verifiedByUserName: string | null
approvedByUserId: string | null
approvedByUserName: string | null
auditTrail: DigitizationEventSummary[]
}
export interface PatientDigitizationHistoryResponse {
patientId: string
totalBatches: number
promotedBatches: number
supersededBatches: number
pendingBatches: number
entries: DigitizationHistoryEntry[]
}
export interface LiveCaptureObservationInput {
observationCode: string
value: number | null
@@ -41,6 +41,25 @@
</span>
</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>
@@ -134,6 +153,21 @@
<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 -->
@@ -188,6 +222,7 @@ import { ref, 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'
@@ -198,6 +233,7 @@ 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)
@@ -231,21 +267,37 @@ async function approve() {
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
}
}
function createCorrection() {
if (!batchId.value) return
router.push({
path: '/intake',
query: {
supersedesBatchId: batchId.value,
patientId: currentBatch.value?.patientId ?? undefined,
batchType: currentBatch.value?.batchType ?? undefined,
},
})
}
async function reject() {
if (!batchId.value) return
processing.value = true
@@ -253,9 +305,12 @@ async function reject() {
try {
await batchStore.rejectBatch(batchId.value, rejectionReason.value)
showRejectDialog.value = false
toast.warning('Batch rejected')
router.push('/approval')
} catch (e: unknown) {
errorMessage.value = e instanceof Error ? e.message : 'Rejection failed'
const msg = e instanceof Error ? e.message : 'Rejection failed'
errorMessage.value = msg
toast.error(msg)
} finally {
processing.value = false
}
+38 -5
View File
@@ -64,6 +64,21 @@
<PatientSearch v-model="patientId" />
</div>
<!-- Supersession (correction) -->
<div v-if="supersedesBatchId" class="bg-blue-50 border border-blue-200 rounded-md p-4">
<p class="text-sm font-medium text-blue-800">Correction Batch</p>
<p class="text-sm text-blue-700 mt-1">
This upload will supersede batch
<span class="font-mono">{{ supersedesBatchId.substring(0, 8) }}...</span>
</p>
<button
@click="clearCorrection"
class="text-xs text-blue-600 hover:text-blue-800 mt-2"
>
Cancel correction (upload as new batch)
</button>
</div>
<div v-if="uploadError" class="text-clinical-danger text-sm">
{{ uploadError }}
</div>
@@ -104,18 +119,23 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useBatchStore } from '../stores/batches'
import { useToast } from '../composables/useToast'
import AppHeader from '../components/AppHeader.vue'
import PatientSearch from '../components/PatientSearch.vue'
import BatchList from '../components/BatchList.vue'
import AssignClerkDialog from '../components/AssignClerkDialog.vue'
const route = useRoute()
const batchStore = useBatchStore()
const toast = useToast()
const selectedFile = ref<File | null>(null)
const batchType = ref('')
const batchType = ref((route.query.batchType as string) || '')
const track = ref('BACKFILL')
const patientId = ref<string | undefined>(undefined)
const patientId = ref<string | undefined>((route.query.patientId as string) || undefined)
const supersedesBatchId = ref<string | undefined>((route.query.supersedesBatchId as string) || undefined)
const uploadError = ref('')
const assignError = ref('')
const assignDialogOpen = ref(false)
@@ -135,20 +155,30 @@ async function handleUpload() {
selectedFile.value,
batchType.value,
track.value,
patientId.value
patientId.value,
supersedesBatchId.value,
)
if (batch) {
const isCorrection = !!supersedesBatchId.value
selectedFile.value = null
batchType.value = ''
track.value = 'BACKFILL'
patientId.value = undefined
supersedesBatchId.value = undefined
toast.success(isCorrection ? 'Correction batch created' : 'Batch uploaded successfully')
await loadRecent()
}
} catch (e: unknown) {
uploadError.value = e instanceof Error ? e.message : 'Upload failed'
const msg = e instanceof Error ? e.message : 'Upload failed'
uploadError.value = msg
toast.error(msg)
}
}
function clearCorrection() {
supersedesBatchId.value = undefined
}
function openAssignDialog(batchId: string) {
assignError.value = ''
assignBatchId.value = batchId
@@ -165,9 +195,12 @@ async function handleAssign(batchId: string, clerkUserId: string) {
try {
await batchStore.assignBatch(batchId, clerkUserId)
closeAssignDialog()
toast.success('Batch assigned to entry clerk')
await loadRecent()
} catch (e: unknown) {
assignError.value = e instanceof Error ? e.message : 'Assignment failed'
const msg = e instanceof Error ? e.message : 'Assignment failed'
assignError.value = msg
toast.error(msg)
}
}
@@ -241,11 +241,13 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useLiveCaptureStore } from '../stores/liveCapture'
import { useToast } from '../composables/useToast'
import AppHeader from '../components/AppHeader.vue'
import PatientSearch from '../components/PatientSearch.vue'
import type { LiveCaptureObservationInput } from '../types'
const store = useLiveCaptureStore()
const toast = useToast()
const mode = ref<'new' | 'existing'>('new')
const patientId = ref<string | undefined>()
@@ -370,8 +372,16 @@ async function submit() {
)
}
passwordConfirm.value = ''
const result = store.lastResult
if (result && result.criticalAlertCount > 0) {
toast.warning(`Vitals recorded — ${result.criticalAlertCount} CRITICAL alert(s) generated`)
} else {
toast.success('Vitals recorded and promoted successfully')
}
} catch (e: unknown) {
errorMessage.value = e instanceof Error ? e.message : 'Submission failed'
const msg = e instanceof Error ? e.message : 'Submission failed'
errorMessage.value = msg
toast.error(msg)
}
}
@@ -0,0 +1,273 @@
<template>
<div class="min-h-screen flex flex-col">
<AppHeader title="Patient History" />
<div class="flex-1 p-4 sm:p-6 lg:p-8 max-w-5xl mx-auto w-full">
<!-- Search when no patient selected -->
<div v-if="!patientId" class="card">
<h2 class="text-lg font-semibold mb-4">Find Patient</h2>
<PatientSearch v-model="selectedPatientId" />
<button
v-if="selectedPatientId"
@click="router.push(`/patients/${selectedPatientId}/history`)"
class="btn-primary mt-4"
>
View History
</button>
</div>
<!-- History view -->
<div v-else>
<div v-if="batchStore.loading" class="text-gray-500 text-center py-8">
Loading history...
</div>
<div v-else-if="batchStore.error" class="text-clinical-danger text-center py-8">
{{ batchStore.error }}
</div>
<div v-else-if="history">
<!-- Summary stats -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
<div class="card text-center">
<p class="text-2xl font-bold">{{ history.totalBatches }}</p>
<p class="text-xs text-gray-500">Total Batches</p>
</div>
<div class="card text-center">
<p class="text-2xl font-bold text-green-700">{{ history.promotedBatches }}</p>
<p class="text-xs text-gray-500">Promoted</p>
</div>
<div class="card text-center">
<p class="text-2xl font-bold text-orange-600">{{ history.pendingBatches }}</p>
<p class="text-xs text-gray-500">Pending</p>
</div>
<div class="card text-center">
<p class="text-2xl font-bold text-gray-400">{{ history.supersededBatches }}</p>
<p class="text-xs text-gray-500">Superseded</p>
</div>
</div>
<!-- Timeline -->
<div class="space-y-4">
<div
v-for="entry in history.entries"
:key="entry.batchId"
:data-batch-id="entry.batchId"
class="card relative transition-all"
:class="{
'border-l-4 border-l-green-500': entry.status === 'PROMOTED' && !entry.hasBeenSuperseded,
'border-l-4 border-l-gray-300': entry.hasBeenSuperseded,
'border-l-4 border-l-blue-500': entry.isCorrection && !entry.hasBeenSuperseded,
'border-l-4 border-l-yellow-500': !['PROMOTED', 'CANCELLED'].includes(entry.status) && !entry.hasBeenSuperseded,
}"
>
<!-- Header row -->
<div class="flex flex-wrap items-center gap-2 mb-3">
<span class="font-mono text-xs text-gray-600">
{{ entry.batchId.substring(0, 8) }}...
</span>
<span :class="statusBadgeClass(entry.status)" class="status-badge">
{{ formatStatus(entry.status) }}
</span>
<span class="status-badge bg-gray-100 text-gray-700">
{{ formatBatchType(entry.batchType) }}
</span>
<span
v-if="entry.isCorrection"
class="status-badge bg-blue-100 text-blue-800"
>
Correction
</span>
<span
v-if="entry.hasBeenSuperseded"
class="status-badge bg-gray-200 text-gray-500"
>
Superseded
</span>
</div>
<!-- Supersession chain info -->
<div
v-if="entry.isCorrection && entry.supersedesBatchId"
class="text-xs text-blue-700 bg-blue-50 rounded px-3 py-2 mb-3"
>
Corrects batch
<button
@click="scrollToBatch(entry.supersedesBatchId!)"
class="font-mono underline hover:text-blue-900"
>
{{ entry.supersedesBatchId.substring(0, 8) }}...
</button>
</div>
<div
v-if="entry.hasBeenSuperseded && entry.supersededByBatchId"
class="text-xs text-gray-500 bg-gray-50 rounded px-3 py-2 mb-3"
>
Superseded by
<button
@click="scrollToBatch(entry.supersededByBatchId!)"
class="font-mono underline hover:text-gray-700"
>
{{ entry.supersededByBatchId.substring(0, 8) }}...
</button>
</div>
<!-- Observation counts -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm mb-3">
<div>
<span class="text-gray-500">Draft obs:</span>
<span class="font-medium ml-1">{{ entry.draftObservationCount }}</span>
</div>
<div>
<span class="text-gray-500">Live obs:</span>
<span class="font-medium ml-1" :class="{ 'line-through text-gray-400': entry.hasBeenSuperseded }">
{{ entry.liveObservationCount }}
</span>
</div>
<div v-if="entry.supersededObservationCount > 0">
<span class="text-gray-500">Superseded obs:</span>
<span class="font-medium ml-1 text-gray-400">{{ entry.supersededObservationCount }}</span>
</div>
<div v-if="entry.promotionEncounterId">
<span class="text-gray-500">Encounter:</span>
<span class="font-mono text-xs ml-1">{{ entry.promotionEncounterId.substring(0, 8) }}...</span>
</div>
</div>
<!-- People & dates -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-xs text-gray-500">
<div>Created: {{ new Date(entry.createdAt).toLocaleString() }}</div>
<div v-if="entry.promotedAt">
Promoted: {{ new Date(entry.promotedAt).toLocaleString() }}
</div>
<div v-if="entry.enteredByUserName">
Entered by: {{ entry.enteredByUserName }}
</div>
<div v-if="entry.verifiedByUserName">
Verified by: {{ entry.verifiedByUserName }}
</div>
<div v-if="entry.approvedByUserName">
Approved by: {{ entry.approvedByUserName }}
</div>
</div>
<!-- Audit trail (collapsible) -->
<details v-if="entry.auditTrail.length > 0" class="mt-3">
<summary class="text-xs text-primary-600 cursor-pointer hover:text-primary-800">
Audit trail ({{ entry.auditTrail.length }} events)
</summary>
<div class="mt-2 space-y-1 pl-3 border-l-2 border-gray-200">
<div
v-for="(event, idx) in entry.auditTrail"
:key="idx"
class="text-xs text-gray-600"
>
<span class="font-medium">{{ formatEventType(event.eventType) }}</span>
<span class="text-gray-400 ml-2">
{{ new Date(event.occurredAt).toLocaleString() }}
</span>
<span class="ml-2">by {{ event.actorName }}</span>
</div>
</div>
</details>
<!-- Create Correction button for promoted, non-superseded batches -->
<div
v-if="entry.status === 'PROMOTED' && !entry.hasBeenSuperseded"
class="mt-3 pt-3 border-t border-gray-100"
>
<button
@click="createCorrection(entry)"
class="text-sm text-primary-600 hover:text-primary-800 font-medium"
>
Create Correction
</button>
</div>
</div>
</div>
<div v-if="history.entries.length === 0" class="text-gray-500 text-center py-8">
No digitization history for this patient.
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useBatchStore } from '../stores/batches'
import AppHeader from '../components/AppHeader.vue'
import PatientSearch from '../components/PatientSearch.vue'
import type { PatientDigitizationHistoryResponse, DigitizationHistoryEntry } from '../types'
const props = defineProps<{ patientId?: string }>()
const route = useRoute()
const router = useRouter()
const batchStore = useBatchStore()
const patientId = ref(props.patientId ?? (route.params.patientId as string | undefined))
const selectedPatientId = ref<string | undefined>()
const history = ref<PatientDigitizationHistoryResponse | null>(null)
watch(
() => props.patientId ?? (route.params.patientId as string | undefined),
async (id) => {
patientId.value = id
if (id) {
history.value = await batchStore.getPatientHistory(id)
}
},
{ immediate: true },
)
function formatBatchType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
function formatStatus(status: string): string {
return status.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
function formatEventType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
function statusBadgeClass(status: string): string {
const classes: Record<string, string> = {
UPLOADED: 'bg-gray-100 text-gray-800',
IN_ENTRY: 'bg-yellow-100 text-yellow-800',
PENDING_VERIFICATION: 'bg-orange-100 text-orange-800',
REJECTED: 'bg-red-100 text-red-800',
VERIFIED: 'bg-blue-100 text-blue-800',
AWAITING_CLINICAL_APPROVAL: 'bg-purple-100 text-purple-800',
APPROVED: 'bg-green-100 text-green-800',
PROMOTED: 'bg-emerald-100 text-emerald-800',
CANCELLED: 'bg-gray-200 text-gray-500',
}
return classes[status] ?? 'bg-gray-100 text-gray-800'
}
function scrollToBatch(batchId: string) {
const el = document.querySelector(`[data-batch-id="${batchId}"]`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
el.classList.add('ring-2', 'ring-primary-400')
setTimeout(() => el.classList.remove('ring-2', 'ring-primary-400'), 2000)
}
}
function createCorrection(entry: DigitizationHistoryEntry) {
router.push({
path: '/intake',
query: {
supersedesBatchId: entry.batchId,
patientId: patientId.value,
batchType: entry.batchType,
},
})
}
</script>