feature: Design System, App Shell, and Login Redesign
CI / backend (push) Successful in 6m31s
CI / frontend (push) Successful in 58s

This commit is contained in:
2026-08-12 03:37:17 +08:00
parent 6e06717b82
commit 56e100a495
31 changed files with 2987 additions and 217 deletions
+2 -1
View File
@@ -213,7 +213,8 @@ VigilCareRecords/
│ ├── run-vigilcare-records-phase-13-verification.sh # OCR config, ocrConfidence API, optional live OCR polling
│ └── fixtures/test-scan.pdf # Sample PDF for upload verification scripts
└── docs/
├── plans/ # Phase 113 implementation guides
├── plans/ # Phase 1418 UI redesign guides (113 historical; not in repo)
├── designs/ # UI/UX design-doc + mockup PNGs
├── digitization-workstation-guide.md # Clinical scenarios and clerk workflow reference
├── vigilcare-records-gap-analysis.md # Known gaps and hardening backlog
└── vigilcare-records-prd.md # Product requirements and phase roadmap
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

File diff suppressed because it is too large Load Diff
+71 -4
View File
@@ -4,6 +4,8 @@
**Phases 113 are complete.** Post-phase hardening (health checks, user management, auth rate limiting, document access audit, batch cancellation, list sorting, unified promotion retry, normalized patient deduplication, assignment-time `IN_ENTRY` transitions, API-proxied document streaming, CORS) is also done. See the [gap analysis](vigilcare-records-gap-analysis.md) summary matrix for remaining open items.
**Phases 1418 (UI redesign)** are planned: restyle and re-layout `vigilcare-records-web` against [designs/design-doc.md](designs/design-doc.md) without new backend behavior. Implementation guides: [plans/](plans/).
| Phase | Scope | Status |
|---|---|---|
| 1 | Schema, auth, roles, batch CRUD, MinIO upload, status machine | Done |
@@ -19,8 +21,13 @@
| 11 | HL7 FHIR R4 read-only API and FHIR Explorer UI | Done |
| 12 | Backend-driven batch-type field requirements (`fieldRequirements` metadata) | Done |
| 13 | Optional OCR-assisted draft pre-fill (Azure or Tesseract; disabled by default) | Done |
| 14 | UI redesign: design tokens, app shell (sidebar), login | Planned |
| 15 | UI redesign: shared UX primitives (status, OCR badges, SoD, sticky actions) | Planned |
| 16 | UI redesign: Entry / Verification / Clinical Approval workstation layouts | Planned |
| 17 | UI redesign: Intake, Cover Sheets, Live Capture, History, Dashboard, FHIR Explorer | Planned |
| 18 | UI redesign: surface unused APIs (batch events, work queues, Users admin) | Planned |
**Verification scripts:** `./scripts/run-vigilcare-records-verification-p9.sh` (full workflow + metrics), `./scripts/run-vigilcare-records-phase-10-verification.sh`, `./scripts/run-vigilcare-records-phase-11-verification.sh`, `./scripts/run-vigilcare-records-phase-13-verification.sh`.
**Verification scripts:** `./scripts/run-vigilcare-records-verification-p9.sh` (full workflow + metrics), `./scripts/run-vigilcare-records-phase-10-verification.sh`, `./scripts/run-vigilcare-records-phase-11-verification.sh`, `./scripts/run-vigilcare-records-phase-13-verification.sh`. Phases 1418 verify via Vitest + manual checklists in each plan.
See [README.md](../README.md) for API reference, quick start, and [digitization-workstation-guide.md](digitization-workstation-guide.md) for operator workflows.
@@ -568,7 +575,7 @@ SMART on FHIR authorization and FHIR write operations remain out of scope.
---
## Digitization Workstation UI — *implemented (Phases 7, 10, 11, 12, 13)*
## Digitization Workstation UI — *implemented (Phases 7, 10, 11, 12, 13); redesign Planned (Phases 1418)*
Vue 3 SPA at `vigilcare-records-web/` (dev server port **3028**, proxies `/api` and `/fhir` → API on **5217**). Role-based routing with JWT refresh.
@@ -586,6 +593,20 @@ Vue 3 SPA at `vigilcare-records-web/` (dev server port **3028**, proxies `/api`
Scan viewer loads documents via authenticated `GET /digitization-batches/:id/document` blob URLs (avoids cross-origin MinIO iframe issues). Not a full EMR UI — clinical alerting views remain in VigilCareClinical's ward dashboard.
### UI redesign roadmap (Phases 1418)
Constrained to **existing API capabilities**. Design source: [designs/design-doc.md](designs/design-doc.md) (section 36 corrections override mockups). Plans: [plans/README.md](plans/README.md).
| Phase | Focus |
|---|---|
| 14 | Navy/blue design tokens, role-filtered sidebar shell, split login (no role picker); keep Public Sans |
| 15 | Shared primitives: status badges, empty/loading/error, sticky action bar, OCR % badges, SoD banner, confirm dialogs |
| 16 | Dense Entry / Verification / Clinical Approval workstation layouts |
| 17 | Restyle Intake, Cover Sheets, Live Capture, Patient History, Queue Dashboard, FHIR Explorer |
| 18 | Wire unused APIs: batch events panel, `work-queue/*` lists, administrator Users page |
Deferred mockup items (global search, notifications, Reports/Master Data, SSO, OCR region highlight, infra health widgets) are listed in [plans/README.md](plans/README.md).
See [digitization-workstation-guide.md](digitization-workstation-guide.md) for operator workflows and clinical scenarios.
---
@@ -666,12 +687,17 @@ If VigilCareClinical is unreachable in split deployment, batch remains `approved
| 11 | HL7 FHIR R4 read-only API and FHIR Explorer UI | Done |
| 12 | Backend-driven batch-type field requirements | Done |
| 13 | Optional OCR-assisted draft pre-fill | Done |
| 14 | UI redesign: tokens, app shell, login | Planned |
| 15 | UI redesign: shared UX primitives | Planned |
| 16 | UI redesign: Entry / Verification / Approval workstation | Planned |
| 17 | UI redesign: supporting screens + dashboard | Planned |
| 18 | UI redesign: surface unused existing APIs | Planned |
---
## Step-by-Step Guide
Complete phases in order. Promotion (Phase 4) must not be built until the draft state machine and separation of duties are correct — debugging promotion bugs alongside workflow bugs is painful.
Complete phases in order. Promotion (Phase 4) must not be built until the draft state machine and separation of duties are correct — debugging promotion bugs alongside workflow bugs is painful. For Phases 1418, complete each UI plan before starting the next; do not invent backend features to match mockups.
---
@@ -796,6 +822,46 @@ This phase connects Records to Clinical. Run against a VigilCareClinical Phase 2
---
### Phase 14 — UI Design System, Shell, Login
**What to do:** Map design-doc color tokens into Tailwind (keep Public Sans); add role-filtered `AppShell` sidebar for existing routes; redesign login as split brand + form without a role selector.
**Plan:** [plans/phase-14-plan.md](plans/phase-14-plan.md)
---
### Phase 15 — Shared UX Primitives
**What to do:** Status badges, empty/loading/error patterns, sticky workstation action bar, OCR percentage badges, separation-of-duties banner, confirmation dialogs for irreversible actions.
**Plan:** [plans/phase-15-plan.md](plans/phase-15-plan.md)
---
### Phase 16 — Workstation Layouts (Entry, Verification, Approval)
**What to do:** Dense scan-first layouts with sticky CTAs and design-doc action labels; apply SoD and OCR primitives; no API contract changes.
**Plan:** [plans/phase-16-plan.md](plans/phase-16-plan.md)
---
### Phase 17 — Supporting Screens
**What to do:** Restyle Intake, Cover Sheets, Live Capture, Patient History, Queue Dashboard (overview metrics only — no infra health), and FHIR Explorer.
**Plan:** [plans/phase-17-plan.md](plans/phase-17-plan.md)
---
### Phase 18 — Surface Unused APIs in the UI
**What to do:** Batch events audit panel; prefer `work-queue/entry|verification|clinical-approval` for queues; administrator Users page via existing `UsersController`.
**Plan:** [plans/phase-18-plan.md](plans/phase-18-plan.md)
---
## Deployment Notes (Small Island Context)
- **Single-site tenant:** One hospital or health district per deployment. No cross-island federation in v1.
@@ -838,7 +904,8 @@ This phase connects Records to Clinical. Run against a VigilCareClinical Phase 2
- [README.md](../README.md) — API reference, quick start, verification scripts, data models
- [digitization-workstation-guide.md](digitization-workstation-guide.md) — clinical scenarios (backfill, live capture, corrections)
- [plans/](plans/) — phase-by-phase implementation guides (Phases 113)
- [plans/](plans/) — phase implementation guides (Phases 1418 UI redesign; Phase 113 plans not in repo)
- [designs/design-doc.md](designs/design-doc.md) — UI/UX design specification for the workstation redesign
- [vigilcare-records-gap-analysis.md](vigilcare-records-gap-analysis.md) — post-phase hardening tracker and remaining open items
- [vigilcare-clinical-api-prd.md](vigilcare-clinical-api-prd.md) — downstream alerting and observation ingest
- [Completed/VigilCareClinicalAPI/VigilCare-Partner-Brief.md](Completed/VigilCareClinicalAPI/VigilCare-Partner-Brief.md) — clinical positioning and scope boundaries
+13 -13
View File
@@ -1397,9 +1397,9 @@
}
},
"node_modules/brace-expansion": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2768,9 +2768,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [
{
"type": "github",
@@ -3005,9 +3005,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"funding": [
{
"type": "opencollective",
@@ -3024,7 +3024,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -3780,9 +3780,9 @@
}
},
"node_modules/undici": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true,
"license": "MIT",
"engines": {
+44 -19
View File
@@ -4,75 +4,100 @@
@tailwind components;
@tailwind utilities;
:root {
--vc-navy: #08254A;
--vc-primary: #155EEF;
--vc-primary-hover: #0B4FD6;
--vc-soft-blue: #EEF4FF;
--vc-bg: #F7F9FC;
--vc-surface: #FFFFFF;
--vc-text-strong: #101828;
--vc-text: #344054;
--vc-text-secondary: #667085;
--vc-text-disabled: #98A2B3;
--vc-border: #E4E7EC;
--vc-border-strong: #D0D5DD;
--vc-success: #079455;
--vc-success-bg: #ECFDF3;
--vc-warning: #DC6803;
--vc-warning-bg: #FFFAEB;
--vc-error: #D92D20;
--vc-error-bg: #FEF3F2;
--vc-critical: #B42318;
--vc-critical-bg: #FEE4E2;
--vc-radius-input: 8px;
--vc-radius-card: 10px;
}
@layer base {
body {
@apply font-sans;
@apply font-sans text-ink bg-canvas;
}
}
@layer components {
.btn-primary {
@apply bg-primary-600 text-white px-4 py-2 rounded-md
@apply bg-primary-600 text-white px-4 py-2 rounded-input
hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed
transition-colors;
}
.btn-secondary {
@apply bg-white text-primary-700 border border-primary-300 px-4 py-2 rounded-md
@apply bg-surface text-primary-700 border border-primary-300 px-4 py-2 rounded-input
hover:bg-primary-50 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
@apply bg-clinical-danger text-white px-4 py-2 rounded-input
hover:bg-clinical-critical 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
@apply block w-full rounded-input border border-line-strong px-4 py-2 text-ink
focus:border-primary-500 focus:ring-2 focus:ring-primary-500
disabled:bg-gray-100 disabled:text-gray-500;
disabled:bg-canvas disabled:text-ink-disabled;
}
.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;
px-4 py-3 sm:px-6 h-auto 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;
@apply flex flex-col gap-2 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;
}
.card {
@apply bg-white rounded-md border border-gray-200 p-4 sm:p-6;
@apply bg-surface rounded-card border border-line p-4 sm:p-6 shadow-card;
}
.card-elevated {
@apply bg-white rounded-lg shadow-md p-4 sm:p-6;
@apply bg-surface rounded-card border border-line p-4 sm:p-6 shadow-card;
}
.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;
h-auto lg:flex-1 lg:min-h-0 min-h-0;
}
.nav-link {
@apply text-sm text-gray-500 hover:text-gray-800 transition-colors;
@apply text-sm text-ink-secondary hover:text-ink-strong transition-colors;
}
.nav-link.router-link-active {
@apply text-gray-900 font-medium;
@apply text-ink-strong font-medium;
}
.status-badge {
@apply px-2 py-1 rounded-full text-xs font-medium;
@apply px-2 py-1 rounded-control text-xs font-medium;
}
.ocr-banner {
@apply bg-blue-50 border border-blue-200 rounded-md p-3 text-sm text-blue-800;
@apply bg-primary-50 border border-primary-100 rounded-input p-3 text-sm text-primary-800;
}
.ocr-high {
@apply border-l-[3px] border-l-green-500;
@apply border-l-[3px] border-l-clinical-safe;
}
.ocr-medium {
@apply border-l-[3px] border-l-yellow-500;
@apply border-l-[3px] border-l-clinical-warning;
}
.ocr-low {
@apply border-l-[3px] border-l-red-500;
@apply border-l-[3px] border-l-clinical-danger;
}
}
@@ -1,45 +1,15 @@
<template>
<div class="app-header">
<div class="app-header-title">
<router-link to="/" class="flex items-center gap-2 shrink-0">
<svg viewBox="0 0 32 32" class="w-6 h-6" aria-hidden="true">
<path fill="currentColor" class="text-primary-700" 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>
<span class="font-bold text-primary-800 tracking-tight">VigilCare</span>
</router-link>
<h1 class="text-lg font-semibold">{{ title }}</h1>
<nav class="flex gap-3 ml-4">
<router-link v-if="auth.canIntake" to="/intake" class="nav-link">Intake</router-link>
<router-link v-if="auth.canIntake" to="/cover-sheets" class="nav-link">Cover Sheets</router-link>
<router-link v-if="auth.canEntry" to="/entry" class="nav-link">Entry</router-link>
<router-link v-if="auth.canVerify" to="/verification" class="nav-link">Verification</router-link>
<router-link v-if="auth.canApprove" to="/approval" class="nav-link">Approval</router-link>
<router-link v-if="auth.canLiveCapture" to="/live-capture" class="nav-link">Live Capture</router-link>
<router-link to="/patients" class="nav-link">Patient History</router-link>
<router-link v-if="auth.canSupervise" to="/dashboard" class="nav-link">Queue Dashboard</router-link>
<router-link v-if="auth.canSupervise" to="/fhir-explorer" class="nav-link">FHIR Explorer</router-link>
</nav>
<h1 class="text-lg font-semibold text-ink-strong">{{ 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,228 @@
<template>
<div class="flex h-screen min-h-0 bg-canvas text-ink">
<!-- Sidebar -->
<aside
:class="[
'flex flex-col shrink-0 bg-navy text-white transition-[width] duration-200 ease-out',
collapsed ? 'w-16' : 'w-60',
]"
aria-label="Main navigation"
>
<div
:class="[
'flex items-center h-16 border-b border-white/10 shrink-0',
collapsed ? '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">
<p class="font-bold tracking-tight text-sm truncate">VigilCare</p>
<p class="text-[11px] text-white/60 truncate">Records</p>
</div>
</div>
<nav class="flex-1 overflow-y-auto py-3 px-2 space-y-4">
<div v-if="workspaceItems.length">
<p
v-if="!collapsed"
class="px-2 mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-white/40"
>
Workspace
</p>
<ul class="space-y-0.5">
<li v-for="item in workspaceItems" :key="item.to">
<router-link
:to="item.to"
:title="collapsed ? item.label : undefined"
:class="navItemClass(item.to)"
>
<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>
</router-link>
</li>
</ul>
</div>
<div v-if="adminItems.length">
<p
v-if="!collapsed"
class="px-2 mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-white/40"
>
Administration
</p>
<ul class="space-y-0.5">
<li v-for="item in adminItems" :key="item.to">
<router-link
:to="item.to"
:title="collapsed ? item.label : undefined"
:class="navItemClass(item.to)"
>
<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>
</router-link>
</li>
</ul>
</div>
</nav>
<div class="shrink-0 border-t border-white/10 p-2">
<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"
:class="collapsed ? 'justify-center' : ''"
:aria-expanded="!collapsed"
:aria-label="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
@click="toggleCollapsed"
>
<svg
class="w-5 h-5 shrink-0 transition-transform"
:class="collapsed ? 'rotate-180' : ''"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" />
</svg>
<span v-if="!collapsed">Collapse</span>
</button>
</div>
</aside>
<!-- 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>
<button
type="button"
class="text-sm text-ink-secondary hover:text-ink-strong transition-colors"
@click="auth.logout()"
>
Sign Out
</button>
</header>
<main class="flex-1 min-h-0 overflow-auto">
<slot />
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, h, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const COLLAPSE_KEY = 'vigilcare_sidebar_collapsed'
const auth = useAuthStore()
const route = useRoute()
const collapsed = ref(localStorage.getItem(COLLAPSE_KEY) === '1')
watch(collapsed, (value) => {
localStorage.setItem(COLLAPSE_KEY, value ? '1' : '0')
})
function toggleCollapsed() {
collapsed.value = !collapsed.value
}
function iconPath(d: string) {
return () =>
h(
'svg',
{ viewBox: '0 0 20 20', fill: 'currentColor', class: 'w-5 h-5' },
[h('path', { 'fill-rule': 'evenodd', 'clip-rule': 'evenodd', d })],
)
}
const icons = {
intake: iconPath(
'M3 4.5A1.5 1.5 0 014.5 3h11A1.5 1.5 0 0117 4.5v11a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 013 15.5v-11zM5.5 6a.5.5 0 000 1h9a.5.5 0 000-1h-9zm0 3a.5.5 0 000 1h9a.5.5 0 000-1h-9zm0 3a.5.5 0 000 1h6a.5.5 0 000-1h-6z',
),
cover: iconPath(
'M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm1 3.5A.5.5 0 015.5 6h9a.5.5 0 010 1h-9A.5.5 0 015 6.5zM5.5 9a.5.5 0 000 1h5a.5.5 0 000-1h-5z',
),
entry: iconPath(
'M3.5 3A1.5 1.5 0 002 4.5v11A1.5 1.5 0 003.5 17h13a1.5 1.5 0 001.5-1.5v-11A1.5 1.5 0 0016.5 3h-13zM5 7.5A.5.5 0 015.5 7h9a.5.5 0 010 1h-9A.5.5 0 015 7.5zm0 3A.5.5 0 015.5 10h9a.5.5 0 010 1h-9A.5.5 0 015 10.5zm0 3A.5.5 0 015.5 13h5a.5.5 0 010 1h-5A.5.5 0 015 13.5z',
),
verify: iconPath(
'M10 2a8 8 0 100 16 8 8 0 000-16zm3.53 6.47a.75.75 0 00-1.06-1.06L9 10.88 7.53 9.41a.75.75 0 10-1.06 1.06l2 2a.75.75 0 001.06 0l4-4z',
),
approve: iconPath(
'M10 1.5l7.5 3v5.25c0 4.5-2.9 8.25-7.5 9.25-4.6-1-7.5-4.75-7.5-9.25V4.5L10 1.5zm3.03 6.03a.75.75 0 00-1.06-1.06L9 9.44 7.53 7.97a.75.75 0 10-1.06 1.06l2 2a.75.75 0 001.06 0l3.5-3.5z',
),
live: iconPath(
'M4 5a2 2 0 012-2h8a2 2 0 012 2v6a2 2 0 01-2 2H9.414l-2.707 2.707A1 1 0 015 15.293V13H6a2 2 0 01-2-2V5zm3.5 2.5a.75.75 0 000 1.5h5a.75.75 0 000-1.5h-5z',
),
patients: iconPath(
'M10 2a4 4 0 100 8 4 4 0 000-8zM3.5 16.5a6.5 6.5 0 0113 0 .75.75 0 01-.75.75h-11.5a.75.75 0 01-.75-.75z',
),
dashboard: iconPath(
'M3 4.5A1.5 1.5 0 014.5 3h3A1.5 1.5 0 019 4.5v3A1.5 1.5 0 017.5 9h-3A1.5 1.5 0 013 7.5v-3zM11 4.5A1.5 1.5 0 0112.5 3h3A1.5 1.5 0 0117 4.5v3A1.5 1.5 0 0115.5 9h-3A1.5 1.5 0 0111 7.5v-3zM3 12.5A1.5 1.5 0 014.5 11h3A1.5 1.5 0 019 12.5v3A1.5 1.5 0 017.5 17h-3A1.5 1.5 0 013 15.5v-3zM11 12.5a1.5 1.5 0 011.5-1.5h3a1.5 1.5 0 011.5 1.5v3a1.5 1.5 0 01-1.5 1.5h-3a1.5 1.5 0 01-1.5-1.5v-3z',
),
fhir: iconPath(
'M4.5 3A1.5 1.5 0 003 4.5v11A1.5 1.5 0 004.5 17h11a1.5 1.5 0 001.5-1.5v-11A1.5 1.5 0 0015.5 3h-11zM6 7.25a.75.75 0 01.75-.75h6.5a.75.75 0 010 1.5h-6.5A.75.75 0 016 7.25zm0 3a.75.75 0 01.75-.75h6.5a.75.75 0 010 1.5h-6.5A.75.75 0 016 10.25zm0 3a.75.75 0 01.75-.75h3.5a.75.75 0 010 1.5h-3.5A.75.75 0 016 13.25z',
),
}
interface NavItem {
label: string
to: string
icon: () => ReturnType<typeof h>
show: boolean
}
const workspaceItems = computed<NavItem[]>(() =>
[
{ label: 'Intake', to: '/intake', icon: icons.intake, show: auth.canIntake },
{ label: 'Cover Sheets', to: '/cover-sheets', icon: icons.cover, show: auth.canIntake },
{ label: 'Data Entry', to: '/entry', icon: icons.entry, show: auth.canEntry },
{ label: 'Verification', to: '/verification', icon: icons.verify, show: auth.canVerify },
{ label: 'Clinical Approval', to: '/approval', icon: icons.approve, show: auth.canApprove },
{ label: 'Live Capture', to: '/live-capture', icon: icons.live, show: auth.canLiveCapture },
{ label: 'Patient History', to: '/patients', icon: icons.patients, show: auth.isAuthenticated },
].filter((item) => item.show),
)
const adminItems = computed<NavItem[]>(() =>
[
{ label: 'Queue Dashboard', to: '/dashboard', icon: icons.dashboard, show: auth.canSupervise },
{ label: 'FHIR Explorer', to: '/fhir-explorer', icon: icons.fhir, show: auth.canSupervise },
].filter((item) => item.show),
)
function isActive(path: string): boolean {
if (path === '/patients') {
return route.path === '/patients' || route.path.startsWith('/patients/')
}
if (path === '/entry') {
return route.path === '/entry' || route.path.startsWith('/entry/')
}
if (path === '/verification') {
return route.path === '/verification' || route.path.startsWith('/verification/')
}
if (path === '/approval') {
return route.path === '/approval' || route.path.startsWith('/approval/')
}
return route.path === path || route.path.startsWith(`${path}/`)
}
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')
if (isActive(path)) {
return `${base} bg-primary-600 text-white font-medium`
}
return `${base} text-white/75 hover:bg-white/10 hover:text-white`
}
</script>
@@ -0,0 +1,9 @@
<template>
<AppShell>
<router-view />
</AppShell>
</template>
<script setup lang="ts">
import AppShell from '../components/AppShell.vue'
</script>
+111 -95
View File
@@ -8,102 +8,118 @@ const routes: RouteRecordRaw[] = [
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: '/cover-sheets',
name: 'CoverSheets',
component: () => import('../views/CoverSheetView.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: '/approval',
name: 'ApprovalQueue',
component: () => import('../views/ApprovalView.vue'),
meta: {
requiresAuth: true,
roles: ['CLINICAL_APPROVER', 'ADMINISTRATOR'],
},
},
{
path: '/approval/:batchId',
name: 'ApprovalBatch',
component: () => import('../views/ApprovalView.vue'),
meta: {
requiresAuth: true,
roles: ['CLINICAL_APPROVER', 'ADMINISTRATOR'],
},
props: true,
},
{
path: '/patients/:patientId/history',
name: 'PatientHistory',
component: () => import('../views/PatientHistoryView.vue'),
meta: { requiresAuth: true },
props: true,
},
{
path: '/patients',
name: 'PatientSearch',
component: () => import('../views/PatientHistoryView.vue'),
meta: { requiresAuth: true },
},
{
path: '/live-capture',
name: 'LiveCapture',
component: () => import('../views/LiveCaptureView.vue'),
meta: { requiresAuth: true, roles: ['CLINICIAN', 'ADMINISTRATOR'] },
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('../views/QueueDashboardView.vue'),
meta: { requiresAuth: true, roles: ['ADMINISTRATOR'] },
},
{
path: '/fhir-explorer',
name: 'FhirExplorer',
component: () => import('../views/FhirExplorerView.vue'),
meta: { requiresAuth: true, roles: ['ADMINISTRATOR'] },
},
{
path: '/',
component: () => import('../layouts/AuthenticatedLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
redirect: () => {
const auth = useAuthStore()
return auth.isAuthenticated
? getDefaultRouteForRole(auth.userRole)
: '/login'
},
},
{
path: 'intake',
name: 'Intake',
component: () => import('../views/IntakeView.vue'),
meta: { requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] },
},
{
path: 'cover-sheets',
name: 'CoverSheets',
component: () => import('../views/CoverSheetView.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: 'approval',
name: 'ApprovalQueue',
component: () => import('../views/ApprovalView.vue'),
meta: {
requiresAuth: true,
roles: ['CLINICAL_APPROVER', 'ADMINISTRATOR'],
},
},
{
path: 'approval/:batchId',
name: 'ApprovalBatch',
component: () => import('../views/ApprovalView.vue'),
meta: {
requiresAuth: true,
roles: ['CLINICAL_APPROVER', 'ADMINISTRATOR'],
},
props: true,
},
{
path: 'patients/:patientId/history',
name: 'PatientHistory',
component: () => import('../views/PatientHistoryView.vue'),
meta: { requiresAuth: true },
props: true,
},
{
path: 'patients',
name: 'PatientSearch',
component: () => import('../views/PatientHistoryView.vue'),
meta: { requiresAuth: true },
},
{
path: 'live-capture',
name: 'LiveCapture',
component: () => import('../views/LiveCaptureView.vue'),
meta: { requiresAuth: true, roles: ['CLINICIAN', 'ADMINISTRATOR'] },
},
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('../views/QueueDashboardView.vue'),
meta: { requiresAuth: true, roles: ['ADMINISTRATOR'] },
},
{
path: 'fhir-explorer',
name: 'FhirExplorer',
component: () => import('../views/FhirExplorerView.vue'),
meta: { requiresAuth: true, roles: ['ADMINISTRATOR'] },
},
],
},
{
path: '/:pathMatch(.*)*',
redirect: '/login',
},
]
@@ -139,4 +155,4 @@ router.beforeEach((to, _from, next) => {
next()
})
export default router
export default router
@@ -1,5 +1,5 @@
<template>
<div class="min-h-screen lg:h-screen flex flex-col">
<div class="h-full min-h-0 flex flex-col">
<AppHeader title="Clinical Approval">
<template #subtitle>
<span v-if="currentBatch" class="text-sm text-gray-500">
@@ -1,5 +1,5 @@
<template>
<div class="min-h-screen flex flex-col">
<div class="h-full min-h-0 flex flex-col">
<AppHeader title="Cover Sheets" />
<div class="p-4 sm:p-6 lg:p-8 max-w-6xl mx-auto flex-1 w-full">
@@ -1,5 +1,5 @@
<template>
<div class="min-h-screen lg:h-screen flex flex-col">
<div class="h-full min-h-0 flex flex-col">
<AppHeader title="Data Entry">
<template #subtitle>
<span v-if="currentBatch" class="text-sm text-gray-500">
@@ -11,7 +11,7 @@
<!-- 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>
<h2 class="text-xl font-semibold mb-4">Data Entry Queue</h2>
<BatchList
:batches="batchStore.batches"
:loading="batchStore.loading"
@@ -1,5 +1,5 @@
<template>
<div class="min-h-screen flex flex-col">
<div class="h-full min-h-0 flex flex-col">
<AppHeader title="FHIR Explorer" />
<div class="p-4 sm:p-6 lg:p-8 max-w-6xl mx-auto flex-1 w-full space-y-6">
@@ -1,5 +1,5 @@
<template>
<div class="min-h-screen flex flex-col">
<div class="h-full min-h-0 flex flex-col">
<AppHeader title="Intake" />
<div class="page-container flex-1">
@@ -1,5 +1,5 @@
<template>
<div class="min-h-screen flex flex-col">
<div class="h-full min-h-0 flex flex-col">
<AppHeader title="Live Capture" />
<div class="flex-1 p-4 sm:p-6 lg:p-8 max-w-5xl mx-auto w-full">
+92 -32
View File
@@ -1,40 +1,100 @@
<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 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"
>
<div
class="pointer-events-none absolute inset-0 opacity-[0.07]"
aria-hidden="true"
style="background-image: radial-gradient(circle at 20% 20%, #155EEF 0%, transparent 45%), radial-gradient(circle at 80% 70%, #EEF4FF 0%, transparent 40%);"
/>
<div class="relative">
<div class="flex items-center gap-3">
<svg viewBox="0 0 32 32" class="w-9 h-9 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 class="leading-tight">
<p class="font-bold text-lg tracking-tight">VigilCare</p>
<p class="text-sm text-white/60">Records</p>
</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">
Digitize. Verify. Trust.
</h1>
<p class="mt-4 text-base text-white/70 leading-relaxed">
Scanned clinical records become governed, structured data your teams can verify and trust.
</p>
</div>
</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
/>
<!-- Restrained flow hint (desktop) -->
<div
class="relative hidden lg:flex items-center gap-3 mt-16 text-sm text-white/50"
aria-hidden="true"
>
<span class="rounded-input border border-white/15 bg-white/5 px-3 py-1.5">Paper record</span>
<span class="text-white/30"></span>
<span class="rounded-input border border-white/15 bg-white/5 px-3 py-1.5">Verified digital</span>
</div>
</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">
<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.
</p>
</div>
<div v-if="errorMessage" class="text-clinical-danger text-sm">
{{ errorMessage }}
</div>
<form @submit.prevent="handleLogin" class="space-y-5">
<div>
<label for="login-username" class="block text-sm font-semibold text-ink mb-2">
Username
</label>
<input
id="login-username"
v-model="username"
type="text"
autocomplete="username"
class="form-input"
placeholder="e.g. intake1, entry1, verifier1"
required
/>
</div>
<div>
<label for="login-password" class="block text-sm font-semibold text-ink mb-2">
Password
</label>
<input
id="login-password"
v-model="password"
type="password"
autocomplete="current-password"
class="form-input"
required
/>
</div>
<button type="submit" class="btn-primary w-full" :disabled="loading">
{{ loading ? 'Signing in...' : 'Sign In' }}
</button>
</form>
<div
v-if="errorMessage"
class="rounded-input bg-clinical-danger-bg border border-clinical-danger/20 px-3 py-2 text-sm text-clinical-danger"
role="alert"
>
{{ errorMessage }}
</div>
<button type="submit" class="btn-primary w-full" :disabled="loading">
{{ loading ? 'Signing in...' : 'Sign In' }}
</button>
</form>
</div>
</div>
</div>
</template>
@@ -60,4 +120,4 @@ async function handleLogin() {
loading.value = false
}
}
</script>
</script>
@@ -1,5 +1,5 @@
<template>
<div class="min-h-screen flex flex-col">
<div class="h-full min-h-0 flex flex-col">
<AppHeader title="Patient History" />
<div class="flex-1 p-4 sm:p-6 lg:p-8 max-w-5xl mx-auto w-full">
@@ -1,6 +1,6 @@
<template>
<div class="min-h-screen lg:h-screen flex flex-col">
<AppHeader title="Supervisor Dashboard">
<div class="h-full min-h-0 flex flex-col">
<AppHeader title="Queue Dashboard">
<template #actions>
<button
type="button"
@@ -1,5 +1,5 @@
<template>
<div class="min-h-screen lg:h-screen flex flex-col">
<div class="h-full min-h-0 flex flex-col">
<AppHeader title="Verification">
<template #subtitle>
<span v-if="currentBatch" class="text-sm text-gray-500">
+36 -11
View File
@@ -7,20 +7,35 @@ export default {
theme: {
extend: {
colors: {
navy: '#08254A',
primary: {
50: '#eff9f8',
100: '#d9f0ee',
300: '#92d2ca',
400: '#5cb9ae',
500: '#2f9d91',
600: '#1f8075',
700: '#17655d',
800: '#124e48',
50: '#EEF4FF',
100: '#D1E0FF',
300: '#84ADFF',
400: '#528BFF',
500: '#155EEF',
600: '#155EEF',
700: '#0B4FD6',
800: '#0A3FAA',
},
surface: '#FFFFFF',
canvas: '#F7F9FC',
// Flat keys so @apply text-ink / border-line resolve (nested DEFAULT breaks @apply)
ink: '#344054',
'ink-strong': '#101828',
'ink-secondary': '#667085',
'ink-disabled': '#98A2B3',
line: '#E4E7EC',
'line-strong': '#D0D5DD',
clinical: {
safe: '#2f9e58',
warning: '#c98a1b',
danger: '#c2293f',
safe: '#079455',
'safe-bg': '#ECFDF3',
warning: '#DC6803',
'warning-bg': '#FFFAEB',
danger: '#D92D20',
'danger-bg': '#FEF3F2',
critical: '#B42318',
'critical-bg': '#FEE4E2',
},
},
fontFamily: {
@@ -32,6 +47,16 @@ export default {
'sans-serif',
],
},
borderRadius: {
control: '6px',
input: '8px',
card: '10px',
panel: '12px',
},
boxShadow: {
card: '0 1px 2px rgba(16, 24, 40, 0.04)',
dialog: '0 8px 24px rgba(16, 24, 40, 0.12)',
},
spacing: {
18: '4.5rem',
22: '5.5rem',