Fix responsiveness
CI / backend (push) Successful in 6m14s
CI / frontend (push) Failing after 59s

This commit is contained in:
2026-08-12 05:45:12 +08:00
parent 00f832aa73
commit 4c5aafe591
27 changed files with 414 additions and 186 deletions
+32
View File
@@ -0,0 +1,32 @@
Use Playwright MCP as a visual QA and frontend polish tool for this page.
Inspect the page at mobile, tablet, and desktop breakpoints, identify styling/layout issues, and fix them directly in the code.
Focus on:
- spacing
- sizing
- alignment
- element positions
- typography scaling
- card proportions
- image/text balance
- overflow/clipping
- responsiveness
Requirements:
- keep Tailwind CSS
- preserve the current design intent
- improve the page until it looks polished and production-ready
- verify each fix visually with Playwright MCP
- iterate until no obvious visual issues remain
Test at:
- 375x812
- 768x1024
- 1280x800
Use VigilCareRecordsAPI/Data/Seed/DataSeeder.cs to get the logins
Do not only report problems.
Make the fixes, re-test, and then provide a short summary of what was improved.
+84
View File
@@ -0,0 +1,84 @@
Here's the updated prompt:
---
## Role
Act as a senior QA engineer testing a Vue 3 + Node.js web application for logic errors, bugs, and edge cases.
## Output Format
A plain text report of all issues found, grouped by file or feature domain, with severity level per issue (Critical / High / Medium / Low).
## Goal
Test the provided functionality, section, or domain by reading the code and mentally executing it across normal, boundary, and failure scenarios. Report every defect found. When testing a view or feature, trace execution downward through all underlying components, composables, and backend routes and controllers that the feature depends on.
## Grounding Rules
- Stay within the confines of the provided code — do not invent features, routes, or behaviors that are not present.
- Do not hallucinate API responses, database states, or UI interactions not inferable from the code.
- Do not suggest third-party testing tools or libraries unless already present in the codebase.
- If a behavior is ambiguous, flag it as a question rather than assuming intent.
- When a view or component calls a composable, follow that composable's logic as part of the same test pass.
- When a composable or service makes an API call, follow the corresponding backend route, middleware, and controller as part of the same test pass.
## Instructions
Test each provided file or domain in this order of priority:
1. **Logic correctness** — Does the code do what it is clearly intended to do?
2. **Edge cases** — Empty inputs, null/undefined values, empty arrays, zero, negative numbers, max-length strings, concurrent calls.
3. **Error handling** — Are errors caught? Are failure states handled gracefully? Do error messages leak sensitive data?
4. **Reactivity correctness** (frontend) — Does state update when it should? Can stale state be observed?
5. **Data flow** — Are values passed, transformed, or mutated in ways that could produce unexpected results downstream? Trace data from the frontend input all the way to the database query and back.
6. **Boundary conditions** — Off-by-one errors, pagination limits, permission boundaries, rate limits.
7. **Race conditions** — Async operations that could resolve out of order or leave state inconsistent.
8. **Contract mismatches** — Does the frontend expect a response shape the backend does not guarantee? Are required fields missing, optional fields assumed present, or error codes unhandled?
9. **Login/Auth Requirements** - If auth or login is required use the following credentials email: bradleystorm.sevt@mockinbox.com and password: Password123!
**Conflict resolution:** If a behavior could be either a bug or an intentional design choice, report it as a flagged ambiguity rather than a confirmed defect. Do not silently assume either way.
**Priority hierarchy:** Logic correctness > Error handling > Edge cases > Data flow > Contract mismatches > Boundary conditions > Race conditions > Reactivity.
## Trace Depth
When a file is provided as the entry point for testing, automatically include in scope:
- All composables imported and called by that file
- All child components rendered by that file
- All backend routes, middleware, and controllers called by those composables or services
- All database queries executed by those controllers
Report issues at the layer where they originate, not just where their effect is observed.
## Examples
**Bad output (do not produce this):**
```
- The login form might have issues.
- Consider adding more validation.
```
**Good output (produce this):**
```
FILE: src/composables/useAuth.js
SEVERITY: Critical
ISSUE: If `refreshToken()` is called while a refresh is already in flight, two concurrent requests are fired. The second response overwrites the token set by the first, leaving the app in a potentially invalid auth state.
REPRODUCTION: Trigger two API calls simultaneously on a near-expired token.
FIX RECOMMENDATION: Guard the refresh call with an in-flight flag or return the existing promise if one is pending.
FILE: backend/controllers/authController.js
SEVERITY: High
ISSUE: The refresh token is not invalidated after use. A leaked token can be replayed indefinitely until expiry.
REPRODUCTION: Capture the refresh token from a valid session and reuse it after the session has been refreshed.
FIX RECOMMENDATION: Implement refresh token rotation — invalidate the used token and issue a new one on each refresh.
```
## Context / Input
Paste files in this order, highest reliability first:
1. Backend routes, middleware, and controllers
2. Composables and services
3. Components and views
## Final Reminder
- Do not fabricate bugs. Every reported issue must be traceable to a specific line or code path in the provided files.
- Do not skip files because they look simple — shallow files are common sources of silent failures.
- Ambiguity is a valid finding. Flag it rather than resolve it silently.
- Always trace execution through the full stack — frontend to composable to backend to database — before closing a test pass on any feature.
## Output
Plain text only. No markdown formatting, no bullet symbols, no headers with hashes. Group findings by file. For each issue state: FILE, SEVERITY, ISSUE, REPRODUCTION STEPS, FIX RECOMMENDATION. If a file has no issues, write the filename followed by "No issues found." Restate this format requirement if the session resets mid-task.
@@ -98,7 +98,7 @@ describe('IntakeView cover sheet upload', () => {
const input = wrapper.find('input[placeholder="VCR-CS-XXXXXXXX"]')
expect(wrapper.text()).toContain('Cover Sheet Code')
expect(wrapper.text()).toContain('Upload Scanned Document')
expect(wrapper.text()).toContain('New Batch')
expect(wrapper.find('.upload-dropzone').exists()).toBe(true)
expect(input.exists()).toBe(true)
expect(input.attributes('autofocus')).toBeDefined()
@@ -19,7 +19,15 @@ vi.mock('@/composables/useToast', () => ({
}))
vi.mock('@/components/AppHeader.vue', () => ({
default: { template: '<div data-testid="app-header" />' },
default: {
props: ['title', 'description'],
template: `
<div data-testid="app-header" :title="title" :description="description">
<slot name="actions" />
<slot name="subtitle" />
</div>
`,
},
}))
vi.mock('@/components/EmptyState.vue', () => ({
+6 -5
View File
@@ -60,14 +60,14 @@
@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-3 sm:px-6 h-auto min-h-16 bg-surface border-b border-line;
@apply flex flex-row items-start justify-between gap-3
px-4 py-3 sm:px-6 sm:items-center min-h-14 sm:min-h-16 bg-surface border-b border-line;
}
.app-header-title {
@apply flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4 min-w-0;
@apply flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-4 min-w-0;
}
.app-header-actions {
@apply flex flex-wrap items-center gap-2 sm:gap-4;
@apply flex flex-wrap items-center justify-end gap-2 sm:gap-4 shrink-0;
}
.card {
@apply bg-surface rounded-card border border-line p-4 sm:p-6 shadow-card;
@@ -92,7 +92,8 @@
@apply flex flex-col min-h-0 overflow-hidden bg-surface;
}
.workstation-scan {
@apply flex flex-col min-h-[40vh] xl:min-h-0 p-3 xl:p-4 bg-canvas border-b xl:border-b-0 xl:border-r border-line;
@apply flex flex-col min-h-[13rem] sm:min-h-[18rem] xl:min-h-0
p-3 xl:p-4 bg-canvas border-b xl:border-b-0 xl:border-r border-line;
}
.workstation-form {
@apply flex flex-col min-h-0 overflow-hidden bg-surface;
@@ -1,15 +1,31 @@
<template>
<div class="app-header">
<div class="app-header-title">
<h1 class="text-lg font-semibold text-ink-strong">{{ title }}</h1>
<slot name="subtitle" />
<div class="app-header-title min-w-0 flex-1">
<div class="min-w-0">
<h1 class="text-lg sm:text-xl font-semibold text-ink-strong leading-tight">
{{ title }}
</h1>
<p
v-if="description"
class="mt-0.5 text-sm text-ink-secondary leading-snug max-w-2xl"
>
{{ description }}
</p>
<slot name="subtitle" />
</div>
</div>
<div class="app-header-actions">
<div class="app-header-actions self-start">
<slot name="actions" />
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{ title: string }>()
withDefaults(
defineProps<{
title: string
description?: string
}>(),
{ description: undefined },
)
</script>
@@ -1,33 +1,54 @@
<template>
<div class="flex h-screen min-h-0 bg-canvas text-ink">
<!-- Mobile backdrop -->
<div
v-if="mobileOpen"
class="fixed inset-0 z-40 bg-navy/40 lg:hidden"
aria-hidden="true"
@click="closeMobile"
/>
<!-- Sidebar -->
<aside
:class="[
'flex flex-col shrink-0 bg-navy text-white transition-[width] duration-200 ease-out',
collapsed ? 'w-16' : 'w-60',
'flex flex-col shrink-0 bg-navy text-white transition-[width,transform] duration-200 ease-out shadow-xl lg:shadow-none',
'fixed inset-y-0 left-0 z-50 lg:static lg:z-auto',
mobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0',
sidebarCollapsed ? 'w-16' : 'w-60',
]"
aria-label="Main navigation"
:aria-hidden="navAriaHidden"
>
<div
:class="[
'flex items-center h-16 border-b border-white/10 shrink-0',
collapsed ? 'justify-center px-2' : 'gap-2.5 px-4',
'flex items-center h-14 sm:h-16 border-b border-white/10 shrink-0',
sidebarCollapsed ? 'justify-center px-2' : 'gap-2.5 px-4',
]"
>
<svg viewBox="0 0 32 32" class="w-7 h-7 shrink-0" aria-hidden="true">
<path fill="#155EEF" d="M16 2 4 6.5v8.2c0 8 5.1 14.6 12 15.3 6.9-.7 12-7.3 12-15.3V6.5z"/>
<path fill="white" d="M16 8.5c-3.6 0-6.6 2.8-6.6 6.3 0 3.4 2.7 6.4 6.6 9.6 3.9-3.2 6.6-6.2 6.6-9.6 0-3.5-3-6.3-6.6-6.3m0 3.4c1.7 0 3.1 1.3 3.1 2.9s-1.4 2.9-3.1 2.9-3.1-1.3-3.1-2.9 1.4-2.9 3.1-2.9"/>
</svg>
<div v-if="!collapsed" class="min-w-0 leading-tight">
<div v-if="!sidebarCollapsed" class="min-w-0 leading-tight">
<p class="font-bold tracking-tight text-sm truncate">VigilCare</p>
<p class="text-[11px] text-white/60 truncate">Records</p>
</div>
<button
type="button"
class="ml-auto lg:hidden rounded-input p-1.5 text-white/70 hover:bg-white/10 hover:text-white"
aria-label="Close navigation"
@click="closeMobile"
>
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
</div>
<nav class="flex-1 overflow-y-auto py-3 px-2 space-y-4">
<div v-if="workspaceItems.length">
<p
v-if="!collapsed"
v-if="!sidebarCollapsed"
class="px-2 mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-white/40"
>
Workspace
@@ -36,13 +57,14 @@
<li v-for="item in workspaceItems" :key="item.to">
<router-link
:to="item.to"
:title="collapsed ? item.label : undefined"
:title="sidebarCollapsed ? item.label : undefined"
:class="navItemClass(item.to)"
@click="closeMobile"
>
<span class="shrink-0 w-5 h-5 flex items-center justify-center" aria-hidden="true">
<component :is="item.icon" />
</span>
<span v-if="!collapsed" class="truncate">{{ item.label }}</span>
<span v-if="!sidebarCollapsed" class="truncate">{{ item.label }}</span>
</router-link>
</li>
</ul>
@@ -50,7 +72,7 @@
<div v-if="adminItems.length">
<p
v-if="!collapsed"
v-if="!sidebarCollapsed"
class="px-2 mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-white/40"
>
Administration
@@ -59,20 +81,21 @@
<li v-for="item in adminItems" :key="item.to">
<router-link
:to="item.to"
:title="collapsed ? item.label : undefined"
:title="sidebarCollapsed ? item.label : undefined"
:class="navItemClass(item.to)"
@click="closeMobile"
>
<span class="shrink-0 w-5 h-5 flex items-center justify-center" aria-hidden="true">
<component :is="item.icon" />
</span>
<span v-if="!collapsed" class="truncate">{{ item.label }}</span>
<span v-if="!sidebarCollapsed" class="truncate">{{ item.label }}</span>
</router-link>
</li>
</ul>
</div>
</nav>
<div class="shrink-0 border-t border-white/10 p-2">
<div class="shrink-0 border-t border-white/10 p-2 hidden lg:block">
<button
type="button"
class="flex w-full items-center gap-2.5 rounded-input px-2.5 py-2 text-sm text-white/70 hover:bg-white/10 hover:text-white transition-colors"
@@ -97,15 +120,33 @@
<!-- Main column -->
<div class="flex flex-col flex-1 min-w-0 min-h-0">
<header class="flex items-center justify-end gap-4 h-16 shrink-0 px-4 sm:px-6 bg-surface border-b border-line">
<span class="text-sm text-ink-secondary truncate">{{ auth.userFullName }}</span>
<header
class="flex items-center justify-between gap-3 h-14 sm:h-16 shrink-0 px-3 sm:px-6 bg-surface border-b border-line"
>
<button
type="button"
class="text-sm text-ink-secondary hover:text-ink-strong transition-colors"
@click="auth.logout()"
class="lg:hidden inline-flex items-center justify-center rounded-input p-2 text-ink-secondary hover:bg-canvas hover:text-ink-strong"
aria-label="Open navigation"
:aria-expanded="mobileOpen"
@click="mobileOpen = true"
>
Sign Out
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M2 4.75A.75.75 0 012.75 4h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 4.75zm0 5.25a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 10zm0 5.25a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75a.75.75 0 01-.75-.75z" clip-rule="evenodd" />
</svg>
</button>
<div class="flex items-center justify-end gap-3 sm:gap-4 min-w-0 ml-auto">
<span class="text-sm text-ink-secondary truncate max-w-[9rem] sm:max-w-none">
{{ auth.userFullName }}
</span>
<button
type="button"
class="shrink-0 text-sm text-ink-secondary hover:text-ink-strong transition-colors"
@click="auth.logout()"
>
Sign Out
</button>
</div>
</header>
<main class="flex-1 min-h-0 overflow-auto">
@@ -116,7 +157,8 @@
</template>
<script setup lang="ts">
import { computed, h, ref, watch } from 'vue'
import { computed, h, onUnmounted, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth'
@@ -124,13 +166,40 @@ const COLLAPSE_KEY = 'vigilcare_sidebar_collapsed'
const auth = useAuthStore()
const route = useRoute()
const isDesktopNav = useMediaQuery('(min-width: 1024px)')
const collapsed = ref(localStorage.getItem(COLLAPSE_KEY) === '1')
const mobileOpen = ref(false)
/** On mobile the drawer is always expanded; desktop uses the collapse preference. */
const sidebarCollapsed = computed(() => collapsed.value && !mobileOpen.value)
/** Hide off-canvas nav from AT on small screens when closed. */
const navAriaHidden = computed(() => !isDesktopNav.value && !mobileOpen.value)
watch(collapsed, (value) => {
localStorage.setItem(COLLAPSE_KEY, value ? '1' : '0')
})
watch(
() => route.fullPath,
() => {
closeMobile()
},
)
watch(mobileOpen, (open) => {
document.body.style.overflow = open ? 'hidden' : ''
})
onUnmounted(() => {
document.body.style.overflow = ''
})
function closeMobile() {
mobileOpen.value = false
}
function toggleCollapsed() {
collapsed.value = !collapsed.value
}
@@ -223,7 +292,7 @@ function isActive(path: string): boolean {
function navItemClass(path: string): string {
const base =
'flex items-center gap-2.5 rounded-input text-sm transition-colors ' +
(collapsed.value ? 'justify-center px-2 py-2.5' : 'px-2.5 py-2')
(sidebarCollapsed.value ? 'justify-center px-2 py-2.5' : 'px-2.5 py-2')
if (isActive(path)) {
return `${base} bg-primary-600 text-white font-medium`
}
@@ -10,7 +10,7 @@
<span class="evidence-level-mark" aria-hidden="true">2</span>
Verified draft
</p>
<h2 class="text-base font-semibold text-ink-strong">Clinical Approval</h2>
<h2 class="text-base font-semibold text-ink-strong">Sign-off review</h2>
<p class="mt-1 text-xs text-ink-secondary max-w-md">
Clinical sign-off before promotion to live clinical tables. Review the source scan and verified draft.
</p>
@@ -125,11 +125,13 @@
</fieldset>
<div
v-if="batch?.verifiedByUserId"
class="rounded-input border border-line bg-canvas px-3 py-2 text-sm text-ink-secondary"
v-if="batch?.verifiedByUserId || batch?.verifiedByUserName"
class="rounded-input border border-line bg-canvas px-3 py-2 text-sm"
>
<span class="text-xs uppercase tracking-wide font-semibold">Verified by</span>
<p class="font-mono text-ink mt-0.5">{{ batch.verifiedByUserId.substring(0, 8) }}</p>
<span class="text-xs uppercase tracking-wide font-semibold text-ink-secondary">Verified by</span>
<p class="text-ink-strong mt-0.5">
{{ batch.verifiedByUserName?.trim() || 'Prior verifier' }}
</p>
</div>
<!-- Level 4 decision controls (copy weight below draft) -->
@@ -230,7 +232,7 @@
<template #left>
<button
type="button"
class="btn-danger"
class="btn-danger w-full sm:w-auto"
:disabled="processing || !!promotionResult"
data-testid="reject-batch"
@click="showRejectDialog = true"
@@ -241,7 +243,7 @@
<template #primary>
<button
type="button"
class="btn-primary"
class="btn-primary w-full sm:w-auto"
:disabled="processing || !!promotionResult"
data-testid="approve-promote"
@click="showApproveConfirm = true"
@@ -252,7 +254,7 @@
<template #right>
<button
type="button"
class="btn-secondary"
class="btn-secondary w-full sm:w-auto"
:disabled="processing"
@click="$emit('back')"
>
@@ -18,7 +18,7 @@
<slot name="emptyAction" />
</template>
</EmptyState>
<table v-else class="w-full min-w-[640px] text-sm">
<table v-else class="w-full min-w-[36rem] text-sm">
<thead>
<tr class="border-b text-left text-ink-secondary">
<th class="py-2 px-4">ID</th>
@@ -7,7 +7,7 @@
<span class="evidence-level-mark" aria-hidden="true">2</span>
Structured draft
</p>
<h2 class="text-base font-semibold text-ink-strong">Data Entry</h2>
<h2 class="text-base font-semibold text-ink-strong">Draft fields</h2>
<p
class="mt-1 text-xs tabular-nums"
:class="{
@@ -1,12 +1,15 @@
<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
class="flex flex-col xl:flex-row xl:items-start gap-3 p-3 rounded-input border border-line bg-canvas"
data-testid="observation-row"
>
<div class="flex-1 grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-3 min-w-0">
<div>
<label class="block text-xs text-gray-500">Code</label>
<label class="workstation-field-label">Code</label>
<select
:value="observation.observationCode"
@change="update('observationCode', ($event.target as HTMLSelectElement).value)"
class="form-input text-sm"
class="form-input text-sm py-1.5"
:disabled="readonly"
>
<option value="">Select...</option>
@@ -23,7 +26,7 @@
</select>
</div>
<div>
<label class="flex items-center gap-2 text-xs text-gray-500">
<label class="workstation-field-label">
Value
<OcrConfidenceBadge :label="ocrLabel" :level="ocrLevel" />
</label>
@@ -32,59 +35,63 @@
@change="update('value', parseFloat(($event.target as HTMLInputElement).value))"
type="number"
step="0.01"
:class="['form-input', 'text-sm', valueInputClass]"
:class="['form-input', 'text-sm', 'py-1.5', valueInputClass]"
:disabled="readonly"
/>
</div>
<div>
<label class="block text-xs text-gray-500">Unit</label>
<label class="workstation-field-label">Unit</label>
<input
:value="observation.unit"
@change="update('unit', ($event.target as HTMLInputElement).value)"
type="text"
class="form-input text-sm"
class="form-input text-sm py-1.5"
:disabled="readonly"
/>
</div>
<div>
<label class="block text-xs text-gray-500">Recorded At</label>
<label class="workstation-field-label">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"
class="form-input text-sm py-1.5"
:disabled="readonly"
/>
</div>
<div>
<label class="block text-xs text-gray-500">Note</label>
<label class="workstation-field-label">Note</label>
<input
:value="observation.note"
@change="update('note', ($event.target as HTMLInputElement).value)"
type="text"
class="form-input text-sm"
class="form-input text-sm py-1.5"
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
v-if="showVerified"
class="flex items-center shrink-0 xl:mt-7"
>
<label class="inline-flex items-center gap-2 cursor-pointer">
<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="text-xs text-ink-secondary">OK</span>
</label>
</div>
<!-- Delete button (entry mode only) -->
<button
v-if="!readonly && !showVerified"
type="button"
@click="$emit('delete', observation.id)"
class="lg:mt-8 text-clinical-danger hover:text-red-800 text-sm"
class="self-start xl:mt-7 text-clinical-danger hover:text-clinical-critical text-sm font-medium"
>
Remove
</button>
@@ -115,4 +122,4 @@ const emit = defineEmits<{
function update(field: string, value: unknown) {
emit('update', field, value)
}
</script>
</script>
@@ -5,7 +5,7 @@
>
<!-- Toolbar light chrome, no decorative overlays on the page -->
<div
class="flex flex-wrap items-center gap-1 px-3 py-2 bg-surface border-b border-line text-sm text-ink shrink-0"
class="flex flex-wrap items-center gap-0.5 sm:gap-1 px-2 sm:px-3 py-2 bg-surface border-b border-line text-sm text-ink shrink-0"
data-testid="scan-viewer-toolbar"
>
<button
@@ -79,7 +79,7 @@
/>
</div>
<div v-else-if="error" class="flex h-full items-center justify-center p-6">
<div v-else-if="error" class="flex min-h-[10rem] items-center justify-center p-4 sm:p-6">
<InlineError
class="max-w-md w-full"
title="Could not load document"
@@ -28,6 +28,7 @@
<SeparationOfDutiesBanner
v-model:blocked="sodBlocked"
:entered-by-user-id="batch?.enteredByUserId"
:entered-by-user-name="enteredByDisplayName"
:current-user-id="auth.userId"
:current-user-name="auth.userFullName"
/>
@@ -117,7 +118,10 @@
</fieldset>
<!-- Encounter review -->
<fieldset class="workstation-form-section">
<fieldset
v-if="showEncounterContext && encounterFields.length > 0"
class="workstation-form-section"
>
<legend class="workstation-form-legend">Encounter Context</legend>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div v-for="field in encounterFields" :key="field.path">
@@ -145,11 +149,20 @@
</fieldset>
<!-- Observations review -->
<fieldset class="workstation-form-section">
<fieldset
v-if="showObservations"
class="workstation-form-section"
>
<legend class="workstation-form-legend">
Observations ({{ observations.length }})
</legend>
<div class="space-y-2">
<p
v-if="observations.length === 0"
class="text-sm text-ink-secondary"
>
No observations recorded.
</p>
<div v-else class="space-y-2">
<ObservationRow
v-for="(obs, index) in observations"
:key="obs.id"
@@ -325,6 +338,15 @@ function observationValueConfidenceClass(observationCode: string): string {
const showAllergies = computed(() => fieldReqs.value?.showAllergies ?? false)
const showMedications = computed(() => fieldReqs.value?.showMedications ?? false)
const showEncounterSummary = computed(() => fieldReqs.value?.showEncounterSummaryFields ?? false)
const showEncounterContext = computed(() => fieldReqs.value?.showEncounterContext ?? false)
const showObservations = computed(() => fieldReqs.value?.showObservations ?? false)
const enteredByDisplayName = computed(() => {
const name = props.batch?.enteredByUserName?.trim()
if (name) return name
const fromQueue = batchStore.batches.find((b) => b.id === props.batchId)
return fromQueue?.enteredByUserName?.trim() || null
})
watch(
() => batchStore.currentDraft,
@@ -385,7 +407,7 @@ watch(
}
}
if (draft.encounter) {
if (draft.encounter && showEncounterContext.value) {
encounterFields.value = [
{ path: 'encounter.admissionDate', label: 'Admission Date', value: draft.encounter.admissionDate ?? '' },
{ path: 'encounter.department', label: 'Department', value: draft.encounter.department ?? '' },
@@ -398,6 +420,12 @@ watch(
{ path: 'encounter.dischargeDiagnosis', label: 'Discharge Diagnosis', value: draft.encounter.dischargeDiagnosis ?? '' },
)
}
} else {
encounterFields.value = []
}
if (!showObservations.value) {
observations.value = []
}
fieldChecks.value = {}
@@ -1,18 +1,18 @@
<template>
<div
class="sticky bottom-0 z-10 -mx-4 mt-auto border-t border-line bg-surface/95 px-4 py-3 backdrop-blur-sm sm:-mx-0 sm:rounded-b-card"
class="sticky bottom-0 z-10 border-t border-line bg-surface/95 px-3 py-3 backdrop-blur-sm sm:px-4"
data-testid="workstation-action-bar"
>
<div
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"
>
<div class="flex flex-wrap items-center gap-2">
<div class="flex flex-wrap items-center gap-2 min-w-0">
<slot name="left" />
</div>
<div class="flex flex-wrap items-center justify-center gap-2">
<div class="flex flex-wrap items-center gap-2 min-w-0 sm:justify-center">
<slot name="center" />
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<div class="flex flex-wrap items-center gap-2 sm:justify-end">
<slot name="primary" />
<slot name="right" />
</div>
+11 -1
View File
@@ -40,6 +40,7 @@ function mapWorkQueueItem(item: WorkQueueItemResponse): BatchDetailResponse {
documentUrl: null,
enableRetroactiveAlerts: false,
enteredByUserId: item.enteredByUserId,
enteredByUserName: item.enteredByUserName,
verifiedByUserId: null,
approvedByUserId: null,
rejectionReason: item.rejectionReason,
@@ -225,7 +226,16 @@ export const useBatchStore = defineStore('batches', () => {
`digitization-batches/${id}`
)
if (response.success && response.data) {
currentBatch.value = response.data
const fromQueue = batches.value.find((b) => b.id === id)
currentBatch.value = {
...response.data,
enteredByUserName:
response.data.enteredByUserName ?? fromQueue?.enteredByUserName ?? null,
verifiedByUserName:
response.data.verifiedByUserName ?? fromQueue?.verifiedByUserName ?? null,
approvedByUserName:
response.data.approvedByUserName ?? fromQueue?.approvedByUserName ?? null,
}
}
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to load batch'
+4
View File
@@ -63,8 +63,12 @@ export interface BatchDetailResponse {
documentUrl: string | null
enableRetroactiveAlerts: boolean
enteredByUserId: string | null
/** Present on work-queue items; optional on batch detail until API includes it */
enteredByUserName?: string | null
verifiedByUserId: string | null
verifiedByUserName?: string | null
approvedByUserId: string | null
approvedByUserName?: string | null
rejectionReason: string | null
promotedAt: string | null
promotionEncounterId: string | null
@@ -3,8 +3,8 @@
<AppHeader title="Clinical Approval">
<template #subtitle>
<span v-if="currentBatch" class="text-sm text-ink-secondary">
Batch: {{ currentBatch.id.substring(0, 8) }}...
| Type: {{ formatBatchType(currentBatch.batchType) }}
Batch {{ currentBatch.id.substring(0, 8) }}
· {{ formatBatchType(currentBatch.batchType) }}
</span>
</template>
</AppHeader>
@@ -1,18 +1,12 @@
<template>
<div class="h-full min-h-0 flex flex-col">
<AppHeader title="Cover Sheets" />
<AppHeader
title="Cover Sheets"
description="Generate printable separators that reconnect scanned paper to batch metadata."
/>
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
<div class="max-w-7xl mx-auto space-y-6 lg:space-y-8">
<div>
<h1 class="text-[1.75rem] font-semibold text-ink-strong leading-tight tracking-tight">
Cover Sheet Management
</h1>
<p class="mt-1 text-sm text-ink-secondary">
Generate printable separators that reconnect scanned paper to batch metadata.
</p>
</div>
<!-- Top: 50 / 50 generation + preview -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:gap-8 items-start">
<section class="card">
@@ -3,8 +3,8 @@
<AppHeader title="Data Entry">
<template #subtitle>
<span v-if="currentBatch" class="text-sm text-ink-secondary">
Batch: {{ currentBatch.id.substring(0, 8) }}...
| Type: {{ currentBatch.batchType.replace(/_/g, ' ') }}
Batch {{ currentBatch.id.substring(0, 8) }}
· {{ currentBatch.batchType.replace(/_/g, ' ') }}
</span>
</template>
</AppHeader>
@@ -1,6 +1,9 @@
<template>
<div class="h-full min-h-0 flex flex-col">
<AppHeader title="FHIR Explorer">
<AppHeader
title="FHIR Explorer"
description="Read-only inspection of exposed FHIR resources for administrators and integration staff."
>
<template #actions>
<button type="button" class="btn-secondary text-sm" @click="openMetadata">
CapabilityStatement
@@ -10,15 +13,6 @@
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
<div class="max-w-7xl mx-auto space-y-6">
<div>
<h1 class="text-[1.75rem] font-semibold text-ink-strong leading-tight tracking-tight">
FHIR R4 Explorer
</h1>
<p class="mt-1 text-sm text-ink-secondary">
Read-only inspection of exposed FHIR resources for administrators and integration staff.
</p>
</div>
<!-- Resource browser -->
<section class="card space-y-5">
<header>
+4 -10
View File
@@ -1,18 +1,12 @@
<template>
<div class="h-full min-h-0 flex flex-col">
<AppHeader title="Intake" />
<AppHeader
title="Intake"
description="Create a digitization batch from a PDF or image scan."
/>
<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">
@@ -1,18 +1,12 @@
<template>
<div class="h-full min-h-0 flex flex-col">
<AppHeader title="Live Capture" />
<AppHeader
title="Live Capture"
description="Bedside observation recording — lighter than backfill digitization."
/>
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
<div class="max-w-5xl mx-auto space-y-6">
<div>
<h1 class="text-[1.75rem] font-semibold text-ink-strong leading-tight tracking-tight">
Live Capture
</h1>
<p class="mt-1 text-sm text-ink-secondary">
Bedside observation recording lighter than backfill digitization.
</p>
</div>
<!-- Mode selector -->
<div class="flex border-b border-line" role="tablist">
<button
@@ -2,7 +2,7 @@
<div class="min-h-screen flex flex-col lg:flex-row bg-canvas">
<!-- Brand panel -->
<aside
class="relative flex flex-col justify-between bg-navy text-white px-8 py-10 lg:w-[45%] lg:min-h-screen lg:px-12 lg:py-14"
class="relative flex flex-col justify-between bg-navy text-white px-6 py-8 sm:px-8 sm:py-10 lg:w-[45%] lg:min-h-screen lg:px-12 lg:py-14"
>
<div
class="pointer-events-none absolute inset-0 opacity-[0.07]"
@@ -22,11 +22,11 @@
</div>
</div>
<div class="mt-10 lg:mt-20 max-w-md">
<h1 class="text-[2rem] sm:text-[2.5rem] lg:text-[2.625rem] font-bold leading-tight tracking-tight">
<div class="mt-6 sm:mt-10 lg:mt-20 max-w-md">
<h1 class="text-[1.75rem] sm:text-[2.5rem] lg:text-[2.625rem] font-bold leading-tight tracking-tight">
Digitize. Verify. Trust.
</h1>
<p class="mt-4 text-base text-white/70 leading-relaxed">
<p class="mt-3 sm:mt-4 text-sm sm:text-base text-white/70 leading-relaxed">
Scanned clinical records become governed, structured data your teams can verify and trust.
</p>
</div>
@@ -44,9 +44,9 @@
</aside>
<!-- Auth panel -->
<div class="flex flex-1 items-center justify-center px-4 py-10 sm:px-8">
<div class="w-full max-w-[440px] bg-surface border border-line rounded-card shadow-card p-6 sm:p-8">
<div class="mb-8">
<div class="flex flex-1 items-center justify-center px-4 py-8 sm:px-8 sm:py-10">
<div class="w-full max-w-[440px] bg-surface border border-line rounded-card shadow-card p-5 sm:p-8">
<div class="mb-6 sm:mb-8">
<h2 class="text-[1.75rem] font-semibold text-ink-strong leading-tight">Sign in</h2>
<p class="mt-2 text-sm text-ink-secondary">
Use your VigilCare workstation credentials.
@@ -1,18 +1,12 @@
<template>
<div class="h-full min-h-0 flex flex-col">
<AppHeader title="Patient History" />
<AppHeader
title="Patient History"
description="Digitization lineage, promotion attribution, and audit trail for a patient."
/>
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
<div class="max-w-5xl mx-auto space-y-6">
<div>
<h1 class="text-[1.75rem] font-semibold text-ink-strong leading-tight tracking-tight">
Patient History
</h1>
<p class="mt-1 text-sm text-ink-secondary">
Digitization lineage, promotion attribution, and audit trail for a patient.
</p>
</div>
<!-- Search when no patient selected -->
<section v-if="!patientId" class="card space-y-4">
<h2 class="text-base font-semibold text-ink-strong">Find Patient</h2>
@@ -1,6 +1,9 @@
<template>
<div class="h-full min-h-0 flex flex-col">
<AppHeader title="Queue Dashboard">
<AppHeader
title="Queue Dashboard"
description="Supervisor view of digitization backlog, aging work, and reject rate."
>
<template #actions>
<button
type="button"
@@ -15,15 +18,6 @@
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
<div class="max-w-7xl mx-auto space-y-6 lg:space-y-8">
<div>
<h1 class="text-[1.75rem] font-semibold text-ink-strong leading-tight tracking-tight">
Queue Dashboard
</h1>
<p class="mt-1 text-sm text-ink-secondary">
Supervisor view of digitization backlog, aging work, and reject rate.
</p>
</div>
<InlineError
v-if="overviewError"
title="Could not load queue overview"
@@ -34,7 +28,7 @@
/>
<!-- Priority work metrics -->
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-3 sm:gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3 sm:gap-4">
<div class="card-elevated">
<p class="text-xs font-semibold uppercase tracking-wide text-ink-secondary">
Pending Entry
@@ -85,17 +79,15 @@
{{ ((overview?.rejectRate ?? 0) * 100).toFixed(1) }}%
</p>
</div>
</div>
<!-- Secondary age metric -->
<div class="card-elevated max-w-md">
<p class="text-xs font-semibold uppercase tracking-wide text-ink-secondary">
Average Time in Queue
</p>
<p class="mt-2 text-2xl font-bold tabular-nums text-ink-strong">
{{ formatMinutes(overview?.averageTimeInQueueMinutes ?? 0) }}
</p>
<p class="text-xs text-ink-secondary mt-2">Target: under 24 hours</p>
<div class="card-elevated">
<p class="text-xs font-semibold uppercase tracking-wide text-ink-secondary">
Average Time in Queue
</p>
<p class="mt-2 text-3xl font-bold tabular-nums text-ink-strong">
{{ formatMinutes(overview?.averageTimeInQueueMinutes ?? 0) }}
</p>
<p class="text-xs text-ink-secondary mt-2">Target: under 24 hours</p>
</div>
</div>
<!-- Status breakdown from overview.statusCounts -->
@@ -113,18 +105,18 @@
<div
v-for="{ status, count } in statusEntries"
:key="status"
class="flex items-center gap-3 sm:gap-4"
class="flex items-center gap-2 sm:gap-4 min-w-0"
>
<div class="w-44 shrink-0">
<div class="w-[7.5rem] sm:w-44 shrink-0 min-w-0">
<StatusBadge :status="status" />
</div>
<div class="flex-1 h-2.5 rounded-full bg-canvas border border-line overflow-hidden">
<div class="flex-1 min-w-0 h-2.5 rounded-full bg-canvas border border-line overflow-hidden">
<div
class="h-full rounded-full bg-primary-500"
:style="{ width: `${(count / maxCount) * 100}%` }"
/>
</div>
<span class="text-sm font-semibold tabular-nums text-ink-strong w-8 text-right">
<span class="text-sm font-semibold tabular-nums text-ink-strong w-6 sm:w-8 text-right shrink-0">
{{ count }}
</span>
</div>
+16 -20
View File
@@ -1,27 +1,23 @@
<template>
<div class="h-full min-h-0 flex flex-col" data-testid="users-view">
<AppHeader title="Users" />
<AppHeader
title="Users"
description="Create accounts, update roles, and reset passwords for active staff."
>
<template #actions>
<button
type="button"
class="btn-primary"
data-testid="users-toggle-create"
@click="showCreate = !showCreate"
>
{{ showCreate ? 'Cancel' : 'Create user' }}
</button>
</template>
</AppHeader>
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
<div class="max-w-7xl mx-auto space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 class="text-[1.75rem] font-semibold text-ink-strong leading-tight tracking-tight">
Users
</h1>
<p class="mt-1 text-sm text-ink-secondary">
Create accounts, update roles, and reset passwords for active staff.
</p>
</div>
<button
type="button"
class="btn-primary"
data-testid="users-toggle-create"
@click="showCreate = !showCreate"
>
{{ showCreate ? 'Cancel' : 'Create user' }}
</button>
</div>
<section v-if="showCreate" class="card space-y-4" data-testid="users-create-form">
<header>
@@ -135,7 +131,7 @@
description="Create a user or clear the role filter."
/>
<div v-else class="overflow-x-auto">
<table class="w-full min-w-[640px] text-sm">
<table class="w-full min-w-[36rem] text-sm">
<thead>
<tr class="border-b border-line text-left text-ink-secondary">
<th class="py-2 px-3 font-medium">Full name</th>
@@ -3,8 +3,8 @@
<AppHeader title="Verification">
<template #subtitle>
<span v-if="currentBatch" class="text-sm text-ink-secondary">
Batch: {{ currentBatch.id.substring(0, 8) }}...
| Entered by: {{ currentBatch.enteredByUserId?.substring(0, 8) }}...
Batch {{ currentBatch.id.substring(0, 8) }}
<span v-if="enteredByLabel"> · Entered by {{ enteredByLabel }}</span>
</span>
</template>
</AppHeader>
@@ -79,6 +79,15 @@ const batchId = computed(() => props.batchId ?? (route.params.batchId as string
const currentBatch = computed(() => batchStore.currentBatch)
const { documentUrl, documentError, documentLoading, refreshUrl } = usePresignedUrl(batchId)
const enteredByLabel = computed(() => {
const batch = currentBatch.value
if (!batch) return null
if (batch.enteredByUserName?.trim()) return batch.enteredByUserName.trim()
const fromQueue = batchStore.batches.find((b) => b.id === batch.id)
if (fromQueue?.enteredByUserName?.trim()) return fromQueue.enteredByUserName.trim()
return null
})
function openBatch(id: string) {
router.push(`/verification/${id}`)
}