Files
vigilcare-records/vigilcare-records-web/src/views/IntakeView.vue
T
Trent 4085aaa549
CI / backend (push) Successful in 6m0s
CI / frontend (push) Failing after 1m7s
feature: Supporting Screens
2026-08-12 04:39:32 +08:00

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>