Fix responsiveness
This commit is contained in:
@@ -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.
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user