581 lines
21 KiB
Vue
581 lines
21 KiB
Vue
<template>
|
|
<div class="h-full min-h-0 flex flex-col">
|
|
<AppHeader title="Intake" />
|
|
|
|
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
|
<div class="max-w-7xl mx-auto">
|
|
<div class="mb-6">
|
|
<h1 class="text-[1.75rem] font-semibold text-ink-strong leading-tight tracking-tight">
|
|
Upload Scanned Document
|
|
</h1>
|
|
<p class="mt-1 text-sm text-ink-secondary">
|
|
Create a digitization batch from a PDF or image scan.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 xl:grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)] gap-6 lg:gap-8 items-start">
|
|
<!-- Main — ~70%: upload + batch details -->
|
|
<div class="space-y-6 min-w-0">
|
|
<!-- Cover sheet quick lookup -->
|
|
<section class="card">
|
|
<h2 class="text-base font-semibold text-ink-strong mb-1">Cover Sheet Code</h2>
|
|
<p class="text-sm text-ink-secondary mb-4">
|
|
Optional. Scan or type a barcode to auto-fill batch details.
|
|
</p>
|
|
|
|
<div class="flex flex-col sm:flex-row gap-3 sm:items-end">
|
|
<div class="flex-1 min-w-0">
|
|
<label for="cover-sheet-code" class="block text-sm font-semibold text-ink mb-2">
|
|
Code
|
|
</label>
|
|
<input
|
|
id="cover-sheet-code"
|
|
v-model="coverSheetCode"
|
|
@keydown.enter.prevent="lookupCoverSheet"
|
|
type="text"
|
|
class="form-input font-mono"
|
|
placeholder="VCR-CS-XXXXXXXX"
|
|
autofocus
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
@click="lookupCoverSheet"
|
|
class="btn-secondary shrink-0"
|
|
:disabled="!coverSheetCode.trim() || lookupLoading"
|
|
>
|
|
{{ lookupLoading ? 'Looking up...' : 'Lookup' }}
|
|
</button>
|
|
</div>
|
|
|
|
<InlineError
|
|
v-if="lookupError"
|
|
class="mt-4"
|
|
title="Cover sheet lookup failed"
|
|
:message="lookupError"
|
|
preserved="You can still upload manually without a cover sheet."
|
|
retry-label="Retry lookup"
|
|
@retry="lookupCoverSheet"
|
|
/>
|
|
|
|
<div
|
|
v-if="resolvedCoverSheet"
|
|
class="mt-4 rounded-input border border-[#ABEFC6] bg-clinical-safe-bg p-4"
|
|
role="status"
|
|
>
|
|
<p class="text-sm font-semibold text-clinical-safe">Cover Sheet Found</p>
|
|
<dl class="mt-2 text-sm text-ink space-y-1">
|
|
<div>
|
|
<dt class="inline font-medium text-ink-secondary">Type:</dt>
|
|
<dd class="inline ml-1">{{ formatBatchType(resolvedCoverSheet.batchType) }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="inline font-medium text-ink-secondary">Track:</dt>
|
|
<dd class="inline ml-1">{{ formatTrack(resolvedCoverSheet.track) }}</dd>
|
|
</div>
|
|
<div v-if="resolvedCoverSheet.patientName">
|
|
<dt class="inline font-medium text-ink-secondary">Patient:</dt>
|
|
<dd class="inline ml-1">
|
|
{{ resolvedCoverSheet.patientName }}
|
|
<span v-if="resolvedCoverSheet.patientMrn" class="text-ink-secondary">
|
|
({{ resolvedCoverSheet.patientMrn }})
|
|
</span>
|
|
</dd>
|
|
</div>
|
|
<div v-if="resolvedCoverSheet.assignToUserName">
|
|
<dt class="inline font-medium text-ink-secondary">Assign to:</dt>
|
|
<dd class="inline ml-1">{{ resolvedCoverSheet.assignToUserName }}</dd>
|
|
</div>
|
|
</dl>
|
|
<p class="text-sm text-ink-secondary mt-3">
|
|
Select a file and upload to create the batch with this cover sheet.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
@click="clearCoverSheet"
|
|
class="text-xs font-medium text-clinical-safe hover:underline mt-2"
|
|
>
|
|
Clear cover sheet (use manual upload)
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Upload + batch form -->
|
|
<section class="card">
|
|
<h2 class="text-base font-semibold text-ink-strong mb-4">New Batch</h2>
|
|
|
|
<form @submit.prevent="handleUpload" class="space-y-5">
|
|
<!-- Drop zone -->
|
|
<div>
|
|
<label class="block text-sm font-semibold text-ink mb-2">
|
|
Scanned Document
|
|
</label>
|
|
|
|
<div
|
|
v-if="!selectedFile"
|
|
class="upload-dropzone"
|
|
:class="{
|
|
'upload-dropzone--active': dragActive,
|
|
'upload-dropzone--error': !!fileError,
|
|
}"
|
|
@dragenter.prevent="onDragEnter"
|
|
@dragover.prevent="onDragEnter"
|
|
@dragleave.prevent="onDragLeave"
|
|
@drop.prevent="onDrop"
|
|
@click="triggerFilePicker"
|
|
@keydown.enter.prevent="triggerFilePicker"
|
|
@keydown.space.prevent="triggerFilePicker"
|
|
role="button"
|
|
tabindex="0"
|
|
aria-label="Choose or drop a scanned document"
|
|
>
|
|
<input
|
|
ref="fileInput"
|
|
type="file"
|
|
class="sr-only"
|
|
accept=".pdf,.jpg,.jpeg,.png"
|
|
@change="onFileChange"
|
|
/>
|
|
<div class="flex flex-col items-center text-center pointer-events-none">
|
|
<svg
|
|
class="w-10 h-10 text-primary-500 mb-3"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
d="M12 16V4m0 0L8 8m4-4l4 4M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2"
|
|
stroke="currentColor"
|
|
stroke-width="1.75"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
/>
|
|
</svg>
|
|
<p class="text-sm font-semibold text-ink-strong">Upload Files</p>
|
|
<p class="mt-1 text-sm text-ink-secondary">
|
|
Drag and drop, or click to browse
|
|
</p>
|
|
<p class="mt-3 text-xs text-ink-disabled">
|
|
PDF, JPEG, PNG — max 25 MB
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-else
|
|
class="rounded-input border border-line bg-canvas p-4 flex items-start justify-between gap-3"
|
|
>
|
|
<div class="min-w-0">
|
|
<p class="text-sm font-medium text-ink-strong truncate">
|
|
{{ selectedFile.name }}
|
|
</p>
|
|
<p class="text-xs text-ink-secondary mt-0.5">
|
|
{{ formatFileSize(selectedFile.size) }}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="text-sm font-medium text-ink-secondary hover:text-ink-strong shrink-0"
|
|
:disabled="batchStore.loading"
|
|
@click="clearFile"
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
|
|
<p v-if="fileError" class="text-sm text-clinical-danger mt-2">{{ fileError }}</p>
|
|
</div>
|
|
|
|
<!-- Batch type -->
|
|
<div>
|
|
<label for="batch-type" class="block text-sm font-semibold text-ink mb-2">
|
|
Batch Type
|
|
</label>
|
|
<select
|
|
id="batch-type"
|
|
v-model="batchType"
|
|
class="form-input"
|
|
:required="!resolvedCoverSheet"
|
|
:disabled="!!resolvedCoverSheet"
|
|
>
|
|
<option value="">Select batch type...</option>
|
|
<option value="PATIENT_REGISTRATION">Patient Registration</option>
|
|
<option value="ENCOUNTER_SUMMARY">Encounter Summary</option>
|
|
<option value="VITALS_SHEET">Vitals Sheet</option>
|
|
<option value="LAB_RESULTS">Lab Results</option>
|
|
<option value="MEDICATION_LIST">Medication List</option>
|
|
<option value="ALLERGY_UPDATE">Allergy Update</option>
|
|
<option value="MIXED">Mixed</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Track -->
|
|
<div>
|
|
<label for="track" class="block text-sm font-semibold text-ink mb-2">Track</label>
|
|
<select
|
|
id="track"
|
|
v-model="track"
|
|
class="form-input"
|
|
:disabled="!!resolvedCoverSheet"
|
|
>
|
|
<option value="BACKFILL">Backfill (Track A)</option>
|
|
<option value="LIVE_CAPTURE">Live Capture (Track B)</option>
|
|
</select>
|
|
<p class="mt-1.5 text-xs text-ink-secondary">
|
|
{{ track === 'BACKFILL'
|
|
? 'Historical or archival document workflow.'
|
|
: 'Documents from current bedside workflows.' }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Patient search -->
|
|
<div>
|
|
<label class="block text-sm font-semibold text-ink mb-2">
|
|
Patient Link
|
|
<span class="font-normal text-ink-secondary">(optional)</span>
|
|
</label>
|
|
<PatientSearch v-model="patientId" />
|
|
</div>
|
|
|
|
<!-- Supersession (correction) -->
|
|
<div
|
|
v-if="supersedesBatchId"
|
|
class="rounded-input border border-primary-100 bg-primary-50 p-4"
|
|
>
|
|
<p class="text-sm font-semibold text-primary-800">Correction Batch</p>
|
|
<p class="text-sm text-primary-700 mt-1">
|
|
This upload will supersede batch
|
|
<span class="font-mono">{{ supersedesBatchId.substring(0, 8) }}…</span>
|
|
</p>
|
|
<button
|
|
type="button"
|
|
@click="clearCorrection"
|
|
class="text-xs font-medium text-primary-700 hover:underline mt-2"
|
|
>
|
|
Cancel correction (upload as new batch)
|
|
</button>
|
|
</div>
|
|
|
|
<InlineError
|
|
v-if="uploadError"
|
|
title="Upload failed"
|
|
:message="uploadError"
|
|
preserved="Your selected file and form values were kept."
|
|
retry-label="Try again"
|
|
@retry="handleUpload"
|
|
/>
|
|
|
|
<div
|
|
v-if="uploadSuccess"
|
|
class="rounded-input border border-[#ABEFC6] bg-clinical-safe-bg p-4"
|
|
role="status"
|
|
>
|
|
<p class="text-sm font-semibold text-clinical-safe">{{ uploadSuccess }}</p>
|
|
<p class="text-sm text-ink-secondary mt-1">
|
|
The batch appears in Recent Uploads and is ready for assignment.
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
class="btn-primary"
|
|
:disabled="!canUpload"
|
|
>
|
|
{{ uploadButtonLabel }}
|
|
</button>
|
|
</form>
|
|
</section>
|
|
</div>
|
|
|
|
<!-- Right — ~30%: guidance + recent uploads -->
|
|
<aside class="space-y-6 min-w-0 xl:sticky xl:top-4">
|
|
<section class="card">
|
|
<h2 class="text-base font-semibold text-ink-strong mb-2">Guidance</h2>
|
|
<ul class="text-sm text-ink-secondary space-y-2 list-disc pl-4">
|
|
<li>Prefer cover sheet lookup when a printed separator is available.</li>
|
|
<li>Link a patient now when known; otherwise assign later in the batch workflow.</li>
|
|
<li>Uploaded batches stay here until an entry clerk is assigned.</li>
|
|
</ul>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<h2 class="text-base font-semibold text-ink-strong mb-4">Recent Uploads</h2>
|
|
<InlineError
|
|
v-if="assignError"
|
|
class="mb-4"
|
|
title="Assignment failed"
|
|
:message="assignError"
|
|
preserved="The batch remains unassigned."
|
|
retry-label=""
|
|
/>
|
|
<BatchList
|
|
:batches="batchStore.batches"
|
|
:loading="batchStore.loading"
|
|
:error="batchStore.error"
|
|
empty-title="No uploaded batches waiting for assignment."
|
|
empty-description="New uploads appear here after a scan is submitted."
|
|
show-assign
|
|
@assign="openAssignDialog"
|
|
@retry="loadRecent"
|
|
/>
|
|
</section>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<AssignClerkDialog
|
|
:show="assignDialogOpen"
|
|
:batch-id="assignBatchId"
|
|
@close="closeAssignDialog"
|
|
@assigned="handleAssign"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, watch } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
import { get } from '../api/client'
|
|
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'
|
|
import InlineError from '../components/InlineError.vue'
|
|
import type { CoverSheetResponse } from '../types'
|
|
|
|
const MAX_FILE_BYTES = 25 * 1024 * 1024
|
|
const ACCEPTED_TYPES = new Set(['application/pdf', 'image/jpeg', 'image/png'])
|
|
|
|
const route = useRoute()
|
|
const batchStore = useBatchStore()
|
|
const toast = useToast()
|
|
|
|
const fileInput = ref<HTMLInputElement | null>(null)
|
|
const selectedFile = ref<File | null>(null)
|
|
const dragActive = ref(false)
|
|
const dragDepth = ref(0)
|
|
const fileError = ref('')
|
|
const batchType = ref((route.query.batchType as string) || '')
|
|
const track = ref('BACKFILL')
|
|
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 uploadSuccess = ref('')
|
|
const assignError = ref('')
|
|
const assignDialogOpen = ref(false)
|
|
const assignBatchId = ref<string | null>(null)
|
|
|
|
const coverSheetCode = ref('')
|
|
const resolvedCoverSheet = ref<CoverSheetResponse | null>(null)
|
|
const lookupLoading = ref(false)
|
|
const lookupError = ref('')
|
|
|
|
const canUpload = computed(() => {
|
|
if (!selectedFile.value || batchStore.loading) return false
|
|
if (resolvedCoverSheet.value) return true
|
|
return !!batchType.value
|
|
})
|
|
|
|
const uploadButtonLabel = computed(() => {
|
|
if (batchStore.loading) return 'Uploading...'
|
|
if (resolvedCoverSheet.value) return 'Upload with Cover Sheet'
|
|
return 'Upload and Create Batch'
|
|
})
|
|
|
|
watch(coverSheetCode, () => {
|
|
if (resolvedCoverSheet.value && coverSheetCode.value.trim().toUpperCase() !== resolvedCoverSheet.value.code) {
|
|
resolvedCoverSheet.value = null
|
|
lookupError.value = ''
|
|
}
|
|
})
|
|
|
|
function formatBatchType(type: string): string {
|
|
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
|
}
|
|
|
|
function formatTrack(value: string): string {
|
|
return value === 'BACKFILL' ? 'Backfill (Track A)' : 'Live Capture (Track B)'
|
|
}
|
|
|
|
function formatFileSize(bytes: number): string {
|
|
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
|
|
}
|
|
|
|
function triggerFilePicker() {
|
|
fileInput.value?.click()
|
|
}
|
|
|
|
function validateFile(file: File): string | null {
|
|
if (file.size > MAX_FILE_BYTES) return 'File exceeds the 25 MB limit.'
|
|
if (file.type && !ACCEPTED_TYPES.has(file.type)) {
|
|
return 'Unsupported file type. Use PDF, JPEG, or PNG.'
|
|
}
|
|
return null
|
|
}
|
|
|
|
function setSelectedFile(file: File | null) {
|
|
fileError.value = ''
|
|
uploadError.value = ''
|
|
uploadSuccess.value = ''
|
|
if (!file) {
|
|
selectedFile.value = null
|
|
return
|
|
}
|
|
const error = validateFile(file)
|
|
if (error) {
|
|
selectedFile.value = null
|
|
fileError.value = error
|
|
return
|
|
}
|
|
selectedFile.value = file
|
|
}
|
|
|
|
function onFileChange(event: Event) {
|
|
const input = event.target as HTMLInputElement
|
|
setSelectedFile(input.files?.[0] ?? null)
|
|
}
|
|
|
|
function clearFile() {
|
|
selectedFile.value = null
|
|
fileError.value = ''
|
|
if (fileInput.value) fileInput.value.value = ''
|
|
}
|
|
|
|
function onDragEnter() {
|
|
dragDepth.value += 1
|
|
dragActive.value = true
|
|
}
|
|
|
|
function onDragLeave() {
|
|
dragDepth.value = Math.max(0, dragDepth.value - 1)
|
|
if (dragDepth.value === 0) dragActive.value = false
|
|
}
|
|
|
|
function onDrop(event: DragEvent) {
|
|
dragDepth.value = 0
|
|
dragActive.value = false
|
|
const file = event.dataTransfer?.files?.[0] ?? null
|
|
setSelectedFile(file)
|
|
}
|
|
|
|
async function lookupCoverSheet(): Promise<void> {
|
|
const code = coverSheetCode.value.trim().toUpperCase()
|
|
if (!code) return
|
|
|
|
lookupLoading.value = true
|
|
lookupError.value = ''
|
|
resolvedCoverSheet.value = null
|
|
|
|
try {
|
|
const response = await get<CoverSheetResponse>(
|
|
`cover-sheets/lookup/${encodeURIComponent(code)}`,
|
|
)
|
|
|
|
if (!response.success || !response.data) {
|
|
throw new Error(response.error?.message ?? 'Cover sheet not found')
|
|
}
|
|
|
|
if (response.data.isUsed) {
|
|
throw new Error('Cover sheet has already been used')
|
|
}
|
|
|
|
resolvedCoverSheet.value = response.data
|
|
coverSheetCode.value = response.data.code
|
|
batchType.value = response.data.batchType
|
|
track.value = response.data.track
|
|
patientId.value = response.data.patientId ?? undefined
|
|
toast.success('Cover sheet found')
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : 'Lookup failed'
|
|
lookupError.value = msg
|
|
toast.error(msg)
|
|
} finally {
|
|
lookupLoading.value = false
|
|
}
|
|
}
|
|
|
|
function clearCoverSheet(): void {
|
|
coverSheetCode.value = ''
|
|
resolvedCoverSheet.value = null
|
|
lookupError.value = ''
|
|
}
|
|
|
|
async function handleUpload() {
|
|
if (!selectedFile.value || !canUpload.value) return
|
|
|
|
uploadError.value = ''
|
|
uploadSuccess.value = ''
|
|
try {
|
|
const batch = await batchStore.uploadBatch(
|
|
selectedFile.value,
|
|
batchType.value,
|
|
track.value,
|
|
patientId.value,
|
|
supersedesBatchId.value,
|
|
resolvedCoverSheet.value?.code,
|
|
)
|
|
if (batch) {
|
|
const isCorrection = !!supersedesBatchId.value
|
|
const usedCoverSheet = !!resolvedCoverSheet.value
|
|
selectedFile.value = null
|
|
if (fileInput.value) fileInput.value.value = ''
|
|
batchType.value = ''
|
|
track.value = 'BACKFILL'
|
|
patientId.value = undefined
|
|
supersedesBatchId.value = undefined
|
|
clearCoverSheet()
|
|
const message = isCorrection
|
|
? 'Correction batch created'
|
|
: usedCoverSheet
|
|
? 'Batch uploaded with cover sheet'
|
|
: 'Batch uploaded successfully'
|
|
uploadSuccess.value = message
|
|
toast.success(message)
|
|
await loadRecent()
|
|
}
|
|
} catch (e: unknown) {
|
|
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
|
|
assignDialogOpen.value = true
|
|
}
|
|
|
|
function closeAssignDialog() {
|
|
assignDialogOpen.value = false
|
|
assignBatchId.value = null
|
|
}
|
|
|
|
async function handleAssign(batchId: string, clerkUserId: string) {
|
|
assignError.value = ''
|
|
try {
|
|
await batchStore.assignBatch(batchId, clerkUserId)
|
|
closeAssignDialog()
|
|
toast.success('Batch assigned to entry clerk')
|
|
await loadRecent()
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : 'Assignment failed'
|
|
assignError.value = msg
|
|
toast.error(msg)
|
|
}
|
|
}
|
|
|
|
async function loadRecent() {
|
|
await batchStore.listBatches({ status: 'UPLOADED', page: 1, pageSize: 20 })
|
|
}
|
|
|
|
onMounted(loadRecent)
|
|
</script>
|