feature: Digitization Workstation UI
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="app-header">
|
||||
<div class="app-header-title">
|
||||
<h1 class="text-lg font-semibold">{{ title }}</h1>
|
||||
<slot name="subtitle" />
|
||||
</div>
|
||||
<div class="app-header-actions">
|
||||
<slot name="actions" />
|
||||
<span class="text-sm text-gray-600">{{ auth.userFullName }}</span>
|
||||
<button
|
||||
type="button"
|
||||
@click="auth.logout()"
|
||||
class="text-sm text-gray-500 hover:text-gray-800"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
defineProps<{ title: string }>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
</script>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 px-4"
|
||||
>
|
||||
<div class="bg-white rounded-lg p-6 max-w-md w-full">
|
||||
<h3 class="text-lg font-semibold mb-4">Assign Entry Clerk</h3>
|
||||
|
||||
<p v-if="loading" class="text-sm text-gray-500 mb-4">Loading clerks...</p>
|
||||
<div v-else class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Entry Clerk</label>
|
||||
<select v-model="selectedClerkId" class="form-input">
|
||||
<option value="">Select entry clerk...</option>
|
||||
<option v-for="clerk in clerks" :key="clerk.id" :value="clerk.id">
|
||||
{{ clerk.fullName }} ({{ clerk.username }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="text-clinical-danger text-sm mb-4">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col-reverse sm:flex-row sm:justify-end gap-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="emit('close')"
|
||||
class="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="confirm"
|
||||
class="btn-primary"
|
||||
:disabled="!selectedClerkId || loading"
|
||||
>
|
||||
Assign Batch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { get } from '../api/client'
|
||||
import type { UserSummary } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
batchId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
assigned: [batchId: string, clerkUserId: string]
|
||||
}>()
|
||||
|
||||
const clerks = ref<UserSummary[]>([])
|
||||
const selectedClerkId = ref('')
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
async (visible) => {
|
||||
if (!visible) return
|
||||
|
||||
selectedClerkId.value = ''
|
||||
errorMessage.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await get<UserSummary[]>('users', { role: 'DATA_ENTRY_CLERK' })
|
||||
if (response.success && response.data) {
|
||||
clerks.value = response.data
|
||||
} else {
|
||||
clerks.value = []
|
||||
errorMessage.value = response.error?.message ?? 'Failed to load entry clerks'
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
clerks.value = []
|
||||
errorMessage.value = e instanceof Error ? e.message : 'Failed to load entry clerks'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function confirm(): void {
|
||||
if (!props.batchId || !selectedClerkId.value) return
|
||||
emit('assigned', props.batchId, selectedClerkId.value)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div class="overflow-x-auto">
|
||||
<div v-if="loading" class="text-gray-500 text-center py-4">Loading...</div>
|
||||
<div v-else-if="batches.length === 0" class="text-gray-500 text-center py-4">
|
||||
No batches found.
|
||||
</div>
|
||||
<table v-else class="w-full min-w-[640px] text-sm">
|
||||
<thead>
|
||||
<tr class="border-b text-left text-gray-600">
|
||||
<th class="py-2 px-4">ID</th>
|
||||
<th class="py-2 px-4">Type</th>
|
||||
<th class="py-2 px-4">Track</th>
|
||||
<th class="py-2 px-4">Status</th>
|
||||
<th class="py-2 px-4">Created</th>
|
||||
<th v-if="showAssign" class="py-2 px-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="batch in batches"
|
||||
:key="batch.id"
|
||||
class="border-b hover:bg-gray-50 cursor-pointer"
|
||||
@click="$emit('select', batch.id)"
|
||||
>
|
||||
<td class="py-2 px-4 font-mono text-xs">{{ batch.id.substring(0, 8) }}...</td>
|
||||
<td class="py-2 px-4">{{ formatBatchType(batch.batchType) }}</td>
|
||||
<td class="py-2 px-4">
|
||||
<span
|
||||
:class="batch.track === 'BACKFILL'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-green-100 text-green-800'"
|
||||
class="status-badge"
|
||||
>
|
||||
{{ batch.track === 'BACKFILL' ? 'Backfill' : 'Live' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 px-4">
|
||||
<span
|
||||
:class="statusColor(batch.status)"
|
||||
class="status-badge"
|
||||
>
|
||||
{{ formatStatus(batch.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 px-4 text-gray-500">
|
||||
{{ new Date(batch.createdAt).toLocaleString() }}
|
||||
</td>
|
||||
<td v-if="showAssign" class="py-2 px-4">
|
||||
<button
|
||||
v-if="batch.status === 'UPLOADED'"
|
||||
@click.stop="$emit('assign', batch.id)"
|
||||
class="text-primary-600 hover:text-primary-800 text-xs font-medium"
|
||||
>
|
||||
Assign
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { BatchDetailResponse } from '../types'
|
||||
|
||||
defineProps<{
|
||||
batches: BatchDetailResponse[]
|
||||
loading: boolean
|
||||
showAssign?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'select', batchId: string): void
|
||||
(e: 'assign', batchId: string): void
|
||||
}>()
|
||||
|
||||
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 statusColor(status: string): string {
|
||||
const colors: 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',
|
||||
}
|
||||
return colors[status] ?? 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,273 @@
|
||||
<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">Data Entry</h2>
|
||||
<span
|
||||
:class="statusColor"
|
||||
class="status-badge"
|
||||
>
|
||||
{{ batch?.status?.replace(/_/g, ' ') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Patient section -->
|
||||
<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>
|
||||
<label class="block text-xs text-gray-500">Full Name</label>
|
||||
<input
|
||||
v-model="patient.fullName"
|
||||
@blur="savePatient"
|
||||
type="text"
|
||||
class="form-input text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500">Date of Birth</label>
|
||||
<input
|
||||
v-model="patient.dateOfBirth"
|
||||
@blur="savePatient"
|
||||
type="date"
|
||||
class="form-input text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500">Sex</label>
|
||||
<select v-model="patient.sex" @change="savePatient" class="form-input text-sm">
|
||||
<option value="">Select...</option>
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500">Blood Type</label>
|
||||
<select v-model="patient.bloodType" @change="savePatient" class="form-input text-sm">
|
||||
<option value="">Unknown</option>
|
||||
<option v-for="bt in bloodTypes" :key="bt" :value="bt">{{ bt }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs text-gray-500">Emergency Contact</label>
|
||||
<input
|
||||
v-model="patient.emergencyContact"
|
||||
@blur="savePatient"
|
||||
type="text"
|
||||
class="form-input text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Encounter section -->
|
||||
<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>
|
||||
<label class="block text-xs text-gray-500">Admission Date</label>
|
||||
<input
|
||||
v-model="encounter.admissionDate"
|
||||
@blur="saveEncounter"
|
||||
type="datetime-local"
|
||||
class="form-input text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500">Department</label>
|
||||
<select v-model="encounter.department" @change="saveEncounter" class="form-input text-sm">
|
||||
<option value="">—</option>
|
||||
<option v-for="dept in departments" :key="dept" :value="dept">{{ dept }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500">Room / Bed</label>
|
||||
<input
|
||||
v-model="encounter.roomBed"
|
||||
@blur="saveEncounter"
|
||||
type="text"
|
||||
class="form-input text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500">Admission Reason</label>
|
||||
<input
|
||||
v-model="encounter.admissionReason"
|
||||
@blur="saveEncounter"
|
||||
type="text"
|
||||
class="form-input text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Observations section -->
|
||||
<fieldset class="border border-gray-200 rounded-md p-4">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">Observations</legend>
|
||||
<div class="space-y-2">
|
||||
<ObservationRow
|
||||
v-for="obs in observations"
|
||||
:key="obs.id"
|
||||
:observation="obs"
|
||||
@update="(field, value) => handleObsUpdate(obs.id, field, value)"
|
||||
@delete="handleObsDelete"
|
||||
/>
|
||||
<button @click="addObservation" class="btn-primary text-sm">
|
||||
+ Add Observation
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 pt-4 border-t">
|
||||
<button
|
||||
@click="submitForVerification"
|
||||
class="btn-primary"
|
||||
:disabled="submitting"
|
||||
>
|
||||
{{ submitting ? 'Submitting...' : 'Submit for Verification' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="text-clinical-danger text-sm">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
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 submitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const bloodTypes = ['A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-']
|
||||
const departments = [
|
||||
'Emergency Department',
|
||||
'Internal Medicine',
|
||||
'General Medicine',
|
||||
'Surgery',
|
||||
'ICU',
|
||||
'NICU',
|
||||
'Medical-Surgical',
|
||||
'Outpatient Clinic',
|
||||
'Pediatrics',
|
||||
'Obstetrics & Gynecology',
|
||||
'Labor & Delivery',
|
||||
'Cardiology',
|
||||
'Orthopedics',
|
||||
'Neurology',
|
||||
'Oncology',
|
||||
'Radiology',
|
||||
'Laboratory',
|
||||
'Psychiatry',
|
||||
'Physical Therapy',
|
||||
'Anesthesiology',
|
||||
]
|
||||
|
||||
const patient = reactive({
|
||||
fullName: '',
|
||||
dateOfBirth: '',
|
||||
sex: '',
|
||||
bloodType: '',
|
||||
emergencyContact: '',
|
||||
})
|
||||
|
||||
const encounter = reactive({
|
||||
admissionDate: '',
|
||||
department: '',
|
||||
roomBed: '',
|
||||
admissionReason: '',
|
||||
})
|
||||
|
||||
const observations = ref<DraftObservation[]>([])
|
||||
|
||||
const statusColor = ref('bg-gray-100 text-gray-800')
|
||||
|
||||
// Load draft data when batch changes
|
||||
watch(
|
||||
() => batchStore.currentDraft,
|
||||
(draft) => {
|
||||
if (!draft) return
|
||||
if (draft.patient) {
|
||||
Object.assign(patient, {
|
||||
fullName: draft.patient.fullName ?? '',
|
||||
dateOfBirth: draft.patient.dateOfBirth ?? '',
|
||||
sex: draft.patient.sex ?? '',
|
||||
bloodType: draft.patient.bloodType ?? '',
|
||||
emergencyContact: draft.patient.emergencyContact ?? '',
|
||||
})
|
||||
}
|
||||
if (draft.encounter) {
|
||||
Object.assign(encounter, {
|
||||
admissionDate: draft.encounter.admissionDate?.substring(0, 16) ?? '',
|
||||
department: draft.encounter.department ?? '',
|
||||
roomBed: draft.encounter.roomBed ?? '',
|
||||
admissionReason: draft.encounter.admissionReason ?? '',
|
||||
})
|
||||
}
|
||||
observations.value = draft.observations ?? []
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
async function savePatient() {
|
||||
try {
|
||||
await batchStore.saveDraftPatient(props.batchId, patient)
|
||||
} catch (e: unknown) {
|
||||
errorMessage.value = e instanceof Error ? e.message : 'Failed to save patient'
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEncounter() {
|
||||
try {
|
||||
await batchStore.saveDraftEncounter(props.batchId, encounter)
|
||||
} catch (e: unknown) {
|
||||
errorMessage.value = e instanceof Error ? e.message : 'Failed to save encounter'
|
||||
}
|
||||
}
|
||||
|
||||
async function addObservation() {
|
||||
await batchStore.addObservation(props.batchId, {
|
||||
observationCode: '',
|
||||
value: 0,
|
||||
unit: '',
|
||||
recordedAt: new Date().toISOString(),
|
||||
note: null,
|
||||
})
|
||||
observations.value = batchStore.currentDraft?.observations ?? []
|
||||
}
|
||||
|
||||
async function handleObsUpdate(obsId: string, field: string, value: unknown) {
|
||||
const obs = observations.value.find((o) => o.id === obsId)
|
||||
if (!obs) return
|
||||
;(obs as Record<string, unknown>)[field] = value
|
||||
await batchStore.updateObservation(props.batchId, obsId, obs)
|
||||
}
|
||||
|
||||
async function handleObsDelete(obsId: string) {
|
||||
await batchStore.deleteObservation(props.batchId, obsId)
|
||||
observations.value = batchStore.currentDraft?.observations ?? []
|
||||
}
|
||||
|
||||
async function submitForVerification() {
|
||||
submitting.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
await batchStore.submitForVerification(props.batchId)
|
||||
} catch (e: unknown) {
|
||||
errorMessage.value = e instanceof Error ? e.message : 'Submit failed'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="flex flex-col lg:flex-row lg:items-start gap-4 p-4 bg-gray-50 rounded-md">
|
||||
<div class="flex-1 grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500">Code</label>
|
||||
<select
|
||||
:value="observation.observationCode"
|
||||
@change="update('observationCode', ($event.target as HTMLSelectElement).value)"
|
||||
class="form-input text-sm"
|
||||
:disabled="readonly"
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
<option value="HEART_RATE">Heart Rate</option>
|
||||
<option value="TEMP_C">Temperature (C)</option>
|
||||
<option value="BP_SYSTOLIC">BP Systolic</option>
|
||||
<option value="BP_DIASTOLIC">BP Diastolic</option>
|
||||
<option value="RESP_RATE">Respiratory Rate</option>
|
||||
<option value="SPO2">SpO2</option>
|
||||
<option value="POTASSIUM_MEQ_L">Potassium</option>
|
||||
<option value="GLUCOSE_MG_DL">Glucose</option>
|
||||
<option value="WBC_K_UL">WBC</option>
|
||||
<option value="LACTATE_MMOL_L">Lactate</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500">Value</label>
|
||||
<input
|
||||
:value="observation.value"
|
||||
@change="update('value', parseFloat(($event.target as HTMLInputElement).value))"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="form-input text-sm"
|
||||
:disabled="readonly"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500">Unit</label>
|
||||
<input
|
||||
:value="observation.unit"
|
||||
@change="update('unit', ($event.target as HTMLInputElement).value)"
|
||||
type="text"
|
||||
class="form-input text-sm"
|
||||
:disabled="readonly"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500">Recorded At</label>
|
||||
<input
|
||||
:value="observation.recordedAt?.substring(0, 16)"
|
||||
@change="update('recordedAt', ($event.target as HTMLInputElement).value)"
|
||||
type="datetime-local"
|
||||
class="form-input text-sm"
|
||||
:disabled="readonly"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500">Note</label>
|
||||
<input
|
||||
:value="observation.note"
|
||||
@change="update('note', ($event.target as HTMLInputElement).value)"
|
||||
type="text"
|
||||
class="form-input text-sm"
|
||||
placeholder="Optional"
|
||||
:disabled="readonly"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Verification checkbox (only in verification mode) -->
|
||||
<div v-if="showVerified" class="flex items-center lg:mt-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="verified"
|
||||
@change="$emit('verify', observation.id, ($event.target as HTMLInputElement).checked)"
|
||||
class="w-4 h-4 text-clinical-safe rounded"
|
||||
/>
|
||||
<span class="ml-2 text-xs text-gray-500">OK</span>
|
||||
</div>
|
||||
|
||||
<!-- Delete button (entry mode only) -->
|
||||
<button
|
||||
v-if="!readonly && !showVerified"
|
||||
@click="$emit('delete', observation.id)"
|
||||
class="lg:mt-8 text-clinical-danger hover:text-red-800 text-sm"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DraftObservation } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
observation: DraftObservation
|
||||
readonly?: boolean
|
||||
showVerified?: boolean
|
||||
verified?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update', field: string, value: unknown): void
|
||||
(e: 'delete', obsId: string): void
|
||||
(e: 'verify', obsId: string, passed: boolean): void
|
||||
}>()
|
||||
|
||||
function update(field: string, value: unknown) {
|
||||
emit('update', field, value)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
@input="debouncedSearch"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="Search by MRN or patient name..."
|
||||
/>
|
||||
<ul
|
||||
v-if="results.length > 0"
|
||||
class="absolute z-10 w-full bg-white border border-gray-200 rounded-md
|
||||
shadow-lg mt-2 max-h-48 overflow-y-auto"
|
||||
>
|
||||
<li
|
||||
v-for="patient in results"
|
||||
:key="patient.id"
|
||||
@click="selectPatient(patient)"
|
||||
class="px-4 py-2 hover:bg-primary-50 cursor-pointer text-sm"
|
||||
>
|
||||
<span class="font-medium">{{ patient.fullName }}</span>
|
||||
<span class="text-gray-500 ml-2">MRN: {{ patient.mrn }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="selectedPatient" class="text-sm text-clinical-safe mt-2">
|
||||
Selected: {{ selectedPatient.fullName }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { get } from '../api/client'
|
||||
import type { PatientSearchResult } from '../types'
|
||||
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', value: string | undefined): void }>()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const results = ref<PatientSearchResult[]>([])
|
||||
const selectedPatient = ref<PatientSearchResult | null>(null)
|
||||
let debounceTimer: ReturnType<typeof setTimeout>
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(search, 300)
|
||||
}
|
||||
|
||||
async function search() {
|
||||
if (searchQuery.value.length < 2) {
|
||||
results.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await get<PatientSearchResult[]>('patients/search', {
|
||||
q: searchQuery.value,
|
||||
})
|
||||
if (response.success && response.data) {
|
||||
results.value = response.data
|
||||
}
|
||||
} catch {
|
||||
results.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function selectPatient(patient: PatientSearchResult) {
|
||||
selectedPatient.value = patient
|
||||
searchQuery.value = patient.fullName
|
||||
results.value = []
|
||||
emit('update:modelValue', patient.id)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col bg-gray-900 rounded-lg overflow-hidden">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex flex-wrap items-center gap-2 p-4 bg-gray-800 text-white text-sm">
|
||||
<button @click="zoomIn" class="px-2 py-1 hover:bg-gray-700 rounded" title="Zoom in">
|
||||
+
|
||||
</button>
|
||||
<button @click="zoomOut" class="px-2 py-1 hover:bg-gray-700 rounded" title="Zoom out">
|
||||
-
|
||||
</button>
|
||||
<button @click="resetZoom" class="px-2 py-1 hover:bg-gray-700 rounded" title="Reset">
|
||||
1:1
|
||||
</button>
|
||||
<button @click="rotateCw" class="px-2 py-1 hover:bg-gray-700 rounded" title="Rotate 90">
|
||||
Rotate
|
||||
</button>
|
||||
<span class="ml-auto text-gray-400 text-xs">{{ Math.round(scale * 100) }}%</span>
|
||||
</div>
|
||||
|
||||
<!-- Document area -->
|
||||
<div
|
||||
ref="viewerContainer"
|
||||
class="flex-1 overflow-auto cursor-grab active:cursor-grabbing"
|
||||
@mousedown="startPan"
|
||||
@mousemove="pan"
|
||||
@mouseup="stopPan"
|
||||
@mouseleave="stopPan"
|
||||
@wheel.prevent="onWheel"
|
||||
>
|
||||
<div
|
||||
:style="{
|
||||
transform: `translate(${panX}px, ${panY}px) scale(${scale}) rotate(${rotation}deg)`,
|
||||
transformOrigin: 'top left',
|
||||
transition: isPanning ? 'none' : 'transform 0.2s',
|
||||
}"
|
||||
>
|
||||
<!-- PDF rendering via iframe for simplicity; production would use pdf.js -->
|
||||
<iframe
|
||||
v-if="isPdf"
|
||||
:src="url"
|
||||
class="w-[800px] h-[1100px] bg-white"
|
||||
frameborder="0"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="url"
|
||||
class="max-w-none"
|
||||
draggable="false"
|
||||
@load="onImageLoad"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
url: string
|
||||
}>()
|
||||
|
||||
const isPdf = computed(() => {
|
||||
const lower = props.url.toLowerCase()
|
||||
return lower.includes('.pdf') || lower.includes('application/pdf')
|
||||
})
|
||||
|
||||
const scale = ref(1)
|
||||
const rotation = ref(0)
|
||||
const panX = ref(0)
|
||||
const panY = ref(0)
|
||||
const isPanning = ref(false)
|
||||
const lastX = ref(0)
|
||||
const lastY = ref(0)
|
||||
|
||||
function zoomIn() { scale.value = Math.min(scale.value + 0.25, 5) }
|
||||
function zoomOut() { scale.value = Math.max(scale.value - 0.25, 0.25) }
|
||||
function resetZoom() {
|
||||
scale.value = 1
|
||||
panX.value = 0
|
||||
panY.value = 0
|
||||
rotation.value = 0
|
||||
}
|
||||
function rotateCw() { rotation.value = (rotation.value + 90) % 360 }
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
if (e.deltaY < 0) zoomIn()
|
||||
else zoomOut()
|
||||
}
|
||||
|
||||
function startPan(e: MouseEvent) {
|
||||
isPanning.value = true
|
||||
lastX.value = e.clientX
|
||||
lastY.value = e.clientY
|
||||
}
|
||||
|
||||
function pan(e: MouseEvent) {
|
||||
if (!isPanning.value) return
|
||||
panX.value += e.clientX - lastX.value
|
||||
panY.value += e.clientY - lastY.value
|
||||
lastX.value = e.clientX
|
||||
lastY.value = e.clientY
|
||||
}
|
||||
|
||||
function stopPan() { isPanning.value = false }
|
||||
|
||||
function onImageLoad() {
|
||||
// Reset view when a new image loads
|
||||
scale.value = 1
|
||||
panX.value = 0
|
||||
panY.value = 0
|
||||
}
|
||||
</script>
|
||||
@@ -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