Add: No toast notification system or success feedback + No corrections/supersession UI
This commit is contained in:
@@ -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"
|
||||
>
|
||||
×
|
||||
</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,
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user