feature: Digitization Workstation UI

This commit is contained in:
voltsrage
2026-06-27 12:15:43 +08:00
parent 88e70b3dbe
commit e22d33b654
55 changed files with 6411 additions and 143 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>vigilcare-records-web</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "vigilcare-records-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@vueuse/core": "^14.3.0",
"axios": "1.7",
"pinia": "^2.3.1",
"vue": "^3.5.38",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.13.2",
"@vitejs/plugin-vue": "^6.0.7",
"@vue/tsconfig": "^0.9.1",
"autoprefixer": "^10.5.2",
"postcss": "^8.5.15",
"tailwindcss": "^3.4.19",
"typescript": "~6.0.2",
"vite": "^8.1.0",
"vue-tsc": "^3.3.5"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+7
View File
@@ -0,0 +1,7 @@
<template>
<router-view />
</template>
<script setup lang="ts">
// App shell — routing handles all layout
</script>
+173
View File
@@ -0,0 +1,173 @@
import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'
import type { ApiResponse, TokenResponse } from '../types'
const REFRESH_BUFFER_MS = 60_000 // refresh 1 minute before access token expiry
const apiClient: AxiosInstance = axios.create({
baseURL: '/api/v1',
headers: {
'Content-Type': 'application/json',
},
timeout: 30000,
})
function getStoredAccessToken(): string | null {
return localStorage.getItem('vigilcare_token')
}
function getStoredRefreshToken(): string | null {
return localStorage.getItem('vigilcare_refresh_token')
}
function setStoredTokens(accessToken: string, refreshToken: string): void {
localStorage.setItem('vigilcare_token', accessToken)
localStorage.setItem('vigilcare_refresh_token', refreshToken)
}
function clearStoredAuth(): void {
localStorage.removeItem('vigilcare_token')
localStorage.removeItem('vigilcare_refresh_token')
localStorage.removeItem('vigilcare_user')
}
function decodeJwtExpiry(token: string): number | null {
try {
const payload = JSON.parse(atob(token.split('.')[1]!))
return typeof payload.exp === 'number' ? payload.exp * 1000 : null
} catch {
return null
}
}
let refreshPromise: Promise<string | null> | null = null
async function refreshAccessToken(): Promise<string | null> {
const refreshToken = getStoredRefreshToken()
if (!refreshToken) return null
if (!refreshPromise) {
refreshPromise = (async () => {
try {
const response = await axios.post<ApiResponse<TokenResponse>>(
'/api/v1/auth/refresh',
{ refreshToken }
)
const data = response.data.data
if (!response.data.success || !data) return null
setStoredTokens(data.token, data.refreshToken)
scheduleProactiveRefresh(data.token)
return data.token
} catch {
return null
} finally {
refreshPromise = null
}
})()
}
return refreshPromise
}
let refreshTimer: ReturnType<typeof setTimeout> | null = null
function scheduleProactiveRefresh(accessToken: string): void {
if (refreshTimer) clearTimeout(refreshTimer)
const expiresAt = decodeJwtExpiry(accessToken)
if (!expiresAt) return
const delay = Math.max(expiresAt - Date.now() - REFRESH_BUFFER_MS, 0)
refreshTimer = setTimeout(() => {
void refreshAccessToken()
}, delay)
}
export function initializeAuthRefresh(): void {
const token = getStoredAccessToken()
if (token) scheduleProactiveRefresh(token)
}
function redirectToLogin(): void {
clearStoredAuth()
window.location.href = '/login'
}
// Request interceptor: attach JWT from localStorage
apiClient.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
const token = getStoredAccessToken()
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
// Response interceptor: retry once on 401 after refresh
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
const newToken = await refreshAccessToken()
if (newToken && originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newToken}`
return apiClient(originalRequest)
}
redirectToLogin()
}
return Promise.reject(error)
}
)
// Typed API helpers
export async function get<T>(url: string, params?: Record<string, unknown>): Promise<ApiResponse<T>> {
const response = await apiClient.get<ApiResponse<T>>(url, { params })
return response.data
}
export async function post<T>(url: string, data?: unknown): Promise<ApiResponse<T>> {
const response = await apiClient.post<ApiResponse<T>>(url, data)
return response.data
}
export async function put<T>(url: string, data?: unknown): Promise<ApiResponse<T>> {
const response = await apiClient.put<ApiResponse<T>>(url, data)
return response.data
}
export async function patch<T>(url: string, data?: unknown): Promise<ApiResponse<T>> {
const response = await apiClient.patch<ApiResponse<T>>(url, data)
return response.data
}
export async function del<T>(url: string): Promise<ApiResponse<T>> {
const response = await apiClient.delete<ApiResponse<T>>(url)
return response.data
}
// Multipart upload helper — used by IntakeView for document upload
export async function uploadFile<T>(
url: string,
file: File,
fields: Record<string, string>
): Promise<ApiResponse<T>> {
const formData = new FormData()
formData.append('file', file)
Object.entries(fields).forEach(([key, value]) => {
formData.append(key, value)
})
const response = await apiClient.post<ApiResponse<T>>(url, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000, // uploads may be larger — extend timeout
})
return response.data
}
export default apiClient
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+44
View File
@@ -0,0 +1,44 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.btn-primary {
@apply bg-primary-600 text-white px-4 py-2 rounded-md
hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed
transition-colors;
}
.btn-danger {
@apply bg-clinical-danger text-white px-4 py-2 rounded-md
hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed
transition-colors;
}
.form-input {
@apply block w-full rounded-md border border-gray-300 px-4 py-2
focus:border-primary-500 focus:ring-2 focus:ring-primary-500
disabled:bg-gray-100 disabled:text-gray-500;
}
.page-container {
@apply p-4 sm:p-6 lg:p-8 max-w-4xl mx-auto;
}
.app-header {
@apply flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between
px-4 py-4 sm:px-6 sm:py-4 bg-white border-b shadow-sm;
}
.app-header-title {
@apply flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4;
}
.app-header-actions {
@apply flex flex-wrap items-center gap-2 sm:gap-4;
}
.card {
@apply bg-white rounded-lg shadow-md p-4 sm:p-6;
}
.split-pane {
@apply grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-8
h-auto lg:h-[calc(100vh-4rem)] min-h-0;
}
.status-badge {
@apply px-2 py-1 rounded-full text-xs font-medium;
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

@@ -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>
@@ -0,0 +1,40 @@
import { computed, watch, onUnmounted, type Ref } from 'vue'
import { useIntervalFn } from '@vueuse/core'
import { useBatchStore } from '../stores/batches'
const PRESIGNED_URL_TTL_MS = 15 * 60 * 1000
const REFRESH_BUFFER_MS = 60_000
export function usePresignedUrl(batchId: Ref<string | undefined>) {
const batchStore = useBatchStore()
const documentUrl = computed(() => batchStore.documentUrl)
async function refreshUrl(): Promise<void> {
if (batchId.value) {
await batchStore.getBatch(batchId.value)
}
}
const { pause, resume } = useIntervalFn(
() => { void refreshUrl() },
PRESIGNED_URL_TTL_MS - REFRESH_BUFFER_MS,
{ immediate: false }
)
watch(
batchId,
async (id) => {
pause()
if (id) {
await refreshUrl()
resume()
}
},
{ immediate: true }
)
onUnmounted(pause)
return { documentUrl, refreshUrl }
}
+22
View File
@@ -0,0 +1,22 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router/index.ts'
import { initializeAuthRefresh } from './api/client.ts'
import { useAuthStore } from './stores/auth.ts'
import './assets/main.css'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
initializeAuthRefresh()
const auth = useAuthStore()
if (auth.isAuthenticated) {
void auth.fetchCurrentUser()
}
app.mount('#app')
+92
View File
@@ -0,0 +1,92 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore, getDefaultRouteForRole } from '../stores/auth'
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('../views/LoginView.vue'),
meta: { requiresAuth: false },
},
{
path: '/intake',
name: 'Intake',
component: () => import('../views/IntakeView.vue'),
meta: { requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] },
},
{
path: '/entry',
name: 'EntryQueue',
component: () => import('../views/EntryView.vue'),
meta: { requiresAuth: true, roles: ['DATA_ENTRY_CLERK', 'ADMINISTRATOR'] },
},
{
path: '/entry/:batchId',
name: 'EntryBatch',
component: () => import('../views/EntryView.vue'),
meta: { requiresAuth: true, roles: ['DATA_ENTRY_CLERK', 'ADMINISTRATOR'] },
props: true,
},
{
path: '/verification',
name: 'VerificationQueue',
component: () => import('../views/VerificationView.vue'),
meta: {
requiresAuth: true,
roles: ['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'],
},
},
{
path: '/verification/:batchId',
name: 'VerificationBatch',
component: () => import('../views/VerificationView.vue'),
meta: {
requiresAuth: true,
roles: ['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'],
},
props: true,
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('../views/QueueDashboardView.vue'),
meta: { requiresAuth: true, roles: ['ADMINISTRATOR'] },
},
{
path: '/',
redirect: '/login',
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// Navigation guard: check auth and role
router.beforeEach((to, _from, next) => {
const auth = useAuthStore()
if (to.path === '/login' && auth.isAuthenticated) {
return next(getDefaultRouteForRole(auth.userRole))
}
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return next('/login')
}
if (to.meta.roles && Array.isArray(to.meta.roles)) {
const allowedRoles = to.meta.roles as string[]
if (!allowedRoles.includes(auth.userRole)) {
const fallback = getDefaultRouteForRole(auth.userRole)
if (fallback !== '/login' && fallback !== to.path) {
return next(fallback)
}
return next('/login')
}
}
next()
})
export default router
+121
View File
@@ -0,0 +1,121 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { post, get } from '../api/client'
import type { User, LoginRequest, LoginResponse, UserProfileResponse } from '../types'
import router from '../router'
export function getDefaultRouteForRole(role: string): string {
switch (role) {
case 'INTAKE_CLERK':
return '/intake'
case 'DATA_ENTRY_CLERK':
return '/entry'
case 'VERIFIER':
case 'CLINICAL_APPROVER':
return '/verification'
case 'ADMINISTRATOR':
return '/dashboard'
default:
return '/login'
}
}
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('vigilcare_token'))
const refreshToken = ref<string | null>(localStorage.getItem('vigilcare_refresh_token'))
const user = ref<User | null>(
JSON.parse(localStorage.getItem('vigilcare_user') || 'null')
)
const isAuthenticated = computed(() => !!token.value)
const userRole = computed(() => user.value?.role ?? '')
const userId = computed(() => user.value?.id ?? '')
const userFullName = computed(() => user.value?.fullName ?? '')
// Role-based view access
const canIntake = computed(() =>
['INTAKE_CLERK', 'ADMINISTRATOR'].includes(userRole.value)
)
const canEntry = computed(() =>
['DATA_ENTRY_CLERK', 'ADMINISTRATOR'].includes(userRole.value)
)
const canVerify = computed(() =>
['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'].includes(userRole.value)
)
const canSupervise = computed(() =>
['ADMINISTRATOR'].includes(userRole.value)
)
async function login(credentials: LoginRequest): Promise<void> {
const response = await post<LoginResponse>('auth/login', credentials)
if (!response.success || !response.data) {
throw new Error(response.error?.message ?? 'Login failed')
}
const loginData = response.data
token.value = loginData.token
refreshToken.value = loginData.refreshToken
user.value = {
id: loginData.userId,
username: loginData.username,
fullName: loginData.fullName,
role: loginData.role,
}
localStorage.setItem('vigilcare_token', loginData.token)
localStorage.setItem('vigilcare_refresh_token', loginData.refreshToken)
localStorage.setItem('vigilcare_user', JSON.stringify(user.value))
router.push(getDefaultRouteForRole(loginData.role))
}
async function fetchCurrentUser(): Promise<void> {
const response = await get<UserProfileResponse>('auth/me')
if (response.success && response.data) {
user.value = {
id: response.data.id,
username: response.data.username,
fullName: response.data.fullName,
role: response.data.role,
}
localStorage.setItem('vigilcare_user', JSON.stringify(user.value))
}
}
async function logout(): Promise<void> {
if (refreshToken.value) {
try {
await post<void>('auth/logout', { refreshToken: refreshToken.value })
} catch {
// Still clear local session if server revoke fails
}
}
token.value = null
refreshToken.value = null
user.value = null
localStorage.removeItem('vigilcare_token')
localStorage.removeItem('vigilcare_refresh_token')
localStorage.removeItem('vigilcare_user')
router.push('/login')
}
return {
token,
refreshToken,
user,
isAuthenticated,
userRole,
userId,
userFullName,
canIntake,
canEntry,
canVerify,
canSupervise,
login,
fetchCurrentUser,
logout,
}
})
+212
View File
@@ -0,0 +1,212 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { get, post, put, del, patch, uploadFile } from '../api/client'
import type {
BatchDetailResponse,
BatchDraft,
DraftPatient,
DraftEncounter,
DraftObservation,
BatchListResponse,
FieldCheck,
} from '../types'
export const useBatchStore = defineStore('batches', () => {
const batches = ref<BatchDetailResponse[]>([])
const currentBatch = ref<BatchDetailResponse | null>(null)
const currentDraft = ref<BatchDraft | null>(null)
const totalCount = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
const documentUrl = computed(() => currentBatch.value?.documentUrl ?? null)
async function listBatches(params: {
status?: string
batchType?: string
assignedTo?: string
track?: string
page?: number
pageSize?: number
}): Promise<void> {
loading.value = true
error.value = null
try {
const response = await get<BatchListResponse>(
'digitization-batches',
params as Record<string, unknown>
)
if (response.success && response.data) {
batches.value = response.data.items
totalCount.value = response.data.totalCount
}
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to load batches'
} finally {
loading.value = false
}
}
async function getBatch(id: string): Promise<void> {
loading.value = true
error.value = null
try {
const response = await get<BatchDetailResponse>(
`digitization-batches/${id}`
)
if (response.success && response.data) {
currentBatch.value = response.data
}
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to load batch'
} finally {
loading.value = false
}
}
async function uploadBatch(
file: File,
batchType: string,
track: string,
patientId?: string
): Promise<BatchDetailResponse | null> {
loading.value = true
error.value = null
try {
const fields: Record<string, string> = { batchType, track }
if (patientId) fields.patientId = patientId
const response = await uploadFile<BatchDetailResponse>(
'digitization-batches',
file,
fields
)
if (response.success && response.data) {
return response.data
}
return null
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Upload failed'
return null
} finally {
loading.value = false
}
}
async function assignBatch(batchId: string, entryClerkUserId: string): Promise<void> {
const response = await patch<BatchDetailResponse>(`digitization-batches/${batchId}/assign`, {
entryClerkUserId,
})
if (!response.success) {
throw new Error(response.error?.message ?? 'Assignment failed')
}
}
async function getDraft(batchId: string): Promise<void> {
loading.value = true
try {
const response = await get<BatchDraft>(
`digitization-batches/${batchId}/draft`
)
if (response.success && response.data) {
currentDraft.value = response.data
}
} finally {
loading.value = false
}
}
async function saveDraftPatient(
batchId: string,
patient: Partial<DraftPatient>
): Promise<void> {
await put<DraftPatient>(
`digitization-batches/${batchId}/draft/patient`,
patient
)
}
async function saveDraftEncounter(
batchId: string,
encounter: Partial<DraftEncounter>
): Promise<void> {
await put<DraftEncounter>(
`digitization-batches/${batchId}/draft/encounter`,
encounter
)
}
async function addObservation(
batchId: string,
observation: Partial<DraftObservation>
): Promise<void> {
await post<DraftObservation>(
`digitization-batches/${batchId}/draft/observations`,
observation
)
await getDraft(batchId) // refresh draft to get new observation ID
}
async function updateObservation(
batchId: string,
obsId: string,
observation: Partial<DraftObservation>
): Promise<void> {
await put<DraftObservation>(
`digitization-batches/${batchId}/draft/observations/${obsId}`,
observation
)
}
async function deleteObservation(batchId: string, obsId: string): Promise<void> {
await del<void>(`digitization-batches/${batchId}/draft/observations/${obsId}`)
await getDraft(batchId)
}
async function submitForVerification(batchId: string): Promise<void> {
await post<void>(`digitization-batches/${batchId}/submit-for-verification`)
}
async function verifyBatch(
batchId: string,
fieldChecks: FieldCheck[],
passed: boolean
): Promise<void> {
await post<void>(`digitization-batches/${batchId}/verify`, {
fieldChecks,
passed,
})
}
async function rejectBatch(batchId: string, reason: string): Promise<void> {
await post<void>(`digitization-batches/${batchId}/reject`, { reason })
}
async function approveBatch(batchId: string): Promise<void> {
await post<void>(`digitization-batches/${batchId}/approve`, null)
}
return {
batches,
currentBatch,
currentDraft,
documentUrl,
totalCount,
loading,
error,
listBatches,
getBatch,
uploadBatch,
assignBatch,
getDraft,
saveDraftPatient,
saveDraftEncounter,
addObservation,
updateObservation,
deleteObservation,
submitForVerification,
verifyBatch,
rejectBatch,
approveBatch,
}
})
+145
View File
@@ -0,0 +1,145 @@
export interface User {
id: string
username: string
fullName: string
role: string
}
export interface LoginRequest {
username: string
password: string
}
export interface LoginResponse {
token: string
refreshToken: string
userId: string
username: string
fullName: string
role: string
}
export interface TokenResponse {
token: string
refreshToken: string
}
export interface ApiResponse<T> {
success: boolean
statusCode: number
data: T | null
error: ApiError | null
}
export interface ApiError {
message: string
code: string
}
export interface BatchDetailResponse {
id: string
status: string
batchType: string
track: string
patientId: string | null
documentRef: string
documentUrl: string | null
enableRetroactiveAlerts: boolean
enteredByUserId: string | null
verifiedByUserId: string | null
approvedByUserId: string | null
rejectionReason: string | null
promotedAt: string | null
promotionEncounterId: string | null
supersedesBatchId: string | null
clinicianAttestation: boolean
createdAt: string
updatedAt: string
}
export interface BatchListResponse {
items: BatchDetailResponse[]
page: number
pageSize: number
totalCount: number
totalPages: number
}
export interface UserProfileResponse {
id: string
username: string
fullName: string
role: string
}
export interface DraftPatient {
id: string
batchId: string
fullName: string | null
dateOfBirth: string | null
sex: string | null
bloodType: string | null
emergencyContact: string | null
allergiesJson: string | null
noKnownAllergies: boolean
}
export interface DraftEncounter {
id: string
batchId: string
admissionDate: string | null
department: string | null
roomBed: string | null
admissionReason: string | null
dischargeDiagnosis: string | null
status: string | null
}
export interface DraftObservation {
id: string
batchId: string
observationCode: string
value: number
unit: string
recordedAt: string
note: string | null
}
export interface BatchDraft {
patient: DraftPatient | null
encounter: DraftEncounter | null
observations: DraftObservation[]
}
export interface FieldCheck {
fieldPath: string
passed: boolean
}
export interface PagedResult<T> {
items: T[]
page: number
pageSize: number
totalCount: number
totalPages: number
}
export interface WorkQueueOverview {
statusCounts: Record<string, number>
averageTimeInQueueMinutes: number
rejectRate: number
oldestPendingVerificationMinutes: number
}
export interface PatientSearchResult {
id: string
fullName: string
mrn: string
}
export interface UserSummary {
id: string
username: string
fullName: string
role: string
}
@@ -0,0 +1,85 @@
<template>
<div class="min-h-screen lg:h-screen flex flex-col">
<AppHeader title="Data Entry">
<template #subtitle>
<span v-if="currentBatch" class="text-sm text-gray-500">
Batch: {{ currentBatch.id.substring(0, 8) }}...
| Type: {{ currentBatch.batchType.replace(/_/g, ' ') }}
</span>
</template>
</AppHeader>
<!-- Queue view (no batch selected) -->
<div v-if="!batchId" class="flex-1 p-4 sm:p-6">
<h2 class="text-xl font-semibold mb-4">Entry Queue</h2>
<BatchList
:batches="batchStore.batches"
:loading="batchStore.loading"
@select="openBatch"
/>
</div>
<!-- Split pane (batch selected) -->
<div v-else class="flex-1 split-pane">
<ScanViewer
v-if="documentUrl"
:url="documentUrl"
/>
<div v-else class="flex items-center justify-center bg-gray-100 rounded-lg">
<p class="text-gray-500">Loading document...</p>
</div>
<EntryForm
:batch="currentBatch"
:batch-id="batchId"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useBatchStore } from '../stores/batches'
import { usePresignedUrl } from '../composables/usePresignedUrl'
import AppHeader from '../components/AppHeader.vue'
import ScanViewer from '../components/ScanViewer.vue'
import EntryForm from '../components/EntryForm.vue'
import BatchList from '../components/BatchList.vue'
const props = defineProps<{ batchId?: string }>()
const auth = useAuthStore()
const batchStore = useBatchStore()
const route = useRoute()
const router = useRouter()
const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined))
const currentBatch = computed(() => batchStore.currentBatch)
const { documentUrl } = usePresignedUrl(batchId)
async function openBatch(id: string) {
router.push(`/entry/${id}`)
}
watch(
batchId,
async (id) => {
if (id) {
await batchStore.getDraft(id)
}
},
{ immediate: true }
)
onMounted(async () => {
if (!batchId.value) {
await batchStore.listBatches({
assignedTo: auth.userId,
page: 1,
pageSize: 50,
})
}
})
</script>
@@ -0,0 +1,179 @@
<template>
<div class="min-h-screen flex flex-col">
<AppHeader title="Intake" />
<div class="page-container flex-1">
<h1 class="text-2xl font-bold mb-6">Upload Scanned Document</h1>
<!-- Upload form -->
<div class="card mb-6">
<h2 class="text-lg font-semibold mb-4">New Batch</h2>
<form @submit.prevent="handleUpload" class="space-y-4">
<!-- File input -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Scanned Document (PDF, JPEG, PNG &mdash; max 25 MB)
</label>
<input
type="file"
ref="fileInput"
@change="onFileChange"
accept=".pdf,.jpg,.jpeg,.png"
class="block w-full text-sm text-gray-500
file:mr-4 file:py-2 file:px-4
file:rounded-md file:border-0
file:text-sm file:font-semibold
file:bg-primary-50 file:text-primary-700
hover:file:bg-primary-100"
/>
<p v-if="selectedFile" class="text-sm text-gray-500 mt-2">
{{ selectedFile.name }} ({{ (selectedFile.size / 1024 / 1024).toFixed(2) }} MB)
</p>
</div>
<!-- Batch type -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Batch Type</label>
<select v-model="batchType" class="form-input" required>
<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 class="block text-sm font-medium text-gray-700 mb-2">Track</label>
<select v-model="track" class="form-input">
<option value="BACKFILL">Backfill (Track A)</option>
<option value="LIVE_CAPTURE">Live Capture (Track B)</option>
</select>
</div>
<!-- Patient search -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Patient (optional &mdash; can link later)
</label>
<PatientSearch v-model="patientId" />
</div>
<div v-if="uploadError" class="text-clinical-danger text-sm">
{{ uploadError }}
</div>
<button
type="submit"
class="btn-primary"
:disabled="!selectedFile || !batchType || batchStore.loading"
>
{{ batchStore.loading ? 'Uploading...' : 'Upload and Create Batch' }}
</button>
</form>
</div>
<!-- Recent uploads -->
<div class="card">
<h2 class="text-lg font-semibold mb-4">Recent Uploads</h2>
<div v-if="assignError" class="text-clinical-danger text-sm mb-4">
{{ assignError }}
</div>
<BatchList
:batches="batchStore.batches"
:loading="batchStore.loading"
show-assign
@assign="openAssignDialog"
/>
</div>
</div>
<AssignClerkDialog
:show="assignDialogOpen"
:batch-id="assignBatchId"
@close="closeAssignDialog"
@assigned="handleAssign"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useBatchStore } from '../stores/batches'
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 batchStore = useBatchStore()
const selectedFile = ref<File | null>(null)
const batchType = ref('')
const track = ref('BACKFILL')
const patientId = ref<string | undefined>(undefined)
const uploadError = ref('')
const assignError = ref('')
const assignDialogOpen = ref(false)
const assignBatchId = ref<string | null>(null)
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
selectedFile.value = input.files?.[0] ?? null
}
async function handleUpload() {
if (!selectedFile.value || !batchType.value) return
uploadError.value = ''
try {
const batch = await batchStore.uploadBatch(
selectedFile.value,
batchType.value,
track.value,
patientId.value
)
if (batch) {
selectedFile.value = null
batchType.value = ''
track.value = 'BACKFILL'
patientId.value = undefined
await loadRecent()
}
} catch (e: unknown) {
uploadError.value = e instanceof Error ? e.message : 'Upload failed'
}
}
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()
await loadRecent()
} catch (e: unknown) {
assignError.value = e instanceof Error ? e.message : 'Assignment failed'
}
}
async function loadRecent() {
await batchStore.listBatches({ status: 'UPLOADED', page: 1, pageSize: 20 })
}
onMounted(loadRecent)
</script>
@@ -0,0 +1,63 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-gray-50 px-4 py-8">
<div class="max-w-md w-full bg-white rounded-lg shadow-md p-6 sm:p-8">
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-gray-900">VigilCare Records</h1>
<p class="text-gray-600 mt-2">Digitization Workstation</p>
</div>
<form @submit.prevent="handleLogin" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Username</label>
<input
v-model="username"
type="text"
class="form-input"
placeholder="e.g. intake1, entry1, verifier1"
required
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Password</label>
<input
v-model="password"
type="password"
class="form-input"
required
/>
</div>
<div v-if="errorMessage" class="text-clinical-danger text-sm">
{{ errorMessage }}
</div>
<button type="submit" class="btn-primary w-full" :disabled="loading">
{{ loading ? 'Signing in...' : 'Sign In' }}
</button>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const username = ref('')
const password = ref('')
const loading = ref(false)
const errorMessage = ref('')
async function handleLogin() {
loading.value = true
errorMessage.value = ''
try {
await auth.login({ username: username.value, password: password.value })
} catch (e: unknown) {
errorMessage.value = e instanceof Error ? e.message : 'Login failed'
} finally {
loading.value = false
}
}
</script>
@@ -0,0 +1,141 @@
<template>
<div class="min-h-screen lg:h-screen flex flex-col">
<AppHeader title="Supervisor Dashboard">
<template #actions>
<button
type="button"
@click="refreshData"
class="text-sm text-primary-600 hover:text-primary-800"
>
Refresh
</button>
</template>
</AppHeader>
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 space-y-6 lg:space-y-8">
<!-- Summary cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 lg:gap-8">
<div class="card">
<p class="text-sm text-gray-500">Pending Entry</p>
<p class="text-3xl font-bold text-yellow-600">
{{ overview?.statusCounts?.UPLOADED ?? 0 }}
</p>
</div>
<div class="card">
<p class="text-sm text-gray-500">In Entry</p>
<p class="text-3xl font-bold text-blue-600">
{{ overview?.statusCounts?.IN_ENTRY ?? 0 }}
</p>
</div>
<div class="card">
<p class="text-sm text-gray-500">Pending Verification</p>
<p class="text-3xl font-bold text-orange-600">
{{ overview?.statusCounts?.PENDING_VERIFICATION ?? 0 }}
</p>
</div>
<div class="card">
<p class="text-sm text-gray-500">Reject Rate</p>
<p
class="text-3xl font-bold"
:class="(overview?.rejectRate ?? 0) > 15
? 'text-clinical-danger'
: 'text-clinical-safe'"
>
{{ ((overview?.rejectRate ?? 0) * 100).toFixed(1) }}%
</p>
</div>
</div>
<!-- Queue age and throughput -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-8">
<div class="card">
<h3 class="text-sm font-medium text-gray-700 mb-2">Average Time in Queue</h3>
<p class="text-2xl font-bold">
{{ formatMinutes(overview?.averageTimeInQueueMinutes ?? 0) }}
</p>
<p class="text-xs text-gray-500 mt-2">
Target: &lt; 24 hours
</p>
</div>
<div class="card">
<h3 class="text-sm font-medium text-gray-700 mb-2">
Oldest Pending Verification
</h3>
<p
class="text-2xl font-bold"
:class="(overview?.oldestPendingVerificationMinutes ?? 0) > 1440
? 'text-clinical-danger'
: 'text-gray-900'"
>
{{ formatMinutes(overview?.oldestPendingVerificationMinutes ?? 0) }}
</p>
</div>
</div>
<!-- Status breakdown -->
<div class="card">
<h3 class="text-sm font-medium text-gray-700 mb-4">Batches by Status</h3>
<div class="space-y-2">
<div
v-for="(count, status) in overview?.statusCounts ?? {}"
:key="status"
class="flex items-center gap-4"
>
<span class="w-40 shrink-0 text-sm text-gray-600">
{{ String(status).replace(/_/g, ' ') }}
</span>
<div class="flex-1 bg-gray-100 rounded-full h-4">
<div
class="h-4 rounded-full bg-primary-500"
:style="{ width: `${(Number(count) / maxCount) * 100}%` }"
/>
</div>
<span class="text-sm font-medium w-8 text-right">{{ count }}</span>
</div>
</div>
</div>
<!-- Recent activity -->
<div class="card">
<h3 class="text-sm font-medium text-gray-700 mb-4">All Batches</h3>
<BatchList
:batches="batchStore.batches"
:loading="batchStore.loading"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useBatchStore } from '../stores/batches'
import { get } from '../api/client'
import AppHeader from '../components/AppHeader.vue'
import BatchList from '../components/BatchList.vue'
import type { WorkQueueOverview } from '../types'
const batchStore = useBatchStore()
const overview = ref<WorkQueueOverview | null>(null)
const maxCount = computed(() => {
if (!overview.value?.statusCounts) return 1
return Math.max(...Object.values(overview.value.statusCounts).map(Number), 1)
})
function formatMinutes(minutes: number): string {
if (minutes < 60) return `${Math.round(minutes)}m`
if (minutes < 1440) return `${(minutes / 60).toFixed(1)}h`
return `${(minutes / 1440).toFixed(1)}d`
}
async function refreshData() {
const response = await get<WorkQueueOverview>('work-queue/overview')
if (response.success && response.data) {
overview.value = response.data
}
await batchStore.listBatches({ page: 1, pageSize: 50 })
}
onMounted(refreshData)
</script>
@@ -0,0 +1,86 @@
<template>
<div class="min-h-screen lg:h-screen flex flex-col">
<AppHeader title="Verification">
<template #subtitle>
<span v-if="currentBatch" class="text-sm text-gray-500">
Batch: {{ currentBatch.id.substring(0, 8) }}...
| Entered by: {{ currentBatch.enteredByUserId?.substring(0, 8) }}...
</span>
</template>
</AppHeader>
<!-- Queue view (no batch selected) -->
<div v-if="!batchId" class="flex-1 p-4 sm:p-6">
<h2 class="text-xl font-semibold mb-4">Verification Queue</h2>
<p class="text-sm text-gray-500 mb-4">
Batches pending verification, sorted by submission time (oldest first).
</p>
<BatchList
:batches="batchStore.batches"
:loading="batchStore.loading"
@select="openBatch"
/>
</div>
<!-- Split pane (batch selected) -->
<div v-else class="flex-1 split-pane">
<ScanViewer
v-if="documentUrl"
:url="documentUrl"
/>
<div v-else class="flex items-center justify-center bg-gray-100 rounded-lg">
<p class="text-gray-500">Loading document...</p>
</div>
<VerificationForm
:batch="currentBatch"
:batch-id="batchId"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useBatchStore } from '../stores/batches'
import { usePresignedUrl } from '../composables/usePresignedUrl'
import AppHeader from '../components/AppHeader.vue'
import ScanViewer from '../components/ScanViewer.vue'
import VerificationForm from '../components/VerificationForm.vue'
import BatchList from '../components/BatchList.vue'
const props = defineProps<{ batchId?: string }>()
const batchStore = useBatchStore()
const route = useRoute()
const router = useRouter()
const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined))
const currentBatch = computed(() => batchStore.currentBatch)
const { documentUrl } = usePresignedUrl(batchId)
function openBatch(id: string) {
router.push(`/verification/${id}`)
}
watch(
batchId,
async (id) => {
if (id) {
await batchStore.getDraft(id)
}
},
{ immediate: true }
)
onMounted(async () => {
if (!batchId.value) {
await batchStore.listBatches({
status: 'PENDING_VERIFICATION',
page: 1,
pageSize: 50,
})
}
})
</script>
+34
View File
@@ -0,0 +1,34 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
},
clinical: {
safe: '#16a34a',
warning: '#eab308',
danger: '#dc2626',
},
},
spacing: {
// Rule of 8 — prefer these multiples of 8px in component spacing
18: '4.5rem',
22: '5.5rem',
},
screens: {
xs: '480px',
},
},
},
plugins: [],
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3028,
proxy: {
'/api': {
target: 'http://localhost:5217',
changeOrigin: true,
},
},
},
})