Compare commits

...
17 Commits
Author SHA1 Message Date
Trent 4fb9c95dfb Add walkthrough
CI / backend (push) Canceled after 15s
CI / frontend (push) Successful in 1m36s
2026-08-12 07:05:37 +08:00
Trent 3680dc5ea7 Fix issue with OCR fixture
CI / backend (push) Canceled after 11s
CI / frontend (push) Successful in 1m27s
2026-08-12 06:27:08 +08:00
Trent 48c5c93358 Fix the frontend test because of new additions
CI / backend (push) Canceled after 22s
CI / frontend (push) Failing after 1m33s
2026-08-12 06:21:23 +08:00
Trent 53338906aa make addition ui fixes for cramped features
CI / backend (push) Successful in 6m6s
CI / frontend (push) Failing after 1m8s
2026-08-12 06:11:30 +08:00
Trent 4c5aafe591 Fix responsiveness
CI / backend (push) Successful in 6m14s
CI / frontend (push) Failing after 59s
2026-08-12 05:45:12 +08:00
Trent 00f832aa73 feature: Surface Existing Unused APIs
CI / backend (push) Successful in 5m58s
CI / frontend (push) Failing after 1m15s
2026-08-12 04:56:06 +08:00
Trent 4085aaa549 feature: Supporting Screens
CI / backend (push) Successful in 6m0s
CI / frontend (push) Failing after 1m7s
2026-08-12 04:39:32 +08:00
Trent 5caf928787 feature: Core Digitization Workstation (Entry, Verification, Approval)
CI / backend (push) Successful in 6m11s
CI / frontend (push) Failing after 1m7s
2026-08-12 04:22:25 +08:00
Trent 9811f2a2ed feature: Shared UX Primitives
CI / backend (push) Successful in 6m16s
CI / frontend (push) Failing after 1m4s
2026-08-12 03:57:43 +08:00
Trent 56e100a495 feature: Design System, App Shell, and Login Redesign
CI / backend (push) Successful in 6m31s
CI / frontend (push) Successful in 58s
2026-08-12 03:37:17 +08:00
Trent 6e06717b82 Update cd
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 4s
2026-08-12 01:47:12 +08:00
Trent 0c31291b69 Fix: Missing using
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 10s
2026-08-12 01:40:19 +08:00
Trent 572cef7b1f Add code to create minio bucket
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 5s
2026-08-12 01:36:39 +08:00
Trent 89da4d094f fix AppDbContext migrate issue
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 6s
2026-08-12 01:12:13 +08:00
Trent 4d95108ac5 Fix docker file issue
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 10s
2026-08-12 00:56:55 +08:00
voltsrage e25b57c779 fix frontend issues
CI / backend (push) Successful in 6m10s
CI / frontend (push) Successful in 55s
2026-08-11 20:54:59 +08:00
voltsrage f2eab26ad0 fix: fix ci issues and update styles to remove ui elements
CI / backend (push) Successful in 6m11s
CI / frontend (push) Failing after 54s
2026-08-11 20:33:23 +08:00
109 changed files with 12039 additions and 2539 deletions
+4 -3
View File
@@ -20,10 +20,11 @@ DASHBOARD_ORIGIN=https://vigilcare-records.vectur45.com
# Runtime (DML-only) connection used by the API container. # Runtime (DML-only) connection used by the API container.
PG_CONNECTION="Host=postgres;Port=5432;Database=vigilcare_records;Username=vigilcare_records_app;Password=CHANGE_ME;SSL Mode=Disable" PG_CONNECTION="Host=postgres;Port=5432;Database=vigilcare_records;Username=vigilcare_records_app;Password=CHANGE_ME;SSL Mode=Disable"
# DDL-privileged connection used ONLY by the EF migration bundle (CD migrate job). # DDL-privileged connection used ONLY by the EF migration bundle (CD migrate job).
# That job runs on the act_runner host directly (not joined to shared-services), so # CD copies migrate-api to the deploy host and runs it in a one-shot container on
# it needs the externally-routable host:port, not the container network name. # shared-services, so use the same Docker DNS name as PG_CONNECTION (Host=postgres),
# not an external hostname. Store this as Gitea secret PG_CONNECTION_DDL.
# Never put this credential in the API container environment. # Never put this credential in the API container environment.
PG_CONNECTION_DDL="Host=postgres.site.com;Port=5432;Database=vigilcare_records;Username=vigilcare_records_migrator;Password=CHANGE_ME;SSL Mode=Require;Trust Server Certificate=false" PG_CONNECTION_DDL="Host=postgres;Port=5432;Database=vigilcare_records;Username=vigilcare_records_migrator;Password=CHANGE_ME;SSL Mode=Disable"
# ---- Redis (shared-services network) ---- # ---- Redis (shared-services network) ----
# "redis" = the service name on the shared Redis compose project. # "redis" = the service name on the shared Redis compose project.
-113
View File
@@ -1,113 +0,0 @@
# ---- image coordinates ----
REGISTRY=git.vectur45.com/trent/vigilcare-clinical
IMAGE_TAG=v1.0.0
# ---- exposed ports on the production host ----
API_PORT=5270
GATEWAY_PORT=5081
DASHBOARD_PORT=8088
DASHBOARD_ORIGIN=https://vigilcare-clinical.vectur45.com
# Gitea Actions var PROD_API_URL (dashboard build) — not read by compose:
# https://api.vigilcare-clinical.vectur45.com
# ---- PostgreSQL (container on the same VM, reached via the shared-service network) ----
# Runtime (DML-only) connection used by the API container.
# "postgres" = the service name in the Postgres compose project - rename to match it exactly.
# Port 5432 is the container's internal port, NOT the 5433 published on the host.
# SSL Mode=Disable: Postgres on the shared-services Docker network has no TLS.
# Use Require only when connecting to a TLS-enabled external Postgres.
PG_CONNECTION="Host=postgres;Port=5432;Database=vigilcare;Username=admin;Password=PartyHard753!;SSL Mode=Disable"
# DDL-privileged connection used ONLY by the EF migration bundle (Step 6 / CD migrate job).
# That job currently runs an act_runner container that is NOT joined to shared-service
# (see .gitea/workflows/cd.yml), so it MUST keep using the externally-routable host:port,
# not the container network name, unless that job is later attached to shared-service too.
# Never put this credential in the API container environment.
PG_CONNECTION_DDL="Host=postgres.site.com;Port=5432;Database=vigilcare;Username=admin;Password=PartyHard753!;SSL Mode=Require;Trust Server Certificate=false"
GATEWAY_PG_CONNECTION="Host=postgres;Port=5432;Database=vigilcare_ward;Username=admin;Password=PartyHard753!;SSL Mode=Disable"
# ---- Redis (container on the same VM, reached via the shared-service network) ----
# "redis" = the service name in the Redis compose project - rename to match it exactly.
# Port 6379 is the container's internal port; confirm it matches (it usually does).
REDIS_CONNECTION=redis:6379,abortConnect=false
GATEWAY_REDIS_CONNECTION=redis:6379,abortConnect=false,defaultDatabase=1
# ---- external Seq ----
# On vectur-home-server (Tailscale) - use the ingestion port (5341), not the web UI
# port (8080->80). No TLS is configured, so plain http, not https.
SEQ_URL=http://vectur-home-server:5341
# The compose only sets SEQ_FIRSTRUN_ADMINUSERNAME/PASSWORD for first-run login,
# it does not provision an API key. Generate one manually via Seq's web UI
# (Settings -> API Keys) after the container's first run, then paste it here.
SEQ_API_KEY=CHANGE_ME
# ---- external Kafka ----
# Single-broker cluster on vectur-home-server (Tailscale) - PLAINTEXT only, no SASL.
# Traffic relies on the Tailscale mesh for encryption in transit.
KAFKA_BOOTSTRAP=vectur-home-server:9092
KAFKA_REPLICATION_FACTOR=1
KAFKA_SECURITY_PROTOCOL=Plaintext
# ---- external Elasticsearch ----
# Unauthenticated cluster: leave ES_API_KEY / ES_USERNAME / ES_PASSWORD unset.
# Program.cs only attaches auth when those values are non-empty.
ES_URI=http://vectur-home-server:9200
ES_API_KEY=
# ES_USERNAME=
# ES_PASSWORD=
# ---- external RabbitMQ ----
# Plain AMQP on vectur-home-server (Tailscale) - only 5672 is exposed, no TLS listener.
# RABBITMQ_USERNAME/PASSWORD must match RABBITMQ_DEFAULT_USER/PASS in the RabbitMQ
# compose's own .env on vectur-home-server.
RABBITMQ_HOST=vectur-home-server
RABBITMQ_PORT=5672
RABBITMQ_USERNAME=admin
RABBITMQ_PASSWORD=2va2xcLWTAlRN4abEv3wa7EVawHXk+tnRkQVOOXqs2M=
RABBITMQ_USE_SSL=false
GATEWAY_RABBITMQ_HOST=vectur-home-server
GATEWAY_RABBITMQ_PORT=5672
GATEWAY_RABBITMQ_USERNAME=admin
GATEWAY_RABBITMQ_PASSWORD=2va2xcLWTAlRN4abEv3wa7EVawHXk+tnRkQVOOXqs2M=
GATEWAY_RABBITMQ_USE_SSL=false
# ---- external MinIO ----
# Host publishes the S3 API on 9002 (mapped to container's 9000), no TLS termination.
# MINIO_ACCESS_KEY/SECRET_KEY must match MINIO_ROOT_USER/MINIO_ROOT_PASSWORD in the
# MinIO compose's own .env on vectur-home-server.
MINIO_ENDPOINT=vectur-home-server:9002
MINIO_ACCESS_KEY=admin
MINIO_SECRET_KEY=p3QUh8mXvosfFjrJYJJPd36tGiPbsOASWdIe6FKzdLI=
MINIO_USE_SSL=false
# ---- application secrets (generate with: openssl rand -base64 48) ----
JWT_SIGNING_KEY=T0oK2f3YhBesoMgZnEB7vmi4Dfbd7LxtuemkXI8j3xA=
# WARNING: rotating PHI_SEARCH_TOKEN_KEY invalidates every stored patient
# search token. See docs/ops/phi-encryption-runbook.md before changing it.
PHI_SEARCH_TOKEN_KEY=tbL0Nku3+bK476bv4zmfyRBiiRMTTF3To4Qq9RUOSVg=
GATEWAY_API_KEY=ZxFKE4wUChEg+VzNGM16zFSuHeM+I+IQc59rIzN0U2g=
FHIR_API_KEY=wY29TLNIzLouIEKDu+XAxmZ3T1R5cjE2IIXxaYWpNAg=
GATEWAY_JWT_SIGNING_KEY=TfxbvLz992kr8simlbr8s61W5gKQbLqjyTuPjgCWjV0=
# ---- gateway identity ----
GATEWAY_ID=ce985c14-42de-4db4-8538-c2a203496d9e
GATEWAY_SITE_ID=e9fba67a-fcf5-4966-acb1-dab58a68bff2
GATEWAY_DEPARTMENT=ICU
GATEWAY_CODE=GW-ICU-1
GATEWAY_SITE_CODE=SITE-01
GATEWAY_SITE_NAME=Primary Site
# GATEWAY_SITE_ADDRESS=
# ---- production bootstrap users (API seeds these when missing; not demo accounts) ----
SEED_ADMIN_USERNAME=admin
SEED_ADMIN_PASSWORD="zx+yv8XtbxQq0E5YZ3d8kP5g"
SEED_ADMIN_DISPLAY_NAME=System Admin
SEED_NURSE_USERNAME=nurse
SEED_NURSE_PASSWORD="qcYtKfgLMezlT63AIxrPdmtK"
SEED_NURSE_DISPLAY_NAME=Charge Nurse
SEED_PHYSICIAN_USERNAME=physician
SEED_PHYSICIAN_PASSWORD="msKxpOQIGHK/StlrPIDBx9ZD"
SEED_PHYSICIAN_DISPLAY_NAME=Attending Physician
SIMULATION_ENABLED=true
SIMULATION_RUNNER_PASSWORD=tlIxrcgEEQKh9BYdjZ6/fWwj4TFoN4zTtCUfICrQpxI=
+36 -3
View File
@@ -70,6 +70,9 @@ jobs:
# Checkout must run on the job host. Build the EF bundle via Dockerfile # Checkout must run on the job host. Build the EF bundle via Dockerfile
# --target migrate (context upload), not docker run -v — under act_runner # --target migrate (context upload), not docker run -v — under act_runner
# bind mounts resolve on the Docker host, not the job workspace. # bind mounts resolve on the Docker host, not the job workspace.
# Postgres lives on the deploy host's shared-services network and is not
# reachable from act_runner, so the bundle is copied there and executed in
# a one-shot container joined to that network (Host=postgres resolves).
# NOTE: Program.cs also runs db.Database.MigrateAsync() on API startup, so # NOTE: Program.cs also runs db.Database.MigrateAsync() on API startup, so
# this job is a defense-in-depth pre-deploy step using a DDL-privileged # this job is a defense-in-depth pre-deploy step using a DDL-privileged
# credential the API container never sees, not the only migration path. # credential the API container never sees, not the only migration path.
@@ -85,12 +88,42 @@ jobs:
docker rm "$cid" docker rm "$cid"
chmod +x ./migrate-api chmod +x ./migrate-api
- name: Configure SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
# Runs while the previous release is still serving traffic, so every # Runs while the previous release is still serving traffic, so every
# migration must be backwards-compatible with the outgoing image # migration must be backwards-compatible with the outgoing image
# (expand-then-contract). Self-contained linux-x64 binary — runs on the # (expand-then-contract). Connection strings contain `;` / spaces
# job host directly. # ("SSL Mode=...") — never pass them as a bare ssh remote argv; the
# remote shell splits on `;` and drops the variable (set -u → unbound).
- name: Apply migrations - name: Apply migrations
run: ./migrate-api --connection "${{ secrets.PG_CONNECTION_DDL }}" env:
PG_CONNECTION_DDL: ${{ secrets.PG_CONNECTION_DDL }}
run: |
REMOTE="${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}"
scp -i ~/.ssh/id_ed25519 ./migrate-api \
"$REMOTE:/tmp/vigilcare-records-migrate-api"
umask 077
printf '%s\n' "$PG_CONNECTION_DDL" > /tmp/vigilcare-records-pg-ddl
scp -i ~/.ssh/id_ed25519 /tmp/vigilcare-records-pg-ddl \
"$REMOTE:/tmp/vigilcare-records-pg-ddl"
rm -f /tmp/vigilcare-records-pg-ddl
ssh -i ~/.ssh/id_ed25519 "$REMOTE" bash -euo pipefail <<'EOF'
chmod +x /tmp/vigilcare-records-migrate-api
chmod 600 /tmp/vigilcare-records-pg-ddl
PG_CONNECTION_DDL="$(cat /tmp/vigilcare-records-pg-ddl)"
docker run --rm \
--network shared-services \
-v /tmp/vigilcare-records-migrate-api:/migrate-api:ro \
--entrypoint /migrate-api \
mcr.microsoft.com/dotnet/runtime-deps:8.0 \
--connection "$PG_CONNECTION_DDL"
rm -f /tmp/vigilcare-records-migrate-api /tmp/vigilcare-records-pg-ddl
EOF
deploy: deploy:
needs: [build-and-push, migrate] needs: [build-and-push, migrate]
+14
View File
@@ -14,6 +14,18 @@ on:
jobs: jobs:
backend: backend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# act_runner's docker executor runs steps inside their own job container — a
# sibling of the "docker compose" containers below — so "localhost" from inside
# the job container is NOT the Docker host and can't reach the published
# Postgres/Redis ports (this is what "Connection refused" on 127.0.0.1:5437
# in the Test step means). The Test step below points at host.docker.internal
# instead; that hostname must resolve inside the job container, which on Linux
# requires the runner itself (not this workflow) to add
# `--add-host=host.docker.internal:host-gateway` — set
# `container: { options: "--add-host=host.docker.internal:host-gateway" }` in the
# act_runner's config.yaml. Docker Desktop runners already resolve this hostname
# out of the box. See docs/25-gitea-cicd-docker-deploy.md ("Runner requirement
# for Compose-based tests").
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -62,6 +74,8 @@ jobs:
- name: Test - name: Test
env: env:
ASPNETCORE_ENVIRONMENT: "Testing" ASPNETCORE_ENVIRONMENT: "Testing"
CONNECTIONSTRINGS__DEFAULTCONNECTION: "Host=host.docker.internal;Port=5437;Database=vigilcare_records_test;Username=postgres;Password=password"
REDIS__CONNECTIONSTRING: "host.docker.internal:6383,defaultDatabase=1,allowAdmin=true"
run: | run: |
dotnet test VigilCareRecords.sln -c Release --no-build \ dotnet test VigilCareRecords.sln -c Release --no-build \
--logger "trx;LogFileName=test-results.trx" \ --logger "trx;LogFileName=test-results.trx" \
+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.
+2 -1
View File
@@ -213,7 +213,8 @@ VigilCareRecords/
│ ├── run-vigilcare-records-phase-13-verification.sh # OCR config, ocrConfidence API, optional live OCR polling │ ├── 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 │ └── fixtures/test-scan.pdf # Sample PDF for upload verification scripts
└── docs/ └── 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 ├── digitization-workstation-guide.md # Clinical scenarios and clerk workflow reference
├── vigilcare-records-gap-analysis.md # Known gaps and hardening backlog ├── vigilcare-records-gap-analysis.md # Known gaps and hardening backlog
└── vigilcare-records-prd.md # Product requirements and phase roadmap └── vigilcare-records-prd.md # Product requirements and phase roadmap
@@ -9,17 +9,30 @@ public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
{ {
// Override configuration to point at a test database — never run tests against // Override configuration to point at a test database — never run tests against
// the development database; a botched rollback could corrupt seed data. // the development database; a botched rollback could corrupt seed data.
//
// Defaults assume Postgres/Redis are reachable on the host's loopback address
// (e.g. `docker compose up -d postgres redis` on a dev machine). CI runners that
// execute the test step inside its own job container (act_runner's default docker
// executor) can't reach ports published on the Docker *host* via "localhost" —
// set CONNECTIONSTRINGS__DEFAULTCONNECTION / REDIS__CONNECTIONSTRING (e.g. to
// host.docker.internal) in that environment to override.
protected override void ConfigureWebHost(IWebHostBuilder builder) protected override void ConfigureWebHost(IWebHostBuilder builder)
{ {
builder.UseEnvironment("Testing"); builder.UseEnvironment("Testing");
builder.ConfigureAppConfiguration((_, config) => builder.ConfigureAppConfiguration((_, config) =>
{ {
var connectionString = Environment.GetEnvironmentVariable("CONNECTIONSTRINGS__DEFAULTCONNECTION")
?? "Host=localhost;Port=5437;Database=vigilcare_records_test;Username=postgres;Password=password";
var redisConnectionString = Environment.GetEnvironmentVariable("REDIS__CONNECTIONSTRING")
?? "localhost:6383,defaultDatabase=1,allowAdmin=true";
var fhirBaseUrl = Environment.GetEnvironmentVariable("FHIR__BASEURL")
?? "http://localhost/fhir";
config.AddInMemoryCollection(new Dictionary<string, string?> config.AddInMemoryCollection(new Dictionary<string, string?>
{ {
["ConnectionStrings:DefaultConnection"] = ["ConnectionStrings:DefaultConnection"] = connectionString,
"Host=localhost;Port=5437;Database=vigilcare_records_test;Username=postgres;Password=password", ["Redis:ConnectionString"] = redisConnectionString,
["Redis:ConnectionString"] = "localhost:6383,defaultDatabase=1,allowAdmin=true", ["Fhir:BaseUrl"] = fhirBaseUrl
["Fhir:BaseUrl"] = "http://localhost/fhir"
}); });
}); });
} }
@@ -252,7 +252,7 @@ public class DigitizationBatchesController : ControllerBase
/// <param name="after">Cursor: ISO-8601 timestamp from the previous page's nextCursor field.</param> /// <param name="after">Cursor: ISO-8601 timestamp from the previous page's nextCursor field.</param>
/// <param name="pageSize">Number of events per page. Default 50, max 200.</param> /// <param name="pageSize">Number of events per page. Default 50, max 200.</param>
[HttpGet("{id:guid}/events")] [HttpGet("{id:guid}/events")]
[Authorize(Roles = "ADMINISTRATOR,VERIFIER,CLINICAL_APPROVER")] [Authorize(Roles = "ADMINISTRATOR,DATA_ENTRY_CLERK,VERIFIER,CLINICAL_APPROVER")]
[ProducesResponseType(typeof(ApiResponse<CursorPagedResult<BatchEventResponse>>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<CursorPagedResult<BatchEventResponse>>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
/// <summary>
/// Design-time factory for EF tools and the CD migrations bundle.
/// Without this, the bundle tries to bootstrap Program.cs (JWT/MinIO/etc.) and fails
/// when those settings are absent next to ./migrate-api; --connection then never applies.
/// </summary>
public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
optionsBuilder.UseNpgsql(ResolveConnectionString());
return new AppDbContext(optionsBuilder.Options);
}
private static string ResolveConnectionString()
{
var fromEnv = Environment.GetEnvironmentVariable("ConnectionStrings__DefaultConnection");
if (!string.IsNullOrWhiteSpace(fromEnv))
return fromEnv;
var config = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile("appsettings.Development.json", optional: true)
.AddEnvironmentVariables()
.Build();
var fromConfig = config.GetConnectionString("DefaultConnection");
if (!string.IsNullOrWhiteSpace(fromConfig))
return fromConfig;
// Placeholder so the factory can construct a context; migrate-api --connection
// replaces this when applying migrations in CD.
return "Host=127.0.0.1;Database=ef_design;Username=ef;Password=ef";
}
}
+3 -3
View File
@@ -45,9 +45,9 @@ RUN apt-get update \
COPY --from=build /app/publish . COPY --from=build /app/publish .
RUN useradd --uid 1654 --user-group --no-create-home appuser \ # aspnet:8.0 already ships non-root user `app` (UID/GID 1654).
&& chown -R appuser:appuser /app RUN chown -R app:app /app
USER appuser USER app
ENV ASPNETCORE_URLS=http://+:8080 ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080 EXPOSE 8080
+7
View File
@@ -6,8 +6,10 @@ using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Minio; using Minio;
using Minio.DataModel.Args;
using Prometheus; using Prometheus;
using Serilog; using Serilog;
using StackExchange.Redis; using StackExchange.Redis;
@@ -233,6 +235,11 @@ try
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync(); await db.Database.MigrateAsync();
await DataSeeder.SeedAsync(db); await DataSeeder.SeedAsync(db);
var minio = scope.ServiceProvider.GetRequiredService<IMinioClient>();
var minioOpts = scope.ServiceProvider.GetRequiredService<IOptions<MinioOptions>>().Value;
if (!await minio.BucketExistsAsync(new BucketExistsArgs().WithBucket(minioOpts.BucketName)))
await minio.MakeBucketAsync(new MakeBucketArgs().WithBucket(minioOpts.BucketName));
} }
app.Run(); app.Run();
+2 -2
View File
@@ -147,7 +147,7 @@ FROM src AS migrate
RUN dotnet ef migrations bundle ... --output /out/migrate-api RUN dotnet ef migrations bundle ... --output /out/migrate-api
``` ```
CD builds `--target migrate`, copies the binary out, and runs it with a **DDL** connection string that never enters the API container. Prefer `docker build` + `docker cp` over `docker run -v` on act_runner — bind mounts resolve on the Docker host, not the job workspace. CD builds `--target migrate`, copies the binary out, SCPs it to the deploy host, and runs it on the `shared-services` Docker network with a **DDL** connection string that never enters the API container. Prefer `docker build` + `docker cp` over `docker run -v` on act_runner — bind mounts resolve on the Docker host, not the job workspace.
### `.dockerignore` ### `.dockerignore`
@@ -272,7 +272,7 @@ build-and-push ──► migrate ──► deploy (smoke + rollback on failure)
1. Build `--target migrate` 1. Build `--target migrate`
2. Extract `migrate-api` 2. Extract `migrate-api`
3. `./migrate-api --connection "${{ secrets.PG_CONNECTION_DDL }}"` 3. `scp` the binary to the deploy host and `docker run --network shared-services` it with `--connection "${{ secrets.PG_CONNECTION_DDL }}"` (Postgres is on that network — not reachable from `act_runner`)
Migrations must be **backwards-compatible** with the still-running previous image (expand-then-contract). Rollback restores the old image tag only — it does not reverse schema. Migrations must be **backwards-compatible** with the still-running previous image (expand-then-contract). Rollback restores the old image tag only — it does not reverse schema.
+16 -2
View File
@@ -43,6 +43,20 @@ ssh deploy@YOUR_HOST "chmod 600 /opt/vigilcare-records/.env"
Fill in `.env` from [`.env.example`](../.env.example) first — generate `JWT_SECRET` with `openssl rand -base64 48`, and get real credentials for the shared Postgres/Redis/MinIO/Seq services from whoever manages that stack. CD never uploads or overwrites `.env`; only the `IMAGE_TAG` line is patched automatically on each release. Fill in `.env` from [`.env.example`](../.env.example) first — generate `JWT_SECRET` with `openssl rand -base64 48`, and get real credentials for the shared Postgres/Redis/MinIO/Seq services from whoever manages that stack. CD never uploads or overwrites `.env`; only the `IMAGE_TAG` line is patched automatically on each release.
Create the Records MinIO bucket once (the API also creates it on startup if missing):
```bash
ssh deploy@YOUR_HOST bash -euo pipefail <<'EOF'
cd /opt/vigilcare-records
env_val() { sed -n "s/^${1}=//p" .env | tail -n1 | tr -d '\r'; }
ACCESS="$(env_val MINIO_ACCESS_KEY)"
SECRET="$(env_val MINIO_SECRET_KEY)"
BUCKET="$(env_val MINIO_BUCKET_NAME)"; BUCKET="${BUCKET:-vigilcare-records-scans}"
docker run --rm --network shared-services --entrypoint /bin/sh minio/mc \
-c "mc alias set local http://minio:9000 '${ACCESS}' '${SECRET}' && mc mb --ignore-existing local/${BUCKET}"
EOF
```
## 4. Gitea secrets and variables ## 4. Gitea secrets and variables
Repo → **Settings****Actions**. Repo → **Settings****Actions**.
@@ -53,7 +67,7 @@ Repo → **Settings** → **Actions**.
|---|---| |---|---|
| `REGISTRY_USERNAME` | `docker login` | | `REGISTRY_USERNAME` | `docker login` |
| `REGISTRY_TOKEN` | `docker login` (access token / PAT with package write) | | `REGISTRY_TOKEN` | `docker login` (access token / PAT with package write) |
| `PG_CONNECTION_DDL` | migrate job only — DDL-privileged connection to the shared Postgres, never given to the API container | | `PG_CONNECTION_DDL` | migrate job only — DDL-privileged connection (`Host=postgres` on `shared-services`), never given to the API container |
| `DEPLOY_HOST` | SSH / SCP | | `DEPLOY_HOST` | SSH / SCP |
| `DEPLOY_USER` | SSH / SCP | | `DEPLOY_USER` | SSH / SCP |
| `DEPLOY_SSH_KEY` | Private key PEM / OpenSSH private key body | | `DEPLOY_SSH_KEY` | Private key PEM / OpenSSH private key body |
@@ -72,7 +86,7 @@ git tag v1.0.0
git push origin v1.0.0 git push origin v1.0.0
``` ```
This triggers `.gitea/workflows/cd.yml`: build & push both images → apply EF Core migrations against `PG_CONNECTION_DDL` → SSH deploy (`scp` the prod compose file, patch `IMAGE_TAG`, `pull` + `up -d`) → smoke test (`/health/ready`, dashboard `/`) → automatic rollback to the previous `IMAGE_TAG` on failure (schema changes are not reverted; see the expand/contract note in `cd.yml`). This triggers `.gitea/workflows/cd.yml`: build & push both images → SSH the EF migrations bundle onto the deploy host and run it on `shared-services` with `PG_CONNECTION_DDL` → SSH deploy (`scp` the prod compose file, patch `IMAGE_TAG`, `pull` + `up -d`) → smoke test (`/health/ready`, dashboard `/`) → automatic rollback to the previous `IMAGE_TAG` on failure (schema changes are not reverted; see the expand/contract note in `cd.yml`).
Manual redeploy of an existing tag: Gitea UI → Actions → CD → Run workflow, with `image_tag` input. Manual redeploy of an existing tag: Gitea UI → Actions → CD → Run workflow, with `image_tag` input.
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
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

+6
View File
@@ -0,0 +1,6 @@
1, Create folder and change permissions
ssh vectur45@35.201.217.185 "sudo mkdir -p /opt/<project-name> && sudo chown -R vectur45:vectur45 /opt/<project-name> && sudo chmod 755 /opt/<project-name>"
2. Copy .env to folder
scp -i ~/.ssh/vectur45 .env vectur45@35.201.217.185:/opt/<project-name>/.env
+72 -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 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 | | Phase | Scope | Status |
|---|---|---| |---|---|---|
| 1 | Schema, auth, roles, batch CRUD, MinIO upload, status machine | Done | | 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 | | 11 | HL7 FHIR R4 read-only API and FHIR Explorer UI | Done |
| 12 | Backend-driven batch-type field requirements (`fieldRequirements` metadata) | 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 | | 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) | Done |
**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. 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. Vue 3 SPA at `vigilcare-records-web/` (dev server port **3028**, proxies `/api` and `/fhir` → API on **5217**). Role-based routing with JWT refresh.
@@ -583,9 +590,24 @@ Vue 3 SPA at `vigilcare-records-web/` (dev server port **3028**, proxies `/api`
| **Patient history** | All roles | Digitization timeline with correction chain and audit trail | | **Patient history** | All roles | Digitization timeline with correction chain and audit trail |
| **Queue dashboard** | Administrator | Backlog metrics from work-queue overview | | **Queue dashboard** | Administrator | Backlog metrics from work-queue overview |
| **FHIR Explorer** | Administrator | Browse and search FHIR resources, inspect JSON, load Patient `$everything` | | **FHIR Explorer** | Administrator | Browse and search FHIR resources, inspect JSON, load Patient `$everything` |
| **Users** | Administrator | Create/update users, deactivate, reset passwords |
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. 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. See [digitization-workstation-guide.md](digitization-workstation-guide.md) for operator workflows and clinical scenarios.
--- ---
@@ -666,12 +688,17 @@ If VigilCareClinical is unreachable in split deployment, batch remains `approved
| 11 | HL7 FHIR R4 read-only API and FHIR Explorer UI | Done | | 11 | HL7 FHIR R4 read-only API and FHIR Explorer UI | Done |
| 12 | Backend-driven batch-type field requirements | Done | | 12 | Backend-driven batch-type field requirements | Done |
| 13 | Optional OCR-assisted draft pre-fill | 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 | Done |
--- ---
## Step-by-Step Guide ## 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 +823,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) ## Deployment Notes (Small Island Context)
- **Single-site tenant:** One hospital or health district per deployment. No cross-island federation in v1. - **Single-site tenant:** One hospital or health district per deployment. No cross-island federation in v1.
@@ -838,7 +905,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 - [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) - [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-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 - [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 - [Completed/VigilCareClinicalAPI/VigilCare-Partner-Brief.md](Completed/VigilCareClinicalAPI/VigilCare-Partner-Brief.md) — clinical positioning and scope boundaries
+13 -43
View File
@@ -579,9 +579,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -599,9 +596,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -619,9 +613,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -639,9 +630,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -659,9 +647,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -679,9 +664,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1415,9 +1397,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.1.1", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2509,9 +2491,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2533,9 +2512,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2557,9 +2533,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2581,9 +2554,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2798,9 +2768,9 @@
} }
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.15", "version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -3035,9 +3005,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.15", "version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -3054,7 +3024,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.12", "nanoid": "^3.3.17",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@@ -3810,9 +3780,9 @@
} }
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "7.28.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 364 B

@@ -0,0 +1,224 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import ApprovalForm from '@/components/ApprovalForm.vue'
import { useBatchStore } from '@/stores/batches'
import type { BatchDetailResponse } from '@/types'
import {
emptyDraft,
fieldRequirementsForBatchType,
} from '@/__tests__/helpers/fieldRequirements'
vi.mock('@/api/client', () => ({
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
del: vi.fn(),
patch: vi.fn(),
uploadFile: vi.fn(),
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
}),
}))
const mountOptions = {
global: {
stubs: {
Teleport: { template: '<div><slot /></div>' },
},
},
}
function makeBatch(overrides: Partial<BatchDetailResponse> = {}): BatchDetailResponse {
const batchType = overrides.batchType ?? 'VITALS'
return {
id: 'b1',
status: 'AWAITING_CLINICAL_APPROVAL',
batchType,
track: 'TRACK_A',
fieldRequirements: fieldRequirementsForBatchType(batchType),
patientId: 'p1',
documentRef: 'docs/scan.pdf',
documentUrl: null,
enableRetroactiveAlerts: false,
enteredByUserId: 'u1',
verifiedByUserId: 'v1-aaaaaaa',
approvedByUserId: null,
rejectionReason: null,
promotedAt: null,
promotionEncounterId: null,
supersedesBatchId: null,
clinicianAttestation: false,
isCorrection: false,
supersession: null,
createdAt: '2026-06-27T10:00:00Z',
updatedAt: '2026-06-27T10:00:00Z',
...overrides,
}
}
function mountForm(opts: {
batch?: BatchDetailResponse
draft?: ReturnType<typeof emptyDraft>
} = {}) {
const batch = opts.batch ?? makeBatch()
const draft =
opts.draft ??
emptyDraft(batch.batchType, {
patient: {
id: 'dp1',
fullName: 'Jane Doe',
dateOfBirth: '1990-05-15',
sex: 'female',
bloodType: 'A+',
emergencyContact: '555-1234',
allergies: ['Penicillin'],
noKnownAllergies: false,
medications: ['Metoprolol 50mg'],
noActiveMedications: false,
},
encounter: {
id: 'de1',
batchId: 'b1',
admissionDate: '2026-06-20T08:00:00',
department: 'ICU',
roomBed: '3A-12',
admissionReason: 'Chest pain',
dischargeDiagnosis: null,
status: null,
},
observations: [
{
id: 'obs-1',
batchId: 'b1',
observationCode: 'TEMP_C',
value: 38.4,
unit: 'C',
recordedAt: '2026-06-27T10:00:00Z',
note: null,
},
],
})
return mount(ApprovalForm, {
props: { batch, batchId: 'b1', draft },
...mountOptions,
})
}
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('ApprovalForm', () => {
it('uses clinical framing distinct from verification', () => {
const wrapper = mountForm()
expect(wrapper.find('[data-testid="approval-form"]').classes()).toContain('approval-frame')
expect(wrapper.text()).toContain('Sign-off review')
expect(wrapper.text()).toContain('Clinical sign-off before promotion')
expect(wrapper.text()).toContain('Verified draft')
})
it('highlights high-stakes fields from draft data', () => {
const wrapper = mountForm()
const summary = wrapper.find('[data-testid="high-stakes-summary"]')
expect(summary.exists()).toBe(true)
expect(summary.text()).toContain('Blood type')
expect(summary.text()).toContain('A+')
expect(summary.text()).toContain('Allergies')
expect(summary.text()).toContain('Penicillin')
expect(summary.text()).toContain('Medications')
expect(summary.text()).toContain('Metoprolol')
expect(summary.text()).toContain('TEMP C')
expect(summary.text()).toContain('38.4')
})
it('uses design-doc retroactive alerts copy', () => {
const wrapper = mountForm()
expect(wrapper.text()).toContain('Run alert evaluation after promotion')
expect(wrapper.text()).toContain(
'May generate alerts for clinical criteria represented in historical records'
)
})
it('shows Approve & Promote confirm dialog with live promotion copy', async () => {
const wrapper = mountForm()
await wrapper.find('[data-testid="approve-promote"]').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Approve & Promote')
expect(wrapper.text()).toContain(
'Approval will promote the verified records into live clinical tables.'
)
})
it('calls approveBatch with retroactive alerts flag', async () => {
const wrapper = mountForm()
const store = useBatchStore()
store.approveBatch = vi.fn().mockResolvedValue({
status: 200,
data: { mrn: 'MRN-1', encounterId: 'enc-aaaaaaaa', observationIds: ['o1', 'o2'] },
})
await wrapper.find('[data-testid="retroactive-alerts"]').setValue(true)
await wrapper.find('[data-testid="approve-promote"]').trigger('click')
await wrapper.vm.$nextTick()
const confirm = wrapper
.findAll('[data-testid="confirm-dialog"] button')
.find((b) => b.text() === 'Approve & Promote')
await confirm!.trigger('click')
await flushPromises()
expect(store.approveBatch).toHaveBeenCalledWith('b1', true)
expect(wrapper.find('[data-testid="promotion-result"]').exists()).toBe(true)
expect(wrapper.text()).toContain('Promoted to live clinical tables')
expect(wrapper.text()).toContain('MRN-1')
expect(wrapper.text()).toContain('Promotion outcome')
})
it('opens Reject Batch dialog and posts reason', async () => {
const wrapper = mountForm()
const store = useBatchStore()
store.rejectBatch = vi.fn().mockResolvedValue(undefined)
await wrapper.find('[data-testid="reject-batch"]').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Reject Batch')
await wrapper.find('textarea').setValue('Incorrect patient')
await wrapper.vm.$nextTick()
const confirm = wrapper
.findAll('[data-testid="confirm-dialog"] button')
.find((b) => b.text() === 'Reject Batch')
await confirm!.trigger('click')
await flushPromises()
expect(store.rejectBatch).toHaveBeenCalledWith('b1', 'Incorrect patient')
expect(wrapper.emitted('rejected')).toHaveLength(1)
})
it('shows deferred promotion state', async () => {
const wrapper = mountForm()
const store = useBatchStore()
store.approveBatch = vi.fn().mockResolvedValue({ status: 202 })
await wrapper.find('[data-testid="approve-promote"]').trigger('click')
await wrapper.vm.$nextTick()
const confirm = wrapper
.findAll('[data-testid="confirm-dialog"] button')
.find((b) => b.text() === 'Approve & Promote')
await confirm!.trigger('click')
await flushPromises()
expect(wrapper.find('[data-testid="promotion-deferred"]').exists()).toBe(true)
})
})
@@ -0,0 +1,103 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import AuditTrailPanel from '@/components/AuditTrailPanel.vue'
import { useBatchStore } from '@/stores/batches'
import type { BatchEventResponse } from '@/types'
vi.mock('@/api/client', () => ({
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
del: vi.fn(),
patch: vi.fn(),
uploadFile: vi.fn(),
}))
function makeEvent(overrides: Partial<BatchEventResponse> = {}): BatchEventResponse {
return {
id: 'evt-1',
batchId: 'b1',
eventType: 'STATUS_CHANGED',
actorUserId: 'u1',
actorUsername: 'entry1',
actorFullName: 'Entry Clerk 1',
occurredAt: '2026-06-27T10:00:00Z',
metadataJson: JSON.stringify({ previousStatus: 'DRAFT', newStatus: 'PENDING_VERIFICATION' }),
...overrides,
}
}
describe('AuditTrailPanel', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('shows empty state when opened with no events', async () => {
const store = useBatchStore()
store.events = []
store.eventsLoading = false
store.eventsError = null
vi.spyOn(store, 'fetchEvents').mockResolvedValue()
const wrapper = mount(AuditTrailPanel, { props: { batchId: 'b1' } })
const details = wrapper.find('details')
details.element.open = true
await details.trigger('toggle')
await flushPromises()
expect(wrapper.text()).toContain('No audit events for this batch yet.')
})
it('renders events as a vertical timeline', async () => {
const store = useBatchStore()
store.events = [
makeEvent(),
makeEvent({
id: 'evt-2',
eventType: 'VERIFIED',
actorFullName: 'Verifier One',
metadataJson: null,
}),
]
store.eventsLoading = false
store.eventsHasMore = false
vi.spyOn(store, 'fetchEvents').mockResolvedValue()
const wrapper = mount(AuditTrailPanel, { props: { batchId: 'b1' } })
const details = wrapper.find('details')
details.element.open = true
await details.trigger('toggle')
await flushPromises()
expect(wrapper.find('[data-testid="audit-trail-timeline"]').exists()).toBe(true)
expect(wrapper.find('table').exists()).toBe(false)
expect(wrapper.text()).toContain('Status Changed')
expect(wrapper.text()).toContain('Entry Clerk 1')
expect(wrapper.text()).toContain('DRAFT → PENDING_VERIFICATION')
expect(wrapper.text()).toContain('Verified')
expect(wrapper.text()).toContain('Verifier One')
})
it('shows load more when hasMore is true', async () => {
const store = useBatchStore()
store.events = [makeEvent()]
store.eventsHasMore = true
store.eventsNextCursor = 'cursor-1'
const fetchSpy = vi.spyOn(store, 'fetchEvents').mockResolvedValue()
const wrapper = mount(AuditTrailPanel, { props: { batchId: 'b1' } })
const details = wrapper.find('details')
details.element.open = true
await details.trigger('toggle')
await flushPromises()
const loadMore = wrapper.find('[data-testid="audit-trail-load-more"]')
expect(loadMore.exists()).toBe(true)
await loadMore.trigger('click')
expect(fetchSpy).toHaveBeenCalledWith('b1', 'cursor-1')
})
})
@@ -0,0 +1,115 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import EmptyState from '@/components/EmptyState.vue'
import SkeletonBlock from '@/components/SkeletonBlock.vue'
import InlineError from '@/components/InlineError.vue'
import BatchList from '@/components/BatchList.vue'
import type { BatchDetailResponse } from '@/types'
function makeBatch(overrides: Partial<BatchDetailResponse> = {}): BatchDetailResponse {
return {
id: 'batch-aaaaaaaa',
status: 'UPLOADED',
batchType: 'VITALS',
track: 'TRACK_A',
createdAt: '2026-01-15T10:00:00Z',
...overrides,
} as BatchDetailResponse
}
describe('EmptyState', () => {
it('renders operational title and description', () => {
const wrapper = mount(EmptyState, {
props: {
title: 'No batches are waiting for verification.',
description: 'New batches appear here after data entry is submitted.',
},
})
expect(wrapper.text()).toContain('No batches are waiting for verification.')
expect(wrapper.text()).toContain('New batches appear here after data entry is submitted.')
})
it('renders action slot', () => {
const wrapper = mount(EmptyState, {
props: { title: 'Empty' },
slots: { action: '<button>Return to Dashboard</button>' },
})
expect(wrapper.find('button').text()).toBe('Return to Dashboard')
})
})
describe('SkeletonBlock', () => {
it('exposes loading status for table variant', () => {
const wrapper = mount(SkeletonBlock, { props: { variant: 'table', rows: 3 } })
expect(wrapper.attributes('role')).toBe('status')
expect(wrapper.attributes('aria-busy')).toBe('true')
})
})
describe('InlineError', () => {
it('shows what happened, what was preserved, and retry', async () => {
const wrapper = mount(InlineError, {
props: {
title: 'Could not load batches',
message: 'Network error',
preserved: 'Your filters were preserved.',
retryLabel: 'Retry',
},
})
expect(wrapper.text()).toContain('Could not load batches')
expect(wrapper.text()).toContain('Network error')
expect(wrapper.text()).toContain('Your filters were preserved.')
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('retry')).toHaveLength(1)
})
})
describe('BatchList patterns', () => {
it('shows skeleton while loading', () => {
const wrapper = mount(BatchList, {
props: { batches: [], loading: true },
})
expect(wrapper.find('[role="status"]').exists()).toBe(true)
expect(wrapper.text()).not.toContain('No batches')
})
it('shows empty state when idle with no batches', () => {
const wrapper = mount(BatchList, {
props: {
batches: [],
loading: false,
emptyTitle: 'No batches are waiting for verification.',
emptyDescription: 'New batches appear here after data entry is submitted.',
},
})
expect(wrapper.text()).toContain('No batches are waiting for verification.')
})
it('shows inline error with retry over empty/loading', async () => {
const wrapper = mount(BatchList, {
props: {
batches: [],
loading: false,
error: 'Timed out',
},
})
expect(wrapper.text()).toContain('Could not load batches')
expect(wrapper.text()).toContain('Timed out')
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('retry')).toHaveLength(1)
})
it('renders StatusBadge for each batch status', () => {
const wrapper = mount(BatchList, {
props: {
batches: [
makeBatch({ id: 'b1', status: 'PENDING_VERIFICATION' }),
makeBatch({ id: 'b2', status: 'PROMOTED' }),
],
loading: false,
},
})
expect(wrapper.text()).toContain('Pending Verification')
expect(wrapper.text()).toContain('Promoted')
})
})
@@ -107,17 +107,43 @@ describe('EntryForm', () => {
const wrapper = mount(EntryForm, { const wrapper = mount(EntryForm, {
props: { batch: makeBatch(), batchId: 'b1' }, props: { batch: makeBatch(), batchId: 'b1' },
}) })
const submitBtn = wrapper.find('button')
const buttons = wrapper.findAll('button') const buttons = wrapper.findAll('button')
const submitButton = buttons.find((b) => b.text().includes('Submit for Verification')) const submitButton = buttons.find((b) => b.text().includes('Submit for Verification'))
expect(submitButton).toBeTruthy() expect(submitButton).toBeTruthy()
expect(wrapper.find('[data-testid="workstation-action-bar"]').exists()).toBe(true)
expect(wrapper.text()).toContain('Save Draft')
})
it('shows autosave status after patient field blur', async () => {
const wrapper = mount(EntryForm, {
props: { batch: makeBatch(), batchId: 'b1' },
})
const store = useBatchStore()
store.saveDraftPatient = vi.fn().mockResolvedValue(undefined)
const nameInput = wrapper.find('input[type="text"]')
await nameInput.setValue('Jane Doe')
await nameInput.trigger('blur')
await flushPromises()
expect(wrapper.find('[data-testid="entry-save-status"]').text()).toMatch(/^Saved /)
})
it('shows Next when nextBatchId is provided', async () => {
const wrapper = mount(EntryForm, {
props: { batch: makeBatch(), batchId: 'b1', nextBatchId: 'b2' },
})
const next = wrapper.find('[data-testid="entry-next-batch"]')
expect(next.exists()).toBe(true)
await next.trigger('click')
expect(wrapper.emitted('open-next')?.[0]).toEqual(['b2'])
}) })
it('displays batch status', () => { it('displays batch status', () => {
const wrapper = mount(EntryForm, { const wrapper = mount(EntryForm, {
props: { batch: makeBatch({ status: 'IN_ENTRY' }), batchId: 'b1' }, props: { batch: makeBatch({ status: 'IN_ENTRY' }), batchId: 'b1' },
}) })
expect(wrapper.text()).toContain('IN ENTRY') expect(wrapper.text()).toContain('In Entry')
}) })
describe('conditional sections by batch type', () => { describe('conditional sections by batch type', () => {
@@ -172,7 +198,6 @@ describe('EntryForm', () => {
store.currentDraft = emptyDraft('VITALS', { store.currentDraft = emptyDraft('VITALS', {
patient: { patient: {
id: 'dp1', id: 'dp1',
batchId: 'b1',
fullName: 'John Doe', fullName: 'John Doe',
dateOfBirth: '1990-05-15', dateOfBirth: '1990-05-15',
sex: 'male', sex: 'male',
@@ -188,7 +213,7 @@ describe('EntryForm', () => {
}) })
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const nameInput = wrapper.find('input[type="text"]') const nameInput = wrapper.find<HTMLInputElement>('input[type="text"]')
expect(nameInput.element.value).toBe('John Doe') expect(nameInput.element.value).toBe('John Doe')
}) })
}) })
@@ -271,7 +296,7 @@ describe('EntryForm', () => {
const store = useBatchStore() const store = useBatchStore()
store.submitForVerification = vi.fn().mockReturnValue( store.submitForVerification = vi.fn().mockReturnValue(
new Promise((resolve) => { new Promise<void>((resolve) => {
resolvePromise = resolve resolvePromise = resolve
}), }),
) )
@@ -0,0 +1,138 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import { createPinia, setActivePinia } from 'pinia'
import HelpPanel from '@/components/HelpPanel.vue'
import TourHelpButton from '@/components/TourHelpButton.vue'
import { useHelpPanel } from '@/composables/useHelpPanel'
import { useAuthStore } from '@/stores/auth'
import { useTourStore } from '@/stores/tour'
vi.mock('@/api/client', () => ({
post: vi.fn(),
get: vi.fn(),
}))
vi.mock('@/router', () => ({
default: {
push: vi.fn().mockResolvedValue(undefined),
currentRoute: { value: { path: '/entry' } },
},
}))
vi.mock('vue-router', () => ({
useRoute: () => ({ path: '/entry' }),
}))
let wrappers: VueWrapper[] = []
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
wrappers = []
useHelpPanel().closePanel()
})
afterEach(() => {
wrappers.forEach((w) => w.unmount())
wrappers = []
useHelpPanel().closePanel()
})
function mountPanel() {
const wrapper = mount(HelpPanel, { attachTo: document.body })
wrappers.push(wrapper)
return wrapper
}
describe('TourHelpButton', () => {
it('opens the help panel when clicked', async () => {
const help = useHelpPanel()
const wrapper = mount(TourHelpButton)
wrappers.push(wrapper)
await wrapper.get('[data-testid="tour-help"]').trigger('click')
expect(help.open.value).toBe(true)
})
it('remains enabled when role has no tour', () => {
const auth = useAuthStore()
auth.user = {
id: 'u1',
username: 'x',
fullName: 'X',
role: 'UNKNOWN',
}
const wrapper = mount(TourHelpButton)
wrappers.push(wrapper)
expect(wrapper.get('[data-testid="tour-help"]').attributes('disabled')).toBeUndefined()
})
})
describe('HelpPanel', () => {
it('renders nothing when closed', () => {
mountPanel()
expect(document.querySelector('[data-testid="help-panel"]')).toBeNull()
})
it('shows page guide content when opened', async () => {
const help = useHelpPanel()
mountPanel()
help.openPanel()
await nextTick()
await flushPromises()
const panel = document.querySelector('[data-testid="help-panel"]')
expect(panel).not.toBeNull()
expect(panel?.textContent).toContain('Data Entry')
expect(panel?.textContent).toContain('Transcribe structured fields')
expect(panel?.textContent).toContain('Replay walkthrough')
})
it('closes on Close button click', async () => {
const help = useHelpPanel()
mountPanel()
help.openPanel()
await nextTick()
const done = document.querySelector('[data-testid="help-panel-done"]') as HTMLButtonElement
expect(done).not.toBeNull()
done.click()
await nextTick()
expect(help.open.value).toBe(false)
expect(document.querySelector('[data-testid="help-panel"]')).toBeNull()
})
it('Replay walkthrough closes panel and starts tour', async () => {
const el = document.createElement('div')
el.setAttribute('data-tour', 'entry-header')
document.body.appendChild(el)
const auth = useAuthStore()
auth.user = {
id: 'u1',
username: 'entry1',
fullName: 'Entry One',
role: 'DATA_ENTRY_CLERK',
}
const tour = useTourStore()
const help = useHelpPanel()
mountPanel()
help.openPanel()
await nextTick()
const replay = document.querySelector('[data-testid="help-panel-replay"]') as HTMLButtonElement
expect(replay).not.toBeNull()
expect(replay.disabled).toBe(false)
replay.click()
await flushPromises()
await nextTick()
expect(help.open.value).toBe(false)
expect(tour.active).toBe(true)
expect(tour.currentStep?.id).toBe('entry-job')
})
})
@@ -17,11 +17,11 @@ function makeObservation(overrides: Partial<DraftObservation> = {}): DraftObserv
} }
describe('ObservationRow', () => { describe('ObservationRow', () => {
it('renders observation code options', () => { it('renders observation code options in editable mode', () => {
const wrapper = mount(ObservationRow, { const wrapper = mount(ObservationRow, {
props: { observation: makeObservation() }, props: { observation: makeObservation() },
}) })
const options = wrapper.findAll('select option') const options = wrapper.findAll<HTMLOptionElement>('select option')
const values = options.map((o) => o.element.value) const values = options.map((o) => o.element.value)
expect(values).toContain('HEART_RATE') expect(values).toContain('HEART_RATE')
expect(values).toContain('TEMP_C') expect(values).toContain('TEMP_C')
@@ -34,23 +34,31 @@ describe('ObservationRow', () => {
const wrapper = mount(ObservationRow, { const wrapper = mount(ObservationRow, {
props: { observation: makeObservation({ value: 98.6, unit: '°F' }) }, props: { observation: makeObservation({ value: 98.6, unit: '°F' }) },
}) })
const numberInput = wrapper.find('input[type="number"]') const numberInput = wrapper.find<HTMLInputElement>('input[type="number"]')
expect(numberInput.element.value).toBe('98.6') expect(numberInput.element.value).toBe('98.6')
const textInputs = wrapper.findAll('input[type="text"]') const textInputs = wrapper.findAll<HTMLInputElement>('input[type="text"]')
const unitInput = textInputs[0] const unitInput = textInputs[0]
expect(unitInput.element.value).toBe('°F') expect(unitInput.element.value).toBe('°F')
}) })
it('emits update event on code change', async () => { it('emits update event on code change and auto-fills unit', async () => {
const wrapper = mount(ObservationRow, { const wrapper = mount(ObservationRow, {
props: { observation: makeObservation() }, props: { observation: makeObservation({ unit: '' }) },
}) })
const select = wrapper.find('select') const select = wrapper.find('select')
await select.setValue('TEMP_C') await select.setValue('TEMP_C')
expect(wrapper.emitted('update')).toBeTruthy() expect(wrapper.emitted('update')).toBeTruthy()
expect(wrapper.emitted('update')![0]).toEqual(['observationCode', 'TEMP_C']) expect(wrapper.emitted('update')![0]).toEqual(['observationCode', 'TEMP_C'])
expect(wrapper.emitted('update')![1]).toEqual(['unit', '°C'])
})
it('hides delete button when canDelete is false', () => {
const wrapper = mount(ObservationRow, {
props: { observation: makeObservation(), canDelete: false },
})
expect(wrapper.find('button').exists()).toBe(false)
}) })
it('emits update event on value change', async () => { it('emits update event on value change', async () => {
@@ -86,15 +94,15 @@ describe('ObservationRow', () => {
expect(wrapper.emitted('delete')![0]).toEqual(['obs-42']) expect(wrapper.emitted('delete')![0]).toEqual(['obs-42'])
}) })
it('disables inputs in readonly mode', () => { it('renders a scannable summary card in readonly mode', () => {
const wrapper = mount(ObservationRow, { const wrapper = mount(ObservationRow, {
props: { observation: makeObservation(), readonly: true }, props: { observation: makeObservation({ value: 88, unit: 'bpm' }), readonly: true },
}) })
const select = wrapper.find('select') expect(wrapper.find('select').exists()).toBe(false)
expect(select.element.disabled).toBe(true) expect(wrapper.find('input[type="number"]').exists()).toBe(false)
expect(wrapper.text()).toContain('Heart Rate')
const numberInput = wrapper.find('input[type="number"]') expect(wrapper.text()).toContain('88')
expect(numberInput.element.disabled).toBe(true) expect(wrapper.text()).toContain('bpm')
}) })
it('hides delete button in readonly mode', () => { it('hides delete button in readonly mode', () => {
@@ -114,7 +122,7 @@ describe('ObservationRow', () => {
verified: false, verified: false,
}, },
}) })
const checkbox = wrapper.find('input[type="checkbox"]') const checkbox = wrapper.find<HTMLInputElement>('input[type="checkbox"]')
expect(checkbox.exists()).toBe(true) expect(checkbox.exists()).toBe(true)
expect(checkbox.element.checked).toBe(false) expect(checkbox.element.checked).toBe(false)
}) })
@@ -128,7 +136,7 @@ describe('ObservationRow', () => {
verified: true, verified: true,
}, },
}) })
const checkbox = wrapper.find('input[type="checkbox"]') const checkbox = wrapper.find<HTMLInputElement>('input[type="checkbox"]')
expect(checkbox.element.checked).toBe(true) expect(checkbox.element.checked).toBe(true)
}) })
@@ -150,7 +158,7 @@ describe('ObservationRow', () => {
it('hides verification checkbox when showVerified is false', () => { it('hides verification checkbox when showVerified is false', () => {
const wrapper = mount(ObservationRow, { const wrapper = mount(ObservationRow, {
props: { observation: makeObservation(), showVerified: false }, props: { observation: makeObservation(), readonly: true, showVerified: false },
}) })
const checkbox = wrapper.find('input[type="checkbox"]') const checkbox = wrapper.find('input[type="checkbox"]')
expect(checkbox.exists()).toBe(false) expect(checkbox.exists()).toBe(false)
@@ -0,0 +1,124 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import SeparationOfDutiesBanner from '@/components/SeparationOfDutiesBanner.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
describe('SeparationOfDutiesBanner', () => {
it('shows enforced info when entered-by and verifier differ', () => {
const wrapper = mount(SeparationOfDutiesBanner, {
props: {
enteredByUserId: 'entry-user-1',
enteredByUserName: 'Arjun Menon',
currentUserId: 'verify-user-2',
currentUserName: 'Priya Nair',
},
})
expect(wrapper.text()).toContain('Separation of Duties Enforced')
expect(wrapper.text()).toContain('Arjun Menon')
expect(wrapper.text()).toContain('Priya Nair')
expect(wrapper.text()).not.toContain('You cannot verify')
expect(wrapper.emitted('update:blocked')?.at(-1)).toEqual([false])
})
it('blocks when the same user entered the batch', () => {
const wrapper = mount(SeparationOfDutiesBanner, {
props: {
enteredByUserId: 'same-user',
currentUserId: 'same-user',
currentUserName: 'Alex Clerk',
},
})
expect(wrapper.text()).toContain('You cannot verify a batch you entered.')
expect(wrapper.emitted('update:blocked')?.at(-1)).toEqual([true])
})
it('falls back to truncated ids when names are omitted', () => {
const wrapper = mount(SeparationOfDutiesBanner, {
props: {
enteredByUserId: 'abcdefghijkl',
currentUserId: 'mnopqrstuvwx',
},
})
expect(wrapper.text()).toContain('abcdefgh…')
expect(wrapper.text()).toContain('mnopqrst…')
})
it('hides when either id is missing', () => {
const wrapper = mount(SeparationOfDutiesBanner, {
props: {
enteredByUserId: 'entry-1',
currentUserId: '',
},
})
expect(wrapper.find('[data-testid="sod-banner"]').exists()).toBe(false)
})
})
describe('ConfirmDialog', () => {
const teleportStub = {
global: {
stubs: {
Teleport: { template: '<div><slot /></div>' },
},
},
}
it('renders title, body, and confirm label when open', () => {
const wrapper = mount(ConfirmDialog, {
props: {
open: true,
title: 'Approve & Promote',
body: 'Approval will promote the verified records into live clinical tables.',
confirmLabel: 'Approve & Promote',
variant: 'primary',
},
...teleportStub,
})
expect(wrapper.text()).toContain('Approve & Promote')
expect(wrapper.text()).toContain(
'Approval will promote the verified records into live clinical tables.'
)
})
it('emits confirm and cancel', async () => {
const wrapper = mount(ConfirmDialog, {
props: {
open: true,
title: 'Reject Batch',
confirmLabel: 'Confirm Rejection',
variant: 'danger',
},
...teleportStub,
})
const buttons = wrapper.findAll('button')
const confirmBtn = buttons.find((b) => b.text() === 'Confirm Rejection')
const cancelBtn = buttons.find((b) => b.text() === 'Cancel')
await confirmBtn!.trigger('click')
await cancelBtn!.trigger('click')
expect(wrapper.emitted('confirm')).toHaveLength(1)
expect(wrapper.emitted('cancel')).toHaveLength(1)
})
it('disables confirm when confirmDisabled is true', () => {
const wrapper = mount(ConfirmDialog, {
props: {
open: true,
title: 'Reject Batch',
confirmLabel: 'Confirm Rejection',
variant: 'danger',
confirmDisabled: true,
},
...teleportStub,
})
const confirmBtn = wrapper
.findAll('button')
.find((b) => b.text() === 'Confirm Rejection')
expect(confirmBtn!.attributes('disabled')).toBeDefined()
})
})
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import StatusBadge from '@/components/StatusBadge.vue'
import { BATCH_STATUS_META, getBatchStatusMeta } from '@/utils/batchStatus'
describe('StatusBadge', () => {
it.each(Object.keys(BATCH_STATUS_META))('renders label and icon for %s', (status) => {
const wrapper = mount(StatusBadge, { props: { status } })
expect(wrapper.text()).toContain(BATCH_STATUS_META[status].label)
expect(wrapper.find('svg').exists()).toBe(true)
})
it('humanizes unknown statuses without relying on color alone', () => {
const wrapper = mount(StatusBadge, { props: { status: 'CUSTOM_STATE' } })
expect(wrapper.text()).toContain('Custom State')
expect(wrapper.find('svg').exists()).toBe(true)
})
it('handles null status', () => {
const wrapper = mount(StatusBadge, { props: { status: null } })
expect(wrapper.text()).toContain('Unknown')
})
})
describe('getBatchStatusMeta', () => {
it('maps REJECTED to Verification Rejected', () => {
expect(getBatchStatusMeta('REJECTED').label).toBe('Verification Rejected')
})
it('maps AWAITING_CLINICAL_APPROVAL to Pending Approval', () => {
expect(getBatchStatusMeta('AWAITING_CLINICAL_APPROVAL').label).toBe('Pending Approval')
})
})
@@ -0,0 +1,66 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import TourOverlay from '@/components/TourOverlay.vue'
import { useTourStore } from '@/stores/tour'
vi.mock('@/router', () => ({
default: {
push: vi.fn().mockResolvedValue(undefined),
currentRoute: { value: { path: '/entry' } },
},
}))
beforeEach(() => {
setActivePinia(createPinia())
document.body.innerHTML = ''
})
afterEach(() => {
document.body.innerHTML = ''
})
describe('TourOverlay', () => {
it('renders nothing when tour is inactive', () => {
mount(TourOverlay, { attachTo: document.body })
expect(document.querySelector('[data-testid="tour-overlay"]')).toBeNull()
})
it('renders title, body, Next and Skip when active', async () => {
const el = document.createElement('div')
el.setAttribute('data-tour', 'entry-header')
document.body.appendChild(el)
const tour = useTourStore()
await tour.start('DATA_ENTRY_CLERK', 'u1')
mount(TourOverlay, { attachTo: document.body })
await flushPromises()
const overlay = document.querySelector('[data-testid="tour-overlay"]')
expect(overlay).not.toBeNull()
expect(overlay?.textContent).toContain('Your job: Data Entry')
expect(overlay?.textContent).toContain('Transcribe structured fields')
expect(document.querySelector('[data-testid="tour-next"]')).not.toBeNull()
expect(document.querySelector('[data-testid="tour-skip"]')).not.toBeNull()
})
it('Skip closes the tour', async () => {
const el = document.createElement('div')
el.setAttribute('data-tour', 'entry-header')
document.body.appendChild(el)
const tour = useTourStore()
await tour.start('DATA_ENTRY_CLERK', 'u1')
mount(TourOverlay, { attachTo: document.body })
await flushPromises()
const skip = document.querySelector('[data-testid="tour-skip"]') as HTMLButtonElement
skip.click()
await flushPromises()
expect(tour.active).toBe(false)
expect(document.querySelector('[data-testid="tour-overlay"]')).toBeNull()
})
})
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import VerificationFieldCard from '@/components/VerificationFieldCard.vue'
describe('VerificationFieldCard', () => {
it('renders soft label, bold value, and OK chip', () => {
const wrapper = mount(VerificationFieldCard, {
props: {
label: 'Full Name',
value: 'Jane Doe',
checked: false,
},
})
expect(wrapper.text()).toContain('Full Name')
expect(wrapper.text()).toContain('Jane Doe')
expect(wrapper.text()).toContain('OK')
expect(wrapper.find('input[type="checkbox"]').exists()).toBe(true)
})
it('shows (empty) when value is blank', () => {
const wrapper = mount(VerificationFieldCard, {
props: {
label: 'MRN',
value: '',
checked: false,
},
})
expect(wrapper.text()).toContain('(empty)')
})
it('emits toggle when OK checkbox changes', async () => {
const wrapper = mount(VerificationFieldCard, {
props: {
label: 'Sex',
value: 'F',
checked: false,
},
})
await wrapper.find('input[type="checkbox"]').setValue(true)
expect(wrapper.emitted('toggle')?.[0]).toEqual([true])
})
})
@@ -3,6 +3,7 @@ import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia' import { setActivePinia, createPinia } from 'pinia'
import VerificationForm from '@/components/VerificationForm.vue' import VerificationForm from '@/components/VerificationForm.vue'
import { useBatchStore } from '@/stores/batches' import { useBatchStore } from '@/stores/batches'
import { useAuthStore } from '@/stores/auth'
import type { BatchDetailResponse } from '@/types' import type { BatchDetailResponse } from '@/types'
import { import {
emptyDraft, emptyDraft,
@@ -31,8 +32,44 @@ vi.mock('vue-router', () => ({
useRouter: () => ({ useRouter: () => ({
push: vi.fn(), push: vi.fn(),
}), }),
useRoute: () => ({
params: {},
query: {},
}),
createRouter: () => ({
beforeEach: vi.fn(),
afterEach: vi.fn(),
push: vi.fn(),
replace: vi.fn(),
}),
createWebHistory: () => ({}),
})) }))
vi.mock('@/router', () => ({
default: {
push: vi.fn(),
replace: vi.fn(),
beforeEach: vi.fn(),
},
}))
const mountOptions = {
global: {
stubs: {
// Render dialog content in-tree (ConfirmDialog uses Teleport)
Teleport: { template: '<div><slot /></div>' },
},
},
}
function mountForm(
props: { batch: BatchDetailResponse | null; batchId: string } = {
batch: makeBatch(),
batchId: 'b1',
},
) {
return mount(VerificationForm, { props, ...mountOptions })
}
function makeBatch(overrides: Partial<BatchDetailResponse> = {}): BatchDetailResponse { function makeBatch(overrides: Partial<BatchDetailResponse> = {}): BatchDetailResponse {
const batchType = overrides.batchType ?? 'VITALS' const batchType = overrides.batchType ?? 'VITALS'
return { return {
@@ -63,15 +100,12 @@ function makeBatch(overrides: Partial<BatchDetailResponse> = {}): BatchDetailRes
function mountWithDraft(batchOverrides: Partial<BatchDetailResponse> = {}) { function mountWithDraft(batchOverrides: Partial<BatchDetailResponse> = {}) {
const batch = makeBatch(batchOverrides) const batch = makeBatch(batchOverrides)
const wrapper = mount(VerificationForm, { const wrapper = mountForm({ batch, batchId: 'b1' })
props: { batch, batchId: 'b1' },
})
const store = useBatchStore() const store = useBatchStore()
store.currentDraft = emptyDraft(batch.batchType, { store.currentDraft = emptyDraft(batch.batchType, {
patient: { patient: {
id: 'dp1', id: 'dp1',
batchId: 'b1',
fullName: 'Jane Doe', fullName: 'Jane Doe',
dateOfBirth: '1990-05-15', dateOfBirth: '1990-05-15',
sex: 'female', sex: 'female',
@@ -124,9 +158,7 @@ beforeEach(() => {
describe('VerificationForm', () => { describe('VerificationForm', () => {
it('renders verification header', () => { it('renders verification header', () => {
const wrapper = mount(VerificationForm, { const wrapper = mountForm()
props: { batch: makeBatch(), batchId: 'b1' },
})
expect(wrapper.text()).toContain('Verification Review') expect(wrapper.text()).toContain('Verification Review')
expect(wrapper.text()).toContain('Pending Verification') expect(wrapper.text()).toContain('Pending Verification')
}) })
@@ -139,6 +171,10 @@ describe('VerificationForm', () => {
expect(wrapper.text()).toContain('Jane Doe') expect(wrapper.text()).toContain('Jane Doe')
expect(wrapper.text()).toContain('1990-05-15') expect(wrapper.text()).toContain('1990-05-15')
const cards = wrapper.findAll('[data-testid="verification-field-card"]')
expect(cards.length).toBeGreaterThan(0)
expect(wrapper.text()).toContain('OK')
const checkboxes = wrapper.findAll('input[type="checkbox"]') const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect(checkboxes.length).toBeGreaterThan(0) expect(checkboxes.length).toBeGreaterThan(0)
}) })
@@ -154,20 +190,13 @@ describe('VerificationForm', () => {
}) })
it('shows rejection reason banner when present', () => { it('shows rejection reason banner when present', () => {
const wrapper = mount(VerificationForm, { const wrapper = mountForm({ batch: makeBatch({ rejectionReason: 'Temperature seems incorrect' }), batchId: 'b1' })
props: {
batch: makeBatch({ rejectionReason: 'Temperature seems incorrect' }),
batchId: 'b1',
},
})
expect(wrapper.text()).toContain('Previous Rejection Reason') expect(wrapper.text()).toContain('Previous Rejection Reason')
expect(wrapper.text()).toContain('Temperature seems incorrect') expect(wrapper.text()).toContain('Temperature seems incorrect')
}) })
it('does not show rejection banner when no reason', () => { it('does not show rejection banner when no reason', () => {
const wrapper = mount(VerificationForm, { const wrapper = mountForm({ batch: makeBatch({ rejectionReason: null }), batchId: 'b1' })
props: { batch: makeBatch({ rejectionReason: null }), batchId: 'b1' },
})
expect(wrapper.text()).not.toContain('Previous Rejection Reason') expect(wrapper.text()).not.toContain('Previous Rejection Reason')
}) })
@@ -193,15 +222,23 @@ describe('VerificationForm', () => {
}) })
describe('approve button', () => { describe('approve button', () => {
async function selectPass(wrapper: ReturnType<typeof mountForm>) {
const passRadio = wrapper.find('input[type="radio"][value="pass"]')
await passRadio.setValue(true)
await wrapper.vm.$nextTick()
}
it('is disabled when not all fields are checked', async () => { it('is disabled when not all fields are checked', async () => {
const { wrapper } = mountWithDraft() const { wrapper } = mountWithDraft()
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
await selectPass(wrapper)
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve')) const approveBtn = wrapper.find('[data-testid="verify-pass"]')
expect(approveBtn!.element.disabled).toBe(true) expect(approveBtn.element).toBeTruthy()
expect((approveBtn.element as HTMLButtonElement).disabled).toBe(true)
}) })
it('is enabled when all fields are checked', async () => { it('is enabled when all fields are checked and Pass is selected', async () => {
const { wrapper } = mountWithDraft() const { wrapper } = mountWithDraft()
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
@@ -209,13 +246,14 @@ describe('VerificationForm', () => {
for (const cb of checkboxes) { for (const cb of checkboxes) {
await cb.setValue(true) await cb.setValue(true)
} }
await wrapper.vm.$nextTick() await selectPass(wrapper)
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve')) const approveBtn = wrapper.find('[data-testid="verify-pass"]')
expect(approveBtn!.element.disabled).toBe(false) expect((approveBtn.element as HTMLButtonElement).disabled).toBe(false)
expect(approveBtn.text()).toBe('Pass Verification')
}) })
it('calls verifyBatch with all field checks on approve', async () => { it('calls verifyBatch with all field checks on Pass Verification', async () => {
const { wrapper, store } = mountWithDraft() const { wrapper, store } = mountWithDraft()
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
@@ -225,10 +263,9 @@ describe('VerificationForm', () => {
for (const cb of checkboxes) { for (const cb of checkboxes) {
await cb.setValue(true) await cb.setValue(true)
} }
await wrapper.vm.$nextTick() await selectPass(wrapper)
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve')) await wrapper.find('[data-testid="verify-pass"]').trigger('click')
await approveBtn!.trigger('click')
await flushPromises() await flushPromises()
expect(store.verifyBatch).toHaveBeenCalledWith( expect(store.verifyBatch).toHaveBeenCalledWith(
@@ -242,88 +279,141 @@ describe('VerificationForm', () => {
}) })
describe('reject flow', () => { describe('reject flow', () => {
it('shows reject dialog when reject button is clicked', async () => { async function selectReject(wrapper: ReturnType<typeof mountForm>) {
const wrapper = mount(VerificationForm, { const rejectRadio = wrapper.find('input[type="radio"][value="reject"]')
props: { batch: makeBatch(), batchId: 'b1' }, await rejectRadio.setValue(true)
}) await wrapper.vm.$nextTick()
}
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') it('shows Return for Rework and opens confirm dialog', async () => {
await rejectBtn!.trigger('click') const wrapper = mountForm()
await selectReject(wrapper)
const returnBtn = wrapper.find('[data-testid="verify-return"]')
expect(returnBtn.text()).toBe('Return for Rework')
await returnBtn.trigger('click')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Reject Batch') expect(wrapper.text()).toContain('Return for Rework')
expect(wrapper.text()).toContain('Confirm Rejection') expect(wrapper.text()).toContain('This returns the batch for rework')
}) })
it('disables confirm button when reason is empty', async () => { it('disables confirm button when reason is empty', async () => {
const wrapper = mount(VerificationForm, { const wrapper = mountForm()
props: { batch: makeBatch(), batchId: 'b1' }, await selectReject(wrapper)
})
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') await wrapper.find('[data-testid="verify-return"]').trigger('click')
await rejectBtn!.trigger('click')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection') const dialogConfirms = wrapper.findAll('[data-testid="confirm-dialog"] button')
expect(confirmBtn!.element.disabled).toBe(true) .filter((b) => b.text() === 'Return for Rework')
expect(dialogConfirms.length).toBeGreaterThan(0)
expect((dialogConfirms[0].element as HTMLButtonElement).disabled).toBe(true)
}) })
it('enables confirm button when reason is entered', async () => { it('enables confirm button when reason is entered', async () => {
const wrapper = mount(VerificationForm, { const wrapper = mountForm()
props: { batch: makeBatch(), batchId: 'b1' }, await selectReject(wrapper)
})
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') await wrapper.find('[data-testid="verify-return"]').trigger('click')
await rejectBtn!.trigger('click')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const textarea = wrapper.find('textarea') const textarea = wrapper.find('textarea')
await textarea.setValue('Temperature value appears incorrect') await textarea.setValue('Temperature value appears incorrect')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection') const dialogConfirms = wrapper.findAll('[data-testid="confirm-dialog"] button')
expect(confirmBtn!.element.disabled).toBe(false) .filter((b) => b.text() === 'Return for Rework')
expect((dialogConfirms[0].element as HTMLButtonElement).disabled).toBe(false)
}) })
it('calls rejectBatch on confirm', async () => { it('calls rejectBatch on confirm', async () => {
const wrapper = mount(VerificationForm, { const wrapper = mountForm()
props: { batch: makeBatch(), batchId: 'b1' },
})
const store = useBatchStore() const store = useBatchStore()
store.rejectBatch = vi.fn().mockResolvedValue(undefined) store.rejectBatch = vi.fn().mockResolvedValue(undefined)
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') await selectReject(wrapper)
await rejectBtn!.trigger('click') await wrapper.find('[data-testid="verify-return"]').trigger('click')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const textarea = wrapper.find('textarea') const textarea = wrapper.find('textarea')
await textarea.setValue('Value incorrect') await textarea.setValue('Value incorrect')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection') const dialogConfirms = wrapper.findAll('[data-testid="confirm-dialog"] button')
await confirmBtn!.trigger('click') .filter((b) => b.text() === 'Return for Rework')
await dialogConfirms[0].trigger('click')
await flushPromises() await flushPromises()
expect(store.rejectBatch).toHaveBeenCalledWith('b1', 'Value incorrect') expect(store.rejectBatch).toHaveBeenCalledWith('b1', 'Value incorrect')
}) })
it('closes reject dialog on cancel', async () => { it('closes reject dialog on cancel', async () => {
const wrapper = mount(VerificationForm, { const wrapper = mountForm()
props: { batch: makeBatch(), batchId: 'b1' }, await selectReject(wrapper)
})
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') await wrapper.find('[data-testid="verify-return"]').trigger('click')
await rejectBtn!.trigger('click')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Reject Batch') expect(wrapper.find('[data-testid="confirm-dialog"]').exists()).toBe(true)
const cancelBtn = wrapper.findAll('button').find((b) => b.text() === 'Cancel') const cancelBtn = wrapper.findAll('button').find((b) => b.text() === 'Cancel')
await cancelBtn!.trigger('click') await cancelBtn!.trigger('click')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(wrapper.text()).not.toContain('Reject Batch') expect(wrapper.find('[data-testid="confirm-dialog"]').exists()).toBe(false)
})
})
describe('separation of duties', () => {
it('shows SoD enforced banner and keeps Pass enabled for a different verifier', async () => {
const auth = useAuthStore()
auth.user = {
id: 'verifier-2',
username: 'verifier',
fullName: 'Priya Nair',
role: 'VERIFIER',
}
const { wrapper } = mountWithDraft({ enteredByUserId: 'entry-1' })
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Separation of Duties Enforced')
expect(wrapper.text()).toContain('Priya Nair')
const checkboxes = wrapper.findAll('input[type="checkbox"]')
for (const cb of checkboxes) {
await cb.setValue(true)
}
await wrapper.find('input[type="radio"][value="pass"]').setValue(true)
await wrapper.vm.$nextTick()
const approveBtn = wrapper.find('[data-testid="verify-pass"]')
expect((approveBtn.element as HTMLButtonElement).disabled).toBe(false)
})
it('blocks Pass when the current user entered the batch', async () => {
const auth = useAuthStore()
auth.user = {
id: 'entry-1',
username: 'clerk',
fullName: 'Alex Clerk',
role: 'VERIFIER',
}
const { wrapper } = mountWithDraft({ enteredByUserId: 'entry-1' })
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('You cannot verify a batch you entered.')
const passRadio = wrapper.find('input[type="radio"][value="pass"]')
expect((passRadio.element as HTMLInputElement).disabled).toBe(true)
const rejectRadio = wrapper.find('input[type="radio"][value="reject"]')
expect((rejectRadio.element as HTMLInputElement).disabled).toBe(true)
expect(wrapper.find('[data-testid="verify-pass"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="verify-return"]').exists()).toBe(false)
}) })
}) })
@@ -354,15 +444,15 @@ describe('VerificationForm', () => {
}) })
it('shows NKA for noKnownAllergies', async () => { it('shows NKA for noKnownAllergies', async () => {
const wrapper = mount(VerificationForm, { const wrapper = mountForm({
props: { batch: makeBatch({ batchType: 'ALLERGY_UPDATE' }), batchId: 'b1' }, batch: makeBatch({ batchType: 'ALLERGY_UPDATE' }),
batchId: 'b1',
}) })
const store = useBatchStore() const store = useBatchStore()
store.currentDraft = emptyDraft('ALLERGY_UPDATE', { store.currentDraft = emptyDraft('ALLERGY_UPDATE', {
patient: { patient: {
id: 'dp1', id: 'dp1',
batchId: 'b1',
fullName: 'Jane', fullName: 'Jane',
dateOfBirth: '1990-01-01', dateOfBirth: '1990-01-01',
sex: 'female', sex: 'female',
@@ -403,10 +493,10 @@ describe('VerificationForm', () => {
for (const cb of checkboxes) { for (const cb of checkboxes) {
await cb.setValue(true) await cb.setValue(true)
} }
await wrapper.find('input[type="radio"][value="pass"]').setValue(true)
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve')) await wrapper.find('[data-testid="verify-pass"]').trigger('click')
await approveBtn!.trigger('click')
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('Server error') expect(wrapper.text()).toContain('Server error')
@@ -0,0 +1,106 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { computed, ref } from 'vue'
import WorkstationActionBar from '@/components/WorkstationActionBar.vue'
import OcrConfidenceBadge from '@/components/OcrConfidenceBadge.vue'
import {
confidenceToLevel,
formatOcrBadgeLabel,
useOcrFieldConfidence,
OCR_HIGH_THRESHOLD,
OCR_MEDIUM_THRESHOLD,
} from '@/composables/useOcrFieldConfidence'
import type { OcrConfidenceMap } from '@/types'
describe('WorkstationActionBar', () => {
it('renders sticky bar with left, center, primary, and right slots', () => {
const wrapper = mount(WorkstationActionBar, {
slots: {
left: '<button class="btn-danger">Reject</button>',
center: '<button class="btn-secondary">Save Draft</button>',
primary: '<button class="btn-primary">Submit for Verification</button>',
right: '<button class="btn-secondary">Next</button>',
},
})
const bar = wrapper.find('[data-testid="workstation-action-bar"]')
expect(bar.exists()).toBe(true)
expect(bar.classes()).toContain('sticky')
expect(bar.classes()).toContain('bottom-0')
expect(wrapper.text()).toContain('Reject')
expect(wrapper.text()).toContain('Save Draft')
expect(wrapper.text()).toContain('Submit for Verification')
expect(wrapper.text()).toContain('Next')
})
})
describe('OCR confidence thresholds (design-doc §14)', () => {
it('maps 95%+ to high, 8094% to medium, below 80% to low', () => {
expect(confidenceToLevel(0.95)).toBe('high')
expect(confidenceToLevel(1)).toBe('high')
expect(confidenceToLevel(0.94)).toBe('medium')
expect(confidenceToLevel(OCR_MEDIUM_THRESHOLD)).toBe('medium')
expect(confidenceToLevel(0.79)).toBe('low')
expect(OCR_HIGH_THRESHOLD).toBe(0.95)
expect(OCR_MEDIUM_THRESHOLD).toBe(0.8)
})
it('formats badge labels as OCR N%', () => {
expect(formatOcrBadgeLabel(0.98)).toBe('OCR 98%')
expect(formatOcrBadgeLabel(0.8)).toBe('OCR 80%')
})
it('exposes confidence, label, level, and border class from composable', () => {
const map = ref<OcrConfidenceMap | null>({
provider: 'test',
processedAt: '2026-06-27T10:00:00Z',
durationMs: 1200,
fieldConfidences: {
'patient.fullName': 0.98,
'patient.sex': 0.85,
'encounter.roomBed': 0.5,
},
})
const {
getFieldConfidence,
fieldConfidenceLabel,
fieldConfidenceLevel,
fieldConfidenceClass,
} = useOcrFieldConfidence(computed(() => map.value))
expect(getFieldConfidence('patient.fullName')).toBe(0.98)
expect(fieldConfidenceLabel('patient.fullName')).toBe('OCR 98%')
expect(fieldConfidenceLevel('patient.fullName')).toBe('high')
expect(fieldConfidenceClass('patient.fullName')).toBe('ocr-high')
expect(fieldConfidenceLevel('patient.sex')).toBe('medium')
expect(fieldConfidenceClass('patient.sex')).toBe('ocr-medium')
expect(fieldConfidenceLevel('encounter.roomBed')).toBe('low')
expect(fieldConfidenceClass('encounter.roomBed')).toBe('ocr-low')
expect(fieldConfidenceLabel('missing.path')).toBeNull()
expect(fieldConfidenceClass('missing.path')).toBe('')
})
})
describe('OcrConfidenceBadge', () => {
it('renders OCR percentage badge from confidence', () => {
const wrapper = mount(OcrConfidenceBadge, { props: { confidence: 0.98 } })
expect(wrapper.text()).toBe('OCR 98%')
expect(wrapper.classes()).toContain('ocr-badge-high')
})
it('hides when confidence is missing', () => {
const wrapper = mount(OcrConfidenceBadge, { props: { confidence: null } })
expect(wrapper.find('span').exists()).toBe(false)
})
it('uses provided label and level', () => {
const wrapper = mount(OcrConfidenceBadge, {
props: { label: 'OCR 72%', level: 'low' },
})
expect(wrapper.text()).toBe('OCR 72%')
expect(wrapper.classes()).toContain('ocr-badge-low')
})
})
@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ScanViewer from '@/components/ScanViewer.vue'
import WorkstationLayout from '@/components/WorkstationLayout.vue'
import WorkstationQueueRail from '@/components/WorkstationQueueRail.vue'
import type { BatchDetailResponse } from '@/types'
function makeBatch(overrides: Partial<BatchDetailResponse> = {}): BatchDetailResponse {
return {
id: 'batch-aaaaaaaa',
status: 'IN_ENTRY',
batchType: 'VITALS',
track: 'TRACK_A',
createdAt: '2026-01-15T10:00:00Z',
...overrides,
} as BatchDetailResponse
}
describe('WorkstationLayout', () => {
it('renders full-width queue when no batch is open', () => {
const wrapper = mount(WorkstationLayout, {
props: { hasBatch: false },
slots: {
queue: '<h2>Data Entry Queue</h2>',
scan: '<div>scan</div>',
form: '<div>form</div>',
},
})
expect(wrapper.find('[data-testid="workstation-queue"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="workstation-split"]').exists()).toBe(false)
expect(wrapper.text()).toContain('Data Entry Queue')
})
it('renders scan + form split when a batch is open', () => {
const wrapper = mount(WorkstationLayout, {
props: { hasBatch: true },
slots: {
scan: '<div data-testid="slot-scan">scan</div>',
form: '<div data-testid="slot-form">form</div>',
},
})
expect(wrapper.find('[data-testid="workstation-split"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="workstation-split"]').classes()).toContain(
'workstation-split--no-rail'
)
expect(wrapper.find('[data-testid="slot-scan"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="slot-form"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="workstation-rail"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="evidence-level-1"]').text()).toContain('Source scan')
})
it('shows left rail when rail slot is provided', () => {
const wrapper = mount(WorkstationLayout, {
props: { hasBatch: true },
slots: {
rail: '<div>rail items</div>',
scan: '<div>scan</div>',
form: '<div>form</div>',
},
})
expect(wrapper.find('[data-testid="workstation-rail"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="workstation-split"]').classes()).not.toContain(
'workstation-split--no-rail'
)
expect(wrapper.text()).toContain('rail items')
})
})
describe('WorkstationQueueRail', () => {
it('lists batches and emits select / back', async () => {
const wrapper = mount(WorkstationQueueRail, {
props: {
title: 'Entry queue',
batches: [
makeBatch({ id: 'batch-11111111' }),
makeBatch({ id: 'batch-22222222', status: 'PENDING_VERIFICATION' }),
],
selectedId: 'batch-11111111',
},
})
expect(wrapper.text()).toContain('Entry queue')
expect(wrapper.text()).toContain('batch-11')
expect(wrapper.text()).toContain('Pending Verification')
await wrapper.findAll('button')[0].trigger('click')
expect(wrapper.emitted('select')?.[0]).toEqual(['batch-11111111'])
await wrapper.findAll('button').at(-1)!.trigger('click')
expect(wrapper.emitted('back')).toHaveLength(1)
})
})
describe('ScanViewer', () => {
it('renders neutral toolbar with zoom and fit width', () => {
const wrapper = mount(ScanViewer, {
props: { url: 'blob:http://localhost/doc.png' },
})
const toolbar = wrapper.find('[data-testid="scan-viewer-toolbar"]')
expect(toolbar.exists()).toBe(true)
expect(toolbar.classes()).toContain('bg-surface')
expect(wrapper.find('[data-testid="scan-fit-width"]').exists()).toBe(true)
expect(wrapper.text()).toContain('Fit width')
expect(wrapper.text()).toContain('Rotate')
})
it('shows skeleton while loading', () => {
const wrapper = mount(ScanViewer, {
props: { loading: true },
})
expect(wrapper.find('[role="status"]').exists()).toBe(true)
expect(wrapper.find('img').exists()).toBe(false)
})
it('shows inline error with retry and preserves draft copy', async () => {
const wrapper = mount(ScanViewer, {
props: { error: 'Network failed', loading: false },
})
expect(wrapper.text()).toContain('Could not load document')
expect(wrapper.text()).toContain('Network failed')
expect(wrapper.text()).toContain('Your form draft was not affected.')
const retry = wrapper.findAll('button').find((b) => b.text() === 'Retry')
expect(retry).toBeTruthy()
await retry!.trigger('click')
expect(wrapper.emitted('retry')).toHaveLength(1)
})
it('does not put decorative AI chrome on the page surface', () => {
const wrapper = mount(ScanViewer, {
props: { url: 'blob:http://localhost/scan.jpg' },
})
expect(wrapper.text()).not.toContain('confidence')
expect(wrapper.text()).not.toContain('OCR')
expect(wrapper.find('.scan-page').exists()).toBe(true)
})
})
@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import WorkstationQueueRail from '@/components/WorkstationQueueRail.vue'
import type { BatchDetailResponse } from '@/types'
import { fieldRequirementsForBatchType } from '@/__tests__/helpers/fieldRequirements'
function makeBatch(overrides: Partial<BatchDetailResponse> = {}): BatchDetailResponse {
const batchType = overrides.batchType ?? 'VITALS'
return {
id: 'abcdef12-3456-7890-abcd-ef1234567890',
status: 'PENDING_ENTRY',
batchType,
track: 'TRACK_A',
fieldRequirements: fieldRequirementsForBatchType(batchType),
patientId: 'p1',
documentRef: 'docs/scan.pdf',
documentUrl: null,
enableRetroactiveAlerts: false,
enteredByUserId: null,
verifiedByUserId: null,
approvedByUserId: null,
rejectionReason: null,
promotedAt: null,
promotionEncounterId: null,
supersedesBatchId: null,
clinicianAttestation: false,
isCorrection: false,
supersession: null,
createdAt: '2026-06-27T10:00:00Z',
updatedAt: '2026-06-27T10:00:00Z',
...overrides,
}
}
describe('WorkstationQueueRail', () => {
it('shows batch type as title with status badge and truncated id as meta', () => {
const wrapper = mount(WorkstationQueueRail, {
props: {
batches: [makeBatch({ batchType: 'ADMISSION', status: 'PENDING_ENTRY' })],
selectedId: 'abcdef12-3456-7890-abcd-ef1234567890',
},
})
const item = wrapper.find('button[aria-current="true"]')
expect(item.exists()).toBe(true)
const text = item.text()
expect(text).toContain('Admission')
expect(text).toContain('Pending Entry')
expect(text).toContain('abcdef12…')
const html = item.html()
const typeIdx = html.indexOf('Admission')
const badgeIdx = html.indexOf('Pending Entry')
const idIdx = html.indexOf('abcdef12…')
expect(typeIdx).toBeGreaterThan(-1)
expect(badgeIdx).toBeGreaterThan(typeIdx)
expect(idIdx).toBeGreaterThan(badgeIdx)
})
it('emits select when a queue item is clicked', async () => {
const batch = makeBatch()
const wrapper = mount(WorkstationQueueRail, {
props: { batches: [batch] },
})
await wrapper.findAll('button')[0].trigger('click')
expect(wrapper.emitted('select')?.[0]).toEqual([batch.id])
})
it('emits back from Full queue action', async () => {
const wrapper = mount(WorkstationQueueRail, {
props: { batches: [] },
})
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('back')).toBeTruthy()
})
})
@@ -0,0 +1,26 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useHelpPanel } from '@/composables/useHelpPanel'
describe('useHelpPanel', () => {
beforeEach(() => {
const { closePanel } = useHelpPanel()
closePanel()
})
it('opens, closes, and toggles shared state', () => {
const a = useHelpPanel()
const b = useHelpPanel()
expect(a.open.value).toBe(false)
a.openPanel()
expect(b.open.value).toBe(true)
a.closePanel()
expect(b.open.value).toBe(false)
a.togglePanel()
expect(a.open.value).toBe(true)
a.togglePanel()
expect(a.open.value).toBe(false)
})
})
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest'
import { getPageGuide, fallbackGuide } from '@/help/pageGuides'
describe('getPageGuide', () => {
it.each([
['/intake', 'intake'],
['/cover-sheets', 'cover-sheets'],
['/entry', 'entry'],
['/entry/batch-uuid-123', 'entry'],
['/verification', 'verification'],
['/verification/abc', 'verification'],
['/approval', 'approval'],
['/approval/xyz', 'approval'],
['/live-capture', 'live-capture'],
['/dashboard', 'dashboard'],
['/users', 'users'],
['/fhir-explorer', 'fhir-explorer'],
['/patients', 'patients'],
['/patients/p1/history', 'patients'],
])('resolves %s to guide %s', (path, expectedId) => {
expect(getPageGuide(path).id).toBe(expectedId)
})
it('returns fallback for unknown paths', () => {
const guide = getPageGuide('/unknown-route')
expect(guide.id).toBe(fallbackGuide.id)
expect(guide.title).toBe(fallbackGuide.title)
})
it('includes steps for entry and tips for verification', () => {
expect(getPageGuide('/entry').steps.length).toBeGreaterThanOrEqual(4)
expect(getPageGuide('/verification').tips?.length).toBeGreaterThan(0)
})
})
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia' import { setActivePinia, createPinia } from 'pinia'
import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router' import type { RouteLocationNormalized } from 'vue-router'
import { useAuthStore, getDefaultRouteForRole } from '@/stores/auth' import { useAuthStore, getDefaultRouteForRole } from '@/stores/auth'
vi.mock('@/api/client', () => ({ vi.mock('@/api/client', () => ({
@@ -26,8 +26,7 @@ function setupGuard() {
const nextCalls: (string | undefined)[] = [] const nextCalls: (string | undefined)[] = []
const auth = useAuthStore() const auth = useAuthStore()
function runGuard(to: RouteLocationNormalized, from?: RouteLocationNormalized) { function runGuard(to: RouteLocationNormalized) {
const _from = from ?? buildRoute('/')
const next = vi.fn((dest?: string) => { const next = vi.fn((dest?: string) => {
nextCalls.push(dest) nextCalls.push(dest)
}) })
@@ -148,6 +147,15 @@ describe('router navigation guard', () => {
expect(next).toHaveBeenCalledWith() expect(next).toHaveBeenCalledWith()
}) })
it('allows ADMINISTRATOR access to users route', () => {
const { runGuard } = authenticatedGuard('ADMINISTRATOR')
const { next } = runGuard(buildRoute('/users', {
requiresAuth: true,
roles: ['ADMINISTRATOR'],
}))
expect(next).toHaveBeenCalledWith()
})
it('redirects DATA_ENTRY_CLERK from fhir-explorer to /entry', () => { it('redirects DATA_ENTRY_CLERK from fhir-explorer to /entry', () => {
const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK') const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK')
const { next } = runGuard(buildRoute('/fhir-explorer', { const { next } = runGuard(buildRoute('/fhir-explorer', {
@@ -157,6 +165,15 @@ describe('router navigation guard', () => {
expect(next).toHaveBeenCalledWith('/entry') expect(next).toHaveBeenCalledWith('/entry')
}) })
it('redirects DATA_ENTRY_CLERK from users to /entry', () => {
const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK')
const { next } = runGuard(buildRoute('/users', {
requiresAuth: true,
roles: ['ADMINISTRATOR'],
}))
expect(next).toHaveBeenCalledWith('/entry')
})
it('redirects DATA_ENTRY_CLERK from cover sheets to /entry', () => { it('redirects DATA_ENTRY_CLERK from cover sheets to /entry', () => {
const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK') const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK')
const { next } = runGuard(buildRoute('/cover-sheets', { const { next } = runGuard(buildRoute('/cover-sheets', {
@@ -36,6 +36,11 @@ describe('useBatchStore', () => {
expect(store.loading).toBe(false) expect(store.loading).toBe(false)
expect(store.error).toBeNull() expect(store.error).toBeNull()
expect(store.documentUrl).toBeNull() expect(store.documentUrl).toBeNull()
expect(store.events).toEqual([])
expect(store.eventsLoading).toBe(false)
expect(store.eventsError).toBeNull()
expect(store.eventsHasMore).toBe(false)
expect(store.eventsNextCursor).toBeNull()
}) })
}) })
@@ -81,7 +86,7 @@ describe('useBatchStore', () => {
}) })
it('sets loading=true during request', async () => { it('sets loading=true during request', async () => {
let resolvePromise: (v: unknown) => void let resolvePromise: (v: any) => void
mockedGet.mockReturnValueOnce( mockedGet.mockReturnValueOnce(
new Promise((resolve) => { new Promise((resolve) => {
resolvePromise = resolve resolvePromise = resolve
@@ -108,7 +113,12 @@ describe('useBatchStore', () => {
const store = useBatchStore() const store = useBatchStore()
await store.getBatch('b1') await store.getBatch('b1')
expect(store.currentBatch).toEqual(batch) expect(store.currentBatch).toEqual({
...batch,
enteredByUserName: null,
verifiedByUserName: null,
approvedByUserName: null,
})
expect(store.documentUrl).toBe('https://example.com/doc') expect(store.documentUrl).toBe('https://example.com/doc')
}) })
@@ -410,4 +420,198 @@ describe('useBatchStore', () => {
expect(store.error).toBe('Not found') expect(store.error).toBe('Not found')
}) })
}) })
describe('fetchEvents', () => {
const eventA = {
id: 'e1',
batchId: 'b1',
eventType: 'uploaded',
actorUserId: 'u1',
actorUsername: 'clerk',
actorFullName: 'Clerk One',
occurredAt: '2026-01-01T10:00:00Z',
metadataJson: null,
}
const eventB = {
...eventA,
id: 'e2',
eventType: 'entry_started',
occurredAt: '2026-01-01T11:00:00Z',
}
it('replaces events on first page', async () => {
mockedGet.mockResolvedValueOnce({
success: true,
statusCode: 200,
data: {
items: [eventA],
pageSize: 50,
nextCursor: '2026-01-01T10:00:00Z',
hasMore: true,
},
error: null,
})
const store = useBatchStore()
await store.fetchEvents('b1')
expect(mockedGet).toHaveBeenCalledWith('digitization-batches/b1/events', { pageSize: 50 })
expect(store.events).toEqual([eventA])
expect(store.eventsHasMore).toBe(true)
expect(store.eventsNextCursor).toBe('2026-01-01T10:00:00Z')
expect(store.eventsLoading).toBe(false)
})
it('appends events when after cursor is passed', async () => {
mockedGet
.mockResolvedValueOnce({
success: true,
statusCode: 200,
data: {
items: [eventA],
pageSize: 50,
nextCursor: 'c1',
hasMore: true,
},
error: null,
})
.mockResolvedValueOnce({
success: true,
statusCode: 200,
data: {
items: [eventB],
pageSize: 50,
nextCursor: null,
hasMore: false,
},
error: null,
})
const store = useBatchStore()
await store.fetchEvents('b1')
await store.fetchEvents('b1', 'c1')
expect(mockedGet).toHaveBeenLastCalledWith('digitization-batches/b1/events', {
pageSize: 50,
after: 'c1',
})
expect(store.events).toEqual([eventA, eventB])
expect(store.eventsHasMore).toBe(false)
expect(store.eventsNextCursor).toBeNull()
})
it('sets eventsError on failure', async () => {
mockedGet.mockRejectedValueOnce(new Error('Forbidden'))
const store = useBatchStore()
await store.fetchEvents('b1')
expect(store.eventsError).toBe('Forbidden')
expect(store.events).toEqual([])
})
it('clearEvents resets event state', async () => {
mockedGet.mockResolvedValueOnce({
success: true,
statusCode: 200,
data: { items: [eventA], pageSize: 50, nextCursor: 'c', hasMore: true },
error: null,
})
const store = useBatchStore()
await store.fetchEvents('b1')
store.clearEvents()
expect(store.events).toEqual([])
expect(store.eventsHasMore).toBe(false)
expect(store.eventsNextCursor).toBeNull()
expect(store.eventsError).toBeNull()
})
})
describe('listWorkQueue', () => {
it('maps entry queue items into batches by id', async () => {
mockedGet.mockResolvedValueOnce({
success: true,
statusCode: 200,
data: {
queueName: 'entry',
items: [
{
batchId: 'wq-1',
status: 'IN_ENTRY',
batchType: 'VITALS',
track: 'TRACK_A',
patientId: null,
enteredByUserId: 'u1',
enteredByUserName: 'Clerk',
rejectionReason: null,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
eventCount: 2,
},
],
page: 1,
pageSize: 50,
totalCount: 1,
totalPages: 1,
},
error: null,
})
const store = useBatchStore()
await store.listEntryQueue({ page: 1, pageSize: 50 })
expect(mockedGet).toHaveBeenCalledWith('work-queue/entry', {
page: 1,
pageSize: 50,
sortBy: 'updatedAt',
sortDirection: 'asc',
})
expect(store.batches).toHaveLength(1)
expect(store.batches[0]!.id).toBe('wq-1')
expect(store.batches[0]!.status).toBe('IN_ENTRY')
expect(store.batches[0]!.batchType).toBe('VITALS')
expect(store.totalCount).toBe(1)
})
it('calls verification and clinical-approval endpoints', async () => {
mockedGet.mockResolvedValue({
success: true,
statusCode: 200,
data: {
queueName: 'verification',
items: [],
page: 1,
pageSize: 50,
totalCount: 0,
totalPages: 0,
},
error: null,
})
const store = useBatchStore()
await store.listVerificationQueue()
expect(mockedGet).toHaveBeenCalledWith(
'work-queue/verification',
expect.objectContaining({ sortDirection: 'asc' }),
)
await store.listClinicalApprovalQueue()
expect(mockedGet).toHaveBeenCalledWith(
'work-queue/clinical-approval',
expect.objectContaining({ page: 1, pageSize: 50 }),
)
})
it('sets error on work queue failure', async () => {
mockedGet.mockRejectedValueOnce(new Error('Queue down'))
const store = useBatchStore()
await store.listEntryQueue()
expect(store.error).toBe('Queue down')
expect(store.loading).toBe(false)
})
})
}) })
@@ -0,0 +1,145 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useTourStore } from '@/stores/tour'
import { tourDefinitions } from '@/tours/definitions'
vi.mock('@/router', () => ({
default: {
push: vi.fn().mockResolvedValue(undefined),
currentRoute: { value: { path: '/entry' } },
},
}))
import router from '@/router'
const mockedPush = vi.mocked(router.push)
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
localStorage.clear()
document.body.innerHTML = ''
// Reset route path between tests
;(router.currentRoute as { value: { path: string } }).value = { path: '/entry' }
})
afterEach(() => {
document.body.innerHTML = ''
})
function mountAnchor(tourId: string) {
const el = document.createElement('div')
el.setAttribute('data-tour', tourId)
document.body.appendChild(el)
return el
}
describe('useTourStore', () => {
it('hasCompleted is false until marked', () => {
const tour = useTourStore()
expect(tour.hasCompleted('u1', 'DATA_ENTRY_CLERK')).toBe(false)
tour.markCompleted('u1', 'DATA_ENTRY_CLERK')
expect(tour.hasCompleted('u1', 'DATA_ENTRY_CLERK')).toBe(true)
expect(localStorage.getItem('vigilcare_tour_u1_DATA_ENTRY_CLERK')).toBe('1')
})
it('keys completion by userId and role', () => {
const tour = useTourStore()
tour.markCompleted('u1', 'VERIFIER')
expect(tour.hasCompleted('u1', 'VERIFIER')).toBe(true)
expect(tour.hasCompleted('u1', 'DATA_ENTRY_CLERK')).toBe(false)
expect(tour.hasCompleted('u2', 'VERIFIER')).toBe(false)
})
it('tryAutoStart starts when incomplete and skips when completed', async () => {
mountAnchor('entry-header')
const tour = useTourStore()
await tour.tryAutoStart('DATA_ENTRY_CLERK', 'u1')
expect(tour.active).toBe(true)
expect(tour.currentStep?.id).toBe('entry-job')
tour.complete()
expect(tour.active).toBe(false)
expect(tour.hasCompleted('u1', 'DATA_ENTRY_CLERK')).toBe(true)
await tour.tryAutoStart('DATA_ENTRY_CLERK', 'u1')
expect(tour.active).toBe(false)
})
it('skip dismisses and prevents auto-restart', async () => {
mountAnchor('entry-header')
const tour = useTourStore()
await tour.start('DATA_ENTRY_CLERK', 'u1')
expect(tour.active).toBe(true)
tour.skip()
expect(tour.active).toBe(false)
expect(tour.hasCompleted('u1', 'DATA_ENTRY_CLERK')).toBe(true)
await tour.tryAutoStart('DATA_ENTRY_CLERK', 'u1')
expect(tour.active).toBe(false)
})
it('replay restarts even after completion', async () => {
mountAnchor('entry-header')
const tour = useTourStore()
tour.markCompleted('u1', 'DATA_ENTRY_CLERK')
await tour.replay('DATA_ENTRY_CLERK', 'u1')
expect(tour.active).toBe(true)
expect(tour.stepIndex).toBe(0)
})
it('skips steps whose selector is missing', async () => {
// Only header and patient nav exist — queue / workstation steps skip
mountAnchor('entry-header')
mountAnchor('nav-patients')
const tour = useTourStore()
await tour.start('DATA_ENTRY_CLERK', 'u1')
expect(tour.active).toBe(true)
expect(tour.currentStep?.id).toBe('entry-job')
await tour.next()
// entry-queue missing → skips through to patient-history
expect(tour.currentStep?.id).toBe('patient-history')
})
it('pushes route for cover-sheets step when needed', async () => {
mountAnchor('intake-header')
mountAnchor('intake-cover-lookup')
mountAnchor('intake-upload')
mountAnchor('intake-metadata')
mountAnchor('intake-recent')
mountAnchor('cover-sheets-header')
mountAnchor('cover-sheets-generate')
mountAnchor('nav-patients')
;(router.currentRoute as { value: { path: string } }).value = { path: '/intake' }
const tour = useTourStore()
await tour.start('INTAKE_CLERK', 'u1')
expect(tour.currentStep?.id).toBe('intake-job')
// Advance until cover-sheets-nav
for (let i = 0; i < 5; i++) {
await tour.next()
}
expect(mockedPush).toHaveBeenCalledWith('/cover-sheets')
expect(tour.currentStep?.id).toBe('cover-sheets-nav')
})
it('every role has a non-empty definition', () => {
const roles = [
'INTAKE_CLERK',
'DATA_ENTRY_CLERK',
'VERIFIER',
'CLINICAL_APPROVER',
'CLINICIAN',
'ADMINISTRATOR',
]
for (const role of roles) {
expect(tourDefinitions[role]?.steps.length).toBeGreaterThan(0)
}
})
})
@@ -0,0 +1,134 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUsersStore } from '@/stores/users'
vi.mock('@/api/client', () => ({
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
}))
import { get, post, patch } from '@/api/client'
const mockedGet = vi.mocked(get)
const mockedPost = vi.mocked(post)
const mockedPatch = vi.mocked(patch)
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('useUsersStore', () => {
it('listUsers populates users', async () => {
const users = [
{ id: 'u1', username: 'admin', fullName: 'Admin', role: 'ADMINISTRATOR' },
]
mockedGet.mockResolvedValueOnce({
success: true,
statusCode: 200,
data: users,
error: null,
})
const store = useUsersStore()
await store.listUsers()
expect(mockedGet).toHaveBeenCalledWith('users', undefined)
expect(store.users).toEqual(users)
expect(store.loading).toBe(false)
})
it('listUsers passes role filter', async () => {
mockedGet.mockResolvedValueOnce({
success: true,
statusCode: 200,
data: [],
error: null,
})
const store = useUsersStore()
await store.listUsers('VERIFIER')
expect(mockedGet).toHaveBeenCalledWith('users', { role: 'VERIFIER' })
})
it('createUser posts body and returns data', async () => {
const created = {
id: 'u2',
username: 'clerk1',
fullName: 'Clerk One',
role: 'DATA_ENTRY_CLERK',
}
mockedPost.mockResolvedValueOnce({
success: true,
statusCode: 201,
data: created,
error: null,
})
const store = useUsersStore()
const result = await store.createUser({
username: 'clerk1',
password: 'Password1',
fullName: 'Clerk One',
role: 'DATA_ENTRY_CLERK',
})
expect(mockedPost).toHaveBeenCalledWith('users', {
username: 'clerk1',
password: 'Password1',
fullName: 'Clerk One',
role: 'DATA_ENTRY_CLERK',
})
expect(result).toEqual(created)
})
it('updateUser patches and returns data', async () => {
const updated = {
id: 'u1',
username: 'admin',
fullName: 'New Name',
role: 'ADMINISTRATOR',
}
mockedPatch.mockResolvedValueOnce({
success: true,
statusCode: 200,
data: updated,
error: null,
})
const store = useUsersStore()
const result = await store.updateUser('u1', { fullName: 'New Name' })
expect(mockedPatch).toHaveBeenCalledWith('users/u1', { fullName: 'New Name' })
expect(result).toEqual(updated)
})
it('resetPassword posts new password', async () => {
mockedPost.mockResolvedValueOnce('' as never)
const store = useUsersStore()
await store.resetPassword('u1', 'Password2')
expect(mockedPost).toHaveBeenCalledWith('users/u1/reset-password', {
newPassword: 'Password2',
})
})
it('createUser surfaces API error message', async () => {
mockedPost.mockRejectedValueOnce({
response: { data: { error: { message: 'Username already taken', code: 'USERNAME_TAKEN' } } },
})
const store = useUsersStore()
await expect(
store.createUser({
username: 'dup',
password: 'Password1',
fullName: 'Dup',
role: 'VERIFIER',
}),
).rejects.toThrow('Username already taken')
})
})
@@ -20,6 +20,10 @@ vi.mock('@/components/AppHeader.vue', () => ({
default: { template: '<div />' }, default: { template: '<div />' },
})) }))
vi.mock('@/components/TourHelpButton.vue', () => ({
default: { template: '<button type="button" data-testid="tour-help" />' },
}))
vi.mock('@/components/PatientSearch.vue', () => ({ vi.mock('@/components/PatientSearch.vue', () => ({
default: { default: {
props: ['modelValue'], props: ['modelValue'],
@@ -28,6 +32,31 @@ vi.mock('@/components/PatientSearch.vue', () => ({
}, },
})) }))
vi.mock('@/components/StatusBadge.vue', () => ({
default: {
props: ['status'],
template: '<span data-testid="status-badge">{{ status }}</span>',
},
}))
vi.mock('@/components/EmptyState.vue', () => ({
default: {
props: ['title', 'description'],
template: '<div data-testid="empty-state">{{ title }}</div>',
},
}))
vi.mock('@/components/SkeletonBlock.vue', () => ({
default: { template: '<div data-testid="skeleton" />' },
}))
vi.mock('@/components/InlineError.vue', () => ({
default: {
props: ['title', 'message'],
template: '<div data-testid="inline-error">{{ message }}</div>',
},
}))
import { get, post } from '@/api/client' import { get, post } from '@/api/client'
const mockedGet = vi.mocked(get) const mockedGet = vi.mocked(get)
@@ -75,18 +104,33 @@ beforeEach(() => {
}) })
describe('CoverSheetView', () => { describe('CoverSheetView', () => {
it('renders generate form and cover sheet list', async () => { it('renders generate form, preview, and cover sheet list', async () => {
const wrapper = mount(CoverSheetView) const wrapper = mount(CoverSheetView, {
global: {
stubs: {
RouterLink: { template: '<a><slot /></a>' },
},
},
})
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('Generate Cover Sheets') expect(wrapper.text()).toContain('Generate Cover Sheets')
expect(wrapper.text()).toContain('Cover Sheet List') expect(wrapper.text()).toContain('Preview')
expect(wrapper.text()).toContain('Existing Cover Sheets')
expect(wrapper.text()).toContain('VCR-CS-AABBCCDD') expect(wrapper.text()).toContain('VCR-CS-AABBCCDD')
expect(wrapper.find('.cover-sheet-preview').exists()).toBe(true)
expect(wrapper.find('[data-testid="status-badge"]').text()).toBe('UNUSED')
expect(wrapper.find('select').exists()).toBe(true) expect(wrapper.find('select').exists()).toBe(true)
}) })
it('loads entry clerks and cover sheets on mount', async () => { it('loads entry clerks and cover sheets on mount', async () => {
mount(CoverSheetView) mount(CoverSheetView, {
global: {
stubs: {
RouterLink: { template: '<a><slot /></a>' },
},
},
})
await flushPromises() await flushPromises()
expect(mockedGet).toHaveBeenCalledWith('users', { role: 'DATA_ENTRY_CLERK' }) expect(mockedGet).toHaveBeenCalledWith('users', { role: 'DATA_ENTRY_CLERK' })
@@ -104,7 +148,13 @@ describe('CoverSheetView', () => {
error: null, error: null,
}) })
const wrapper = mount(CoverSheetView) const wrapper = mount(CoverSheetView, {
global: {
stubs: {
RouterLink: { template: '<a><slot /></a>' },
},
},
})
await flushPromises() await flushPromises()
const batchTypeSelect = wrapper.findAll('select')[0] const batchTypeSelect = wrapper.findAll('select')[0]
@@ -134,7 +184,13 @@ describe('CoverSheetView', () => {
error: null, error: null,
}) })
const wrapper = mount(CoverSheetView) const wrapper = mount(CoverSheetView, {
global: {
stubs: {
RouterLink: { template: '<a><slot /></a>' },
},
},
})
await flushPromises() await flushPromises()
await wrapper.findAll('select')[0].setValue('LAB_RESULTS') await wrapper.findAll('select')[0].setValue('LAB_RESULTS')
@@ -142,5 +198,6 @@ describe('CoverSheetView', () => {
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('Print Cover Sheets') expect(wrapper.text()).toContain('Print Cover Sheets')
expect(wrapper.find('button.btn-primary').text()).toContain('Generate Cover Sheets')
}) })
}) })
@@ -9,7 +9,13 @@ vi.mock('@/api/fhirClient', () => ({
})) }))
vi.mock('@/components/AppHeader.vue', () => ({ vi.mock('@/components/AppHeader.vue', () => ({
default: { template: '<div />' }, default: {
template: '<div data-testid="app-header"><slot name="actions" /></div>',
},
}))
vi.mock('@/components/TourHelpButton.vue', () => ({
default: { template: '<button type="button" data-testid="tour-help" />' },
})) }))
vi.mock('@/components/PatientSearch.vue', () => ({ vi.mock('@/components/PatientSearch.vue', () => ({
@@ -20,6 +26,20 @@ vi.mock('@/components/PatientSearch.vue', () => ({
}, },
})) }))
vi.mock('@/components/InlineError.vue', () => ({
default: {
props: ['title', 'message'],
template: '<div data-testid="inline-error">{{ message }}</div>',
},
}))
vi.mock('@/components/EmptyState.vue', () => ({
default: {
props: ['title', 'description'],
template: '<div data-testid="empty-state">{{ title }}</div>',
},
}))
import { fhirGet, openFhirJsonInNewTab } from '@/api/fhirClient' import { fhirGet, openFhirJsonInNewTab } from '@/api/fhirClient'
const mockedFhirGet = vi.mocked(fhirGet) const mockedFhirGet = vi.mocked(fhirGet)
@@ -76,6 +96,7 @@ describe('FhirExplorerView', () => {
expect(wrapper.text()).toContain('Patient $everything') expect(wrapper.text()).toContain('Patient $everything')
expect(wrapper.find('select').element).toBeTruthy() expect(wrapper.find('select').element).toBeTruthy()
expect(wrapper.text()).toContain('MRN (identifier)') expect(wrapper.text()).toContain('MRN (identifier)')
expect(wrapper.text()).toContain('CapabilityStatement')
}) })
it('searches FHIR Patient resources and shows results table', async () => { it('searches FHIR Patient resources and shows results table', async () => {
@@ -106,6 +127,7 @@ describe('FhirExplorerView', () => {
expect(wrapper.text()).toContain('"resourceType": "Patient"') expect(wrapper.text()).toContain('"resourceType": "Patient"')
expect(wrapper.text()).toContain('"id": "patient-1"') expect(wrapper.text()).toContain('"id": "patient-1"')
expect(wrapper.find('.fhir-json-panel').exists()).toBe(true)
}) })
it('loads Patient $everything bundle grouped by resource type', async () => { it('loads Patient $everything bundle grouped by resource type', async () => {
@@ -18,6 +18,10 @@ vi.mock('@/components/AppHeader.vue', () => ({
default: { template: '<div />' }, default: { template: '<div />' },
})) }))
vi.mock('@/components/TourHelpButton.vue', () => ({
default: { template: '<button type="button" data-testid="tour-help" />' },
}))
vi.mock('@/components/PatientSearch.vue', () => ({ vi.mock('@/components/PatientSearch.vue', () => ({
default: { default: {
props: ['modelValue'], props: ['modelValue'],
@@ -42,12 +46,20 @@ vi.mock('@/components/AssignClerkDialog.vue', () => ({
}, },
})) }))
vi.mock('@/components/InlineError.vue', () => ({
default: {
props: ['title', 'message'],
template: '<div data-testid="inline-error"><slot /><p>{{ title }}</p><p>{{ message }}</p></div>',
},
}))
const uploadBatchMock = vi.fn() const uploadBatchMock = vi.fn()
vi.mock('@/stores/batches', () => ({ vi.mock('@/stores/batches', () => ({
useBatchStore: () => ({ useBatchStore: () => ({
batches: [], batches: [],
loading: false, loading: false,
error: null,
listBatches: vi.fn(), listBatches: vi.fn(),
uploadBatch: uploadBatchMock, uploadBatch: uploadBatchMock,
assignBatch: vi.fn(), assignBatch: vi.fn(),
@@ -89,7 +101,9 @@ describe('IntakeView cover sheet upload', () => {
const wrapper = mount(IntakeView) const wrapper = mount(IntakeView)
const input = wrapper.find('input[placeholder="VCR-CS-XXXXXXXX"]') const input = wrapper.find('input[placeholder="VCR-CS-XXXXXXXX"]')
expect(wrapper.text()).toContain('Quick Upload with Cover Sheet') expect(wrapper.text()).toContain('Cover Sheet Code')
expect(wrapper.text()).toContain('New Batch')
expect(wrapper.find('.upload-dropzone').exists()).toBe(true)
expect(input.exists()).toBe(true) expect(input.exists()).toBe(true)
expect(input.attributes('autofocus')).toBeDefined() expect(input.attributes('autofocus')).toBeDefined()
expect(wrapper.find('button.btn-secondary').text()).toBe('Lookup') expect(wrapper.find('button.btn-secondary').text()).toBe('Lookup')
@@ -147,7 +161,7 @@ describe('IntakeView cover sheet upload', () => {
undefined, undefined,
'VCR-CS-A3F7B2D1', 'VCR-CS-A3F7B2D1',
) )
expect(wrapper.text()).toContain('Upload with Cover Sheet') expect(wrapper.text()).toContain('Batch uploaded with cover sheet')
}) })
it('keeps manual upload available without a cover sheet', async () => { it('keeps manual upload available without a cover sheet', async () => {
@@ -155,6 +169,7 @@ describe('IntakeView cover sheet upload', () => {
expect(wrapper.text()).toContain('New Batch') expect(wrapper.text()).toContain('New Batch')
expect(wrapper.text()).toContain('Upload and Create Batch') expect(wrapper.text()).toContain('Upload and Create Batch')
expect(wrapper.text()).toContain('Recent Uploads')
const file = new File(['pdf'], 'scan.pdf', { type: 'application/pdf' }) const file = new File(['pdf'], 'scan.pdf', { type: 'application/pdf' })
const fileInput = wrapper.find('input[type="file"]') const fileInput = wrapper.find('input[type="file"]')
@@ -0,0 +1,211 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import UsersView from '@/views/UsersView.vue'
vi.mock('@/api/client', () => ({
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
}),
}))
vi.mock('@/components/AppHeader.vue', () => ({
default: {
props: ['title', 'description'],
template: `
<div data-testid="app-header" :title="title" :description="description">
<slot name="actions" />
<slot name="subtitle" />
</div>
`,
},
}))
vi.mock('@/components/TourHelpButton.vue', () => ({
default: { template: '<button type="button" data-testid="tour-help" />' },
}))
vi.mock('@/components/EmptyState.vue', () => ({
default: {
props: ['title', 'description'],
template: '<div data-testid="empty-state">{{ title }}</div>',
},
}))
vi.mock('@/components/SkeletonBlock.vue', () => ({
default: { template: '<div data-testid="skeleton" />' },
}))
vi.mock('@/components/InlineError.vue', () => ({
default: {
props: ['title', 'message'],
template: '<div data-testid="inline-error">{{ message }}</div>',
},
}))
vi.mock('@/components/ConfirmDialog.vue', () => ({
default: {
props: ['open', 'title', 'confirmDisabled'],
emits: ['confirm', 'cancel'],
template: `
<div v-if="open" data-testid="confirm-dialog">
<slot />
<button type="button" data-testid="confirm-ok" @click="$emit('confirm')">OK</button>
<button type="button" data-testid="confirm-cancel" @click="$emit('cancel')">Cancel</button>
</div>
`,
},
}))
import { get, post, patch } from '@/api/client'
const mockedGet = vi.mocked(get)
const mockedPost = vi.mocked(post)
const mockedPatch = vi.mocked(patch)
const seedUsers = [
{
id: 'u1',
username: 'entry1',
fullName: 'Entry Clerk',
role: 'DATA_ENTRY_CLERK',
},
{
id: 'u2',
username: 'verifier1',
fullName: 'Verifier One',
role: 'VERIFIER',
},
]
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockedGet.mockResolvedValue({
success: true,
statusCode: 200,
data: seedUsers,
error: null,
})
})
describe('UsersView', () => {
it('loads and lists users on mount', async () => {
const wrapper = mount(UsersView)
await flushPromises()
expect(mockedGet).toHaveBeenCalledWith('users', undefined)
expect(wrapper.text()).toContain('Entry Clerk')
expect(wrapper.text()).toContain('Verifier One')
expect(wrapper.find('[data-testid="user-row-entry1"]').exists()).toBe(true)
})
it('creates a user from the create form', async () => {
mockedPost.mockResolvedValueOnce({
success: true,
statusCode: 201,
data: {
id: 'u3',
username: 'newclerk',
fullName: 'New Clerk',
role: 'DATA_ENTRY_CLERK',
},
error: null,
})
const wrapper = mount(UsersView)
await flushPromises()
await wrapper.get('[data-testid="users-toggle-create"]').trigger('click')
expect(wrapper.find('[data-testid="users-create-form"]').exists()).toBe(true)
await wrapper.get('#create-username').setValue('newclerk')
await wrapper.get('#create-fullname').setValue('New Clerk')
await wrapper.get('#create-password').setValue('Password1')
await wrapper.get('[data-testid="users-create-form"] form').trigger('submit')
await flushPromises()
expect(mockedPost).toHaveBeenCalledWith('users', {
username: 'newclerk',
fullName: 'New Clerk',
role: 'DATA_ENTRY_CLERK',
password: 'Password1',
})
expect(mockedGet).toHaveBeenCalledTimes(2)
})
it('edits a user full name and role', async () => {
mockedPatch.mockResolvedValueOnce({
success: true,
statusCode: 200,
data: {
id: 'u1',
username: 'entry1',
fullName: 'Updated Clerk',
role: 'VERIFIER',
},
error: null,
})
const wrapper = mount(UsersView)
await flushPromises()
const row = wrapper.get('[data-testid="user-row-entry1"]')
await row.findAll('button').find((b) => b.text() === 'Edit')!.trigger('click')
expect(wrapper.find('[data-testid="users-edit-form"]').exists()).toBe(true)
await wrapper.get('#edit-fullname').setValue('Updated Clerk')
await wrapper.get('#edit-role').setValue('VERIFIER')
await wrapper.get('[data-testid="users-edit-form"] form').trigger('submit')
await flushPromises()
expect(mockedPatch).toHaveBeenCalledWith('users/u1', {
fullName: 'Updated Clerk',
role: 'VERIFIER',
})
})
it('resets password via confirm dialog', async () => {
mockedPost.mockResolvedValueOnce('' as never)
const wrapper = mount(UsersView)
await flushPromises()
await wrapper.get('[data-testid="user-row-entry1"] [data-testid="users-reset-password"]').trigger('click')
expect(wrapper.find('[data-testid="confirm-dialog"]').exists()).toBe(true)
await wrapper.get('[data-testid="users-reset-password-input"]').setValue('Password9')
await wrapper.get('[data-testid="confirm-ok"]').trigger('click')
await flushPromises()
expect(mockedPost).toHaveBeenCalledWith('users/u1/reset-password', {
newPassword: 'Password9',
})
})
it('deactivates a user', async () => {
mockedPatch.mockResolvedValueOnce({
success: true,
statusCode: 200,
data: seedUsers[0],
error: null,
})
const wrapper = mount(UsersView)
await flushPromises()
await wrapper.get('[data-testid="user-row-entry1"] [data-testid="users-deactivate"]').trigger('click')
await flushPromises()
expect(mockedPatch).toHaveBeenCalledWith('users/u1', { isActive: false })
})
})
+170 -19
View File
@@ -1,67 +1,218 @@
@import url('https://fonts.googleapis.com/css2?family=Public+Sans:wght@400;500;600;700;800&display=swap');
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @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 text-ink bg-canvas;
}
}
@layer components { @layer components {
.btn-primary { .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 hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed
transition-colors; transition-colors;
} }
.btn-secondary { .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 hover:bg-primary-50 disabled:opacity-50 disabled:cursor-not-allowed
transition-colors; transition-colors;
} }
.btn-danger { .btn-danger {
@apply bg-clinical-danger text-white px-4 py-2 rounded-md @apply bg-clinical-danger text-white px-4 py-2 rounded-input
hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-clinical-critical disabled:opacity-50 disabled:cursor-not-allowed
transition-colors; transition-colors;
} }
.form-input { .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 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 { .page-container {
@apply p-4 sm:p-6 lg:p-8 max-w-4xl mx-auto; @apply p-4 sm:p-6 lg:p-8 max-w-4xl mx-auto;
} }
.app-header { .app-header {
@apply flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between @apply flex flex-row items-start justify-between gap-3
px-4 py-4 sm:px-6 sm:py-4 bg-white border-b shadow-sm; 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 { .app-header-title {
@apply flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4; @apply flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-4 min-w-0;
} }
.app-header-actions { .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 { .card {
@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;
}
.card-elevated {
@apply bg-surface rounded-card border border-line p-4 sm:p-6 shadow-card;
} }
.split-pane { .split-pane {
@apply grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-8 @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;
}
/* Phase 16 workstation: scan-first split (≥1280px / xl) */
.workstation-split {
@apply grid grid-cols-1 gap-0
h-auto xl:flex-1 xl:min-h-0 min-h-0
xl:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)];
}
.workstation-split:not(.workstation-split--no-rail) {
@apply xl:grid-cols-[minmax(160px,0.2fr)_minmax(0,0.42fr)_minmax(0,0.38fr)];
}
.workstation-rail {
@apply flex flex-col min-h-0 overflow-hidden bg-surface;
}
.workstation-scan {
@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;
}
.workstation-form-section {
@apply border border-line rounded-input p-3;
}
.workstation-form-legend {
@apply text-xs font-semibold uppercase tracking-wide text-ink-secondary px-1;
}
.workstation-field-label {
@apply flex items-center gap-1.5 text-xs text-ink-secondary mb-1;
}
/* Observation cards — scannable list (design tips: hero value + timeline meta) */
.observation-card {
@apply rounded-card border border-line bg-surface p-3 shadow-card;
}
.observation-card--readonly {
@apply flex gap-2.5 bg-canvas p-2.5 shadow-none;
}
.observation-card--verified {
@apply border-clinical-safe/40 bg-clinical-safe-bg/40;
}
.observation-card__rail {
@apply flex w-3 shrink-0 flex-col items-center pt-1.5;
}
.observation-card__dot {
@apply h-2.5 w-2.5 rounded-full bg-primary-600 ring-4 ring-primary-50;
}
/* Design-doc §34 evidence hierarchy */
.evidence-level {
@apply flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-ink-secondary mb-2 shrink-0;
}
.evidence-level-mark {
@apply inline-flex h-4 w-4 items-center justify-center rounded-full text-[9px] font-bold leading-none;
}
.evidence-level--1 .evidence-level-mark {
@apply bg-navy text-white;
}
.evidence-level--2 .evidence-level-mark {
@apply bg-primary-600 text-white;
}
.evidence-level--3 .evidence-level-mark {
@apply bg-ink-secondary text-white;
}
.evidence-level--4 .evidence-level-mark {
@apply bg-clinical-safe text-white;
}
.evidence-level--5 .evidence-level-mark {
@apply bg-clinical-safe text-white ring-2 ring-clinical-safe-bg;
}
.clinical-signal {
@apply rounded-input border border-[#FEDF89] bg-clinical-warning-bg px-3 py-2 text-sm;
}
.clinical-signal--critical {
@apply border-[#FECDCA] bg-clinical-danger-bg;
}
.approval-frame {
@apply border-l-[3px] border-l-navy;
}
.promotion-outcome {
@apply rounded-input border border-[#ABEFC6] bg-clinical-safe-bg p-4 text-sm;
} }
.nav-link { .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 { .nav-link.router-link-active {
@apply text-gray-900 font-medium; @apply text-ink-strong font-medium;
} }
.status-badge { .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 { .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 { .ocr-high {
@apply border-l-[3px] border-l-green-500; @apply border-l-[3px] border-l-clinical-safe;
} }
.ocr-medium { .ocr-medium {
@apply border-l-[3px] border-l-yellow-500; @apply border-l-[3px] border-l-clinical-warning;
} }
.ocr-low { .ocr-low {
@apply border-l-[3px] border-l-red-500; @apply border-l-[3px] border-l-clinical-danger;
}
.ocr-confidence-badge {
@apply inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold tracking-wide uppercase leading-none;
}
.ocr-badge-high {
@apply bg-clinical-safe-bg text-clinical-safe;
}
.ocr-badge-medium {
@apply bg-clinical-warning-bg text-clinical-warning;
}
.ocr-badge-low {
@apply bg-clinical-danger-bg text-clinical-danger;
}
/* Phase 17 — Intake upload zone */
.upload-dropzone {
@apply flex items-center justify-center min-h-[200px] sm:min-h-[240px]
rounded-card border-2 border-dashed border-primary-300 bg-primary-50/40
px-6 py-10 cursor-pointer transition-colors
hover:border-primary-500 hover:bg-primary-50;
}
.upload-dropzone--active {
@apply border-primary-600 bg-primary-50;
}
.upload-dropzone--error {
@apply border-clinical-danger bg-clinical-danger-bg;
}
/* Phase 17 — Cover sheet paper preview */
.cover-sheet-preview {
@apply bg-white border border-line-strong rounded-sm
aspect-[210/297] max-h-[520px] w-full mx-auto overflow-hidden
flex flex-col;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.06), 0 4px 16px rgba(16, 24, 40, 0.08);
}
/* Phase 17 — FHIR raw JSON panel */
.fhir-json-panel {
@apply bg-navy text-[#D1E0FF] text-[13px] leading-5 font-mono
p-4 rounded-input overflow-x-auto max-h-96 border border-navy;
} }
} }
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 496 B

@@ -1,38 +1,33 @@
<template> <template>
<div class="app-header"> <div class="app-header" :data-tour="tourAnchor || undefined">
<div class="app-header-title"> <div class="app-header-title min-w-0 flex-1">
<h1 class="text-lg font-semibold">{{ title }}</h1> <div class="min-w-0">
<nav class="flex gap-3 ml-4"> <h1 class="text-lg sm:text-xl font-semibold text-ink-strong leading-tight">
<router-link v-if="auth.canIntake" to="/intake" class="nav-link">Intake</router-link> {{ title }}
<router-link v-if="auth.canIntake" to="/cover-sheets" class="nav-link">Cover Sheets</router-link> </h1>
<router-link v-if="auth.canEntry" to="/entry" class="nav-link">Entry</router-link> <p
<router-link v-if="auth.canVerify" to="/verification" class="nav-link">Verification</router-link> v-if="description"
<router-link v-if="auth.canApprove" to="/approval" class="nav-link">Approval</router-link> class="mt-0.5 text-sm text-ink-secondary leading-snug max-w-2xl"
<router-link v-if="auth.canLiveCapture" to="/live-capture" class="nav-link">Live Capture</router-link> >
<router-link to="/patients" class="nav-link">History</router-link> {{ description }}
<router-link v-if="auth.canSupervise" to="/dashboard" class="nav-link">Dashboard</router-link> </p>
<router-link v-if="auth.canSupervise" to="/fhir-explorer" class="nav-link">FHIR Explorer</router-link>
</nav>
<slot name="subtitle" /> <slot name="subtitle" />
</div> </div>
<div class="app-header-actions"> </div>
<div class="app-header-actions self-start">
<slot name="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>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useAuthStore } from '../stores/auth' withDefaults(
defineProps<{
defineProps<{ title: string }>() title: string
description?: string
const auth = useAuthStore() /** Stable walkthrough anchor, e.g. intake-header */
tourAnchor?: string
}>(),
{ description: undefined, tourAnchor: undefined },
)
</script> </script>
@@ -0,0 +1,310 @@
<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,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-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="!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" data-tour="nav-workspace">
<p
v-if="!sidebarCollapsed"
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="sidebarCollapsed ? item.label : undefined"
:class="navItemClass(item.to)"
:data-tour="item.tourAnchor"
@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="!sidebarCollapsed" class="truncate">{{ item.label }}</span>
</router-link>
</li>
</ul>
</div>
<div v-if="adminItems.length" data-tour="nav-admin">
<p
v-if="!sidebarCollapsed"
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="sidebarCollapsed ? item.label : undefined"
:class="navItemClass(item.to)"
:data-tour="item.tourAnchor"
@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="!sidebarCollapsed" class="truncate">{{ item.label }}</span>
</router-link>
</li>
</ul>
</div>
</nav>
<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"
: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-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="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"
>
<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">
<slot />
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, h, onUnmounted, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth'
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
}
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',
),
users: iconPath(
'M7 8a3 3 0 116 0 3 3 0 01-6 0zm-3.5 8.5a5.5 5.5 0 0111 0 .75.75 0 01-.75.75h-9.5a.75.75 0 01-.75-.75zM14.5 9a2.5 2.5 0 100-5 2.5 2.5 0 000 5zm1.75 1.5a4 4 0 013.75 2.75.75.75 0 01-.72.95h-2.28a.75.75 0 01-.75-.75 5.48 5.48 0 00-.75-2.7.75.75 0 01.75-1.25z',
),
}
interface NavItem {
label: string
to: string
icon: () => ReturnType<typeof h>
show: boolean
tourAnchor?: string
}
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,
tourAnchor: 'nav-patients',
},
].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 },
{ label: 'Users', to: '/users', icon: icons.users, 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 ' +
(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`
}
return `${base} text-white/75 hover:bg-white/10 hover:text-white`
}
</script>
@@ -0,0 +1,487 @@
<template>
<div
class="h-full min-h-0 flex flex-col approval-frame"
data-testid="approval-form"
data-tour="approval-form"
>
<div class="flex-1 min-h-0 overflow-y-auto p-4 space-y-4">
<div class="flex items-start justify-between gap-3">
<div>
<p class="evidence-level evidence-level--2 mb-1">
<span class="evidence-level-mark" aria-hidden="true">2</span>
Verified draft
</p>
<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>
</div>
<StatusBadge :status="batch?.status ?? 'AWAITING_CLINICAL_APPROVAL'" />
</div>
<div
v-if="batch?.supersedesBatchId"
class="rounded-input border border-primary-100 bg-primary-50 p-3 text-sm"
>
<p class="font-medium text-primary-800">Correction Batch</p>
<p class="text-primary-700 mt-1">
This batch corrects and will supersede batch
<span class="font-mono">{{ batch.supersedesBatchId.substring(0, 8) }}...</span>
</p>
<button
v-if="batch.patientId"
type="button"
class="text-xs text-primary-600 hover:text-primary-800 mt-2"
@click="$emit('view-history', batch.patientId)"
>
View patient history
</button>
</div>
<!-- High-stakes clinical signals (design-doc §16) -->
<section
v-if="highStakeItems.length > 0"
class="rounded-input border border-[#FEDF89] bg-clinical-warning-bg/60 p-3"
data-testid="high-stakes-summary"
>
<p class="evidence-level evidence-level--4 mb-2 !text-clinical-warning">
<span class="evidence-level-mark" aria-hidden="true">!</span>
High-stakes clinical signals
</p>
<ul class="space-y-2">
<li
v-for="item in highStakeItems"
:key="item.key"
class="clinical-signal"
:class="{ 'clinical-signal--critical': item.critical }"
>
<span class="text-xs font-semibold uppercase tracking-wide text-ink-secondary">
{{ item.label }}
</span>
<p class="font-medium text-ink-strong mt-0.5">{{ item.value }}</p>
<p v-if="item.reason" class="text-xs text-ink-secondary mt-0.5">{{ item.reason }}</p>
</li>
</ul>
</section>
<fieldset v-if="draft?.patient" class="workstation-form-section">
<legend class="workstation-form-legend">Patient</legend>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div>
<span class="text-ink-secondary">Full Name</span>
<p class="font-medium text-ink-strong">{{ draft.patient.fullName || '—' }}</p>
</div>
<div>
<span class="text-ink-secondary">DOB</span>
<p class="font-medium text-ink-strong">{{ draft.patient.dateOfBirth ?? 'N/A' }}</p>
</div>
<div>
<span class="text-ink-secondary">Sex</span>
<p class="font-medium text-ink-strong">{{ draft.patient.sex ?? 'N/A' }}</p>
</div>
<div :class="draft.patient.bloodType ? 'rounded-input bg-clinical-warning-bg/50 px-2 py-1 -mx-2' : ''">
<span class="text-ink-secondary">Blood Type</span>
<p class="font-medium text-ink-strong">{{ draft.patient.bloodType ?? 'N/A' }}</p>
</div>
</div>
</fieldset>
<fieldset v-if="draft?.encounter" class="workstation-form-section">
<legend class="workstation-form-legend">Encounter</legend>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div>
<span class="text-ink-secondary">Admission</span>
<p class="font-medium text-ink-strong">{{ draft.encounter.admissionDate ?? 'N/A' }}</p>
</div>
<div>
<span class="text-ink-secondary">Department</span>
<p class="font-medium text-ink-strong">{{ draft.encounter.department ?? 'N/A' }}</p>
</div>
<div>
<span class="text-ink-secondary">Room / Bed</span>
<p class="font-medium text-ink-strong">{{ draft.encounter.roomBed ?? 'N/A' }}</p>
</div>
<div>
<span class="text-ink-secondary">Reason</span>
<p class="font-medium text-ink-strong">{{ draft.encounter.admissionReason ?? 'N/A' }}</p>
</div>
</div>
</fieldset>
<fieldset class="workstation-form-section">
<legend class="workstation-form-legend">
Observations ({{ draft?.observations?.length ?? 0 }})
</legend>
<div class="space-y-3">
<ObservationRow
v-for="obs in (draft?.observations ?? [])"
:key="obs.id"
:observation="obs"
:readonly="true"
/>
<p v-if="!draft?.observations?.length" class="text-sm text-ink-secondary">
No observations recorded.
</p>
</div>
</fieldset>
<div
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 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) -->
<section
class="rounded-input border border-line bg-canvas p-3 space-y-3"
data-testid="approval-decision"
>
<p class="evidence-level evidence-level--4 mb-0">
<span class="evidence-level-mark" aria-hidden="true">4</span>
Clinical decision
</p>
<label class="flex items-start gap-3 cursor-pointer">
<input
v-model="enableRetroactiveAlerts"
type="checkbox"
class="mt-1 w-4 h-4 text-clinical-safe rounded"
data-testid="retroactive-alerts"
/>
<div>
<span class="text-sm font-medium text-ink-strong">
Run alert evaluation after promotion
</span>
<p class="text-xs text-ink-secondary mt-0.5">
May generate alerts for clinical criteria represented in historical records.
It should not sound like alerts occurred contemporaneously.
</p>
</div>
</label>
</section>
<!-- Level 5 promotion outcome -->
<section
v-if="promotionResult"
class="promotion-outcome"
data-testid="promotion-result"
>
<p class="evidence-level evidence-level--5 mb-2">
<span class="evidence-level-mark" aria-hidden="true">5</span>
Promotion outcome
</p>
<p class="font-semibold text-clinical-safe">Promoted to live clinical tables</p>
<dl class="mt-3 grid gap-2 sm:grid-cols-3 text-sm">
<div>
<dt class="text-xs text-ink-secondary uppercase tracking-wide">Patient MRN</dt>
<dd class="font-mono font-medium text-ink-strong mt-0.5">{{ promotionResult.mrn }}</dd>
</div>
<div>
<dt class="text-xs text-ink-secondary uppercase tracking-wide">Encounter</dt>
<dd class="font-mono font-medium text-ink-strong mt-0.5">
{{ promotionResult.encounterId?.substring(0, 8) }}
</dd>
</div>
<div>
<dt class="text-xs text-ink-secondary uppercase tracking-wide">Observations</dt>
<dd class="font-medium text-ink-strong mt-0.5">
{{ promotionResult.observationIds?.length ?? 0 }} promoted
</dd>
</div>
</dl>
<div class="flex flex-wrap gap-4 mt-3 pt-3 border-t border-[#ABEFC6]">
<button
type="button"
class="text-sm text-primary-600 hover:text-primary-800 font-medium"
@click="$emit('create-correction')"
>
Create Correction
</button>
<button
v-if="batch?.patientId"
type="button"
class="text-sm text-ink-secondary hover:text-ink-strong"
@click="$emit('view-history', batch.patientId)"
>
View Patient History
</button>
</div>
</section>
<div
v-if="deferred"
class="rounded-input border border-[#FEDF89] bg-clinical-warning-bg p-4 text-sm"
data-testid="promotion-deferred"
>
<p class="font-medium text-clinical-warning">Approved Promotion Deferred</p>
<p class="text-ink mt-1">
Promotion will be retried automatically due to a temporary infrastructure issue.
</p>
</div>
<div v-if="errorMessage" class="text-clinical-danger text-sm" role="alert">
{{ errorMessage }}
</div>
<AuditTrailPanel :batch-id="batchId" />
</div>
<WorkstationActionBar>
<template #left>
<button
type="button"
class="btn-danger w-full sm:w-auto"
:disabled="processing || !!promotionResult"
data-testid="reject-batch"
@click="showRejectDialog = true"
>
Reject Batch
</button>
</template>
<template #primary>
<button
type="button"
class="btn-primary w-full sm:w-auto"
:disabled="processing || !!promotionResult"
data-testid="approve-promote"
@click="showApproveConfirm = true"
>
{{ processing ? 'Promoting...' : 'Approve & Promote' }}
</button>
</template>
<template #right>
<button
type="button"
class="btn-secondary w-full sm:w-auto"
:disabled="processing"
@click="$emit('back')"
>
Back to Queue
</button>
</template>
</WorkstationActionBar>
<ConfirmDialog
:open="showApproveConfirm"
title="Approve & Promote"
body="Approval will promote the verified records into live clinical tables."
confirm-label="Approve & Promote"
variant="primary"
:confirm-disabled="processing"
@confirm="confirmApprove"
@cancel="showApproveConfirm = false"
/>
<ConfirmDialog
:open="showRejectDialog"
title="Reject Batch"
body="This returns the batch for rework. A reason is required."
confirm-label="Reject Batch"
variant="danger"
:confirm-disabled="!rejectionReason.trim() || processing"
@confirm="reject"
@cancel="closeRejectDialog"
>
<textarea
v-model="rejectionReason"
class="form-input"
rows="4"
placeholder="Reason for rejection (required)..."
/>
</ConfirmDialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useBatchStore } from '../stores/batches'
import { useToast } from '../composables/useToast'
import ObservationRow from './ObservationRow.vue'
import StatusBadge from './StatusBadge.vue'
import ConfirmDialog from './ConfirmDialog.vue'
import AuditTrailPanel from './AuditTrailPanel.vue'
import WorkstationActionBar from './WorkstationActionBar.vue'
import type { BatchDetailResponse, BatchDraft, DraftObservation } from '../types'
const props = defineProps<{
batch: BatchDetailResponse | null
batchId: string
draft: BatchDraft | null
}>()
const emit = defineEmits<{
(e: 'back'): void
(e: 'view-history', patientId: string): void
(e: 'create-correction'): void
(e: 'approved'): void
(e: 'rejected'): void
}>()
const batchStore = useBatchStore()
const toast = useToast()
const processing = ref(false)
const errorMessage = ref('')
const enableRetroactiveAlerts = ref(false)
const showApproveConfirm = ref(false)
const showRejectDialog = ref(false)
const rejectionReason = ref('')
const promotionResult = ref<{ mrn: string; encounterId: string; observationIds: string[] } | null>(null)
const deferred = ref(false)
watch(
() => props.batchId,
() => {
promotionResult.value = null
deferred.value = false
errorMessage.value = ''
enableRetroactiveAlerts.value = false
rejectionReason.value = ''
}
)
interface HighStakeItem {
key: string
label: string
value: string
reason?: string
critical?: boolean
}
function isCriticalObservation(obs: DraftObservation): boolean {
const code = (obs.observationCode ?? '').toUpperCase()
const value = Number(obs.value)
if (Number.isNaN(value)) return false
if (code.includes('TEMP') && value >= 38) return true
if (code.includes('SPO2') && value < 92) return true
if ((code.includes('HEART') || code === 'HR') && (value > 120 || value < 40)) return true
if (code.includes('BP_SYSTOLIC') && (value >= 180 || value < 90)) return true
if (code.includes('LACTATE') && value >= 2) return true
if (code.includes('POTASSIUM') && (value < 3 || value > 5.5)) return true
return false
}
function formatObsLabel(code: string): string {
return code.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
const highStakeItems = computed((): HighStakeItem[] => {
const draft = props.draft
if (!draft) return []
const items: HighStakeItem[] = []
const patient = draft.patient
if (patient?.bloodType) {
items.push({
key: 'bloodType',
label: 'Blood type',
value: patient.bloodType,
reason: 'High-stakes demographic for transfusion safety',
})
}
if (patient?.noKnownAllergies) {
items.push({
key: 'nka',
label: 'Allergies',
value: 'No known allergies (NKA)',
})
} else if (patient?.allergies?.length) {
items.push({
key: 'allergies',
label: 'Allergies',
value: patient.allergies.join(', '),
reason: 'Allergy documentation requires clinical oversight',
critical: true,
})
}
if (patient?.noActiveMedications) {
items.push({
key: 'nam',
label: 'Medications',
value: 'No active medications',
})
} else if (patient?.medications?.length) {
items.push({
key: 'medications',
label: 'Medications',
value: patient.medications.join(', '),
reason: 'Medication list requires clinical oversight',
critical: true,
})
}
for (const obs of draft.observations ?? []) {
if (!isCriticalObservation(obs)) continue
items.push({
key: `obs-${obs.id}`,
label: formatObsLabel(obs.observationCode),
value: `${obs.value}${obs.unit ? ` ${obs.unit}` : ''}`,
reason: 'Out-of-range clinical signal',
critical: true,
})
}
return items
})
function closeRejectDialog() {
showRejectDialog.value = false
}
async function confirmApprove() {
showApproveConfirm.value = false
await approve()
}
async function approve() {
processing.value = true
errorMessage.value = ''
promotionResult.value = null
deferred.value = false
try {
const response = await batchStore.approveBatch(props.batchId, enableRetroactiveAlerts.value)
if (response?.status === 202) {
deferred.value = true
toast.info('Approved. Promotion will be retried automatically.')
} else if (response?.data) {
promotionResult.value = response.data
toast.success('Batch approved and promoted successfully')
emit('approved')
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Approval failed'
if (msg.includes('PROMOTION_DEFERRED')) {
deferred.value = true
toast.info('Approved. Promotion will be retried automatically.')
} else {
errorMessage.value = msg
toast.error(msg)
}
} finally {
processing.value = false
}
}
async function reject() {
processing.value = true
errorMessage.value = ''
try {
await batchStore.rejectBatch(props.batchId, rejectionReason.value)
showRejectDialog.value = false
toast.warning('Batch rejected')
emit('rejected')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Rejection failed'
errorMessage.value = msg
toast.error(msg)
} finally {
processing.value = false
}
}
</script>
@@ -0,0 +1,164 @@
<template>
<details
ref="root"
class="mt-2 group workstation-form-section !p-3"
data-testid="audit-trail-panel"
data-tour="audit-trail"
@toggle="onToggle"
>
<summary
class="text-sm font-medium text-primary-700 cursor-pointer hover:text-primary-800 list-none flex items-center gap-2"
>
<span
class="inline-flex h-5 w-5 items-center justify-center rounded border border-line text-xs text-ink-secondary group-open:rotate-90 transition-transform"
aria-hidden="true"
>
</span>
Audit trail
<span v-if="events.length > 0" class="text-ink-secondary font-normal">
({{ events.length }}{{ hasMore ? '+' : '' }} events)
</span>
</summary>
<div class="mt-3">
<p v-if="loading && events.length === 0" class="text-sm text-ink-secondary">
Loading events
</p>
<p v-else-if="error" class="text-sm text-clinical-danger" role="alert">
{{ error }}
</p>
<p v-else-if="!loading && events.length === 0" class="text-sm text-ink-secondary">
No audit events for this batch yet.
</p>
<ol v-else class="space-y-0" data-testid="audit-trail-timeline">
<li
v-for="event in events"
:key="event.id"
class="flex gap-3"
>
<div class="w-[5.5rem] shrink-0 pt-0.5 text-right">
<time
class="block text-[11px] leading-snug text-ink-secondary tabular-nums"
:datetime="event.occurredAt"
>
{{ formatDateTime(event.occurredAt) }}
</time>
</div>
<div class="observation-card__rail self-stretch" aria-hidden="true">
<span class="observation-card__dot" />
<span class="mt-1 w-px flex-1 bg-line" />
</div>
<div class="min-w-0 flex-1 pb-4">
<p class="text-sm font-bold leading-snug text-ink-strong">
{{ formatEventType(event.eventType) }}
</p>
<p class="mt-0.5 text-xs text-ink">
{{ event.actorFullName || event.actorUsername || '—' }}
</p>
<p
v-if="summarizePayload(event.metadataJson) !== '—'"
class="mt-1 text-xs text-ink-secondary"
>
{{ summarizePayload(event.metadataJson) }}
</p>
</div>
</li>
</ol>
<button
v-if="hasMore"
type="button"
class="mt-1 text-sm font-medium text-primary-700 hover:text-primary-800 disabled:opacity-50"
:disabled="loading"
data-testid="audit-trail-load-more"
@click="loadMore"
>
{{ loading ? 'Loading…' : 'Load more' }}
</button>
</div>
</details>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useBatchStore } from '../stores/batches'
const props = defineProps<{
batchId: string
}>()
const batchStore = useBatchStore()
const { events, eventsLoading: loading, eventsError: error, eventsHasMore: hasMore } =
storeToRefs(batchStore)
const root = ref<HTMLDetailsElement | null>(null)
let loadedForBatchId: string | null = null
watch(
() => props.batchId,
async (id) => {
loadedForBatchId = null
batchStore.clearEvents()
if (root.value?.open) {
loadedForBatchId = id
await batchStore.fetchEvents(id)
}
}
)
async function onToggle(e: Event) {
const el = e.currentTarget as HTMLDetailsElement
if (!el.open) return
if (loadedForBatchId === props.batchId) return
loadedForBatchId = props.batchId
await batchStore.fetchEvents(props.batchId)
}
async function loadMore() {
await batchStore.fetchEvents(props.batchId, batchStore.eventsNextCursor ?? undefined)
}
function formatEventType(type: string): string {
return type
.toLowerCase()
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase())
}
function formatDateTime(iso: string): string {
try {
return new Date(iso).toLocaleString(undefined, {
dateStyle: 'short',
timeStyle: 'short',
})
} catch {
return iso
}
}
function summarizePayload(metadataJson: string | null): string {
if (!metadataJson) return '—'
try {
const meta = JSON.parse(metadataJson) as Record<string, unknown>
const parts: string[] = []
const prev = meta.previousStatus ?? meta.previous_status ?? meta.fromStatus
const next = meta.newStatus ?? meta.new_status ?? meta.toStatus ?? meta.status
if (typeof prev === 'string' && typeof next === 'string') {
parts.push(`${prev}${next}`)
} else if (typeof next === 'string') {
parts.push(String(next))
}
const reason = meta.reason ?? meta.rejectionReason
if (typeof reason === 'string' && reason.trim()) {
parts.push(reason.trim().length > 80 ? `${reason.trim().slice(0, 80)}` : reason.trim())
}
return parts.length > 0 ? parts.join(' · ') : '—'
} catch {
return '—'
}
}
</script>
@@ -1,12 +1,26 @@
<template> <template>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<div v-if="loading" class="text-gray-500 text-center py-4">Loading...</div> <InlineError
<div v-else-if="batches.length === 0" class="text-gray-500 text-center py-4"> v-if="error"
No batches found. title="Could not load batches"
</div> :message="error"
<table v-else class="w-full min-w-[640px] text-sm"> preserved="Your filters and previous selections were preserved."
retry-label="Retry"
@retry="$emit('retry')"
/>
<SkeletonBlock v-else-if="loading" variant="table" :rows="5" />
<EmptyState
v-else-if="batches.length === 0"
:title="emptyTitle"
:description="emptyDescription"
>
<template v-if="$slots.emptyAction" #action>
<slot name="emptyAction" />
</template>
</EmptyState>
<table v-else class="w-full min-w-[36rem] text-sm">
<thead> <thead>
<tr class="border-b text-left text-gray-600"> <tr class="border-b text-left text-ink-secondary">
<th class="py-2 px-4">ID</th> <th class="py-2 px-4">ID</th>
<th class="py-2 px-4">Type</th> <th class="py-2 px-4">Type</th>
<th class="py-2 px-4">Track</th> <th class="py-2 px-4">Track</th>
@@ -19,7 +33,7 @@
<tr <tr
v-for="batch in batches" v-for="batch in batches"
:key="batch.id" :key="batch.id"
class="border-b hover:bg-gray-50 cursor-pointer" class="border-b hover:bg-primary-50 cursor-pointer"
@click="$emit('select', batch.id)" @click="$emit('select', batch.id)"
> >
<td class="py-2 px-4 font-mono text-xs">{{ batch.id.substring(0, 8) }}...</td> <td class="py-2 px-4 font-mono text-xs">{{ batch.id.substring(0, 8) }}...</td>
@@ -27,27 +41,23 @@
<td class="py-2 px-4"> <td class="py-2 px-4">
<span <span
:class="batch.track === 'BACKFILL' :class="batch.track === 'BACKFILL'
? 'bg-blue-100 text-blue-800' ? 'bg-primary-50 text-primary-800 border border-primary-100'
: 'bg-green-100 text-green-800'" : 'bg-clinical-safe-bg text-clinical-safe border border-[#ABEFC6]'"
class="status-badge" class="status-badge"
> >
{{ batch.track === 'BACKFILL' ? 'Backfill' : 'Live' }} {{ batch.track === 'BACKFILL' ? 'Backfill' : 'Live' }}
</span> </span>
</td> </td>
<td class="py-2 px-4"> <td class="py-2 px-4">
<span <StatusBadge :status="batch.status" />
:class="statusColor(batch.status)"
class="status-badge"
>
{{ formatStatus(batch.status) }}
</span>
</td> </td>
<td class="py-2 px-4 text-gray-500"> <td class="py-2 px-4 text-ink-secondary">
{{ new Date(batch.createdAt).toLocaleString() }} {{ new Date(batch.createdAt).toLocaleString() }}
</td> </td>
<td v-if="showAssign" class="py-2 px-4"> <td v-if="showAssign" class="py-2 px-4">
<button <button
v-if="batch.status === 'UPLOADED'" v-if="batch.status === 'UPLOADED'"
type="button"
@click.stop="$emit('assign', batch.id)" @click.stop="$emit('assign', batch.id)"
class="text-primary-600 hover:text-primary-800 text-xs font-medium" class="text-primary-600 hover:text-primary-800 text-xs font-medium"
> >
@@ -62,37 +72,33 @@
<script setup lang="ts"> <script setup lang="ts">
import type { BatchDetailResponse } from '../types' import type { BatchDetailResponse } from '../types'
import StatusBadge from './StatusBadge.vue'
import EmptyState from './EmptyState.vue'
import SkeletonBlock from './SkeletonBlock.vue'
import InlineError from './InlineError.vue'
defineProps<{ withDefaults(
defineProps<{
batches: BatchDetailResponse[] batches: BatchDetailResponse[]
loading: boolean loading: boolean
showAssign?: boolean showAssign?: boolean
}>() error?: string | null
emptyTitle?: string
emptyDescription?: string
}>(),
{
emptyTitle: 'No batches found.',
emptyDescription: undefined,
}
)
defineEmits<{ defineEmits<{
(e: 'select', batchId: string): void (e: 'select', batchId: string): void
(e: 'assign', batchId: string): void (e: 'assign', batchId: string): void
(e: 'retry'): void
}>() }>()
function formatBatchType(type: string): string { function formatBatchType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
function formatStatus(status: string): string {
return status.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
function statusColor(status: string): string {
const colors: Record<string, string> = {
UPLOADED: 'bg-gray-100 text-gray-800',
IN_ENTRY: 'bg-yellow-100 text-yellow-800',
PENDING_VERIFICATION: 'bg-orange-100 text-orange-800',
REJECTED: 'bg-red-100 text-red-800',
VERIFIED: 'bg-blue-100 text-blue-800',
AWAITING_CLINICAL_APPROVAL: 'bg-purple-100 text-purple-800',
APPROVED: 'bg-green-100 text-green-800',
PROMOTED: 'bg-emerald-100 text-emerald-800',
}
return colors[status] ?? 'bg-gray-100 text-gray-800'
} }
</script> </script>
@@ -0,0 +1,72 @@
<template>
<Teleport to="body">
<div
v-if="open"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
:aria-labelledby="titleId"
data-testid="confirm-dialog"
@keydown.esc.prevent="$emit('cancel')"
>
<div
class="w-full max-w-md rounded-card border border-line bg-surface p-6 shadow-dialog"
@click.stop
>
<h3 :id="titleId" class="text-lg font-semibold text-ink-strong">
{{ title }}
</h3>
<p v-if="body" class="mt-2 text-sm text-ink">{{ body }}</p>
<div v-if="$slots.default" class="mt-4">
<slot />
</div>
<div class="mt-6 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button
type="button"
class="px-4 py-2 text-sm text-ink-secondary hover:text-ink-strong"
@click="$emit('cancel')"
>
{{ cancelLabel }}
</button>
<button
type="button"
:class="variant === 'danger' ? 'btn-danger' : 'btn-primary'"
:disabled="confirmDisabled"
@click="$emit('confirm')"
>
{{ confirmLabel }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { useId } from 'vue'
withDefaults(
defineProps<{
open: boolean
title: string
body?: string
confirmLabel?: string
cancelLabel?: string
variant?: 'primary' | 'danger'
confirmDisabled?: boolean
}>(),
{
confirmLabel: 'Confirm',
cancelLabel: 'Cancel',
variant: 'primary',
confirmDisabled: false,
}
)
defineEmits<{
(e: 'confirm'): void
(e: 'cancel'): void
}>()
const titleId = useId()
</script>
@@ -0,0 +1,18 @@
<template>
<div class="flex flex-col items-center justify-center text-center py-10 px-4">
<h3 class="text-base font-semibold text-ink-strong">{{ title }}</h3>
<p v-if="description" class="mt-2 text-sm text-ink-secondary max-w-md">
{{ description }}
</p>
<div v-if="$slots.action" class="mt-4">
<slot name="action" />
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
title: string
description?: string
}>()
</script>
+211 -118
View File
@@ -1,48 +1,70 @@
<template> <template>
<div class="h-full overflow-y-auto p-4 space-y-6"> <div class="h-full min-h-0 flex flex-col" data-testid="entry-form" data-tour="entry-form">
<div class="flex items-center justify-between"> <div class="flex-1 min-h-0 overflow-y-auto p-4 space-y-4">
<h2 class="text-lg font-semibold">Data Entry</h2> <div class="flex items-start justify-between gap-3">
<span <div>
:class="statusColor" <p class="evidence-level evidence-level--2 mb-1">
class="status-badge" <span class="evidence-level-mark" aria-hidden="true">2</span>
Structured draft
</p>
<h2 class="text-base font-semibold text-ink-strong">Draft fields</h2>
<p
class="mt-1 text-xs tabular-nums"
:class="{
'text-ink-secondary': saveState === 'saved' || saveState === 'idle',
'text-ink-secondary animate-pulse': saveState === 'saving',
'text-clinical-danger font-medium': saveState === 'failed',
}"
data-testid="entry-save-status"
> >
{{ batch?.status?.replace(/_/g, ' ') }} {{ saveStatusLabel }}
</span> </p>
</div>
<StatusBadge :status="batch?.status" />
</div> </div>
<div v-if="ocrConfidence" class="ocr-banner"> <div v-if="ocrConfidence" class="ocr-banner">
Pre-filled by OCR ({{ ocrConfidence.provider }}). Pre-filled by OCR ({{ ocrConfidence.provider }}) review against the scan.
Review all values against the scan before submitting. OCR is assistive, not authoritative.
</div> </div>
<!-- Patient section --> <!-- Patient section -->
<fieldset class="border border-gray-200 rounded-md p-4"> <fieldset class="workstation-form-section">
<legend class="text-sm font-medium text-gray-700 px-2">Patient Demographics</legend> <legend class="workstation-form-legend">Patient Demographics</legend>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div> <div>
<label class="block text-xs text-gray-500">Full Name</label> <label class="workstation-field-label">
Full Name
<OcrConfidenceBadge :label="fieldConfidenceLabel('patient.fullName')" :level="fieldConfidenceLevel('patient.fullName')" />
</label>
<input <input
v-model="patient.fullName" v-model="patient.fullName"
@blur="savePatient"
type="text" type="text"
:class="['form-input', 'text-sm', fieldConfidenceClass('patient.fullName')]" :class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('patient.fullName')]"
@blur="savePatient"
/> />
</div> </div>
<div> <div>
<label class="block text-xs text-gray-500">Date of Birth</label> <label class="workstation-field-label">
Date of Birth
<OcrConfidenceBadge :label="fieldConfidenceLabel('patient.dateOfBirth')" :level="fieldConfidenceLevel('patient.dateOfBirth')" />
</label>
<input <input
v-model="patient.dateOfBirth" v-model="patient.dateOfBirth"
@blur="savePatient"
type="date" type="date"
:class="['form-input', 'text-sm', fieldConfidenceClass('patient.dateOfBirth')]" :class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('patient.dateOfBirth')]"
@blur="savePatient"
/> />
</div> </div>
<div> <div>
<label class="block text-xs text-gray-500">Sex</label> <label class="workstation-field-label">
Sex
<OcrConfidenceBadge :label="fieldConfidenceLabel('patient.sex')" :level="fieldConfidenceLevel('patient.sex')" />
</label>
<select <select
v-model="patient.sex" v-model="patient.sex"
:class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('patient.sex')]"
@change="savePatient" @change="savePatient"
:class="['form-input', 'text-sm', fieldConfidenceClass('patient.sex')]"
> >
<option value="">Select...</option> <option value="">Select...</option>
<option value="male">Male</option> <option value="male">Male</option>
@@ -51,195 +73,243 @@
</select> </select>
</div> </div>
<div> <div>
<label class="block text-xs text-gray-500">Blood Type</label> <label class="workstation-field-label">Blood Type</label>
<select v-model="patient.bloodType" @change="savePatient" class="form-input text-sm"> <select v-model="patient.bloodType" class="form-input text-sm py-1.5" @change="savePatient">
<option value="">Unknown</option> <option value="">Unknown</option>
<option v-for="bt in bloodTypes" :key="bt" :value="bt">{{ bt }}</option> <option v-for="bt in bloodTypes" :key="bt" :value="bt">{{ bt }}</option>
</select> </select>
</div> </div>
<div class="col-span-2"> <div class="md:col-span-2">
<label class="block text-xs text-gray-500">Emergency Contact</label> <label class="workstation-field-label">Emergency Contact</label>
<input <input
v-model="patient.emergencyContact" v-model="patient.emergencyContact"
@blur="savePatient"
type="text" type="text"
class="form-input text-sm" class="form-input text-sm py-1.5"
@blur="savePatient"
/> />
</div> </div>
</div> </div>
</fieldset> </fieldset>
<!-- Allergies section (ALLERGY_UPDATE or MIXED) --> <!-- Allergies section (ALLERGY_UPDATE or MIXED) -->
<fieldset v-if="showAllergies" class="border border-gray-200 rounded-md p-4"> <fieldset v-if="showAllergies" class="workstation-form-section">
<legend class="text-sm font-medium text-gray-700 px-2">Allergies</legend> <legend class="workstation-form-legend">Allergies</legend>
<label class="flex items-center gap-2 mb-3 cursor-pointer"> <label class="flex items-center gap-2 mb-2 cursor-pointer">
<input <input
v-model="patient.noKnownAllergies" v-model="patient.noKnownAllergies"
@change="onNoKnownAllergiesChange"
type="checkbox" type="checkbox"
class="w-4 h-4 text-clinical-safe rounded" class="w-4 h-4 text-clinical-safe rounded"
@change="onNoKnownAllergiesChange"
/> />
<span class="text-sm">No known allergies (NKA)</span> <span class="text-sm">No known allergies (NKA)</span>
</label> </label>
<div v-if="!patient.noKnownAllergies" class="space-y-2"> <div v-if="!patient.noKnownAllergies" class="space-y-2">
<div <div
v-for="(allergy, idx) in allergies" v-for="(_allergy, idx) in allergies"
:key="idx" :key="idx"
class="flex items-center gap-2" class="flex items-center gap-2"
> >
<input <input
v-model="allergies[idx]" v-model="allergies[idx]"
@blur="saveAllergies"
type="text" type="text"
class="form-input text-sm flex-1" class="form-input text-sm py-1.5 flex-1"
placeholder="Allergy (e.g. Penicillin)" placeholder="Allergy (e.g. Penicillin)"
@blur="saveAllergies"
/> />
<button <button
type="button"
class="text-clinical-danger hover:text-clinical-critical text-sm"
@click="removeAllergy(idx)" @click="removeAllergy(idx)"
class="text-clinical-danger hover:text-red-800 text-sm"
> >
Remove Remove
</button> </button>
</div> </div>
<button @click="addAllergy" class="text-sm text-primary-600 hover:text-primary-800"> <button type="button" class="text-sm text-primary-600 hover:text-primary-800" @click="addAllergy">
+ Add Allergy + Add Allergy
</button> </button>
</div> </div>
</fieldset> </fieldset>
<!-- Medications section (MEDICATION_LIST or MIXED) --> <!-- Medications section (MEDICATION_LIST or MIXED) -->
<fieldset v-if="showMedications" class="border border-gray-200 rounded-md p-4"> <fieldset v-if="showMedications" class="workstation-form-section">
<legend class="text-sm font-medium text-gray-700 px-2">Medications</legend> <legend class="workstation-form-legend">Medications</legend>
<label class="flex items-center gap-2 mb-3 cursor-pointer"> <label class="flex items-center gap-2 mb-2 cursor-pointer">
<input <input
v-model="patient.noActiveMedications" v-model="patient.noActiveMedications"
@change="onNoActiveMedicationsChange"
type="checkbox" type="checkbox"
class="w-4 h-4 text-clinical-safe rounded" class="w-4 h-4 text-clinical-safe rounded"
@change="onNoActiveMedicationsChange"
/> />
<span class="text-sm">No active medications</span> <span class="text-sm">No active medications</span>
</label> </label>
<div v-if="!patient.noActiveMedications" class="space-y-2"> <div v-if="!patient.noActiveMedications" class="space-y-2">
<div <div
v-for="(med, idx) in medications" v-for="(_med, idx) in medications"
:key="idx" :key="idx"
class="flex items-center gap-2" class="flex items-center gap-2"
> >
<input <input
v-model="medications[idx]" v-model="medications[idx]"
@blur="saveMedications"
type="text" type="text"
class="form-input text-sm flex-1" class="form-input text-sm py-1.5 flex-1"
placeholder="Medication (e.g. Metoprolol 50mg BID)" placeholder="Medication (e.g. Metoprolol 50mg BID)"
@blur="saveMedications"
/> />
<button <button
type="button"
class="text-clinical-danger hover:text-clinical-critical text-sm"
@click="removeMedication(idx)" @click="removeMedication(idx)"
class="text-clinical-danger hover:text-red-800 text-sm"
> >
Remove Remove
</button> </button>
</div> </div>
<button @click="addMedication" class="text-sm text-primary-600 hover:text-primary-800"> <button type="button" class="text-sm text-primary-600 hover:text-primary-800" @click="addMedication">
+ Add Medication + Add Medication
</button> </button>
</div> </div>
</fieldset> </fieldset>
<!-- Encounter section --> <!-- Encounter section -->
<fieldset class="border border-gray-200 rounded-md p-4"> <fieldset class="workstation-form-section">
<legend class="text-sm font-medium text-gray-700 px-2">Encounter Context</legend> <legend class="workstation-form-legend">Encounter Context</legend>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div> <div>
<label class="block text-xs text-gray-500">Admission Date</label> <label class="workstation-field-label">
Admission Date
<OcrConfidenceBadge :label="fieldConfidenceLabel('encounter.admissionDate')" :level="fieldConfidenceLevel('encounter.admissionDate')" />
</label>
<input <input
v-model="encounter.admissionDate" v-model="encounter.admissionDate"
@blur="saveEncounter"
type="datetime-local" type="datetime-local"
:class="['form-input', 'text-sm', fieldConfidenceClass('encounter.admissionDate')]" :class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('encounter.admissionDate')]"
@blur="saveEncounter"
/> />
</div> </div>
<div> <div>
<label class="block text-xs text-gray-500">Department</label> <label class="workstation-field-label">
Department
<OcrConfidenceBadge :label="fieldConfidenceLabel('encounter.department')" :level="fieldConfidenceLevel('encounter.department')" />
</label>
<select <select
v-model="encounter.department" v-model="encounter.department"
:class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('encounter.department')]"
@change="saveEncounter" @change="saveEncounter"
:class="['form-input', 'text-sm', fieldConfidenceClass('encounter.department')]"
> >
<option value=""></option> <option value=""></option>
<option v-for="dept in departments" :key="dept" :value="dept">{{ dept }}</option> <option v-for="dept in departments" :key="dept" :value="dept">{{ dept }}</option>
</select> </select>
</div> </div>
<div> <div>
<label class="block text-xs text-gray-500">Room / Bed</label> <label class="workstation-field-label">
Room / Bed
<OcrConfidenceBadge :label="fieldConfidenceLabel('encounter.roomBed')" :level="fieldConfidenceLevel('encounter.roomBed')" />
</label>
<input <input
v-model="encounter.roomBed" v-model="encounter.roomBed"
@blur="saveEncounter"
type="text" type="text"
:class="['form-input', 'text-sm', fieldConfidenceClass('encounter.roomBed')]" :class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('encounter.roomBed')]"
@blur="saveEncounter"
/> />
</div> </div>
<div> <div>
<label class="block text-xs text-gray-500">Admission Reason</label> <label class="workstation-field-label">
Admission Reason
<OcrConfidenceBadge :label="fieldConfidenceLabel('encounter.admissionReason')" :level="fieldConfidenceLevel('encounter.admissionReason')" />
</label>
<input <input
v-model="encounter.admissionReason" v-model="encounter.admissionReason"
@blur="saveEncounter"
type="text" type="text"
:class="['form-input', 'text-sm', fieldConfidenceClass('encounter.admissionReason')]" :class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('encounter.admissionReason')]"
@blur="saveEncounter"
/> />
</div> </div>
<div v-if="showEncounterSummary"> <div v-if="showEncounterSummary">
<label class="block text-xs text-gray-500">Encounter Status</label> <label class="workstation-field-label">Encounter Status</label>
<select v-model="encounter.status" @change="saveEncounter" class="form-input text-sm"> <select v-model="encounter.status" class="form-input text-sm py-1.5" @change="saveEncounter">
<option value=""></option> <option value=""></option>
<option value="active">Active</option> <option value="active">Active</option>
<option value="discharged">Discharged</option> <option value="discharged">Discharged</option>
</select> </select>
</div> </div>
<div v-if="showEncounterSummary" class="col-span-2"> <div v-if="showEncounterSummary" class="md:col-span-2">
<label class="block text-xs text-gray-500">Discharge Diagnosis</label> <label class="workstation-field-label">Discharge Diagnosis</label>
<textarea <textarea
v-model="encounter.dischargeDiagnosis" v-model="encounter.dischargeDiagnosis"
@blur="saveEncounter" class="form-input text-sm py-1.5"
class="form-input text-sm"
rows="2" rows="2"
placeholder="Discharge diagnosis..." placeholder="Discharge diagnosis..."
@blur="saveEncounter"
/> />
</div> </div>
</div> </div>
</fieldset> </fieldset>
<!-- Observations section --> <!-- Observations section -->
<fieldset class="border border-gray-200 rounded-md p-4"> <fieldset class="workstation-form-section">
<legend class="text-sm font-medium text-gray-700 px-2">Observations</legend> <legend class="workstation-form-legend">Observations</legend>
<div class="space-y-2"> <div class="space-y-3">
<ObservationRow <ObservationRow
v-for="obs in observations" v-for="obs in observations"
:key="obs.id" :key="obs.id"
:observation="obs" :observation="obs"
:value-input-class="observationValueConfidenceClass(obs.observationCode)" :value-input-class="observationValueConfidenceClass(obs.observationCode)"
:ocr-label="fieldConfidenceLabel(`observation.${obs.observationCode}.value`)"
:ocr-level="fieldConfidenceLevel(`observation.${obs.observationCode}.value`)"
@update="(field, value) => handleObsUpdate(obs.id, field, value)" @update="(field, value) => handleObsUpdate(obs.id, field, value)"
@delete="handleObsDelete" @delete="handleObsDelete"
/> />
<button @click="addObservation" class="btn-primary text-sm"> <button type="button" class="btn-secondary text-sm py-1.5 w-full sm:w-auto" @click="addObservation">
+ Add Observation + Add Observation
</button> </button>
</div> </div>
</fieldset> </fieldset>
<!-- Submit --> <div v-if="errorMessage" class="text-clinical-danger text-sm" role="alert">
<div class="flex flex-col sm:flex-row gap-4 pt-4 border-t"> {{ errorMessage }}
</div>
<AuditTrailPanel :batch-id="batchId" />
</div>
<WorkstationActionBar>
<template #center>
<button <button
@click="submitForVerification" type="button"
class="btn-secondary"
:disabled="saveState === 'saving' || submitting"
@click="saveDraft"
>
Save Draft
</button>
</template>
<template #primary>
<div class="flex flex-col items-end gap-1">
<p class="evidence-level evidence-level--3 mb-0 hidden sm:flex">
<span class="evidence-level-mark" aria-hidden="true">3</span>
Submit decision
</p>
<button
type="button"
class="btn-primary" class="btn-primary"
:disabled="submitting" :disabled="submitting || saveState === 'saving'"
@click="submitForVerification"
> >
{{ submitting ? 'Submitting...' : 'Submit for Verification' }} {{ submitting ? 'Submitting...' : 'Submit for Verification' }}
</button> </button>
</div> </div>
</template>
<div v-if="errorMessage" class="text-clinical-danger text-sm"> <template v-if="nextBatchId" #right>
{{ errorMessage }} <button
</div> type="button"
class="btn-secondary"
:disabled="submitting"
data-testid="entry-next-batch"
@click="emit('open-next', nextBatchId)"
>
Next
</button>
</template>
</WorkstationActionBar>
</div> </div>
</template> </template>
@@ -249,17 +319,38 @@ import { useBatchStore } from '../stores/batches'
import { useToast } from '../composables/useToast' import { useToast } from '../composables/useToast'
import { useOcrFieldConfidence } from '../composables/useOcrFieldConfidence' import { useOcrFieldConfidence } from '../composables/useOcrFieldConfidence'
import ObservationRow from '../components/ObservationRow.vue' import ObservationRow from '../components/ObservationRow.vue'
import AuditTrailPanel from '../components/AuditTrailPanel.vue'
import StatusBadge from '../components/StatusBadge.vue'
import OcrConfidenceBadge from '../components/OcrConfidenceBadge.vue'
import WorkstationActionBar from '../components/WorkstationActionBar.vue'
import type { BatchDetailResponse, DraftObservation } from '../types' import type { BatchDetailResponse, DraftObservation } from '../types'
const props = defineProps<{ const props = defineProps<{
batch: BatchDetailResponse | null batch: BatchDetailResponse | null
batchId: string batchId: string
/** Next batch in the current queue list, if any */
nextBatchId?: string
}>()
const emit = defineEmits<{
(e: 'open-next', id: string): void
}>() }>()
const batchStore = useBatchStore() const batchStore = useBatchStore()
const toast = useToast() const toast = useToast()
const submitting = ref(false) const submitting = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const saveState = ref<'idle' | 'saving' | 'saved' | 'failed'>('idle')
const lastSavedAt = ref<Date | null>(null)
const saveStatusLabel = computed(() => {
if (saveState.value === 'saving') return 'Saving…'
if (saveState.value === 'failed') return 'Save failed'
if (saveState.value === 'saved' && lastSavedAt.value) {
return `Saved ${lastSavedAt.value.toLocaleTimeString()}`
}
return ''
})
const bloodTypes = ['A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-'] const bloodTypes = ['A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-']
const departments = [ const departments = [
@@ -287,7 +378,11 @@ const departments = [
const fieldReqs = computed(() => batchStore.currentDraft?.fieldRequirements) const fieldReqs = computed(() => batchStore.currentDraft?.fieldRequirements)
const ocrConfidence = computed(() => batchStore.currentDraft?.ocrConfidence ?? null) const ocrConfidence = computed(() => batchStore.currentDraft?.ocrConfidence ?? null)
const { fieldConfidenceClass } = useOcrFieldConfidence(ocrConfidence) const {
fieldConfidenceClass,
fieldConfidenceLabel,
fieldConfidenceLevel,
} = useOcrFieldConfidence(ocrConfidence)
function observationValueConfidenceClass(observationCode: string): string { function observationValueConfidenceClass(observationCode: string): string {
if (!observationCode) return '' if (!observationCode) return ''
@@ -322,19 +417,6 @@ const encounter = reactive({
const observations = ref<DraftObservation[]>([]) const observations = ref<DraftObservation[]>([])
const statusColor = ref('bg-gray-100 text-gray-800')
function parseJsonList(json: string | null): string[] {
if (!json) return []
try {
const parsed = JSON.parse(json)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
// Load draft data when batch changes
watch( watch(
() => batchStore.currentDraft, () => batchStore.currentDraft,
(draft) => { (draft) => {
@@ -376,18 +458,29 @@ function removeAllergy(idx: number) {
saveAllergies() saveAllergies()
} }
async function saveAllergies() { async function withSaveFeedback(fn: () => Promise<void>) {
saveState.value = 'saving'
errorMessage.value = ''
try { try {
await fn()
lastSavedAt.value = new Date()
saveState.value = 'saved'
} catch (e: unknown) {
saveState.value = 'failed'
const msg = e instanceof Error ? e.message : 'Failed to save'
errorMessage.value = msg
toast.error(msg)
throw e
}
}
async function saveAllergies() {
await withSaveFeedback(async () => {
await batchStore.saveDraftPatient(props.batchId, { await batchStore.saveDraftPatient(props.batchId, {
...patient, ...patient,
allergies: allergies.value.filter(a => a.trim()), allergies: allergies.value.filter(a => a.trim()),
}) })
toast.success('Allergies saved') })
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Failed to save allergies'
errorMessage.value = msg
toast.error(msg)
}
} }
function onNoKnownAllergiesChange() { function onNoKnownAllergiesChange() {
@@ -407,17 +500,12 @@ function removeMedication(idx: number) {
} }
async function saveMedications() { async function saveMedications() {
try { await withSaveFeedback(async () => {
await batchStore.saveDraftPatient(props.batchId, { await batchStore.saveDraftPatient(props.batchId, {
...patient, ...patient,
medications: medications.value.filter(m => m.trim()), medications: medications.value.filter(m => m.trim()),
}) })
toast.success('Medications saved') })
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Failed to save medications'
errorMessage.value = msg
toast.error(msg)
}
} }
function onNoActiveMedicationsChange() { function onNoActiveMedicationsChange() {
@@ -428,28 +516,33 @@ function onNoActiveMedicationsChange() {
} }
async function savePatient() { async function savePatient() {
try { await withSaveFeedback(async () => {
await batchStore.saveDraftPatient(props.batchId, { await batchStore.saveDraftPatient(props.batchId, {
...patient, ...patient,
allergies: patient.noKnownAllergies ? null : allergies.value.filter(a => a.trim()), allergies: patient.noKnownAllergies ? null : allergies.value.filter(a => a.trim()),
medications: patient.noActiveMedications ? null : medications.value.filter(m => m.trim()), medications: patient.noActiveMedications ? null : medications.value.filter(m => m.trim()),
}) })
toast.success('Patient demographics saved') })
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Failed to save patient'
errorMessage.value = msg
toast.error(msg)
}
} }
async function saveEncounter() { async function saveEncounter() {
try { await withSaveFeedback(async () => {
await batchStore.saveDraftEncounter(props.batchId, encounter) await batchStore.saveDraftEncounter(props.batchId, encounter)
toast.success('Encounter context saved') })
} catch (e: unknown) { }
const msg = e instanceof Error ? e.message : 'Failed to save encounter'
errorMessage.value = msg async function saveDraft() {
toast.error(msg) try {
await withSaveFeedback(async () => {
await batchStore.saveDraftPatient(props.batchId, {
...patient,
allergies: patient.noKnownAllergies ? null : allergies.value.filter(a => a.trim()),
medications: patient.noActiveMedications ? null : medications.value.filter(m => m.trim()),
})
await batchStore.saveDraftEncounter(props.batchId, encounter)
})
} catch {
// Feedback already set
} }
} }
@@ -0,0 +1,145 @@
<template>
<Teleport to="body">
<div
v-if="open"
class="fixed inset-0 z-50 flex justify-end"
data-testid="help-panel"
>
<div
class="absolute inset-0 bg-black/50"
aria-hidden="true"
data-testid="help-panel-backdrop"
@click="closePanel()"
/>
<aside
class="relative z-10 flex h-full w-full max-w-md flex-col border-l border-line bg-surface shadow-dialog"
role="dialog"
aria-modal="true"
:aria-labelledby="titleId"
data-testid="help-panel-drawer"
@keydown.esc.prevent="closePanel()"
>
<header class="flex items-start justify-between gap-3 border-b border-line px-5 py-4 shrink-0">
<div class="min-w-0">
<p class="text-[11px] font-semibold uppercase tracking-wider text-ink-secondary">
Page instructions
</p>
<h2 :id="titleId" class="mt-1 text-lg font-semibold text-ink-strong">
{{ guide.title }}
</h2>
</div>
<button
ref="closeBtnRef"
type="button"
class="shrink-0 rounded-input p-2 text-ink-secondary hover:bg-canvas hover:text-ink-strong"
aria-label="Close help"
data-testid="help-panel-close"
@click="closePanel()"
>
<svg class="h-5 w-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>
</header>
<div class="flex-1 min-h-0 overflow-y-auto px-5 py-4 space-y-5">
<p class="text-sm text-ink leading-relaxed">{{ guide.summary }}</p>
<section>
<h3 class="text-sm font-semibold text-ink-strong mb-2">Steps</h3>
<ol class="list-decimal pl-5 space-y-2 text-sm text-ink leading-relaxed">
<li v-for="(step, index) in guide.steps" :key="index">{{ step }}</li>
</ol>
</section>
<section v-if="guide.tips?.length">
<h3 class="text-sm font-semibold text-ink-strong mb-2">Tips</h3>
<ul class="list-disc pl-5 space-y-2 text-sm text-ink-secondary leading-relaxed">
<li v-for="(tip, index) in guide.tips" :key="index">{{ tip }}</li>
</ul>
</section>
</div>
<footer
class="flex flex-col-reverse gap-3 border-t border-line px-5 py-4 sm:flex-row sm:items-center sm:justify-between shrink-0"
>
<button
type="button"
class="btn-secondary text-sm"
data-testid="help-panel-replay"
:disabled="!canReplay"
:title="replayTitle"
@click="onReplay"
>
Replay walkthrough
</button>
<button
type="button"
class="btn-primary text-sm"
data-testid="help-panel-done"
@click="closePanel()"
>
Close
</button>
</footer>
</aside>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, useId, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useTourStore } from '../stores/tour'
import { useHelpPanel } from '../composables/useHelpPanel'
import { getPageGuide } from '../help/pageGuides'
import { getTourForRole } from '../tours/definitions'
const { open, closePanel } = useHelpPanel()
const route = useRoute()
const auth = useAuthStore()
const tour = useTourStore()
const titleId = useId()
const closeBtnRef = ref<HTMLButtonElement | null>(null)
let focusRestore: HTMLElement | null = null
const guide = computed(() => getPageGuide(route.path))
const canReplay = computed(
() =>
!!auth.userRole &&
!!auth.userId &&
!!getTourForRole(auth.userRole) &&
!tour.active,
)
const replayTitle = computed(() => {
if (tour.active) return 'A walkthrough is already running'
if (!getTourForRole(auth.userRole)) return 'No walkthrough for this role'
return 'Start the guided walkthrough for your role'
})
async function onReplay() {
if (!canReplay.value) return
closePanel()
await tour.replay(auth.userRole, auth.userId)
}
watch(open, async (isOpen) => {
if (isOpen) {
focusRestore = document.activeElement as HTMLElement | null
await nextTick()
closeBtnRef.value?.focus()
return
}
if (focusRestore) {
focusRestore.focus()
focusRestore = null
}
})
</script>
@@ -0,0 +1,40 @@
<template>
<div
class="rounded-input border border-[#FECDCA] bg-clinical-danger-bg p-4"
role="alert"
>
<p class="text-sm font-semibold text-clinical-danger">{{ title }}</p>
<p v-if="message" class="mt-1 text-sm text-ink">{{ message }}</p>
<p v-if="preserved" class="mt-2 text-sm text-ink-secondary">{{ preserved }}</p>
<div class="mt-3 flex flex-wrap items-center gap-3">
<button
v-if="retryLabel"
type="button"
class="btn-secondary text-sm py-1.5"
@click="$emit('retry')"
>
{{ retryLabel }}
</button>
<slot name="actions" />
</div>
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
title: string
message?: string
/** What was preserved / not lost */
preserved?: string
retryLabel?: string
}>(),
{
retryLabel: 'Retry',
}
)
defineEmits<{
(e: 'retry'): void
}>()
</script>
@@ -1,103 +1,204 @@
<template> <template>
<div class="flex flex-col lg:flex-row lg:items-start gap-4 p-4 bg-gray-50 rounded-md"> <!-- Read-only: activity-list row bold value, timestamp meta, optional OK -->
<div class="flex-1 grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-4"> <div
<div> v-if="readonly"
<label class="block text-xs text-gray-500">Code</label> class="observation-card observation-card--readonly"
<select :class="{ 'observation-card--verified': showVerified && verified }"
:value="observation.observationCode" data-testid="observation-row"
@change="update('observationCode', ($event.target as HTMLSelectElement).value)"
class="form-input text-sm"
:disabled="readonly"
> >
<option value="">Select...</option> <div class="observation-card__rail" aria-hidden="true">
<option value="HEART_RATE">Heart Rate</option> <span class="observation-card__dot" />
<option value="TEMP_C">Temperature (C)</option>
<option value="BP_SYSTOLIC">BP Systolic</option>
<option value="BP_DIASTOLIC">BP Diastolic</option>
<option value="RESP_RATE">Respiratory Rate</option>
<option value="SPO2">SpO2</option>
<option value="POTASSIUM_MEQ_L">Potassium</option>
<option value="GLUCOSE_MG_DL">Glucose</option>
<option value="WBC_K_UL">WBC</option>
<option value="LACTATE_MMOL_L">Lactate</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500">Value</label>
<input
:value="observation.value"
@change="update('value', parseFloat(($event.target as HTMLInputElement).value))"
type="number"
step="0.01"
:class="['form-input', 'text-sm', valueInputClass]"
:disabled="readonly"
/>
</div>
<div>
<label class="block text-xs text-gray-500">Unit</label>
<input
:value="observation.unit"
@change="update('unit', ($event.target as HTMLInputElement).value)"
type="text"
class="form-input text-sm"
:disabled="readonly"
/>
</div>
<div>
<label class="block text-xs text-gray-500">Recorded At</label>
<input
:value="observation.recordedAt?.substring(0, 16)"
@change="update('recordedAt', ($event.target as HTMLInputElement).value)"
type="datetime-local"
class="form-input text-sm"
:disabled="readonly"
/>
</div>
<div>
<label class="block text-xs text-gray-500">Note</label>
<input
:value="observation.note"
@change="update('note', ($event.target as HTMLInputElement).value)"
type="text"
class="form-input text-sm"
placeholder="Optional"
:disabled="readonly"
/>
</div>
</div> </div>
<!-- Verification checkbox (only in verification mode) --> <div class="min-w-0 flex-1">
<div v-if="showVerified" class="flex items-center lg:mt-8"> <div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="text-xs font-semibold uppercase tracking-wide text-ink-secondary">
{{ codeLabel }}
</p>
<p
class="mt-0.5 flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5 tabular-nums"
:class="valueInputClass || 'text-ink-strong'"
>
<span class="text-xl font-bold leading-tight tracking-tight">
{{ displayValue }}
</span>
<span
v-if="observation.unit"
class="text-sm font-medium text-ink-secondary"
>
{{ observation.unit }}
</span>
</p>
<p
v-if="recordedAtLabel"
class="mt-1 text-[11px] leading-snug text-ink-secondary"
>
{{ recordedAtLabel }}
</p>
<p
v-if="observation.note"
class="mt-1 text-xs text-ink"
>
<span class="text-ink-secondary">Note:</span>
{{ observation.note }}
</p>
<OcrConfidenceBadge
v-if="ocrLabel"
class="mt-1"
:label="ocrLabel"
:level="ocrLevel"
/>
</div>
<label
v-if="showVerified"
class="inline-flex shrink-0 items-center gap-1.5 rounded-input border px-2 py-1 text-xs font-semibold cursor-pointer transition-colors"
:class="verified
? 'border-clinical-safe bg-clinical-safe-bg text-clinical-safe'
: 'border-line bg-surface text-ink-secondary hover:border-primary-300'"
>
<input <input
type="checkbox" type="checkbox"
class="w-3.5 h-3.5 text-clinical-safe rounded"
:checked="verified" :checked="verified"
@change="$emit('verify', observation.id, ($event.target as HTMLInputElement).checked)" @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> OK
</label>
</div>
</div>
</div> </div>
<!-- Delete button (entry mode only) --> <!-- Editable: card with hero value, then secondary fields (not a 5-col crunch) -->
<div
v-else
class="observation-card"
data-testid="observation-row"
>
<div class="flex items-start justify-between gap-2 mb-2.5">
<div class="min-w-0 flex-1">
<label class="workstation-field-label">Observation</label>
<select
:value="observation.observationCode"
class="form-input text-sm font-semibold py-1.5"
@change="onCodeChange(($event.target as HTMLSelectElement).value)"
>
<option value="">Select code</option>
<option
v-for="opt in CODE_OPTIONS"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
</div>
<button <button
v-if="!readonly && !showVerified" v-if="canDelete"
type="button"
class="shrink-0 mt-5 text-sm font-medium text-clinical-danger hover:text-clinical-critical"
@click="$emit('delete', observation.id)" @click="$emit('delete', observation.id)"
class="lg:mt-8 text-clinical-danger hover:text-red-800 text-sm"
> >
Remove Remove
</button> </button>
</div> </div>
<div class="flex items-end gap-2 mb-2.5">
<div class="min-w-0 flex-[1.4]">
<label class="workstation-field-label">
Value
<OcrConfidenceBadge :label="ocrLabel" :level="ocrLevel" />
</label>
<input
:value="observation.value"
type="number"
step="0.01"
:class="[
'form-input text-xl font-bold tabular-nums py-1.5 leading-none',
valueInputClass,
]"
@change="update('value', parseFloat(($event.target as HTMLInputElement).value))"
/>
</div>
<div class="w-[4.75rem] shrink-0">
<label class="workstation-field-label">Unit</label>
<input
:value="observation.unit"
type="text"
class="form-input text-sm py-1.5"
@change="update('unit', ($event.target as HTMLInputElement).value)"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-2">
<div>
<label class="workstation-field-label">Recorded at</label>
<input
:value="observation.recordedAt?.substring(0, 16)"
type="datetime-local"
class="form-input text-sm py-1.5"
@change="update('recordedAt', ($event.target as HTMLInputElement).value)"
/>
</div>
<div>
<label class="workstation-field-label">Note</label>
<input
:value="observation.note"
type="text"
class="form-input text-sm py-1.5"
placeholder="Optional"
@change="update('note', ($event.target as HTMLInputElement).value)"
/>
</div>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { DraftObservation } from '../types' import { computed } from 'vue'
import type { OcrConfidenceLevel } from '../composables/useOcrFieldConfidence'
import OcrConfidenceBadge from './OcrConfidenceBadge.vue'
const props = defineProps<{ /** Minimal observation shape for editable/readonly cards (DraftObservation satisfies this). */
observation: DraftObservation export interface ObservationRowModel {
id: string
observationCode: string
value: number | null
unit: string | null
recordedAt: string | null
note: string | null
}
const CODE_OPTIONS = [
{ value: 'HEART_RATE', label: 'Heart Rate', unit: 'bpm' },
{ value: 'TEMP_C', label: 'Temperature (C)', unit: '°C' },
{ value: 'BP_SYSTOLIC', label: 'BP Systolic', unit: 'mmHg' },
{ value: 'BP_DIASTOLIC', label: 'BP Diastolic', unit: 'mmHg' },
{ value: 'RESP_RATE', label: 'Respiratory Rate', unit: 'breaths/min' },
{ value: 'SPO2', label: 'SpO2', unit: '%' },
{ value: 'POTASSIUM_MEQ_L', label: 'Potassium', unit: 'mEq/L' },
{ value: 'GLUCOSE_MG_DL', label: 'Glucose', unit: 'mg/dL' },
{ value: 'WBC_K_UL', label: 'WBC', unit: '×10³/µL' },
{ value: 'LACTATE_MMOL_L', label: 'Lactate', unit: 'mmol/L' },
] as const
const props = withDefaults(
defineProps<{
observation: ObservationRowModel
readonly?: boolean readonly?: boolean
showVerified?: boolean showVerified?: boolean
verified?: boolean verified?: boolean
canDelete?: boolean
valueInputClass?: string valueInputClass?: string
}>() ocrLabel?: string | null
ocrLevel?: OcrConfidenceLevel | null
}>(),
{
canDelete: true,
},
)
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update', field: string, value: unknown): void (e: 'update', field: string, value: unknown): void
@@ -105,7 +206,38 @@ const emit = defineEmits<{
(e: 'verify', obsId: string, passed: boolean): void (e: 'verify', obsId: string, passed: boolean): void
}>() }>()
const codeLabel = computed(() => {
const code = props.observation.observationCode
if (!code) return 'Unspecified observation'
return CODE_OPTIONS.find((o) => o.value === code)?.label ?? code.replace(/_/g, ' ')
})
const displayValue = computed(() => {
const value = props.observation.value
if (value == null || Number.isNaN(Number(value))) return '—'
return String(value)
})
const recordedAtLabel = computed(() => {
const raw = props.observation.recordedAt
if (!raw) return null
const date = new Date(raw)
if (Number.isNaN(date.getTime())) return raw
return date.toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
})
})
function update(field: string, value: unknown) { function update(field: string, value: unknown) {
emit('update', field, value) emit('update', field, value)
} }
function onCodeChange(code: string) {
emit('update', 'observationCode', code)
const match = CODE_OPTIONS.find((o) => o.value === code)
if (match?.unit) {
emit('update', 'unit', match.unit)
}
}
</script> </script>
@@ -0,0 +1,49 @@
<template>
<span
v-if="label"
class="ocr-confidence-badge"
:class="badgeClass"
:title="title"
>
{{ label }}
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
confidenceToLevel,
formatOcrBadgeLabel,
type OcrConfidenceLevel,
} from '../composables/useOcrFieldConfidence'
const props = defineProps<{
/** 01 confidence; omit or undefined hides the badge */
confidence?: number | null
label?: string | null
level?: OcrConfidenceLevel | null
}>()
const resolvedLevel = computed<OcrConfidenceLevel | null>(() => {
if (props.level) return props.level
if (props.confidence === undefined || props.confidence === null) return null
return confidenceToLevel(props.confidence)
})
const label = computed(() => {
if (props.label) return props.label
if (props.confidence === undefined || props.confidence === null) return null
return formatOcrBadgeLabel(props.confidence)
})
const badgeClass = computed(() => {
const level = resolvedLevel.value
if (!level) return ''
return `ocr-badge-${level}`
})
const title = computed(() => {
if (!label.value) return undefined
return 'OCR extraction confidence — assistive only; verify against the scan'
})
</script>
@@ -1,67 +1,160 @@
<template> <template>
<div class="h-full flex flex-col bg-gray-900 rounded-lg overflow-hidden"> <div
<!-- Toolbar --> class="h-full flex flex-col bg-canvas rounded-card border border-line overflow-hidden"
<div class="flex flex-wrap items-center gap-2 p-4 bg-gray-800 text-white text-sm"> data-testid="scan-viewer"
<button @click="zoomIn" class="px-2 py-1 hover:bg-gray-700 rounded" title="Zoom in"> >
<!-- Toolbar light chrome, no decorative overlays on the page -->
<div
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
type="button"
class="scan-toolbar-btn"
title="Zoom out"
:disabled="!url"
@click="zoomOut"
>
</button>
<button
type="button"
class="scan-toolbar-btn"
title="Zoom in"
:disabled="!url"
@click="zoomIn"
>
+ +
</button> </button>
<button @click="zoomOut" class="px-2 py-1 hover:bg-gray-700 rounded" title="Zoom out"> <button
- type="button"
class="scan-toolbar-btn"
title="Fit width"
:disabled="!url"
data-testid="scan-fit-width"
@click="fitWidth"
>
Fit width
</button> </button>
<button @click="resetZoom" class="px-2 py-1 hover:bg-gray-700 rounded" title="Reset"> <button
type="button"
class="scan-toolbar-btn"
title="Reset zoom"
:disabled="!url"
@click="resetZoom"
>
1:1 1:1
</button> </button>
<button @click="rotateCw" class="px-2 py-1 hover:bg-gray-700 rounded" title="Rotate 90"> <button
type="button"
class="scan-toolbar-btn"
title="Rotate 90°"
:disabled="!url"
@click="rotateCw"
>
Rotate Rotate
</button> </button>
<span class="ml-auto text-gray-400 text-xs">{{ Math.round(scale * 100) }}%</span> <span class="ml-auto text-ink-secondary text-xs tabular-nums">
{{ url ? `${Math.round(scale * 100)}%` : '' }}
</span>
</div> </div>
<!-- Document area --> <!-- Document surface -->
<div <div
ref="viewerContainer" ref="viewerContainer"
class="flex-1 overflow-auto cursor-grab active:cursor-grabbing" class="flex-1 min-h-0 overflow-auto bg-[#E8ECF1]"
:class="url && !loading && !error ? 'cursor-grab active:cursor-grabbing' : ''"
@mousedown="startPan" @mousedown="startPan"
@mousemove="pan" @mousemove="pan"
@mouseup="stopPan" @mouseup="stopPan"
@mouseleave="stopPan" @mouseleave="stopPan"
@wheel.prevent="onWheel" @wheel.prevent="onWheel"
>
<div v-if="loading" class="flex h-full items-center justify-center p-8">
<SkeletonBlock
variant="block"
height="min(70vh, 640px)"
width="min(100%, 480px)"
class="max-w-full shadow-page bg-surface"
/>
</div>
<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"
:message="error"
preserved="Your form draft was not affected."
retry-label="Retry"
@retry="$emit('retry')"
/>
</div>
<div
v-else-if="url"
class="p-6 inline-block min-w-full"
> >
<div <div
:style="{ :style="{
transform: `translate(${panX}px, ${panY}px) scale(${scale}) rotate(${rotation}deg)`, transform: `translate(${panX}px, ${panY}px) scale(${scale}) rotate(${rotation}deg)`,
transformOrigin: 'top left', transformOrigin: 'top left',
transition: isPanning ? 'none' : 'transform 0.2s', transition: isPanning ? 'none' : 'transform 0.15s ease-out',
}" }"
> >
<!-- PDF/image rendered from same-origin blob URL (avoids cross-origin MinIO iframe issues) --> <!-- PDF/image from same-origin blob URL (avoids cross-origin MinIO iframe issues) -->
<iframe <iframe
v-if="isPdf" v-if="isPdf"
:src="url" :src="url"
class="w-[800px] h-[1100px] bg-white" class="scan-page w-[800px] h-[1100px] bg-white"
frameborder="0" frameborder="0"
title="Scanned document" title="Scanned document"
/> />
<img <img
v-else v-else
:src="url" :src="url"
class="max-w-none" class="scan-page max-w-none bg-white"
draggable="false" draggable="false"
alt="Scanned document"
@load="onImageLoad" @load="onImageLoad"
/> />
</div> </div>
</div> </div>
<EmptyState
v-else
title="No document available"
description="The source scan will appear here when the batch document loads."
class="h-full"
/>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, watch, nextTick } from 'vue'
import SkeletonBlock from './SkeletonBlock.vue'
import InlineError from './InlineError.vue'
import EmptyState from './EmptyState.vue'
const props = defineProps<{ const props = withDefaults(
url: string defineProps<{
url?: string | null
loading?: boolean
error?: string | null
}>(),
{
url: null,
loading: false,
error: null,
}
)
defineEmits<{
(e: 'retry'): void
}>() }>()
const isPdf = computed(() => { const isPdf = computed(() => {
if (!props.url) return false
const lower = props.url.toLowerCase() const lower = props.url.toLowerCase()
return lower.includes('.pdf') || lower.includes('application/pdf') return lower.includes('.pdf') || lower.includes('application/pdf')
}) })
@@ -73,23 +166,44 @@ const panY = ref(0)
const isPanning = ref(false) const isPanning = ref(false)
const lastX = ref(0) const lastX = ref(0)
const lastY = ref(0) const lastY = ref(0)
const viewerContainer = ref<HTMLElement | null>(null)
const naturalPageWidth = ref(800)
function zoomIn() { scale.value = Math.min(scale.value + 0.25, 5) } function zoomIn() {
function zoomOut() { scale.value = Math.max(scale.value - 0.25, 0.25) } scale.value = Math.min(scale.value + 0.25, 5)
}
function zoomOut() {
scale.value = Math.max(scale.value - 0.25, 0.25)
}
function resetZoom() { function resetZoom() {
scale.value = 1 scale.value = 1
panX.value = 0 panX.value = 0
panY.value = 0 panY.value = 0
rotation.value = 0 rotation.value = 0
} }
function rotateCw() { rotation.value = (rotation.value + 90) % 360 } function rotateCw() {
rotation.value = (rotation.value + 90) % 360
}
function fitWidth() {
const container = viewerContainer.value
if (!container) return
const padding = 48 // matches p-6
const available = Math.max(container.clientWidth - padding, 120)
scale.value = Math.min(Math.max(available / naturalPageWidth.value, 0.25), 5)
panX.value = 0
panY.value = 0
}
function onWheel(e: WheelEvent) { function onWheel(e: WheelEvent) {
if (!props.url || props.loading || props.error) return
if (e.deltaY < 0) zoomIn() if (e.deltaY < 0) zoomIn()
else zoomOut() else zoomOut()
} }
function startPan(e: MouseEvent) { function startPan(e: MouseEvent) {
if (!props.url || props.loading || props.error) return
if ((e.target as HTMLElement)?.closest('button')) return
isPanning.value = true isPanning.value = true
lastX.value = e.clientX lastX.value = e.clientX
lastY.value = e.clientY lastY.value = e.clientY
@@ -103,12 +217,40 @@ function pan(e: MouseEvent) {
lastY.value = e.clientY lastY.value = e.clientY
} }
function stopPan() { isPanning.value = false } function stopPan() {
isPanning.value = false
}
function onImageLoad() { function onImageLoad(e: Event) {
// Reset view when a new image loads const img = e.target as HTMLImageElement
if (img.naturalWidth > 0) {
naturalPageWidth.value = img.naturalWidth
}
scale.value = 1 scale.value = 1
panX.value = 0 panX.value = 0
panY.value = 0 panY.value = 0
nextTick(() => fitWidth())
} }
watch(
() => props.url,
(url) => {
if (!url) return
if (isPdf.value) {
naturalPageWidth.value = 800
nextTick(() => fitWidth())
}
}
)
</script> </script>
<style scoped>
.scan-toolbar-btn {
@apply px-2 py-1 rounded-control text-ink hover:bg-canvas
disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent
transition-colors;
}
.scan-page {
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.06), 0 4px 16px rgba(16, 24, 40, 0.08);
}
</style>
@@ -0,0 +1,78 @@
<template>
<div
v-if="show"
class="rounded-input border p-4 text-sm"
:class="blocked
? 'border-[#FECDCA] bg-clinical-danger-bg'
: 'border-primary-100 bg-primary-50'"
role="status"
data-testid="sod-banner"
data-tour="sod-banner"
>
<template v-if="blocked">
<p class="font-semibold text-clinical-danger">
You cannot verify a batch you entered.
</p>
<p class="mt-1 text-ink-secondary">
Separation of Duties requires a different verifier. Select another batch or ask a colleague to continue.
</p>
</template>
<template v-else>
<p class="font-semibold text-primary-800">Separation of Duties Enforced</p>
<dl class="mt-2 grid gap-1 text-ink sm:grid-cols-2">
<div>
<dt class="text-xs text-ink-secondary">Entered by</dt>
<dd class="font-medium">{{ enteredDisplay }}</dd>
</div>
<div>
<dt class="text-xs text-ink-secondary">Current verifier</dt>
<dd class="font-medium">{{ currentDisplay }}</dd>
</div>
</dl>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
const props = defineProps<{
enteredByUserId?: string | null
enteredByUserName?: string | null
currentUserId?: string | null
currentUserName?: string | null
}>()
const emit = defineEmits<{
(e: 'update:blocked', value: boolean): void
}>()
const show = computed(
() => !!(props.enteredByUserId && props.currentUserId)
)
const blocked = computed(
() =>
show.value &&
props.enteredByUserId === props.currentUserId
)
watch(
blocked,
(value) => emit('update:blocked', value),
{ immediate: true }
)
function displayUser(id: string | null | undefined, name?: string | null): string {
if (name?.trim()) return name.trim()
if (!id) return 'Unknown'
return id.length > 8 ? `${id.substring(0, 8)}` : id
}
const enteredDisplay = computed(() =>
displayUser(props.enteredByUserId, props.enteredByUserName)
)
const currentDisplay = computed(() =>
displayUser(props.currentUserId, props.currentUserName)
)
</script>
@@ -0,0 +1,61 @@
<template>
<div
class="animate-pulse"
:class="variant === 'inline' ? 'inline-block' : 'w-full'"
role="status"
aria-busy="true"
aria-label="Loading"
>
<template v-if="variant === 'table'">
<div class="space-y-3 py-2">
<div
v-for="i in rows"
:key="i"
class="grid grid-cols-5 gap-3 items-center"
>
<div class="h-3 rounded bg-line" />
<div class="h-3 rounded bg-line" />
<div class="h-3 rounded bg-line w-3/4" />
<div class="h-5 rounded-control bg-line w-24" />
<div class="h-3 rounded bg-line w-2/3" />
</div>
</div>
</template>
<template v-else-if="variant === 'row'">
<div class="space-y-2">
<div
v-for="i in rows"
:key="i"
class="h-4 rounded bg-line"
:style="{ width: rowWidth(i) }"
/>
</div>
</template>
<div
v-else
class="rounded bg-line"
:style="{ height: height ?? '1rem', width: width ?? '100%' }"
/>
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
/** `block` = single rectangle; `row` = stacked lines; `table` = list-row placeholders */
variant?: 'block' | 'row' | 'table' | 'inline'
rows?: number
height?: string
width?: string
}>(),
{
variant: 'block',
rows: 5,
}
)
function rowWidth(index: number): string {
const widths = ['100%', '92%', '85%', '96%', '78%']
return widths[(index - 1) % widths.length]
}
</script>
@@ -0,0 +1,66 @@
<template>
<span
class="status-badge inline-flex items-center gap-1.5 border"
:class="toneClass"
:title="meta.label"
>
<span class="inline-flex shrink-0" aria-hidden="true">
<!-- upload -->
<svg v-if="meta.icon === 'upload'" class="w-3 h-3" viewBox="0 0 16 16" fill="none">
<path d="M8 11V3M8 3L5 6M8 3l3 3M3 13h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<!-- edit -->
<svg v-else-if="meta.icon === 'edit'" class="w-3 h-3" viewBox="0 0 16 16" fill="none">
<path d="M11.5 2.5l2 2L5 13H3v-2L11.5 2.5z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" />
</svg>
<!-- clock -->
<svg v-else-if="meta.icon === 'clock'" class="w-3 h-3" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="5.5" stroke="currentColor" stroke-width="1.5" />
<path d="M8 5v3.5l2 1.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<!-- reject -->
<svg v-else-if="meta.icon === 'reject'" class="w-3 h-3" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="5.5" stroke="currentColor" stroke-width="1.5" />
<path d="M5.5 5.5l5 5M10.5 5.5l-5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
<!-- check -->
<svg v-else-if="meta.icon === 'check'" class="w-3 h-3" viewBox="0 0 16 16" fill="none">
<path d="M3.5 8.5l3 3 6-7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<!-- shield -->
<svg v-else-if="meta.icon === 'shield'" class="w-3 h-3" viewBox="0 0 16 16" fill="none">
<path d="M8 2.5l5 2v3.5c0 3-2.2 5.2-5 6-2.8-.8-5-3-5-6V4.5l5-2z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" />
</svg>
<!-- approve -->
<svg v-else-if="meta.icon === 'approve'" class="w-3 h-3" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="5.5" stroke="currentColor" stroke-width="1.5" />
<path d="M5.5 8l2 2 3.5-3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<!-- done -->
<svg v-else-if="meta.icon === 'done'" class="w-3 h-3" viewBox="0 0 16 16" fill="none">
<path d="M2.5 8.5l2 2M6 9l3.5-4M9.5 8.5l2 2 3-3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<!-- cancel / unknown -->
<svg v-else class="w-3 h-3" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="5.5" stroke="currentColor" stroke-width="1.5" />
<path d="M5.5 8h5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</span>
<span>{{ meta.label }}</span>
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
BATCH_STATUS_TONE_CLASSES,
getBatchStatusMeta,
} from '../utils/batchStatus'
const props = defineProps<{
status: string | null | undefined
}>()
const meta = computed(() => getBatchStatusMeta(props.status))
const toneClass = computed(() => BATCH_STATUS_TONE_CLASSES[meta.value.tone])
</script>
@@ -0,0 +1,17 @@
<template>
<button
type="button"
class="btn-secondary text-sm"
data-testid="tour-help"
title="Page instructions and walkthrough"
@click="togglePanel()"
>
Help
</button>
</template>
<script setup lang="ts">
import { useHelpPanel } from '../composables/useHelpPanel'
const { togglePanel } = useHelpPanel()
</script>
@@ -0,0 +1,240 @@
<template>
<Teleport to="body">
<div
v-if="tour.active && tour.currentStep"
class="fixed inset-0 z-[60]"
role="dialog"
aria-modal="true"
:aria-labelledby="titleId"
:aria-describedby="bodyId"
data-testid="tour-overlay"
@keydown.esc.prevent="tour.skip()"
>
<!-- Dimmed backdrop with cutout -->
<svg class="absolute inset-0 h-full w-full pointer-events-none" aria-hidden="true">
<defs>
<mask :id="maskId">
<rect width="100%" height="100%" fill="white" />
<rect
v-if="highlight"
:x="highlight.x"
:y="highlight.y"
:width="highlight.width"
:height="highlight.height"
rx="8"
fill="black"
/>
</mask>
</defs>
<rect
width="100%"
height="100%"
fill="rgba(0,0,0,0.5)"
:mask="`url(#${maskId})`"
class="pointer-events-auto"
@click="tour.skip()"
/>
</svg>
<!-- Highlight ring -->
<div
v-if="highlight"
class="pointer-events-none absolute rounded-input ring-2 ring-primary-500 ring-offset-2 ring-offset-transparent transition-all duration-150"
:style="highlightStyle"
aria-hidden="true"
/>
<!-- Popover -->
<div
ref="popoverRef"
class="absolute z-[61] w-[min(100vw-2rem,22rem)] rounded-card border border-line bg-surface p-5 shadow-dialog"
:style="popoverStyle"
data-testid="tour-popover"
@click.stop
>
<p class="text-[11px] font-semibold uppercase tracking-wider text-ink-secondary">
Step {{ tour.stepIndex + 1 }} of {{ tour.stepCount }}
</p>
<h3 :id="titleId" class="mt-1 text-lg font-semibold text-ink-strong">
{{ tour.currentStep.title }}
</h3>
<p :id="bodyId" class="mt-2 text-sm text-ink leading-relaxed">
{{ tour.currentStep.body }}
</p>
<div class="mt-5 flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between">
<button
type="button"
class="px-3 py-2 text-sm text-ink-secondary hover:text-ink-strong"
data-testid="tour-skip"
@click="tour.skip()"
>
Skip
</button>
<div class="flex gap-2 justify-end">
<button
v-if="!tour.isFirstStep"
type="button"
class="btn-secondary"
data-testid="tour-prev"
:disabled="tour.preparing"
@click="tour.prev()"
>
Back
</button>
<button
ref="nextBtnRef"
type="button"
class="btn-primary"
data-testid="tour-next"
:disabled="tour.preparing"
@click="tour.next()"
>
{{ tour.isLastStep ? 'Done' : 'Next' }}
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, useId, watch } from 'vue'
import { useTourStore } from '../stores/tour'
import type { TourPlacement } from '../tours/types'
const tour = useTourStore()
const titleId = useId()
const bodyId = useId()
const maskId = useId()
const nextBtnRef = ref<HTMLButtonElement | null>(null)
const popoverRef = ref<HTMLElement | null>(null)
interface Rect {
x: number
y: number
width: number
height: number
}
const highlight = ref<Rect | null>(null)
const popoverPos = ref({ top: 16, left: 16 })
const PAD = 8
const POPOVER_GAP = 12
const highlightStyle = computed(() => {
if (!highlight.value) return {}
return {
top: `${highlight.value.y}px`,
left: `${highlight.value.x}px`,
width: `${highlight.value.width}px`,
height: `${highlight.value.height}px`,
}
})
const popoverStyle = computed(() => ({
top: `${popoverPos.value.top}px`,
left: `${popoverPos.value.left}px`,
}))
function measureTarget(selector: string): Rect | null {
const el = document.querySelector(selector)
if (!el) return null
const r = el.getBoundingClientRect()
if (r.width === 0 && r.height === 0) return null
return {
x: Math.max(0, r.left - PAD),
y: Math.max(0, r.top - PAD),
width: r.width + PAD * 2,
height: r.height + PAD * 2,
}
}
function placePopover(target: Rect, placement: TourPlacement = 'bottom') {
const popW = popoverRef.value?.offsetWidth ?? 352
const popH = popoverRef.value?.offsetHeight ?? 200
const vw = window.innerWidth
const vh = window.innerHeight
let top = target.y + target.height + POPOVER_GAP
let left = target.x
if (placement === 'top') {
top = target.y - popH - POPOVER_GAP
} else if (placement === 'left') {
top = target.y
left = target.x - popW - POPOVER_GAP
} else if (placement === 'right') {
top = target.y
left = target.x + target.width + POPOVER_GAP
}
left = Math.min(Math.max(16, left), vw - popW - 16)
top = Math.min(Math.max(16, top), vh - popH - 16)
if (placement === 'top' && target.y - popH - POPOVER_GAP < 16) {
top = Math.min(target.y + target.height + POPOVER_GAP, vh - popH - 16)
}
if (placement === 'bottom' && target.y + target.height + POPOVER_GAP + popH > vh - 16) {
top = Math.max(16, target.y - popH - POPOVER_GAP)
}
popoverPos.value = { top, left }
}
function updateLayout() {
const step = tour.currentStep
if (!step) {
highlight.value = null
return
}
const rect = measureTarget(step.selector)
highlight.value = rect
if (rect) {
nextTick(() => placePopover(rect, step.placement ?? 'bottom'))
} else {
popoverPos.value = { top: 24, left: 24 }
}
}
let focusRestore: HTMLElement | null = null
watch(
() => [tour.active, tour.stepIndex, tour.currentStep?.selector] as const,
async ([isActive]) => {
if (!isActive) {
highlight.value = null
if (focusRestore) {
focusRestore.focus()
focusRestore = null
}
return
}
if (!focusRestore) {
focusRestore = document.activeElement as HTMLElement | null
}
await nextTick()
updateLayout()
await nextTick()
nextBtnRef.value?.focus()
},
{ immediate: true },
)
function onResize() {
if (tour.active) updateLayout()
}
onMounted(() => {
window.addEventListener('resize', onResize)
window.addEventListener('scroll', onResize, true)
})
onUnmounted(() => {
window.removeEventListener('resize', onResize)
window.removeEventListener('scroll', onResize, true)
})
</script>
@@ -0,0 +1,74 @@
<template>
<div
class="observation-card observation-card--readonly"
:class="{ 'observation-card--verified': checked }"
data-testid="verification-field-card"
>
<div class="observation-card__rail" aria-hidden="true">
<span class="observation-card__dot" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-1.5">
<p class="text-xs font-semibold uppercase tracking-wide text-ink-secondary">
{{ label }}
</p>
<OcrConfidenceBadge
v-if="ocrLabel"
:label="ocrLabel"
:level="ocrLevel"
/>
</div>
<p
class="mt-0.5 text-base font-bold leading-snug text-ink-strong break-words"
:class="valueClass"
>
{{ displayValue }}
</p>
</div>
<label
class="inline-flex shrink-0 items-center gap-1.5 rounded-input border px-2 py-1 text-xs font-semibold cursor-pointer transition-colors"
:class="checked
? 'border-clinical-safe bg-clinical-safe-bg text-clinical-safe'
: 'border-line bg-surface text-ink-secondary hover:border-primary-300'"
>
<input
type="checkbox"
class="w-3.5 h-3.5 text-clinical-safe rounded"
:checked="checked"
@change="$emit('toggle', ($event.target as HTMLInputElement).checked)"
/>
OK
</label>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { OcrConfidenceLevel } from '../composables/useOcrFieldConfidence'
import OcrConfidenceBadge from './OcrConfidenceBadge.vue'
const props = defineProps<{
label: string
value: string | null | undefined
checked: boolean
valueClass?: string
ocrLabel?: string | null
ocrLevel?: OcrConfidenceLevel | null
}>()
defineEmits<{
(e: 'toggle', checked: boolean): void
}>()
const displayValue = computed(() => {
const raw = props.value
if (raw == null || String(raw).trim() === '') return '(empty)'
return String(raw)
})
</script>
@@ -1,124 +1,132 @@
<template> <template>
<div class="h-full overflow-y-auto p-4 space-y-6"> <div
<div class="flex items-center justify-between"> class="h-full min-h-0 flex flex-col"
<h2 class="text-lg font-semibold">Verification Review</h2> data-testid="verification-form"
<span class="bg-orange-100 text-orange-800 status-badge"> data-tour="verification-form"
Pending Verification >
</span> <div class="flex-1 min-h-0 overflow-y-auto p-4 space-y-4">
<div class="flex items-center justify-between gap-3">
<div>
<p class="evidence-level evidence-level--2 mb-1">
<span class="evidence-level-mark" aria-hidden="true">2</span>
Verified draft
</p>
<h2 class="text-base font-semibold text-ink-strong">Verification Review</h2>
</div>
<StatusBadge :status="batch?.status ?? 'PENDING_VERIFICATION'" />
</div> </div>
<div v-if="batch?.rejectionReason" class="bg-red-50 border border-red-200 rounded-md p-4"> <div
<p class="text-sm font-medium text-red-800">Previous Rejection Reason:</p> v-if="batch?.rejectionReason"
<p class="text-sm text-red-700">{{ batch.rejectionReason }}</p> class="rounded-input border border-[#FECDCA] bg-clinical-danger-bg p-3"
>
<p class="text-sm font-medium text-clinical-danger">Previous Rejection Reason:</p>
<p class="text-sm text-ink mt-1">{{ batch.rejectionReason }}</p>
</div> </div>
<div v-if="ocrConfidence" class="ocr-banner"> <div v-if="ocrConfidence" class="ocr-banner">
Values pre-filled by OCR ({{ ocrConfidence.provider }}). Pre-filled by OCR ({{ ocrConfidence.provider }}) review against the scan.
Colored borders indicate extraction confidence verify each value against the scan. OCR is assistive, not authoritative. Colored borders and badges indicate extraction confidence only.
</div> </div>
<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"
/>
<!-- Patient review --> <!-- Patient review -->
<fieldset class="border border-gray-200 rounded-md p-4"> <fieldset v-if="patientFields.length > 0" class="workstation-form-section">
<legend class="text-sm font-medium text-gray-700 px-2">Patient Demographics</legend> <legend class="workstation-form-legend">Patient Demographics</legend>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="space-y-2.5">
<div v-for="field in patientFields" :key="field.path"> <VerificationFieldCard
<div class="flex items-center gap-2"> v-for="field in patientFields"
<input :key="field.path"
type="checkbox" :label="field.label"
:checked="fieldChecks[field.path]" :value="field.value"
@change="toggleCheck(field.path)" :checked="fieldChecks[field.path] ?? false"
class="w-4 h-4 text-clinical-safe rounded" :value-class="fieldConfidenceClass(field.path)"
:ocr-label="fieldConfidenceLabel(field.path)"
:ocr-level="fieldConfidenceLevel(field.path)"
@toggle="(passed) => toggleCheck(field.path, passed)"
/> />
<label class="text-xs text-gray-500">{{ field.label }}</label>
</div>
<p
class="text-sm mt-2 pl-8 font-medium"
:class="fieldConfidenceClass(field.path)"
>
{{ field.value || '(empty)' }}
</p>
</div>
</div> </div>
</fieldset> </fieldset>
<!-- Allergies review (ALLERGY_UPDATE or MIXED) --> <!-- Allergies review -->
<fieldset v-if="allergyFields.length > 0" class="border border-gray-200 rounded-md p-4"> <fieldset v-if="allergyFields.length > 0" class="workstation-form-section">
<legend class="text-sm font-medium text-gray-700 px-2">Allergies</legend> <legend class="workstation-form-legend">Allergies</legend>
<div class="space-y-3"> <div class="space-y-2.5">
<div v-for="field in allergyFields" :key="field.path"> <VerificationFieldCard
<div class="flex items-center gap-2"> v-for="field in allergyFields"
<input :key="field.path"
type="checkbox" :label="field.label"
:checked="fieldChecks[field.path]" :value="field.value"
@change="toggleCheck(field.path)" :checked="fieldChecks[field.path] ?? false"
class="w-4 h-4 text-clinical-safe rounded" :value-class="fieldConfidenceClass(field.path)"
:ocr-label="fieldConfidenceLabel(field.path)"
:ocr-level="fieldConfidenceLevel(field.path)"
@toggle="(passed) => toggleCheck(field.path, passed)"
/> />
<label class="text-xs text-gray-500">{{ field.label }}</label>
</div>
<p
class="text-sm mt-2 pl-8 font-medium"
:class="fieldConfidenceClass(field.path)"
>
{{ field.value || '(empty)' }}
</p>
</div>
</div> </div>
</fieldset> </fieldset>
<!-- Medications review (MEDICATION_LIST or MIXED) --> <!-- Medications review -->
<fieldset v-if="medicationFields.length > 0" class="border border-gray-200 rounded-md p-4"> <fieldset v-if="medicationFields.length > 0" class="workstation-form-section">
<legend class="text-sm font-medium text-gray-700 px-2">Medications</legend> <legend class="workstation-form-legend">Medications</legend>
<div class="space-y-3"> <div class="space-y-2.5">
<div v-for="field in medicationFields" :key="field.path"> <VerificationFieldCard
<div class="flex items-center gap-2"> v-for="field in medicationFields"
<input :key="field.path"
type="checkbox" :label="field.label"
:checked="fieldChecks[field.path]" :value="field.value"
@change="toggleCheck(field.path)" :checked="fieldChecks[field.path] ?? false"
class="w-4 h-4 text-clinical-safe rounded" :value-class="fieldConfidenceClass(field.path)"
:ocr-label="fieldConfidenceLabel(field.path)"
:ocr-level="fieldConfidenceLevel(field.path)"
@toggle="(passed) => toggleCheck(field.path, passed)"
/> />
<label class="text-xs text-gray-500">{{ field.label }}</label>
</div>
<p
class="text-sm mt-2 pl-8 font-medium"
:class="fieldConfidenceClass(field.path)"
>
{{ field.value || '(empty)' }}
</p>
</div>
</div> </div>
</fieldset> </fieldset>
<!-- Encounter review --> <!-- Encounter review -->
<fieldset class="border border-gray-200 rounded-md p-4"> <fieldset
<legend class="text-sm font-medium text-gray-700 px-2">Encounter Context</legend> v-if="showEncounterContext && encounterFields.length > 0"
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> class="workstation-form-section"
<div v-for="field in encounterFields" :key="field.path">
<div class="flex items-center gap-2">
<input
type="checkbox"
:checked="fieldChecks[field.path]"
@change="toggleCheck(field.path)"
class="w-4 h-4 text-clinical-safe rounded"
/>
<label class="text-xs text-gray-500">{{ field.label }}</label>
</div>
<p
class="text-sm mt-2 pl-8 font-medium"
:class="fieldConfidenceClass(field.path)"
> >
{{ field.value || '(empty)' }} <legend class="workstation-form-legend">Encounter Context</legend>
</p> <div class="space-y-2.5">
</div> <VerificationFieldCard
v-for="field in encounterFields"
:key="field.path"
:label="field.label"
:value="field.value"
:checked="fieldChecks[field.path] ?? false"
:value-class="fieldConfidenceClass(field.path)"
:ocr-label="fieldConfidenceLabel(field.path)"
:ocr-level="fieldConfidenceLevel(field.path)"
@toggle="(passed) => toggleCheck(field.path, passed)"
/>
</div> </div>
</fieldset> </fieldset>
<!-- Observations review --> <!-- Observations review -->
<fieldset class="border border-gray-200 rounded-md p-4"> <fieldset
<legend class="text-sm font-medium text-gray-700 px-2"> v-if="showObservations"
class="workstation-form-section"
>
<legend class="workstation-form-legend">
Observations ({{ observations.length }}) Observations ({{ observations.length }})
</legend> </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-3">
<ObservationRow <ObservationRow
v-for="(obs, index) in observations" v-for="(obs, index) in observations"
:key="obs.id" :key="obs.id"
@@ -127,89 +135,125 @@
:show-verified="true" :show-verified="true"
:verified="fieldChecks[`observations[${index}].value`] ?? false" :verified="fieldChecks[`observations[${index}].value`] ?? false"
:value-input-class="observationValueConfidenceClass(obs.observationCode)" :value-input-class="observationValueConfidenceClass(obs.observationCode)"
:ocr-label="fieldConfidenceLabel(`observation.${obs.observationCode}.value`)"
:ocr-level="fieldConfidenceLevel(`observation.${obs.observationCode}.value`)"
@verify="(_obsId, passed) => toggleCheck(`observations[${index}].value`, passed)" @verify="(_obsId, passed) => toggleCheck(`observations[${index}].value`, passed)"
/> />
</div> </div>
</fieldset> </fieldset>
<!-- Verification progress --> <!-- Verification progress (field checks only not analytics chrome) -->
<div class="bg-gray-50 rounded-md p-4"> <div class="rounded-input border border-line bg-canvas p-3">
<div class="flex items-center justify-between text-sm"> <div class="flex items-center justify-between text-sm">
<span>Fields verified:</span> <span class="text-ink-secondary">Fields verified:</span>
<span :class="allChecked ? 'text-clinical-safe font-bold' : 'text-gray-600'"> <span :class="allChecked ? 'text-clinical-safe font-semibold' : 'text-ink'">
{{ checkedCount }} / {{ totalFields }} {{ checkedCount }} / {{ totalFields }}
</span> </span>
</div> </div>
<div class="w-full bg-gray-200 rounded-full h-2 mt-2"> <div class="w-full bg-line rounded-full h-1.5 mt-2">
<div <div
class="bg-clinical-safe h-2 rounded-full transition-all" class="bg-clinical-safe h-1.5 rounded-full transition-all"
:style="{ width: `${(checkedCount / Math.max(totalFields, 1)) * 100}%` }" :style="{ width: `${(checkedCount / Math.max(totalFields, 1)) * 100}%` }"
/> />
</div> </div>
</div> </div>
<!-- Actions --> <div v-if="errorMessage" class="text-clinical-danger text-sm" role="alert">
<div class="flex flex-col sm:flex-row gap-4 pt-4 border-t"> {{ errorMessage }}
<button
@click="approveVerification"
class="btn-primary"
:disabled="!allChecked || processing"
>
{{ processing ? 'Processing...' : 'Approve - Verified' }}
</button>
<button
@click="showRejectDialog = true"
class="btn-danger"
:disabled="processing"
>
Reject
</button>
</div> </div>
<!-- Reject dialog --> <AuditTrailPanel :batch-id="batchId" />
<div </div>
v-if="showRejectDialog"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" <WorkstationActionBar>
<template #center>
<div class="flex flex-col gap-2">
<p class="evidence-level evidence-level--3 mb-0">
<span class="evidence-level-mark" aria-hidden="true">3</span>
Verification decision
</p>
<div class="flex flex-wrap items-center gap-4 text-sm" role="radiogroup" aria-label="Verification decision">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input
v-model="decision"
type="radio"
value="pass"
class="text-clinical-safe"
:disabled="sodBlocked || processing"
/>
<span :class="sodBlocked ? 'text-ink-disabled' : 'text-ink'">Pass</span>
</label>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input
v-model="decision"
type="radio"
value="reject"
class="text-clinical-danger"
:disabled="sodBlocked || processing"
/>
<span :class="sodBlocked ? 'text-ink-disabled' : 'text-ink'">Reject</span>
</label>
</div>
</div>
</template>
<template #primary>
<button
v-if="decision === 'pass'"
type="button"
class="btn-primary"
:disabled="!allChecked || processing || sodBlocked"
data-testid="verify-pass"
@click="approveVerification"
>
{{ processing ? 'Processing...' : 'Pass Verification' }}
</button>
<button
v-else-if="decision === 'reject'"
type="button"
class="btn-danger"
:disabled="processing || sodBlocked"
data-testid="verify-return"
@click="showRejectDialog = true"
>
Return for Rework
</button>
</template>
</WorkstationActionBar>
<ConfirmDialog
:open="showRejectDialog"
title="Return for Rework"
body="This returns the batch for rework. A reason is required."
confirm-label="Return for Rework"
variant="danger"
:confirm-disabled="!rejectionReason.trim() || processing"
@confirm="rejectVerification"
@cancel="showRejectDialog = false"
> >
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-semibold mb-4">Reject Batch</h3>
<textarea <textarea
v-model="rejectionReason" v-model="rejectionReason"
class="form-input" class="form-input"
rows="4" rows="4"
placeholder="Reason for rejection (required)..." placeholder="Reason for rejection (required)..."
/> />
<div class="flex flex-col-reverse sm:flex-row sm:justify-end gap-4 mt-4"> </ConfirmDialog>
<button
@click="showRejectDialog = false"
class="px-4 py-2 text-gray-600 hover:text-gray-800"
>
Cancel
</button>
<button
@click="rejectVerification"
class="btn-danger"
:disabled="!rejectionReason.trim()"
>
Confirm Rejection
</button>
</div>
</div>
</div>
<div v-if="errorMessage" class="text-clinical-danger text-sm">
{{ errorMessage }}
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useBatchStore } from '../stores/batches' import { useBatchStore } from '../stores/batches'
import { useToast } from '../composables/useToast' import { useToast } from '../composables/useToast'
import { useOcrFieldConfidence } from '../composables/useOcrFieldConfidence' import { useOcrFieldConfidence } from '../composables/useOcrFieldConfidence'
import ObservationRow from '../components/ObservationRow.vue' import ObservationRow from '../components/ObservationRow.vue'
import VerificationFieldCard from '../components/VerificationFieldCard.vue'
import StatusBadge from '../components/StatusBadge.vue'
import SeparationOfDutiesBanner from '../components/SeparationOfDutiesBanner.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import WorkstationActionBar from '../components/WorkstationActionBar.vue'
import AuditTrailPanel from '../components/AuditTrailPanel.vue'
import type { BatchDetailResponse, DraftObservation } from '../types' import type { BatchDetailResponse, DraftObservation } from '../types'
const props = defineProps<{ const props = defineProps<{
@@ -217,6 +261,7 @@ const props = defineProps<{
batchId: string batchId: string
}>() }>()
const auth = useAuthStore()
const batchStore = useBatchStore() const batchStore = useBatchStore()
const router = useRouter() const router = useRouter()
const toast = useToast() const toast = useToast()
@@ -224,6 +269,8 @@ const processing = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const showRejectDialog = ref(false) const showRejectDialog = ref(false)
const rejectionReason = ref('') const rejectionReason = ref('')
const sodBlocked = ref(false)
const decision = ref<'pass' | 'reject' | null>(null)
const fieldChecks = ref<Record<string, boolean>>({}) const fieldChecks = ref<Record<string, boolean>>({})
const observations = ref<DraftObservation[]>([]) const observations = ref<DraftObservation[]>([])
@@ -241,7 +288,11 @@ const encounterFields = ref<FieldInfo[]>([])
const fieldReqs = computed(() => batchStore.currentDraft?.fieldRequirements) const fieldReqs = computed(() => batchStore.currentDraft?.fieldRequirements)
const ocrConfidence = computed(() => batchStore.currentDraft?.ocrConfidence ?? null) const ocrConfidence = computed(() => batchStore.currentDraft?.ocrConfidence ?? null)
const { fieldConfidenceClass } = useOcrFieldConfidence(ocrConfidence) const {
fieldConfidenceClass,
fieldConfidenceLabel,
fieldConfidenceLevel,
} = useOcrFieldConfidence(ocrConfidence)
function observationValueConfidenceClass(observationCode: string): string { function observationValueConfidenceClass(observationCode: string): string {
if (!observationCode) return '' if (!observationCode) return ''
@@ -251,16 +302,15 @@ function observationValueConfidenceClass(observationCode: string): string {
const showAllergies = computed(() => fieldReqs.value?.showAllergies ?? false) const showAllergies = computed(() => fieldReqs.value?.showAllergies ?? false)
const showMedications = computed(() => fieldReqs.value?.showMedications ?? false) const showMedications = computed(() => fieldReqs.value?.showMedications ?? false)
const showEncounterSummary = computed(() => fieldReqs.value?.showEncounterSummaryFields ?? false) const showEncounterSummary = computed(() => fieldReqs.value?.showEncounterSummaryFields ?? false)
const showEncounterContext = computed(() => fieldReqs.value?.showEncounterContext ?? false)
const showObservations = computed(() => fieldReqs.value?.showObservations ?? false)
function parseJsonList(json: string | null): string[] { const enteredByDisplayName = computed(() => {
if (!json) return [] const name = props.batch?.enteredByUserName?.trim()
try { if (name) return name
const parsed = JSON.parse(json) const fromQueue = batchStore.batches.find((b) => b.id === props.batchId)
return Array.isArray(parsed) ? parsed : [] return fromQueue?.enteredByUserName?.trim() || null
} catch { })
return []
}
}
watch( watch(
() => batchStore.currentDraft, () => batchStore.currentDraft,
@@ -269,7 +319,6 @@ watch(
observations.value = draft.observations ?? [] observations.value = draft.observations ?? []
// Build patient field list
if (draft.patient) { if (draft.patient) {
patientFields.value = [ patientFields.value = [
{ path: 'patient.fullName', label: 'Full Name', value: draft.patient.fullName ?? '' }, { path: 'patient.fullName', label: 'Full Name', value: draft.patient.fullName ?? '' },
@@ -279,7 +328,6 @@ watch(
{ path: 'patient.emergencyContact', label: 'Emergency Contact', value: draft.patient.emergencyContact ?? '' }, { path: 'patient.emergencyContact', label: 'Emergency Contact', value: draft.patient.emergencyContact ?? '' },
] ]
// Build allergy fields
allergyFields.value = [] allergyFields.value = []
if (showAllergies.value) { if (showAllergies.value) {
if (draft.patient.noKnownAllergies) { if (draft.patient.noKnownAllergies) {
@@ -301,7 +349,6 @@ watch(
} }
} }
// Build medication fields
medicationFields.value = [] medicationFields.value = []
if (showMedications.value) { if (showMedications.value) {
if (draft.patient.noActiveMedications) { if (draft.patient.noActiveMedications) {
@@ -322,10 +369,13 @@ watch(
} }
} }
} }
} else {
patientFields.value = []
allergyFields.value = []
medicationFields.value = []
} }
// Build encounter field list if (draft.encounter && showEncounterContext.value) {
if (draft.encounter) {
encounterFields.value = [ encounterFields.value = [
{ path: 'encounter.admissionDate', label: 'Admission Date', value: draft.encounter.admissionDate ?? '' }, { path: 'encounter.admissionDate', label: 'Admission Date', value: draft.encounter.admissionDate ?? '' },
{ path: 'encounter.department', label: 'Department', value: draft.encounter.department ?? '' }, { path: 'encounter.department', label: 'Department', value: draft.encounter.department ?? '' },
@@ -338,9 +388,14 @@ watch(
{ path: 'encounter.dischargeDiagnosis', label: 'Discharge Diagnosis', value: draft.encounter.dischargeDiagnosis ?? '' }, { path: 'encounter.dischargeDiagnosis', label: 'Discharge Diagnosis', value: draft.encounter.dischargeDiagnosis ?? '' },
) )
} }
} else {
encounterFields.value = []
}
if (!showObservations.value) {
observations.value = []
} }
// Initialize all checks to false
fieldChecks.value = {} fieldChecks.value = {}
patientFields.value.forEach((f) => { fieldChecks.value[f.path] = false }) patientFields.value.forEach((f) => { fieldChecks.value[f.path] = false })
allergyFields.value.forEach((f) => { fieldChecks.value[f.path] = false }) allergyFields.value.forEach((f) => { fieldChecks.value[f.path] = false })
@@ -351,6 +406,10 @@ watch(
{ immediate: true } { immediate: true }
) )
watch(sodBlocked, (blocked) => {
if (blocked) decision.value = null
})
const totalFields = computed( const totalFields = computed(
() => patientFields.value.length + allergyFields.value.length + medicationFields.value.length () => patientFields.value.length + allergyFields.value.length + medicationFields.value.length
+ encounterFields.value.length + observations.value.length + encounterFields.value.length + observations.value.length
@@ -365,6 +424,7 @@ function toggleCheck(path: string, value?: boolean) {
} }
async function approveVerification() { async function approveVerification() {
if (sodBlocked.value || decision.value !== 'pass') return
processing.value = true processing.value = true
errorMessage.value = '' errorMessage.value = ''
try { try {
@@ -385,12 +445,13 @@ async function approveVerification() {
} }
async function rejectVerification() { async function rejectVerification() {
if (sodBlocked.value) return
processing.value = true processing.value = true
errorMessage.value = '' errorMessage.value = ''
try { try {
await batchStore.rejectBatch(props.batchId, rejectionReason.value) await batchStore.rejectBatch(props.batchId, rejectionReason.value)
showRejectDialog.value = false showRejectDialog.value = false
toast.warning('Batch rejected') toast.warning('Batch returned for rework')
router.push('/verification') router.push('/verification')
} catch (e: unknown) { } catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Rejection failed' const msg = e instanceof Error ? e.message : 'Rejection failed'
@@ -0,0 +1,30 @@
<template>
<div
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"
data-tour="workstation-action-bar"
>
<div
class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"
>
<div class="flex flex-wrap items-center gap-2 min-w-0">
<slot name="left" />
</div>
<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 gap-2 sm:justify-end">
<slot name="primary" />
<slot name="right" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
/**
* Sticky bottom action bar for Entry / Verification / Approval workstations.
* Wire into forms in Phase 16. Slots: left (Reject), center (Save Draft),
* primary (Submit), right (Next). Prefer btn-primary / btn-danger / btn-secondary.
*/
</script>
@@ -0,0 +1,55 @@
<template>
<!-- Queue-only: full-width list with empty/skeleton patterns from the list slot -->
<div
v-if="!hasBatch"
class="flex-1 min-h-0 overflow-auto p-4 sm:p-6"
data-testid="workstation-queue"
data-tour="workstation-queue"
>
<slot name="queue" />
</div>
<!-- Work mode: optional left rail + dominant scan + form (desktop 1280px) -->
<div
v-else
class="workstation-split flex-1 min-h-0"
:class="{ 'workstation-split--no-rail': !showRail }"
data-testid="workstation-split"
>
<aside
v-if="showRail"
class="workstation-rail hidden xl:flex min-h-0"
data-testid="workstation-rail"
>
<slot name="rail" />
</aside>
<section
class="workstation-scan min-h-0 min-w-0"
data-testid="workstation-scan"
data-tour="workstation-scan"
>
<p class="evidence-level evidence-level--1" data-testid="evidence-level-1">
<span class="evidence-level-mark" aria-hidden="true">1</span>
Source scan
</p>
<div class="min-h-0 flex-1 flex flex-col">
<slot name="scan" />
</div>
</section>
<section class="workstation-form min-h-0 min-w-0" data-testid="workstation-form">
<slot name="form" />
</section>
</div>
</template>
<script setup lang="ts">
import { computed, useSlots } from 'vue'
const props = defineProps<{
/** When false, render full-width queue slot only */
hasBatch: boolean
}>()
const slots = useSlots()
const showRail = computed(() => props.hasBatch && !!slots.rail)
</script>
@@ -0,0 +1,92 @@
<template>
<div
class="flex h-full w-full min-h-0 flex-col bg-surface border-r border-line"
data-testid="workstation-queue-rail"
>
<div class="shrink-0 border-b border-line px-3 py-2.5">
<h3 class="text-xs font-semibold uppercase tracking-wide text-ink-secondary">
{{ title }}
</h3>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
<SkeletonBlock
v-if="loading && batches.length === 0"
variant="row"
:rows="5"
class="p-3"
/>
<EmptyState
v-else-if="batches.length === 0"
title="No batches in queue"
description="Assigned or pending items appear here."
class="!py-6"
/>
<button
v-for="batch in batches"
:key="batch.id"
type="button"
class="w-full border-b border-line border-l-2 px-3 py-2.5 text-left transition-colors hover:bg-primary-50"
:class="
batch.id === selectedId
? 'border-l-primary-600 bg-primary-50'
: 'border-l-transparent'
"
:aria-current="batch.id === selectedId ? 'true' : undefined"
@click="$emit('select', batch.id)"
>
<div class="text-sm font-semibold text-ink-strong truncate">
{{ formatBatchType(batch.batchType) }}
</div>
<div class="mt-1.5">
<StatusBadge :status="batch.status" />
</div>
<div class="mt-1 font-mono text-[11px] text-ink-secondary truncate">
{{ batch.id.substring(0, 8) }}
</div>
</button>
</div>
<div class="shrink-0 border-t border-line p-2">
<button
type="button"
class="w-full rounded-input px-2 py-1.5 text-xs font-medium text-primary-600 hover:bg-primary-50"
@click="$emit('back')"
>
Full queue
</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { BatchDetailResponse } from '../types'
import StatusBadge from './StatusBadge.vue'
import EmptyState from './EmptyState.vue'
import SkeletonBlock from './SkeletonBlock.vue'
withDefaults(
defineProps<{
title?: string
batches: BatchDetailResponse[]
selectedId?: string
loading?: boolean
}>(),
{
title: 'Queue',
loading: false,
}
)
defineEmits<{
(e: 'select', id: string): void
(e: 'back'): void
}>()
function formatBatchType(type: string): string {
return type
.toLowerCase()
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase())
}
</script>
@@ -0,0 +1,24 @@
import { ref } from 'vue'
const open = ref(false)
export function useHelpPanel() {
function openPanel() {
open.value = true
}
function closePanel() {
open.value = false
}
function togglePanel() {
open.value = !open.value
}
return {
open,
openPanel,
closePanel,
togglePanel,
}
}
@@ -1,17 +1,55 @@
import { type ComputedRef } from 'vue' import { type ComputedRef } from 'vue'
import type { OcrConfidenceMap } from '../types' import type { OcrConfidenceMap } from '../types'
/** Design-doc §14: 95100% high, 8094% medium, <80% low */
export type OcrConfidenceLevel = 'high' | 'medium' | 'low'
export const OCR_HIGH_THRESHOLD = 0.95
export const OCR_MEDIUM_THRESHOLD = 0.8
export function confidenceToLevel(confidence: number): OcrConfidenceLevel {
if (confidence >= OCR_HIGH_THRESHOLD) return 'high'
if (confidence >= OCR_MEDIUM_THRESHOLD) return 'medium'
return 'low'
}
export function formatOcrBadgeLabel(confidence: number): string {
const pct = Math.round(confidence * 100)
return `OCR ${pct}%`
}
export function useOcrFieldConfidence( export function useOcrFieldConfidence(
ocrConfidence: ComputedRef<OcrConfidenceMap | null | undefined>, ocrConfidence: ComputedRef<OcrConfidenceMap | null | undefined>,
) { ) {
function getFieldConfidence(fieldPath: string): number | undefined {
if (!ocrConfidence.value) return undefined
return ocrConfidence.value.fieldConfidences[fieldPath]
}
function fieldConfidenceLevel(fieldPath: string): OcrConfidenceLevel | null {
const confidence = getFieldConfidence(fieldPath)
if (confidence === undefined) return null
return confidenceToLevel(confidence)
}
function fieldConfidenceLabel(fieldPath: string): string | null {
const confidence = getFieldConfidence(fieldPath)
if (confidence === undefined) return null
return formatOcrBadgeLabel(confidence)
}
function fieldConfidenceClass(fieldPath: string): string { function fieldConfidenceClass(fieldPath: string): string {
if (!ocrConfidence.value) return '' const level = fieldConfidenceLevel(fieldPath)
const confidence = ocrConfidence.value.fieldConfidences[fieldPath] if (!level) return ''
if (confidence === undefined) return '' if (level === 'high') return 'ocr-high'
if (confidence >= 0.85) return 'ocr-high' if (level === 'medium') return 'ocr-medium'
if (confidence >= 0.7) return 'ocr-medium'
return 'ocr-low' return 'ocr-low'
} }
return { fieldConfidenceClass } return {
getFieldConfidence,
fieldConfidenceLevel,
fieldConfidenceLabel,
fieldConfidenceClass,
}
} }
@@ -6,6 +6,7 @@ export function usePresignedUrl(batchId: Ref<string | undefined>) {
const batchStore = useBatchStore() const batchStore = useBatchStore()
const documentUrl = ref<string | null>(null) const documentUrl = ref<string | null>(null)
const documentError = ref<string | null>(null) const documentError = ref<string | null>(null)
const documentLoading = ref(false)
let currentBlobUrl: string | null = null let currentBlobUrl: string | null = null
@@ -21,8 +22,12 @@ export function usePresignedUrl(batchId: Ref<string | undefined>) {
documentUrl.value = null documentUrl.value = null
documentError.value = null documentError.value = null
if (!batchId.value) return if (!batchId.value) {
documentLoading.value = false
return
}
documentLoading.value = true
try { try {
await batchStore.getBatch(batchId.value) await batchStore.getBatch(batchId.value)
const blob = await getBlob(`digitization-batches/${batchId.value}/document`) const blob = await getBlob(`digitization-batches/${batchId.value}/document`)
@@ -33,6 +38,8 @@ export function usePresignedUrl(batchId: Ref<string | undefined>) {
documentUrl.value = currentBlobUrl documentUrl.value = currentBlobUrl
} catch (e: unknown) { } catch (e: unknown) {
documentError.value = e instanceof Error ? e.message : 'Failed to load document' documentError.value = e instanceof Error ? e.message : 'Failed to load document'
} finally {
documentLoading.value = false
} }
} }
@@ -45,6 +52,7 @@ export function usePresignedUrl(batchId: Ref<string | undefined>) {
revokeBlobUrl() revokeBlobUrl()
documentUrl.value = null documentUrl.value = null
documentError.value = null documentError.value = null
documentLoading.value = false
} }
}, },
{ immediate: true } { immediate: true }
@@ -52,5 +60,5 @@ export function usePresignedUrl(batchId: Ref<string | undefined>) {
onUnmounted(revokeBlobUrl) onUnmounted(revokeBlobUrl)
return { documentUrl, documentError, refreshUrl } return { documentUrl, documentError, documentLoading, refreshUrl }
} }
@@ -0,0 +1,185 @@
export interface PageGuide {
id: string
match: (path: string) => boolean
title: string
summary: string
steps: string[]
tips?: string[]
}
function pathIs(prefix: string) {
return (path: string) => path === prefix || path.startsWith(`${prefix}/`)
}
export const fallbackGuide: PageGuide = {
id: 'fallback',
match: () => true,
title: 'Using VigilCare Records',
summary:
'Use the sidebar to open your workspace. Patient History is available when you need prior digitization records.',
steps: [
'Open the workspace link for your role from the left navigation.',
'Work oldest items first when a queue is shown.',
'Use Help on any page for instructions specific to that screen.',
'Replay the walkthrough from Help when you want a guided tour again.',
],
}
export const pageGuides: PageGuide[] = [
{
id: 'intake',
match: (path) => path === '/intake',
title: 'Intake',
summary:
'Create digitization batches from paper scans and attach cover sheet details so clerks can enter data.',
steps: [
'Optionally look up a cover sheet code to auto-fill batch type, track, and assignment.',
'Upload a PDF or image scan — this becomes the source document.',
'Confirm batch type, track, and patient link, then create the batch.',
'Assign an entry clerk from Recent Uploads when the batch is ready for data entry.',
],
tips: [
'Prefer cover sheet lookup when a printed separator is available.',
'You can upload without a cover sheet and set details manually.',
],
},
{
id: 'cover-sheets',
match: pathIs('/cover-sheets'),
title: 'Cover Sheets',
summary:
'Generate and print cover sheets before scanning so intake can look them up by code.',
steps: [
'Set quantity, batch type, track, and optional patient or entry clerk.',
'Generate cover sheets, then print the PDF.',
'Place a printed sheet with the paper packet before scanning.',
'Review existing cover sheets and filter by status when needed.',
],
},
{
id: 'entry',
match: pathIs('/entry'),
title: 'Data Entry',
summary:
'Transcribe structured fields from the source scan, then submit for verification.',
steps: [
'Open the oldest batch from the Data Entry queue (returned rework appears here too).',
'Keep the source scan (level 1) visible — it is the source of truth.',
'Fill the structured draft (level 2). Watch OCR confidence badges on uncertain fields.',
'Save Draft to continue later, or Submit for Verification when the draft matches the scan.',
],
tips: [
'If the queue is empty, wait for intake to assign new batches.',
'Use the left rail or Full queue to switch batches while working.',
],
},
{
id: 'verification',
match: pathIs('/verification'),
title: 'Verification',
summary:
'Compare every field to the scan. Pass only when all fields match; return otherwise.',
steps: [
'Select a batch pending verification, oldest first.',
'Compare each field card to the source scan and mark fields as you verify them.',
'Pass sends the batch to clinical approval.',
'Return sends it back to data entry with a reason.',
'Review the audit trail before you decide.',
],
tips: [
'Separation of duties: you cannot verify a batch you entered.',
'If the SoD banner blocks you, pick another batch or ask a colleague.',
],
},
{
id: 'approval',
match: pathIs('/approval'),
title: 'Clinical Approval',
summary:
'Give final clinical sign-off. Approve promotes the record; reject returns it with a reason.',
steps: [
'Open a batch from the Clinical Approval queue.',
'Review the verified draft and any high-stakes or retroactive alerts.',
'Approve & Promote publishes the clinical record.',
'Reject sends the batch back with a reason.',
],
tips: [
'Promotion attributes the approving clinician — confirm the content carefully.',
'Use Patient History if you need prior digitization context for the patient.',
],
},
{
id: 'live-capture',
match: pathIs('/live-capture'),
title: 'Live Capture',
summary: 'Record vitals at the bedside. This is lighter than backfill data entry.',
steps: [
'Choose New Encounter or Existing Encounter.',
'Select the patient and fill encounter context (or enter an existing encounter ID).',
'Add observation rows for vitals and related measurements.',
'Confirm clinician attestation with your password, then Record Vitals.',
],
tips: [
'Critical alerts after promotion are clinical signals — review them immediately.',
'You can record more vitals after a successful submission without leaving the page.',
],
},
{
id: 'dashboard',
match: pathIs('/dashboard'),
title: 'Queue Dashboard',
summary:
'Monitor queues, aging work, and reject rate. Open any workspace from the sidebar when needed.',
steps: [
'Review pending entry, in-entry, verification volume, and reject rate.',
'Watch oldest pending verification and average time in queue against the 24-hour target.',
'Browse All Batches across statuses.',
'Use Administration for Users and FHIR Explorer.',
],
},
{
id: 'users',
match: pathIs('/users'),
title: 'Users',
summary: 'Create accounts, update roles, and reset passwords for active staff.',
steps: [
'Create a user with username, full name, role, and a strong password.',
'Filter the active users list by role when searching.',
'Edit role or name from a user row.',
'Reset password or deactivate when staff leave or credentials must change.',
],
tips: [
'Password must be at least 8 characters with one uppercase letter and one digit.',
],
},
{
id: 'fhir-explorer',
match: pathIs('/fhir-explorer'),
title: 'FHIR Explorer',
summary:
'Read-only inspection of exposed FHIR resources for administrators and integration staff.',
steps: [
'Choose a resource type, search, and inspect result JSON.',
'Use Patient $everything to load all data for a selected patient.',
'Open CapabilityStatement when you need server metadata.',
],
},
{
id: 'patients',
match: pathIs('/patients'),
title: 'Patient History',
summary:
'Review digitization lineage, promotion attribution, and audit trail for a patient.',
steps: [
'Search by MRN or name, then view history.',
'Scan summary metrics (total, promoted, pending, superseded).',
'Expand timeline entries for audit detail.',
'Create a correction from history when a promoted record must be superseded.',
],
},
]
export function getPageGuide(path: string): PageGuide {
const found = pageGuides.find((guide) => guide.match(path))
return found ?? fallbackGuide
}
@@ -0,0 +1,35 @@
<template>
<AppShell>
<router-view />
</AppShell>
<HelpPanel />
<TourOverlay />
</template>
<script setup lang="ts">
import { onMounted, watch } from 'vue'
import AppShell from '../components/AppShell.vue'
import TourOverlay from '../components/TourOverlay.vue'
import HelpPanel from '../components/HelpPanel.vue'
import { useAuthStore } from '../stores/auth'
import { useTourStore } from '../stores/tour'
const auth = useAuthStore()
const tour = useTourStore()
async function maybeStartTour() {
if (!auth.isAuthenticated || !auth.userRole || !auth.userId) return
await tour.tryAutoStart(auth.userRole, auth.userId)
}
onMounted(() => {
void maybeStartTour()
})
watch(
() => [auth.isAuthenticated, auth.userId, auth.userRole] as const,
([isAuth]) => {
if (isAuth) void maybeStartTour()
},
)
</script>
+36 -14
View File
@@ -9,32 +9,46 @@ const routes: RouteRecordRaw[] = [
meta: { requiresAuth: false }, meta: { requiresAuth: false },
}, },
{ {
path: '/intake', 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', name: 'Intake',
component: () => import('../views/IntakeView.vue'), component: () => import('../views/IntakeView.vue'),
meta: { requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] }, meta: { requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] },
}, },
{ {
path: '/cover-sheets', path: 'cover-sheets',
name: 'CoverSheets', name: 'CoverSheets',
component: () => import('../views/CoverSheetView.vue'), component: () => import('../views/CoverSheetView.vue'),
meta: { requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] }, meta: { requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] },
}, },
{ {
path: '/entry', path: 'entry',
name: 'EntryQueue', name: 'EntryQueue',
component: () => import('../views/EntryView.vue'), component: () => import('../views/EntryView.vue'),
meta: { requiresAuth: true, roles: ['DATA_ENTRY_CLERK', 'ADMINISTRATOR'] }, meta: { requiresAuth: true, roles: ['DATA_ENTRY_CLERK', 'ADMINISTRATOR'] },
}, },
{ {
path: '/entry/:batchId', path: 'entry/:batchId',
name: 'EntryBatch', name: 'EntryBatch',
component: () => import('../views/EntryView.vue'), component: () => import('../views/EntryView.vue'),
meta: { requiresAuth: true, roles: ['DATA_ENTRY_CLERK', 'ADMINISTRATOR'] }, meta: { requiresAuth: true, roles: ['DATA_ENTRY_CLERK', 'ADMINISTRATOR'] },
props: true, props: true,
}, },
{ {
path: '/verification', path: 'verification',
name: 'VerificationQueue', name: 'VerificationQueue',
component: () => import('../views/VerificationView.vue'), component: () => import('../views/VerificationView.vue'),
meta: { meta: {
@@ -43,7 +57,7 @@ const routes: RouteRecordRaw[] = [
}, },
}, },
{ {
path: '/verification/:batchId', path: 'verification/:batchId',
name: 'VerificationBatch', name: 'VerificationBatch',
component: () => import('../views/VerificationView.vue'), component: () => import('../views/VerificationView.vue'),
meta: { meta: {
@@ -53,7 +67,7 @@ const routes: RouteRecordRaw[] = [
props: true, props: true,
}, },
{ {
path: '/approval', path: 'approval',
name: 'ApprovalQueue', name: 'ApprovalQueue',
component: () => import('../views/ApprovalView.vue'), component: () => import('../views/ApprovalView.vue'),
meta: { meta: {
@@ -62,7 +76,7 @@ const routes: RouteRecordRaw[] = [
}, },
}, },
{ {
path: '/approval/:batchId', path: 'approval/:batchId',
name: 'ApprovalBatch', name: 'ApprovalBatch',
component: () => import('../views/ApprovalView.vue'), component: () => import('../views/ApprovalView.vue'),
meta: { meta: {
@@ -72,38 +86,46 @@ const routes: RouteRecordRaw[] = [
props: true, props: true,
}, },
{ {
path: '/patients/:patientId/history', path: 'patients/:patientId/history',
name: 'PatientHistory', name: 'PatientHistory',
component: () => import('../views/PatientHistoryView.vue'), component: () => import('../views/PatientHistoryView.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
props: true, props: true,
}, },
{ {
path: '/patients', path: 'patients',
name: 'PatientSearch', name: 'PatientSearch',
component: () => import('../views/PatientHistoryView.vue'), component: () => import('../views/PatientHistoryView.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{ {
path: '/live-capture', path: 'live-capture',
name: 'LiveCapture', name: 'LiveCapture',
component: () => import('../views/LiveCaptureView.vue'), component: () => import('../views/LiveCaptureView.vue'),
meta: { requiresAuth: true, roles: ['CLINICIAN', 'ADMINISTRATOR'] }, meta: { requiresAuth: true, roles: ['CLINICIAN', 'ADMINISTRATOR'] },
}, },
{ {
path: '/dashboard', path: 'dashboard',
name: 'Dashboard', name: 'Dashboard',
component: () => import('../views/QueueDashboardView.vue'), component: () => import('../views/QueueDashboardView.vue'),
meta: { requiresAuth: true, roles: ['ADMINISTRATOR'] }, meta: { requiresAuth: true, roles: ['ADMINISTRATOR'] },
}, },
{ {
path: '/fhir-explorer', path: 'fhir-explorer',
name: 'FhirExplorer', name: 'FhirExplorer',
component: () => import('../views/FhirExplorerView.vue'), component: () => import('../views/FhirExplorerView.vue'),
meta: { requiresAuth: true, roles: ['ADMINISTRATOR'] }, meta: { requiresAuth: true, roles: ['ADMINISTRATOR'] },
}, },
{ {
path: '/', path: 'users',
name: 'Users',
component: () => import('../views/UsersView.vue'),
meta: { requiresAuth: true, roles: ['ADMINISTRATOR'] },
},
],
},
{
path: '/:pathMatch(.*)*',
redirect: '/login', redirect: '/login',
}, },
] ]
+145 -1
View File
@@ -8,10 +8,53 @@ import type {
DraftEncounter, DraftEncounter,
DraftObservation, DraftObservation,
BatchListResponse, BatchListResponse,
BatchEventResponse,
CursorPagedResult,
FieldCheck, FieldCheck,
PatientDigitizationHistoryResponse, PatientDigitizationHistoryResponse,
WorkQueueItemResponse,
WorkQueueResponse,
} from '../types' } from '../types'
export type WorkQueueName = 'entry' | 'verification' | 'clinical-approval'
const EMPTY_FIELD_REQUIREMENTS = {
showPatientDemographics: false,
showEncounterContext: false,
showEncounterSummaryFields: false,
showObservations: false,
showAllergies: false,
showMedications: false,
}
/** Map work-queue items into BatchDetailResponse shape used by BatchList / queue rail. */
function mapWorkQueueItem(item: WorkQueueItemResponse): BatchDetailResponse {
return {
id: item.batchId,
status: item.status,
batchType: item.batchType,
track: item.track,
fieldRequirements: { ...EMPTY_FIELD_REQUIREMENTS },
patientId: item.patientId,
documentRef: '',
documentUrl: null,
enableRetroactiveAlerts: false,
enteredByUserId: item.enteredByUserId,
enteredByUserName: item.enteredByUserName,
verifiedByUserId: null,
approvedByUserId: null,
rejectionReason: item.rejectionReason,
promotedAt: null,
promotionEncounterId: null,
supersedesBatchId: null,
clinicianAttestation: false,
isCorrection: false,
supersession: null,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
}
}
function nullIfEmpty(value: string | null | undefined): string | null { function nullIfEmpty(value: string | null | undefined): string | null {
if (value == null) return null if (value == null) return null
const trimmed = value.trim() const trimmed = value.trim()
@@ -66,6 +109,12 @@ export const useBatchStore = defineStore('batches', () => {
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const events = ref<BatchEventResponse[]>([])
const eventsLoading = ref(false)
const eventsError = ref<string | null>(null)
const eventsHasMore = ref(false)
const eventsNextCursor = ref<string | null>(null)
const documentUrl = computed(() => currentBatch.value?.documentUrl ?? null) const documentUrl = computed(() => currentBatch.value?.documentUrl ?? null)
async function listBatches(params: { async function listBatches(params: {
@@ -94,6 +143,81 @@ export const useBatchStore = defineStore('batches', () => {
} }
} }
async function listWorkQueue(
queue: WorkQueueName,
params: { page?: number; pageSize?: number } = {}
): Promise<void> {
loading.value = true
error.value = null
try {
const response = await get<WorkQueueResponse>(`work-queue/${queue}`, {
page: params.page ?? 1,
pageSize: params.pageSize ?? 50,
sortBy: 'updatedAt',
sortDirection: 'asc',
})
if (response.success && response.data) {
batches.value = response.data.items.map(mapWorkQueueItem)
totalCount.value = response.data.totalCount
}
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to load work queue'
} finally {
loading.value = false
}
}
async function listEntryQueue(params?: { page?: number; pageSize?: number }): Promise<void> {
await listWorkQueue('entry', params)
}
async function listVerificationQueue(params?: { page?: number; pageSize?: number }): Promise<void> {
await listWorkQueue('verification', params)
}
async function listClinicalApprovalQueue(params?: {
page?: number
pageSize?: number
}): Promise<void> {
await listWorkQueue('clinical-approval', params)
}
function clearEvents(): void {
events.value = []
eventsError.value = null
eventsHasMore.value = false
eventsNextCursor.value = null
}
/**
* Load batch audit events. Pass `after` (nextCursor) to append the next page.
*/
async function fetchEvents(batchId: string, after?: string | null): Promise<void> {
eventsLoading.value = true
eventsError.value = null
try {
const params: Record<string, unknown> = { pageSize: 50 }
if (after) params.after = after
const response = await get<CursorPagedResult<BatchEventResponse>>(
`digitization-batches/${batchId}/events`,
params
)
if (response.success && response.data) {
const page = response.data
events.value = after ? [...events.value, ...page.items] : page.items
eventsHasMore.value = page.hasMore
eventsNextCursor.value = page.nextCursor
} else {
eventsError.value = response.error?.message ?? 'Failed to load audit events'
}
} catch (e: unknown) {
eventsError.value = e instanceof Error ? e.message : 'Failed to load audit events'
} finally {
eventsLoading.value = false
}
}
async function getBatch(id: string): Promise<void> { async function getBatch(id: string): Promise<void> {
loading.value = true loading.value = true
error.value = null error.value = null
@@ -102,7 +226,16 @@ export const useBatchStore = defineStore('batches', () => {
`digitization-batches/${id}` `digitization-batches/${id}`
) )
if (response.success && response.data) { 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) { } catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to load batch' error.value = e instanceof Error ? e.message : 'Failed to load batch'
@@ -276,7 +409,18 @@ async function assignBatch(batchId: string, entryClerkUserId: string): Promise<v
totalCount, totalCount,
loading, loading,
error, error,
events,
eventsLoading,
eventsError,
eventsHasMore,
eventsNextCursor,
listBatches, listBatches,
listWorkQueue,
listEntryQueue,
listVerificationQueue,
listClinicalApprovalQueue,
fetchEvents,
clearEvents,
getBatch, getBatch,
uploadBatch, uploadBatch,
assignBatch, assignBatch,
+191
View File
@@ -0,0 +1,191 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import router from '../router'
import { getTourForRole } from '../tours/definitions'
import type { TourStep } from '../tours/types'
const STORAGE_PREFIX = 'vigilcare_tour_'
function completionKey(userId: string, role: string): string {
return `${STORAGE_PREFIX}${userId}_${role}`
}
function targetExists(selector: string): boolean {
if (typeof document === 'undefined') return false
try {
return !!document.querySelector(selector)
} catch {
return false
}
}
const SELECTOR_WAIT_MS = import.meta.env.MODE === 'test' ? 30 : 800
const SELECTOR_POLL_MS = import.meta.env.MODE === 'test' ? 5 : 50
function waitForSelector(selector: string, timeoutMs = SELECTOR_WAIT_MS): Promise<boolean> {
if (targetExists(selector)) return Promise.resolve(true)
return new Promise((resolve) => {
const start = Date.now()
const timer = window.setInterval(() => {
if (targetExists(selector)) {
window.clearInterval(timer)
resolve(true)
return
}
if (Date.now() - start >= timeoutMs) {
window.clearInterval(timer)
resolve(false)
}
}, SELECTOR_POLL_MS)
})
}
export const useTourStore = defineStore('tour', () => {
const active = ref(false)
const role = ref('')
const userId = ref('')
const stepIndex = ref(0)
const steps = ref<TourStep[]>([])
const preparing = ref(false)
const currentStep = computed(() => steps.value[stepIndex.value] ?? null)
const stepCount = computed(() => steps.value.length)
const isFirstStep = computed(() => stepIndex.value <= 0)
const isLastStep = computed(() => stepIndex.value >= steps.value.length - 1)
function hasCompleted(uid: string, userRole: string): boolean {
if (!uid || !userRole) return false
return localStorage.getItem(completionKey(uid, userRole)) === '1'
}
function markCompleted(uid: string, userRole: string): void {
if (!uid || !userRole) return
localStorage.setItem(completionKey(uid, userRole), '1')
}
function markDismissed(uid: string, userRole: string): void {
// Dismissing (Skip / Esc) also prevents auto-restart on next login.
markCompleted(uid, userRole)
}
async function goToStep(index: number): Promise<void> {
if (index < 0 || index >= steps.value.length) {
complete()
return
}
preparing.value = true
try {
const step = steps.value[index]
if (step.route && router.currentRoute.value.path !== step.route) {
await router.push(step.route)
}
const found = await waitForSelector(step.selector)
if (!found) {
// Skip missing targets (empty queue, no open batch, etc.)
if (index + 1 < steps.value.length) {
stepIndex.value = index + 1
await goToStep(index + 1)
} else {
complete()
}
return
}
stepIndex.value = index
} finally {
preparing.value = false
}
}
async function start(userRole: string, uid: string): Promise<void> {
const definition = getTourForRole(userRole)
if (!definition || definition.steps.length === 0) return
role.value = userRole
userId.value = uid
steps.value = [...definition.steps]
active.value = true
await goToStep(0)
}
async function replay(userRole: string, uid: string): Promise<void> {
await start(userRole, uid)
}
async function next(): Promise<void> {
if (!active.value) return
if (isLastStep.value) {
complete()
return
}
await goToStep(stepIndex.value + 1)
}
async function prev(): Promise<void> {
if (!active.value || isFirstStep.value) return
// Walk backward until a visible target is found
let candidate = stepIndex.value - 1
while (candidate >= 0) {
const step = steps.value[candidate]
if (step.route && router.currentRoute.value.path !== step.route) {
await router.push(step.route)
}
const found = await waitForSelector(step.selector)
if (found) {
stepIndex.value = candidate
return
}
candidate -= 1
}
}
function skip(): void {
if (!active.value) return
markDismissed(userId.value, role.value)
active.value = false
steps.value = []
stepIndex.value = 0
}
function complete(): void {
if (!active.value) return
markCompleted(userId.value, role.value)
active.value = false
steps.value = []
stepIndex.value = 0
}
/** Auto-start after login when the user has not completed/dismissed this role tour. */
async function tryAutoStart(userRole: string, uid: string): Promise<void> {
if (!userRole || !uid) return
if (active.value) return
if (hasCompleted(uid, userRole)) return
if (!getTourForRole(userRole)) return
await start(userRole, uid)
}
return {
active,
role,
userId,
stepIndex,
steps,
preparing,
currentStep,
stepCount,
isFirstStep,
isLastStep,
hasCompleted,
markCompleted,
start,
replay,
next,
prev,
skip,
complete,
tryAutoStart,
}
})
+90
View File
@@ -0,0 +1,90 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { get, post, patch } from '../api/client'
import type {
ApiResponse,
CreateUserRequest,
UpdateUserRequest,
UserSummary,
} from '../types'
function apiErrorMessage(e: unknown, fallback: string): string {
if (e && typeof e === 'object' && 'response' in e) {
const data = (e as { response?: { data?: ApiResponse<unknown> } }).response?.data
if (data?.error?.message) return data.error.message
}
if (e instanceof Error && e.message) return e.message
return fallback
}
export const useUsersStore = defineStore('users', () => {
const users = ref<UserSummary[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
async function listUsers(role?: string): Promise<void> {
loading.value = true
error.value = null
try {
const params = role ? { role } : undefined
const response = await get<UserSummary[]>('users', params)
if (response.success && response.data) {
users.value = response.data
} else {
error.value = response.error?.message ?? 'Failed to load users'
}
} catch (e: unknown) {
error.value = apiErrorMessage(e, 'Failed to load users')
} finally {
loading.value = false
}
}
async function createUser(body: CreateUserRequest): Promise<UserSummary> {
try {
const response = await post<UserSummary>('users', body)
if (!response.success || !response.data) {
throw new Error(response.error?.message ?? 'Failed to create user')
}
return response.data
} catch (e: unknown) {
throw new Error(apiErrorMessage(e, 'Failed to create user'))
}
}
async function updateUser(id: string, body: UpdateUserRequest): Promise<UserSummary> {
try {
const response = await patch<UserSummary>(`users/${id}`, body)
if (!response.success || !response.data) {
throw new Error(response.error?.message ?? 'Failed to update user')
}
return response.data
} catch (e: unknown) {
throw new Error(apiErrorMessage(e, 'Failed to update user'))
}
}
async function resetPassword(id: string, newPassword: string): Promise<void> {
try {
const response = await post<void>(`users/${id}/reset-password`, { newPassword })
// 204 No Content may yield an empty body
if (response && typeof response === 'object' && 'success' in response && !response.success) {
throw new Error(
(response as ApiResponse<void>).error?.message ?? 'Failed to reset password',
)
}
} catch (e: unknown) {
throw new Error(apiErrorMessage(e, 'Failed to reset password'))
}
}
return {
users,
loading,
error,
listUsers,
createUser,
updateUser,
resetPassword,
}
})
@@ -0,0 +1,311 @@
import type { TourDefinition } from './types'
const patientHistoryStep = {
id: 'patient-history',
selector: '[data-tour="nav-patients"]',
title: 'Patient History',
body: 'Open Patient History from the sidebar when you need prior digitization records for a patient.',
placement: 'right' as const,
}
export const tourDefinitions: Record<string, TourDefinition> = {
INTAKE_CLERK: {
role: 'INTAKE_CLERK',
steps: [
{
id: 'intake-job',
route: '/intake',
selector: '[data-tour="intake-header"]',
title: 'Your job: Intake',
body: 'Create digitization batches from paper scans and attach cover sheet details so clerks can enter data.',
placement: 'bottom',
},
{
id: 'intake-cover-lookup',
route: '/intake',
selector: '[data-tour="intake-cover-lookup"]',
title: 'Cover sheet lookup',
body: 'Scan or type a cover sheet code to auto-fill batch type, track, and assignment.',
placement: 'bottom',
},
{
id: 'intake-upload',
route: '/intake',
selector: '[data-tour="intake-upload"]',
title: 'Upload a scan',
body: 'Drop or choose a PDF or image. This becomes the source document for the batch.',
placement: 'bottom',
},
{
id: 'intake-metadata',
route: '/intake',
selector: '[data-tour="intake-metadata"]',
title: 'Batch details',
body: 'Confirm type, track, and patient, then create the batch.',
placement: 'top',
},
{
id: 'intake-recent',
route: '/intake',
selector: '[data-tour="intake-recent"]',
title: 'Recent uploads',
body: 'Assign clerks to batches waiting for data entry from this list.',
placement: 'left',
},
{
id: 'cover-sheets-nav',
route: '/cover-sheets',
selector: '[data-tour="cover-sheets-header"]',
title: 'Cover Sheets',
body: 'Generate and print cover sheets before scanning so intake can look them up by code.',
placement: 'bottom',
},
{
id: 'cover-sheets-generate',
route: '/cover-sheets',
selector: '[data-tour="cover-sheets-generate"]',
title: 'Generate cover sheets',
body: 'Set count, batch type, track, and optional clerk, then generate and print.',
placement: 'bottom',
},
patientHistoryStep,
],
},
DATA_ENTRY_CLERK: {
role: 'DATA_ENTRY_CLERK',
steps: [
{
id: 'entry-job',
route: '/entry',
selector: '[data-tour="entry-header"]',
title: 'Your job: Data Entry',
body: 'Transcribe structured fields from the source scan, then submit for verification.',
placement: 'bottom',
},
{
id: 'entry-queue',
route: '/entry',
selector: '[data-tour="workstation-queue"]',
title: 'Data Entry queue',
body: 'Open the oldest batch first. Batches returned for rework appear here too.',
placement: 'bottom',
},
{
id: 'entry-scan',
selector: '[data-tour="workstation-scan"]',
title: 'Source scan (level 1)',
body: 'The scan is the source of truth. Read from it while you fill the draft.',
placement: 'right',
},
{
id: 'entry-form',
selector: '[data-tour="entry-form"]',
title: 'Structured draft (level 2)',
body: 'Enter demographics and observations. Watch OCR confidence badges for uncertain fields.',
placement: 'left',
},
{
id: 'entry-actions',
selector: '[data-tour="workstation-action-bar"]',
title: 'Save or submit',
body: 'Save Draft to continue later. Submit for Verification when the draft matches the scan.',
placement: 'top',
},
patientHistoryStep,
],
},
VERIFIER: {
role: 'VERIFIER',
steps: [
{
id: 'verify-job',
route: '/verification',
selector: '[data-tour="verification-header"]',
title: 'Your job: Verification',
body: 'Compare every field to the scan. Pass only when all fields match; return otherwise.',
placement: 'bottom',
},
{
id: 'verify-queue',
route: '/verification',
selector: '[data-tour="workstation-queue"]',
title: 'Verification queue',
body: 'Select a batch pending verification. Work oldest first.',
placement: 'bottom',
},
{
id: 'verify-scan',
selector: '[data-tour="workstation-scan"]',
title: 'Source scan',
body: 'Keep the scan visible while you review each field card.',
placement: 'right',
},
{
id: 'verify-form',
selector: '[data-tour="verification-form"]',
title: 'Field review',
body: 'Check each field against the scan. Mark fields as you verify them.',
placement: 'left',
},
{
id: 'verify-sod',
selector: '[data-tour="sod-banner"]',
title: 'Separation of duties',
body: 'You cannot verify a batch you entered. The banner explains when that applies.',
placement: 'bottom',
},
{
id: 'verify-decision',
selector: '[data-tour="workstation-action-bar"]',
title: 'Pass or return',
body: 'Pass sends the batch to clinical approval. Return sends it back to data entry with a reason.',
placement: 'top',
},
{
id: 'verify-audit',
selector: '[data-tour="audit-trail"]',
title: 'Audit trail',
body: 'Review who entered and changed the batch before you decide.',
placement: 'top',
},
patientHistoryStep,
],
},
CLINICAL_APPROVER: {
role: 'CLINICAL_APPROVER',
steps: [
{
id: 'approval-job',
route: '/approval',
selector: '[data-tour="approval-header"]',
title: 'Your job: Clinical Approval',
body: 'Give final clinical sign-off. Approve promotes the record; reject returns it with a reason.',
placement: 'bottom',
},
{
id: 'approval-queue',
route: '/approval',
selector: '[data-tour="workstation-queue"]',
title: 'Clinical Approval queue',
body: 'Open a batch awaiting clinical approval.',
placement: 'bottom',
},
{
id: 'approval-form',
selector: '[data-tour="approval-form"]',
title: 'Sign-off review',
body: 'Review the structured data and any high-stakes or retroactive alerts before deciding.',
placement: 'left',
},
{
id: 'approval-decision',
selector: '[data-tour="workstation-action-bar"]',
title: 'Approve or reject',
body: 'Approve & Promote publishes the clinical record. Reject sends the batch back with a reason.',
placement: 'top',
},
patientHistoryStep,
],
},
CLINICIAN: {
role: 'CLINICIAN',
steps: [
{
id: 'live-job',
route: '/live-capture',
selector: '[data-tour="live-capture-header"]',
title: 'Your job: Live Capture',
body: 'Record vitals at the bedside. This is lighter than backfill data entry.',
placement: 'bottom',
},
{
id: 'live-tabs',
route: '/live-capture',
selector: '[data-tour="live-capture-tabs"]',
title: 'New or existing encounter',
body: 'Start a new encounter or attach observations to an existing one.',
placement: 'bottom',
},
{
id: 'live-patient',
route: '/live-capture',
selector: '[data-tour="live-capture-patient"]',
title: 'Patient and encounter',
body: 'Select the patient and fill encounter context (or pick an existing encounter).',
placement: 'bottom',
},
{
id: 'live-observations',
route: '/live-capture',
selector: '[data-tour="live-capture-observations"]',
title: 'Observations',
body: 'Add vitals and other observations for this encounter.',
placement: 'top',
},
{
id: 'live-attest',
route: '/live-capture',
selector: '[data-tour="live-capture-attest"]',
title: 'Attest and record',
body: 'Confirm clinician attestation with your password, then Record Vitals.',
placement: 'top',
},
patientHistoryStep,
],
},
ADMINISTRATOR: {
role: 'ADMINISTRATOR',
steps: [
{
id: 'admin-job',
route: '/dashboard',
selector: '[data-tour="dashboard-header"]',
title: 'Your job: Supervise',
body: 'Monitor queues, manage users, and inspect FHIR data. You can also open any workspace from the sidebar.',
placement: 'bottom',
},
{
id: 'admin-metrics',
route: '/dashboard',
selector: '[data-tour="dashboard-metrics"]',
title: 'Queue metrics',
body: 'Watch pending entry, verification, approval volume, and reject rate.',
placement: 'bottom',
},
{
id: 'admin-batches',
route: '/dashboard',
selector: '[data-tour="dashboard-batches"]',
title: 'All batches',
body: 'Browse every batch across statuses from this list.',
placement: 'top',
},
{
id: 'admin-nav',
route: '/dashboard',
selector: '[data-tour="nav-admin"]',
title: 'Administration',
body: 'Users manages accounts. FHIR Explorer inspects promoted clinical data.',
placement: 'right',
},
{
id: 'admin-workspace',
route: '/dashboard',
selector: '[data-tour="nav-workspace"]',
title: 'Workspace access',
body: 'As administrator you can open Intake, Data Entry, Verification, Approval, and Live Capture when needed.',
placement: 'right',
},
patientHistoryStep,
],
},
}
export function getTourForRole(role: string): TourDefinition | null {
return tourDefinitions[role] ?? null
}
+17
View File
@@ -0,0 +1,17 @@
export type TourPlacement = 'top' | 'bottom' | 'left' | 'right'
export interface TourStep {
id: string
/** Navigate here before highlighting (if different from current route). */
route?: string
/** CSS selector; prefer [data-tour="…"]. Missing targets are skipped. */
selector: string
title: string
body: string
placement?: TourPlacement
}
export interface TourDefinition {
role: string
steps: TourStep[]
}
+77
View File
@@ -63,8 +63,12 @@ export interface BatchDetailResponse {
documentUrl: string | null documentUrl: string | null
enableRetroactiveAlerts: boolean enableRetroactiveAlerts: boolean
enteredByUserId: string | null enteredByUserId: string | null
/** Present on work-queue items; optional on batch detail until API includes it */
enteredByUserName?: string | null
verifiedByUserId: string | null verifiedByUserId: string | null
verifiedByUserName?: string | null
approvedByUserId: string | null approvedByUserId: string | null
approvedByUserName?: string | null
rejectionReason: string | null rejectionReason: string | null
promotedAt: string | null promotedAt: string | null
promotionEncounterId: string | null promotionEncounterId: string | null
@@ -177,6 +181,36 @@ export interface UserSummary {
role: string role: string
} }
export type UserRoleString =
| 'INTAKE_CLERK'
| 'DATA_ENTRY_CLERK'
| 'VERIFIER'
| 'CLINICAL_APPROVER'
| 'CLINICIAN'
| 'ADMINISTRATOR'
export interface CreateUserRequest {
username: string
password: string
fullName: string
role: string
}
export interface UpdateUserRequest {
fullName?: string
role?: string
isActive?: boolean
}
export interface ResetPasswordRequest {
newPassword: string
}
export interface ChangePasswordRequest {
currentPassword: string
newPassword: string
}
export interface CoverSheetResponse { export interface CoverSheetResponse {
id: string id: string
code: string code: string
@@ -209,6 +243,49 @@ export interface DigitizationEventSummary {
metadataJson: string | null metadataJson: string | null
} }
/** Cursor-paginated audit event from GET digitization-batches/{id}/events */
export interface BatchEventResponse {
id: string
batchId: string
eventType: string
actorUserId: string
actorUsername: string
actorFullName: string
occurredAt: string
metadataJson: string | null
}
export interface CursorPagedResult<T> {
items: T[]
pageSize: number
nextCursor: string | null
hasMore: boolean
}
/** Item from GET work-queue/{entry|verification|clinical-approval} */
export interface WorkQueueItemResponse {
batchId: string
status: string
batchType: string
track: string
patientId: string | null
enteredByUserId: string | null
enteredByUserName: string | null
rejectionReason: string | null
createdAt: string
updatedAt: string
eventCount: number
}
export interface WorkQueueResponse {
queueName: string
items: WorkQueueItemResponse[]
page: number
pageSize: number
totalCount: number
totalPages: number
}
export interface DigitizationHistoryEntry { export interface DigitizationHistoryEntry {
batchId: string batchId: string
status: string status: string
@@ -0,0 +1,69 @@
export type BatchStatusTone =
| 'neutral'
| 'info'
| 'warning'
| 'danger'
| 'success'
| 'accent'
| 'muted'
export type BatchStatusIcon =
| 'upload'
| 'edit'
| 'clock'
| 'reject'
| 'check'
| 'shield'
| 'approve'
| 'done'
| 'cancel'
| 'unknown'
export interface BatchStatusMeta {
label: string
tone: BatchStatusTone
icon: BatchStatusIcon
}
/** Canonical labels aligned with design-doc § Status labels. */
export const BATCH_STATUS_META: Record<string, BatchStatusMeta> = {
UPLOADED: { label: 'Uploaded', tone: 'neutral', icon: 'upload' },
IN_ENTRY: { label: 'In Entry', tone: 'warning', icon: 'edit' },
PENDING_VERIFICATION: { label: 'Pending Verification', tone: 'warning', icon: 'clock' },
REJECTED: { label: 'Verification Rejected', tone: 'danger', icon: 'reject' },
VERIFIED: { label: 'Verified', tone: 'info', icon: 'check' },
AWAITING_CLINICAL_APPROVAL: { label: 'Pending Approval', tone: 'accent', icon: 'shield' },
APPROVED: { label: 'Approved', tone: 'success', icon: 'approve' },
PROMOTED: { label: 'Promoted', tone: 'success', icon: 'done' },
CANCELLED: { label: 'Cancelled', tone: 'muted', icon: 'cancel' },
/** Cover sheet list statuses */
UNUSED: { label: 'Unused', tone: 'success', icon: 'check' },
USED: { label: 'Used', tone: 'muted', icon: 'done' },
}
export const BATCH_STATUS_TONE_CLASSES: Record<BatchStatusTone, string> = {
neutral: 'bg-canvas text-ink-strong border border-line',
info: 'bg-primary-50 text-primary-800 border border-primary-100',
warning: 'bg-clinical-warning-bg text-clinical-warning border border-[#FEDF89]',
danger: 'bg-clinical-danger-bg text-clinical-danger border border-[#FECDCA]',
success: 'bg-clinical-safe-bg text-clinical-safe border border-[#ABEFC6]',
accent: 'bg-primary-50 text-primary-700 border border-primary-100',
muted: 'bg-canvas text-ink-disabled border border-line',
}
export function getBatchStatusMeta(status: string | null | undefined): BatchStatusMeta {
if (!status) {
return { label: 'Unknown', tone: 'neutral', icon: 'unknown' }
}
const key = status.toUpperCase()
return (
BATCH_STATUS_META[key] ?? {
label: key
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, (c) => c.toUpperCase()),
tone: 'neutral',
icon: 'unknown',
}
)
}
+58 -264
View File
@@ -1,289 +1,100 @@
<template> <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"> <AppHeader title="Clinical Approval" tour-anchor="approval-header">
<template #subtitle> <template #subtitle>
<span v-if="currentBatch" class="text-sm text-gray-500"> <span v-if="currentBatch" class="text-sm text-ink-secondary">
Batch: {{ currentBatch.id.substring(0, 8) }}... Batch {{ currentBatch.id.substring(0, 8) }}
| Type: {{ formatBatchType(currentBatch.batchType) }} · {{ formatBatchType(currentBatch.batchType) }}
</span> </span>
</template> </template>
<template #actions>
<TourHelpButton />
</template>
</AppHeader> </AppHeader>
<!-- Queue view (no batch selected) --> <WorkstationLayout :has-batch="!!batchId">
<div v-if="!batchId" class="flex-1 p-4 sm:p-6"> <template #queue>
<h2 class="text-xl font-semibold mb-4">Clinical Approval Queue</h2> <h2 class="text-xl font-semibold mb-4 text-ink-strong">Clinical Approval Queue</h2>
<p class="text-sm text-gray-500 mb-4"> <p class="text-sm text-ink-secondary mb-4">
Verified batches awaiting clinical sign-off before promotion to live tables. Verified batches awaiting clinical sign-off before promotion to live tables.
</p> </p>
<BatchList <BatchList
:batches="batchStore.batches" :batches="batchStore.batches"
:loading="batchStore.loading" :loading="batchStore.loading"
:error="batchStore.error"
empty-title="No batches are waiting for clinical approval."
empty-description="Verified batches appear here after verification is complete."
@select="openBatch" @select="openBatch"
@retry="loadQueue"
/> />
</div> </template>
<!-- Split pane (batch selected) --> <template #rail>
<div v-else class="flex-1 split-pane"> <WorkstationQueueRail
title="Approval queue"
:batches="batchStore.batches"
:selected-id="batchId"
:loading="batchStore.loading"
@select="openBatch"
@back="router.push('/approval')"
/>
</template>
<template #scan>
<ScanViewer <ScanViewer
v-if="documentUrl"
:url="documentUrl" :url="documentUrl"
:loading="documentLoading"
:error="documentError"
@retry="refreshUrl"
/> />
<div v-else class="flex items-center justify-center bg-gray-100 rounded-lg"> </template>
<p class="text-gray-500">{{ documentError ?? 'Loading document...' }}</p>
</div>
<!-- Approval panel --> <template #form>
<div class="h-full overflow-y-auto p-4 space-y-6"> <ApprovalForm
<div class="flex items-center justify-between"> v-if="batchId"
<h2 class="text-lg font-semibold">Clinical Review</h2> :batch="currentBatch"
<span class="bg-purple-100 text-purple-800 status-badge"> :batch-id="batchId"
Awaiting Clinical Approval :draft="draft"
</span> @back="router.push('/approval')"
</div> @view-history="(patientId) => router.push(`/patients/${patientId}/history`)"
@create-correction="createCorrection"
<!-- Supersession info --> @rejected="router.push('/approval')"
<div
v-if="currentBatch?.supersedesBatchId"
class="bg-blue-50 border border-blue-200 rounded-md p-4 text-sm"
>
<p class="font-medium text-blue-800">Correction Batch</p>
<p class="text-blue-700 mt-1">
This batch corrects and will supersede batch
<span class="font-mono">{{ currentBatch.supersedesBatchId.substring(0, 8) }}...</span>
</p>
<button
v-if="currentBatch.patientId"
@click="router.push(`/patients/${currentBatch.patientId}/history`)"
class="text-xs text-blue-600 hover:text-blue-800 mt-2"
>
View patient history
</button>
</div>
<!-- Patient summary (read-only) -->
<fieldset v-if="draft?.patient" class="border border-gray-200 rounded-md p-4">
<legend class="text-sm font-medium text-gray-700 px-2">Patient Demographics</legend>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div><span class="text-gray-500">Full Name:</span> <span class="font-medium ml-1">{{ draft.patient.fullName }}</span></div>
<div><span class="text-gray-500">DOB:</span> <span class="font-medium ml-1">{{ draft.patient.dateOfBirth ?? 'N/A' }}</span></div>
<div><span class="text-gray-500">Sex:</span> <span class="font-medium ml-1">{{ draft.patient.sex ?? 'N/A' }}</span></div>
<div><span class="text-gray-500">Blood Type:</span> <span class="font-medium ml-1">{{ draft.patient.bloodType ?? 'N/A' }}</span></div>
</div>
</fieldset>
<!-- Encounter context (read-only) -->
<fieldset v-if="draft?.encounter" class="border border-gray-200 rounded-md p-4">
<legend class="text-sm font-medium text-gray-700 px-2">Encounter Context</legend>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div><span class="text-gray-500">Admission:</span> <span class="font-medium ml-1">{{ draft.encounter.admissionDate ?? 'N/A' }}</span></div>
<div><span class="text-gray-500">Department:</span> <span class="font-medium ml-1">{{ draft.encounter.department ?? 'N/A' }}</span></div>
<div><span class="text-gray-500">Room/Bed:</span> <span class="font-medium ml-1">{{ draft.encounter.roomBed ?? 'N/A' }}</span></div>
<div><span class="text-gray-500">Reason:</span> <span class="font-medium ml-1">{{ draft.encounter.admissionReason ?? 'N/A' }}</span></div>
</div>
</fieldset>
<!-- Observations (read-only) -->
<fieldset class="border border-gray-200 rounded-md p-4">
<legend class="text-sm font-medium text-gray-700 px-2">
Observations ({{ draft?.observations?.length ?? 0 }})
</legend>
<div class="space-y-2">
<ObservationRow
v-for="obs in (draft?.observations ?? [])"
:key="obs.id"
:observation="obs"
:readonly="true"
/> />
<p v-if="!draft?.observations?.length" class="text-sm text-gray-500"> </template>
No observations recorded. </WorkstationLayout>
</p>
</div>
</fieldset>
<!-- Verification info -->
<div v-if="currentBatch?.verifiedByUserId" class="bg-blue-50 border border-blue-200 rounded-md p-4 text-sm">
<p class="font-medium text-blue-800">Verified by: {{ currentBatch.verifiedByUserId.substring(0, 8) }}...</p>
</div>
<!-- Enable retroactive alerts toggle -->
<div class="bg-gray-50 rounded-md p-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="enableRetroactiveAlerts"
type="checkbox"
class="w-4 h-4 text-clinical-safe rounded"
/>
<div>
<span class="text-sm font-medium">Enable retroactive alerts</span>
<p class="text-xs text-gray-500 mt-0.5">
If checked, backfill observations will be evaluated by the alert engine.
</p>
</div>
</label>
</div>
<!-- Actions -->
<div class="flex flex-col sm:flex-row gap-4 pt-4 border-t">
<button
@click="approve"
class="btn-primary"
:disabled="processing"
>
{{ processing ? 'Promoting...' : 'Approve & Promote' }}
</button>
<button
@click="showRejectDialog = true"
class="btn-danger"
:disabled="processing"
>
Reject
</button>
<button
@click="router.push('/approval')"
class="px-4 py-2 text-gray-600 hover:text-gray-800"
:disabled="processing"
>
Back to Queue
</button>
</div>
<!-- Promotion result banner -->
<div v-if="promotionResult" class="bg-green-50 border border-green-200 rounded-md p-4 text-sm">
<p class="font-medium text-green-800">Promotion successful</p>
<p class="text-green-700 mt-1">Patient MRN: {{ promotionResult.mrn }}</p>
<p class="text-green-700">Encounter: {{ promotionResult.encounterId?.substring(0, 8) }}...</p>
<p class="text-green-700">Observations promoted: {{ promotionResult.observationIds?.length }}</p>
<div class="flex gap-4 mt-3 pt-3 border-t border-green-200">
<button
@click="createCorrection"
class="text-sm text-primary-600 hover:text-primary-800 font-medium"
>
Create Correction
</button>
<button
v-if="currentBatch?.patientId"
@click="router.push(`/patients/${currentBatch!.patientId}/history`)"
class="text-sm text-gray-600 hover:text-gray-800"
>
View Patient History
</button>
</div>
</div>
<!-- Deferred banner -->
<div v-if="deferred" class="bg-yellow-50 border border-yellow-200 rounded-md p-4 text-sm">
<p class="font-medium text-yellow-800">Approved - Promotion Deferred</p>
<p class="text-yellow-700 mt-1">
Promotion will be retried automatically due to a temporary infrastructure issue.
</p>
</div>
<!-- Reject dialog -->
<div
v-if="showRejectDialog"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
>
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-semibold mb-4">Reject Batch</h3>
<textarea
v-model="rejectionReason"
class="form-input"
rows="4"
placeholder="Reason for rejection (required)..."
/>
<div class="flex flex-col-reverse sm:flex-row sm:justify-end gap-4 mt-4">
<button
@click="showRejectDialog = false"
class="px-4 py-2 text-gray-600 hover:text-gray-800"
>
Cancel
</button>
<button
@click="reject"
class="btn-danger"
:disabled="!rejectionReason.trim()"
>
Confirm Rejection
</button>
</div>
</div>
</div>
<div v-if="errorMessage" class="text-clinical-danger text-sm">
{{ errorMessage }}
</div>
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useBatchStore } from '../stores/batches' import { useBatchStore } from '../stores/batches'
import { usePresignedUrl } from '../composables/usePresignedUrl' import { usePresignedUrl } from '../composables/usePresignedUrl'
import { useToast } from '../composables/useToast'
import AppHeader from '../components/AppHeader.vue' import AppHeader from '../components/AppHeader.vue'
import TourHelpButton from '../components/TourHelpButton.vue'
import ScanViewer from '../components/ScanViewer.vue' import ScanViewer from '../components/ScanViewer.vue'
import BatchList from '../components/BatchList.vue' import BatchList from '../components/BatchList.vue'
import ObservationRow from '../components/ObservationRow.vue' import WorkstationLayout from '../components/WorkstationLayout.vue'
import WorkstationQueueRail from '../components/WorkstationQueueRail.vue'
import ApprovalForm from '../components/ApprovalForm.vue'
const props = defineProps<{ batchId?: string }>() const props = defineProps<{ batchId?: string }>()
const batchStore = useBatchStore() const batchStore = useBatchStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const toast = useToast()
const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined)) const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined))
const currentBatch = computed(() => batchStore.currentBatch) const currentBatch = computed(() => batchStore.currentBatch)
const draft = computed(() => batchStore.currentDraft) const draft = computed(() => batchStore.currentDraft)
const { documentUrl, documentError } = usePresignedUrl(batchId) const { documentUrl, documentError, documentLoading, refreshUrl } = usePresignedUrl(batchId)
const processing = ref(false)
const errorMessage = ref('')
const enableRetroactiveAlerts = ref(false)
const showRejectDialog = ref(false)
const rejectionReason = ref('')
const promotionResult = ref<{ mrn: string; encounterId: string; observationIds: string[] } | null>(null)
const deferred = ref(false)
function openBatch(id: string) { function openBatch(id: string) {
router.push(`/approval/${id}`) router.push(`/approval/${id}`)
} }
function formatBatchType(type: string): string { function formatBatchType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
async function approve() {
if (!batchId.value) return
processing.value = true
errorMessage.value = ''
promotionResult.value = null
deferred.value = false
try {
const response = await batchStore.approveBatch(batchId.value, enableRetroactiveAlerts.value)
if (response?.status === 202) {
deferred.value = true
toast.info('Approved. Promotion will be retried automatically.')
} else if (response?.data) {
promotionResult.value = response.data
toast.success('Batch approved and promoted successfully')
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Approval failed'
if (msg.includes('PROMOTION_DEFERRED')) {
deferred.value = true
toast.info('Approved. Promotion will be retried automatically.')
} else {
errorMessage.value = msg
toast.error(msg)
}
} finally {
processing.value = false
}
} }
function createCorrection() { function createCorrection() {
@@ -298,24 +109,6 @@ function createCorrection() {
}) })
} }
async function reject() {
if (!batchId.value) return
processing.value = true
errorMessage.value = ''
try {
await batchStore.rejectBatch(batchId.value, rejectionReason.value)
showRejectDialog.value = false
toast.warning('Batch rejected')
router.push('/approval')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Rejection failed'
errorMessage.value = msg
toast.error(msg)
} finally {
processing.value = false
}
}
watch( watch(
batchId, batchId,
async (id) => { async (id) => {
@@ -328,12 +121,13 @@ watch(
) )
onMounted(async () => { onMounted(async () => {
if (!batchId.value) { await loadQueue()
await batchStore.listBatches({ })
status: 'AWAITING_CLINICAL_APPROVAL',
async function loadQueue() {
await batchStore.listClinicalApprovalQueue({
page: 1, page: 1,
pageSize: 50, pageSize: 50,
}) })
} }
})
</script> </script>
@@ -1,20 +1,29 @@
<template> <template>
<div class="min-h-screen flex flex-col"> <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."
tour-anchor="cover-sheets-header"
>
<template #actions>
<TourHelpButton />
</template>
</AppHeader>
<div class="p-4 sm:p-6 lg:p-8 max-w-6xl mx-auto flex-1 w-full"> <div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
<h1 class="text-2xl font-bold mb-6">Cover Sheet Management</h1> <div class="max-w-7xl mx-auto space-y-6 lg:space-y-8">
<!-- Top: 50 / 50 generation + preview -->
<!-- Generate --> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:gap-8 items-start">
<div class="card mb-6"> <section class="card" data-tour="cover-sheets-generate">
<h2 class="text-lg font-semibold mb-4">Generate Cover Sheets</h2> <h2 class="text-base font-semibold text-ink-strong mb-4">Generate Cover Sheets</h2>
<form @submit.prevent="handleGenerate" class="space-y-4"> <form @submit.prevent="handleGenerate" class="space-y-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2"> <label for="cs-count" class="block text-sm font-semibold text-ink mb-2">
Count (1&ndash;100) Quantity (1100)
</label> </label>
<input <input
id="cs-count"
v-model.number="count" v-model.number="count"
type="number" type="number"
min="1" min="1"
@@ -25,8 +34,10 @@
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Batch Type</label> <label for="cs-batch-type" class="block text-sm font-semibold text-ink mb-2">
<select v-model="batchType" class="form-input" required> Batch Type
</label>
<select id="cs-batch-type" v-model="batchType" class="form-input" required>
<option value="">Select batch type...</option> <option value="">Select batch type...</option>
<option value="PATIENT_REGISTRATION">Patient Registration</option> <option value="PATIENT_REGISTRATION">Patient Registration</option>
<option value="ENCOUNTER_SUMMARY">Encounter Summary</option> <option value="ENCOUNTER_SUMMARY">Encounter Summary</option>
@@ -39,25 +50,32 @@
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Track</label> <label for="cs-track" class="block text-sm font-semibold text-ink mb-2">Track</label>
<select v-model="track" class="form-input"> <select id="cs-track" v-model="track" class="form-input">
<option value="BACKFILL">Backfill (Track A)</option> <option value="BACKFILL">Backfill (Track A)</option>
<option value="LIVE_CAPTURE">Live Capture (Track B)</option> <option value="LIVE_CAPTURE">Live Capture (Track B)</option>
</select> </select>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2"> <label class="block text-sm font-semibold text-ink mb-2">
Patient (optional) Patient
<span class="font-normal text-ink-secondary">(optional)</span>
</label> </label>
<PatientSearch v-model="patientId" /> <PatientSearch v-model="patientId" />
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2"> <label for="cs-clerk" class="block text-sm font-semibold text-ink mb-2">
Assign to Clerk (optional) Entry Clerk
<span class="font-normal text-ink-secondary">(optional)</span>
</label> </label>
<select v-model="assignToUserId" class="form-input" :disabled="clerksLoading"> <select
id="cs-clerk"
v-model="assignToUserId"
class="form-input"
:disabled="clerksLoading"
>
<option value="">No pre-assignment</option> <option value="">No pre-assignment</option>
<option v-for="clerk in clerks" :key="clerk.id" :value="clerk.id"> <option v-for="clerk in clerks" :key="clerk.id" :value="clerk.id">
{{ clerk.fullName }} ({{ clerk.username }}) {{ clerk.fullName }} ({{ clerk.username }})
@@ -65,13 +83,18 @@
</select> </select>
</div> </div>
<div v-if="generateError" class="text-clinical-danger text-sm"> <InlineError
{{ generateError }} v-if="generateError"
</div> title="Generation failed"
:message="generateError"
preserved="Your form values were kept."
retry-label="Try again"
@retry="handleGenerate"
/>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3 pt-1">
<button type="submit" class="btn-primary" :disabled="generating || !batchType"> <button type="submit" class="btn-primary" :disabled="generating || !batchType">
{{ generating ? 'Generating...' : 'Generate' }} {{ generating ? 'Generating...' : 'Generate Cover Sheets' }}
</button> </button>
<button <button
v-if="lastGeneratedIds.length > 0" v-if="lastGeneratedIds.length > 0"
@@ -83,65 +106,159 @@
{{ printing ? 'Opening PDF...' : 'Print Cover Sheets' }} {{ printing ? 'Opening PDF...' : 'Print Cover Sheets' }}
</button> </button>
</div> </div>
<p
v-if="lastGeneratedIds.length > 0"
class="text-sm text-ink-secondary"
>
Last run: {{ lastGeneratedIds.length }} cover sheet(s) ready to print.
</p>
</form> </form>
</section>
<!-- Live paper preview -->
<section class="card flex flex-col">
<h2 class="text-base font-semibold text-ink-strong mb-1">Preview</h2>
<p class="text-sm text-ink-secondary mb-4">
Approximate printable layout for the current form values.
</p>
<div class="cover-sheet-preview">
<div class="flex items-center gap-2 px-5 py-4 border-b border-line bg-navy text-white">
<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 class="leading-tight">
<p class="text-sm font-bold tracking-tight">VigilCare</p>
<p class="text-[11px] text-white/60">Records · Cover Sheet</p>
</div>
</div> </div>
<!-- List --> <div class="flex-1 flex flex-col items-center justify-center px-6 py-8 gap-5">
<div class="card"> <!-- QR placeholder -->
<div
class="w-28 h-28 border-2 border-ink-strong grid grid-cols-5 grid-rows-5 gap-0.5 p-1.5"
aria-hidden="true"
>
<span
v-for="n in 25"
:key="n"
class="bg-ink-strong"
:class="qrCellClass(n)"
/>
</div>
<div class="text-center space-y-1">
<p class="font-mono text-xs text-ink-secondary tracking-wider">
VCR-CS-XXXXXXXX
</p>
<p class="text-lg font-semibold text-ink-strong">
{{ previewBatchType }}
</p>
<p class="text-sm text-ink-secondary">
{{ track === 'BACKFILL' ? 'Backfill (Track A)' : 'Live Capture (Track B)' }}
</p>
</div>
<dl class="w-full max-w-xs text-sm space-y-2 border-t border-line pt-4">
<div class="flex justify-between gap-3">
<dt class="text-ink-secondary">Quantity</dt>
<dd class="font-medium text-ink-strong">{{ previewCount }}</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-ink-secondary">Patient</dt>
<dd class="font-medium text-ink-strong text-right truncate">
{{ patientId ? 'Linked' : 'Not linked' }}
</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-ink-secondary">Entry clerk</dt>
<dd class="font-medium text-ink-strong text-right truncate">
{{ assignedClerkLabel }}
</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-ink-secondary">Generated</dt>
<dd class="font-medium text-ink-strong">{{ previewTimestamp }}</dd>
</div>
</dl>
</div>
</div>
</section>
</div>
<!-- Existing cover sheets table -->
<section class="card">
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-4"> <div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-4">
<h2 class="text-lg font-semibold">Cover Sheet List</h2> <h2 class="text-base font-semibold text-ink-strong">Existing Cover Sheets</h2>
<div class="flex flex-col sm:flex-row gap-4"> <div class="flex flex-col sm:flex-row gap-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Status</label> <label for="cs-status-filter" class="block text-sm font-semibold text-ink mb-2">
<select v-model="statusFilter" class="form-input" @change="onFiltersChanged"> Status
</label>
<select
id="cs-status-filter"
v-model="statusFilter"
class="form-input"
@change="onFiltersChanged"
>
<option value="all">All</option> <option value="all">All</option>
<option value="unused">Unused</option> <option value="unused">Unused</option>
<option value="used">Used</option> <option value="used">Used</option>
</select> </select>
</div> </div>
<div class="min-w-[240px]"> <div class="min-w-[240px]">
<label class="block text-sm font-medium text-gray-700 mb-2">Patient</label> <label class="block text-sm font-semibold text-ink mb-2">Patient</label>
<PatientSearch v-model="filterPatientId" @update:model-value="onFiltersChanged" /> <PatientSearch v-model="filterPatientId" @update:model-value="onFiltersChanged" />
</div> </div>
</div> </div>
</div> </div>
<div v-if="listError" class="text-clinical-danger text-sm mb-4"> <InlineError
{{ listError }} v-if="listError"
</div> class="mb-4"
title="Could not load cover sheets"
:message="listError"
preserved="Your filters were preserved."
retry-label="Retry"
@retry="loadCoverSheets"
/>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<div v-if="listLoading" class="text-gray-500 text-center py-4">Loading...</div> <SkeletonBlock v-if="listLoading" variant="table" :rows="5" />
<div v-else-if="coverSheets.length === 0" class="text-gray-500 text-center py-4"> <EmptyState
No cover sheets found. v-else-if="coverSheets.length === 0"
</div> title="No cover sheets found."
description="Generate cover sheets above, or adjust your filters."
/>
<table v-else class="w-full min-w-[800px] text-sm"> <table v-else class="w-full min-w-[800px] text-sm">
<thead> <thead>
<tr class="border-b text-left text-gray-600"> <tr class="border-b text-left text-ink-secondary">
<th class="py-2 px-4">Code</th> <th class="py-2 px-4 font-medium">Code</th>
<th class="py-2 px-4">Batch Type</th> <th class="py-2 px-4 font-medium">Batch Type</th>
<th class="py-2 px-4">Track</th> <th class="py-2 px-4 font-medium">Track</th>
<th class="py-2 px-4">Patient</th> <th class="py-2 px-4 font-medium">Patient</th>
<th class="py-2 px-4">Assigned To</th> <th class="py-2 px-4 font-medium">Assigned To</th>
<th class="py-2 px-4">Status</th> <th class="py-2 px-4 font-medium">Status</th>
<th class="py-2 px-4">Linked Batch</th> <th class="py-2 px-4 font-medium">Linked Batch</th>
<th class="py-2 px-4">Created</th> <th class="py-2 px-4 font-medium">Created</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr
v-for="sheet in coverSheets" v-for="sheet in coverSheets"
:key="sheet.id" :key="sheet.id"
class="border-b hover:bg-gray-50" class="border-b hover:bg-primary-50"
> >
<td class="py-2 px-4 font-mono text-xs">{{ sheet.code }}</td> <td class="py-2 px-4 font-mono text-xs text-ink-strong">{{ sheet.code }}</td>
<td class="py-2 px-4">{{ formatBatchType(sheet.batchType) }}</td> <td class="py-2 px-4">{{ formatBatchType(sheet.batchType) }}</td>
<td class="py-2 px-4"> <td class="py-2 px-4">
<span <span
:class="sheet.track === 'BACKFILL' :class="sheet.track === 'BACKFILL'
? 'bg-blue-100 text-blue-800' ? 'bg-primary-50 text-primary-800 border border-primary-100'
: 'bg-green-100 text-green-800'" : 'bg-clinical-safe-bg text-clinical-safe border border-[#ABEFC6]'"
class="status-badge" class="status-badge"
> >
{{ sheet.track === 'BACKFILL' ? 'Backfill' : 'Live' }} {{ sheet.track === 'BACKFILL' ? 'Backfill' : 'Live' }}
@@ -150,22 +267,17 @@
<td class="py-2 px-4"> <td class="py-2 px-4">
<template v-if="sheet.patientName"> <template v-if="sheet.patientName">
{{ sheet.patientName }} {{ sheet.patientName }}
<span v-if="sheet.patientMrn" class="text-gray-500">({{ sheet.patientMrn }})</span> <span v-if="sheet.patientMrn" class="text-ink-secondary">
({{ sheet.patientMrn }})
</span>
</template> </template>
<span v-else class="text-gray-400">&mdash;</span> <span v-else class="text-ink-disabled"></span>
</td> </td>
<td class="py-2 px-4"> <td class="py-2 px-4">
{{ sheet.assignToUserName ?? '—' }} {{ sheet.assignToUserName ?? '—' }}
</td> </td>
<td class="py-2 px-4"> <td class="py-2 px-4">
<span <StatusBadge :status="sheet.isUsed ? 'USED' : 'UNUSED'" />
:class="sheet.isUsed
? 'bg-gray-100 text-gray-800'
: 'bg-green-100 text-green-800'"
class="status-badge"
>
{{ sheet.isUsed ? 'Used' : 'Unused' }}
</span>
</td> </td>
<td class="py-2 px-4"> <td class="py-2 px-4">
<router-link <router-link
@@ -173,11 +285,11 @@
:to="{ path: '/intake', query: { batchId: sheet.batchId } }" :to="{ path: '/intake', query: { batchId: sheet.batchId } }"
class="font-mono text-xs text-primary-600 hover:text-primary-800" class="font-mono text-xs text-primary-600 hover:text-primary-800"
> >
{{ sheet.batchId.substring(0, 8) }}... {{ sheet.batchId.substring(0, 8) }}
</router-link> </router-link>
<span v-else class="text-gray-400">&mdash;</span> <span v-else class="text-ink-disabled"></span>
</td> </td>
<td class="py-2 px-4 text-gray-500"> <td class="py-2 px-4 text-ink-secondary">
{{ formatDate(sheet.createdAt) }} {{ formatDate(sheet.createdAt) }}
</td> </td>
</tr> </tr>
@@ -185,8 +297,8 @@
</table> </table>
</div> </div>
<div class="flex items-center justify-between mt-4 pt-4 border-t"> <div class="flex items-center justify-between mt-4 pt-4 border-t border-line">
<p class="text-sm text-gray-500">Page {{ page }}</p> <p class="text-sm text-ink-secondary">Page {{ page }}</p>
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
type="button" type="button"
@@ -206,6 +318,7 @@
</button> </button>
</div> </div>
</div> </div>
</section>
</div> </div>
</div> </div>
</div> </div>
@@ -216,7 +329,12 @@ import { ref, computed, onMounted } from 'vue'
import { get, post, postBlob } from '../api/client' import { get, post, postBlob } from '../api/client'
import { useToast } from '../composables/useToast' import { useToast } from '../composables/useToast'
import AppHeader from '../components/AppHeader.vue' import AppHeader from '../components/AppHeader.vue'
import TourHelpButton from '../components/TourHelpButton.vue'
import PatientSearch from '../components/PatientSearch.vue' import PatientSearch from '../components/PatientSearch.vue'
import StatusBadge from '../components/StatusBadge.vue'
import EmptyState from '../components/EmptyState.vue'
import SkeletonBlock from '../components/SkeletonBlock.vue'
import InlineError from '../components/InlineError.vue'
import type { CoverSheetResponse, UserSummary } from '../types' import type { CoverSheetResponse, UserSummary } from '../types'
const toast = useToast() const toast = useToast()
@@ -244,6 +362,25 @@ const filterPatientId = ref<string | undefined>()
const hasNextPage = computed(() => coverSheets.value.length === pageSize) const hasNextPage = computed(() => coverSheets.value.length === pageSize)
const previewCount = computed(() => Math.min(100, Math.max(1, count.value || 1)))
const previewBatchType = computed(() =>
batchType.value ? formatBatchType(batchType.value) : 'Select a batch type',
)
const assignedClerkLabel = computed(() => {
if (!assignToUserId.value) return 'None'
const clerk = clerks.value.find(c => c.id === assignToUserId.value)
return clerk?.fullName ?? 'Selected'
})
const previewTimestamp = computed(() =>
new Date().toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}),
)
function formatBatchType(type: string): string { function formatBatchType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
} }
@@ -252,6 +389,15 @@ function formatDate(value: string): string {
return new Date(value).toLocaleString() return new Date(value).toLocaleString()
} }
/** Sparse checker pattern for QR placeholder cells */
function qrCellClass(n: number): string {
const row = Math.ceil(n / 5)
const col = ((n - 1) % 5) + 1
const corner = (row <= 2 && col <= 2) || (row <= 2 && col >= 4) || (row >= 4 && col <= 2)
const mid = (row + col) % 2 === 0
return corner || mid ? 'opacity-100' : 'opacity-20'
}
async function loadClerks(): Promise<void> { async function loadClerks(): Promise<void> {
clerksLoading.value = true clerksLoading.value = true
try { try {
+57 -26
View File
@@ -1,68 +1,105 @@
<template> <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"> <AppHeader title="Data Entry" tour-anchor="entry-header">
<template #subtitle> <template #subtitle>
<span v-if="currentBatch" class="text-sm text-gray-500"> <span v-if="currentBatch" class="text-sm text-ink-secondary">
Batch: {{ currentBatch.id.substring(0, 8) }}... Batch {{ currentBatch.id.substring(0, 8) }}
| Type: {{ currentBatch.batchType.replace(/_/g, ' ') }} · {{ currentBatch.batchType.replace(/_/g, ' ') }}
</span> </span>
</template> </template>
<template #actions>
<TourHelpButton />
</template>
</AppHeader> </AppHeader>
<!-- Queue view (no batch selected) --> <WorkstationLayout :has-batch="!!batchId">
<div v-if="!batchId" class="flex-1 p-4 sm:p-6"> <template #queue>
<h2 class="text-xl font-semibold mb-4">Entry Queue</h2> <h2 class="text-xl font-semibold mb-4 text-ink-strong">Data Entry Queue</h2>
<BatchList <BatchList
:batches="batchStore.batches" :batches="batchStore.batches"
:loading="batchStore.loading" :loading="batchStore.loading"
:error="batchStore.error"
empty-title="No batches are waiting for data entry."
empty-description="Batches in entry or returned for rework appear here, oldest first."
@select="openBatch" @select="openBatch"
@retry="loadQueue"
/> />
</div> </template>
<!-- Split pane (batch selected) --> <template #rail>
<div v-else class="flex-1 split-pane"> <WorkstationQueueRail
title="Entry queue"
:batches="batchStore.batches"
:selected-id="batchId"
:loading="batchStore.loading"
@select="openBatch"
@back="router.push('/entry')"
/>
</template>
<template #scan>
<ScanViewer <ScanViewer
v-if="documentUrl"
:url="documentUrl" :url="documentUrl"
:loading="documentLoading"
:error="documentError"
@retry="refreshUrl"
/> />
<div v-else class="flex items-center justify-center bg-gray-100 rounded-lg"> </template>
<p class="text-gray-500">{{ documentError ?? 'Loading document...' }}</p>
</div>
<template #form>
<EntryForm <EntryForm
v-if="batchId"
:batch="currentBatch" :batch="currentBatch"
:batch-id="batchId" :batch-id="batchId"
:next-batch-id="nextBatchId"
@open-next="openBatch"
/> />
</div> </template>
</WorkstationLayout>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, watch } from 'vue' import { computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useBatchStore } from '../stores/batches' import { useBatchStore } from '../stores/batches'
import { usePresignedUrl } from '../composables/usePresignedUrl' import { usePresignedUrl } from '../composables/usePresignedUrl'
import AppHeader from '../components/AppHeader.vue' import AppHeader from '../components/AppHeader.vue'
import TourHelpButton from '../components/TourHelpButton.vue'
import ScanViewer from '../components/ScanViewer.vue' import ScanViewer from '../components/ScanViewer.vue'
import EntryForm from '../components/EntryForm.vue' import EntryForm from '../components/EntryForm.vue'
import BatchList from '../components/BatchList.vue' import BatchList from '../components/BatchList.vue'
import WorkstationLayout from '../components/WorkstationLayout.vue'
import WorkstationQueueRail from '../components/WorkstationQueueRail.vue'
const props = defineProps<{ batchId?: string }>() const props = defineProps<{ batchId?: string }>()
const auth = useAuthStore()
const batchStore = useBatchStore() const batchStore = useBatchStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined)) const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined))
const currentBatch = computed(() => batchStore.currentBatch) const currentBatch = computed(() => batchStore.currentBatch)
const { documentUrl, documentError } = usePresignedUrl(batchId) const { documentUrl, documentError, documentLoading, refreshUrl } = usePresignedUrl(batchId)
/** Next item in the loaded entry queue after the open batch (optional action-bar nav). */
const nextBatchId = computed(() => {
const id = batchId.value
if (!id) return undefined
const list = batchStore.batches
const idx = list.findIndex((b) => b.id === id)
if (idx < 0 || idx >= list.length - 1) return undefined
return list[idx + 1]?.id
})
async function openBatch(id: string) { async function openBatch(id: string) {
router.push(`/entry/${id}`) router.push(`/entry/${id}`)
} }
async function loadQueue() {
await batchStore.listEntryQueue({ page: 1, pageSize: 50 })
}
watch( watch(
batchId, batchId,
async (id) => { async (id) => {
@@ -74,12 +111,6 @@ watch(
) )
onMounted(async () => { onMounted(async () => {
if (!batchId.value) { await loadQueue()
await batchStore.listBatches({
assignedTo: auth.userId,
page: 1,
pageSize: 50,
})
}
}) })
</script> </script>
@@ -1,23 +1,40 @@
<template> <template>
<div class="min-h-screen flex flex-col"> <div class="h-full min-h-0 flex flex-col">
<AppHeader title="FHIR Explorer" /> <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"> description="Read-only inspection of exposed FHIR resources for administrators and integration staff."
<div class="flex flex-wrap items-center justify-between gap-3"> tour-anchor="fhir-header"
<h1 class="text-2xl font-bold">FHIR R4 Explorer</h1> >
<template #actions>
<TourHelpButton />
<button type="button" class="btn-secondary text-sm" @click="openMetadata"> <button type="button" class="btn-secondary text-sm" @click="openMetadata">
Open CapabilityStatement (/fhir/metadata) CapabilityStatement
</button> </button>
</div> </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">
<!-- Resource browser --> <!-- Resource browser -->
<div class="card"> <section class="card space-y-5">
<h2 class="text-lg font-semibold mb-4">Resource Browser</h2> <header>
<h2 class="text-base font-semibold text-ink-strong">Resource Browser</h2>
<p class="text-sm text-ink-secondary mt-1">
Search Patient, Encounter, or Observation, then inspect raw JSON.
</p>
</header>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-4"> <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Resource Type</label> <label for="fhir-resource-type" class="block text-sm font-semibold text-ink mb-2">
<select v-model="resourceType" class="form-input" @change="clearResults"> Resource Type
</label>
<select
id="fhir-resource-type"
v-model="resourceType"
class="form-input"
@change="clearResults"
>
<option value="Patient">Patient</option> <option value="Patient">Patient</option>
<option value="Encounter">Encounter</option> <option value="Encounter">Encounter</option>
<option value="Observation">Observation</option> <option value="Observation">Observation</option>
@@ -26,26 +43,40 @@
<template v-if="resourceType === 'Patient'"> <template v-if="resourceType === 'Patient'">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Name</label> <label class="block text-sm font-semibold text-ink mb-2">Name</label>
<input v-model="patientSearch.name" type="text" class="form-input" placeholder="e.g. Santos" /> <input
v-model="patientSearch.name"
type="text"
class="form-input"
placeholder="e.g. Santos"
/>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Birth Date</label> <label class="block text-sm font-semibold text-ink mb-2">Birth Date</label>
<input v-model="patientSearch.birthdate" type="date" class="form-input" /> <input v-model="patientSearch.birthdate" type="date" class="form-input" />
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">MRN (identifier)</label> <label class="block text-sm font-semibold text-ink mb-2">MRN (identifier)</label>
<input v-model="patientSearch.identifier" type="text" class="form-input" placeholder="VCR-000001" /> <input
v-model="patientSearch.identifier"
type="text"
class="form-input font-mono"
placeholder="VCR-000001"
/>
</div> </div>
</template> </template>
<template v-else-if="resourceType === 'Encounter'"> <template v-else-if="resourceType === 'Encounter'">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Patient ID</label> <label class="block text-sm font-semibold text-ink mb-2">Patient ID</label>
<input v-model="encounterSearch.patient" type="text" class="form-input" /> <input
v-model="encounterSearch.patient"
type="text"
class="form-input font-mono"
/>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Status</label> <label class="block text-sm font-semibold text-ink mb-2">Status</label>
<select v-model="encounterSearch.status" class="form-input"> <select v-model="encounterSearch.status" class="form-input">
<option value="">Any</option> <option value="">Any</option>
<option value="in-progress">in-progress</option> <option value="in-progress">in-progress</option>
@@ -53,22 +84,31 @@
</select> </select>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Date</label> <label class="block text-sm font-semibold text-ink mb-2">Date</label>
<input v-model="encounterSearch.date" type="date" class="form-input" /> <input v-model="encounterSearch.date" type="date" class="form-input" />
</div> </div>
</template> </template>
<template v-else> <template v-else>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Patient ID</label> <label class="block text-sm font-semibold text-ink mb-2">Patient ID</label>
<input v-model="observationSearch.patient" type="text" class="form-input" /> <input
v-model="observationSearch.patient"
type="text"
class="form-input font-mono"
/>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">LOINC Code</label> <label class="block text-sm font-semibold text-ink mb-2">LOINC Code</label>
<input v-model="observationSearch.code" type="text" class="form-input" placeholder="8867-4" /> <input
v-model="observationSearch.code"
type="text"
class="form-input font-mono"
placeholder="8867-4"
/>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Category</label> <label class="block text-sm font-semibold text-ink mb-2">Category</label>
<select v-model="observationSearch.category" class="form-input"> <select v-model="observationSearch.category" class="form-input">
<option value="">Any</option> <option value="">Any</option>
<option value="vital-signs">vital-signs</option> <option value="vital-signs">vital-signs</option>
@@ -76,72 +116,86 @@
</select> </select>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Date (FHIR prefix)</label> <label class="block text-sm font-semibold text-ink mb-2">Date (FHIR prefix)</label>
<input <input
v-model="observationSearch.date" v-model="observationSearch.date"
type="text" type="text"
class="form-input" class="form-input font-mono"
placeholder="ge2026-06-20" placeholder="ge2026-06-20"
/> />
</div> </div>
</template> </template>
</div> </div>
<button type="button" class="btn-primary" :disabled="searching" @click="runSearch"> <button
type="button"
class="btn-primary"
:disabled="searching"
@click="runSearch"
>
{{ searching ? 'Searching...' : 'Search' }} {{ searching ? 'Searching...' : 'Search' }}
</button> </button>
<p v-if="searchError" class="text-clinical-danger text-sm mt-3">{{ searchError }}</p> <InlineError
v-if="searchError"
title="FHIR search failed"
:message="searchError"
preserved="Your search fields were kept."
retry-label="Retry search"
@retry="runSearch"
/>
<div v-if="searchResults.length > 0" class="mt-6 overflow-x-auto"> <div v-if="searchResults.length > 0" class="overflow-x-auto">
<p class="text-sm text-gray-600 mb-2"> <p class="text-sm text-ink-secondary mb-2">
{{ searchTotal }} result(s) {{ searchTotal }} result(s)
</p> </p>
<table class="min-w-full text-sm border border-gray-200 rounded-md overflow-hidden"> <table class="w-full min-w-[640px] text-sm">
<thead class="bg-gray-50 text-left"> <thead>
<tr> <tr class="border-b border-line text-left text-ink-secondary">
<th class="px-3 py-2">ID</th> <th class="py-2 px-3 font-medium">ID</th>
<th v-if="resourceType === 'Patient'" class="px-3 py-2">Name</th> <th v-if="resourceType === 'Patient'" class="py-2 px-3 font-medium">Name</th>
<th v-if="resourceType === 'Patient'" class="px-3 py-2">MRN</th> <th v-if="resourceType === 'Patient'" class="py-2 px-3 font-medium">MRN</th>
<th v-if="resourceType === 'Patient'" class="px-3 py-2">DOB</th> <th v-if="resourceType === 'Patient'" class="py-2 px-3 font-medium">DOB</th>
<th v-if="resourceType === 'Encounter'" class="px-3 py-2">Status</th> <th v-if="resourceType === 'Encounter'" class="py-2 px-3 font-medium">Status</th>
<th v-if="resourceType === 'Encounter'" class="px-3 py-2">Patient</th> <th v-if="resourceType === 'Encounter'" class="py-2 px-3 font-medium">Patient</th>
<th v-if="resourceType === 'Observation'" class="px-3 py-2">Code</th> <th v-if="resourceType === 'Observation'" class="py-2 px-3 font-medium">Code</th>
<th v-if="resourceType === 'Observation'" class="px-3 py-2">Value</th> <th v-if="resourceType === 'Observation'" class="py-2 px-3 font-medium">Value</th>
<th v-if="resourceType === 'Observation'" class="px-3 py-2">Recorded</th> <th v-if="resourceType === 'Observation'" class="py-2 px-3 font-medium">Recorded</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr
v-for="entry in searchResults" v-for="entry in searchResults"
:key="String(entry.resource?.id)" :key="String(entry.resource?.id)"
class="border-t border-gray-100 hover:bg-primary-50 cursor-pointer" class="border-b border-line hover:bg-primary-50 cursor-pointer"
:class="{ 'bg-primary-50': selectedResource?.id === entry.resource?.id }" :class="{ 'bg-primary-50': selectedResource?.id === entry.resource?.id }"
@click="selectResource(entry.resource)" @click="selectResource(entry.resource)"
> >
<td class="px-3 py-2 font-mono text-xs">{{ entry.resource?.id }}</td> <td class="py-2 px-3 font-mono text-xs text-ink-strong">
<td v-if="resourceType === 'Patient'" class="px-3 py-2"> {{ entry.resource?.id }}
</td>
<td v-if="resourceType === 'Patient'" class="py-2 px-3">
{{ patientDisplayName(entry.resource) }} {{ patientDisplayName(entry.resource) }}
</td> </td>
<td v-if="resourceType === 'Patient'" class="px-3 py-2"> <td v-if="resourceType === 'Patient'" class="py-2 px-3 font-mono text-xs">
{{ patientMrn(entry.resource) }} {{ patientMrn(entry.resource) }}
</td> </td>
<td v-if="resourceType === 'Patient'" class="px-3 py-2"> <td v-if="resourceType === 'Patient'" class="py-2 px-3">
{{ entry.resource?.birthDate ?? '—' }} {{ entry.resource?.birthDate ?? '—' }}
</td> </td>
<td v-if="resourceType === 'Encounter'" class="px-3 py-2"> <td v-if="resourceType === 'Encounter'" class="py-2 px-3">
{{ entry.resource?.status ?? '—' }} {{ entry.resource?.status ?? '—' }}
</td> </td>
<td v-if="resourceType === 'Encounter'" class="px-3 py-2"> <td v-if="resourceType === 'Encounter'" class="py-2 px-3 font-mono text-xs">
{{ subjectReference(entry.resource) }} {{ subjectReference(entry.resource) }}
</td> </td>
<td v-if="resourceType === 'Observation'" class="px-3 py-2"> <td v-if="resourceType === 'Observation'" class="py-2 px-3">
{{ observationCodeDisplay(entry.resource) }} {{ observationCodeDisplay(entry.resource) }}
</td> </td>
<td v-if="resourceType === 'Observation'" class="px-3 py-2"> <td v-if="resourceType === 'Observation'" class="py-2 px-3">
{{ observationValueDisplay(entry.resource) }} {{ observationValueDisplay(entry.resource) }}
</td> </td>
<td v-if="resourceType === 'Observation'" class="px-3 py-2"> <td v-if="resourceType === 'Observation'" class="py-2 px-3 text-ink-secondary">
{{ observationEffective(entry.resource) }} {{ observationEffective(entry.resource) }}
</td> </td>
</tr> </tr>
@@ -149,21 +203,29 @@
</table> </table>
</div> </div>
<div v-if="selectedResource" class="mt-6"> <EmptyState
<h3 class="text-sm font-semibold text-gray-700 mb-2">Resource JSON</h3> v-else-if="searched && !searching && !searchError"
<pre class="bg-gray-900 text-green-100 text-xs p-4 rounded-md overflow-x-auto max-h-96">{{ formattedSelectedResource }}</pre> title="No matching FHIR resources."
</div> description="Adjust search parameters and try again."
/>
<div v-if="selectedResource">
<h3 class="text-sm font-semibold text-ink-strong mb-2">Resource JSON</h3>
<pre class="fhir-json-panel">{{ formattedSelectedResource }}</pre>
</div> </div>
</section>
<!-- Patient $everything --> <!-- Patient $everything -->
<div class="card"> <section class="card space-y-4">
<h2 class="text-lg font-semibold mb-4">Patient $everything</h2> <header>
<p class="text-sm text-gray-600 mb-4"> <h2 class="text-base font-semibold text-ink-strong">Patient $everything</h2>
<p class="text-sm text-ink-secondary mt-1">
Load all FHIR resources for a patient in one Bundle. Load all FHIR resources for a patient in one Bundle.
</p> </p>
</header>
<div class="max-w-md mb-4"> <div class="max-w-md">
<label class="block text-sm font-medium text-gray-700 mb-2">Patient</label> <label class="block text-sm font-semibold text-ink mb-2">Patient</label>
<PatientSearch v-model="everythingPatientId" /> <PatientSearch v-model="everythingPatientId" />
</div> </div>
@@ -176,49 +238,94 @@
{{ everythingLoading ? 'Loading...' : 'Load All Data' }} {{ everythingLoading ? 'Loading...' : 'Load All Data' }}
</button> </button>
<p v-if="everythingError" class="text-clinical-danger text-sm mt-3">{{ everythingError }}</p> <InlineError
v-if="everythingError"
title="Failed to load patient Bundle"
:message="everythingError"
preserved="Your patient selection was kept."
retry-label="Retry"
@retry="loadEverything"
/>
<div v-if="everythingBundle" class="mt-6 space-y-6"> <div v-if="everythingBundle" class="space-y-5 pt-2">
<div v-if="everythingPatient" class="card bg-primary-50 border border-primary-100"> <div
<h3 class="font-semibold mb-2">Patient</h3> v-if="everythingPatient"
<p class="text-sm"><span class="text-gray-500">Name:</span> {{ patientDisplayName(everythingPatient) }}</p> class="rounded-input border border-primary-100 bg-primary-50 p-4"
<p class="text-sm"><span class="text-gray-500">MRN:</span> {{ patientMrn(everythingPatient) }}</p> >
<p class="text-sm"><span class="text-gray-500">DOB:</span> {{ everythingPatient.birthDate ?? '—' }}</p> <h3 class="text-sm font-semibold text-ink-strong mb-2">Patient</h3>
<p class="text-sm"><span class="text-gray-500">Gender:</span> {{ everythingPatient.gender ?? '—' }}</p> <dl class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
<div>
<dt class="text-ink-secondary">Name</dt>
<dd class="text-ink-strong">{{ patientDisplayName(everythingPatient) }}</dd>
</div>
<div>
<dt class="text-ink-secondary">MRN</dt>
<dd class="font-mono text-ink-strong">{{ patientMrn(everythingPatient) }}</dd>
</div>
<div>
<dt class="text-ink-secondary">DOB</dt>
<dd class="text-ink">{{ everythingPatient.birthDate ?? '—' }}</dd>
</div>
<div>
<dt class="text-ink-secondary">Gender</dt>
<dd class="text-ink">{{ everythingPatient.gender ?? '—' }}</dd>
</div>
</dl>
</div> </div>
<div v-if="everythingEncounters.length > 0"> <div v-if="everythingEncounters.length > 0">
<h3 class="font-semibold mb-2">Encounters ({{ everythingEncounters.length }})</h3> <h3 class="text-sm font-semibold text-ink-strong mb-2">
Encounters ({{ everythingEncounters.length }})
</h3>
<ul class="space-y-2"> <ul class="space-y-2">
<li <li
v-for="enc in everythingEncounters" v-for="enc in everythingEncounters"
:key="String(enc.id)" :key="String(enc.id)"
class="text-sm border border-gray-200 rounded-md px-3 py-2" class="text-sm border border-line rounded-input px-3 py-2 bg-surface"
> >
<span class="font-mono text-xs text-gray-500">{{ enc.id }}</span> <span class="font-mono text-xs text-ink-secondary">{{ enc.id }}</span>
{{ enc.status }} {{ enc.status }}
<span v-if="enc.period?.start"> · {{ enc.period.start }}</span> <span v-if="enc.period?.start" class="text-ink-secondary">
· {{ enc.period.start }}
</span>
</li> </li>
</ul> </ul>
</div> </div>
<div v-if="everythingObservations.length > 0"> <div v-if="everythingObservations.length > 0">
<h3 class="font-semibold mb-2">Observations ({{ everythingObservations.length }})</h3> <h3 class="text-sm font-semibold text-ink-strong mb-2">
<ul class="space-y-2"> Observations ({{ everythingObservations.length }})
<li </h3>
<div class="overflow-x-auto">
<table class="w-full min-w-[480px] text-sm">
<thead>
<tr class="border-b border-line text-left text-ink-secondary">
<th class="py-2 px-2 font-medium">ID</th>
<th class="py-2 px-2 font-medium">Code</th>
<th class="py-2 px-2 font-medium">Value</th>
<th class="py-2 px-2 font-medium">Recorded</th>
</tr>
</thead>
<tbody>
<tr
v-for="obs in everythingObservations" v-for="obs in everythingObservations"
:key="String(obs.id)" :key="String(obs.id)"
class="text-sm border border-gray-200 rounded-md px-3 py-2 flex flex-wrap gap-x-3" class="border-b border-line"
> >
<span class="font-mono text-xs text-gray-500">{{ obs.id }}</span> <td class="py-2 px-2 font-mono text-xs text-ink-secondary">{{ obs.id }}</td>
<span>{{ observationCodeDisplay(obs) }}</span> <td class="py-2 px-2">{{ observationCodeDisplay(obs) }}</td>
<span>{{ observationValueDisplay(obs) }}</span> <td class="py-2 px-2">{{ observationValueDisplay(obs) }}</td>
<span class="text-gray-500">{{ observationEffective(obs) }}</span> <td class="py-2 px-2 text-ink-secondary">
</li> {{ observationEffective(obs) }}
</ul> </td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
</section>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -226,7 +333,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref } from 'vue' import { computed, reactive, ref } from 'vue'
import AppHeader from '../components/AppHeader.vue' import AppHeader from '../components/AppHeader.vue'
import TourHelpButton from '../components/TourHelpButton.vue'
import PatientSearch from '../components/PatientSearch.vue' import PatientSearch from '../components/PatientSearch.vue'
import InlineError from '../components/InlineError.vue'
import EmptyState from '../components/EmptyState.vue'
import { import {
fhirGet, fhirGet,
openFhirJsonInNewTab, openFhirJsonInNewTab,
@@ -239,6 +349,7 @@ type ResourceType = 'Patient' | 'Encounter' | 'Observation'
const resourceType = ref<ResourceType>('Patient') const resourceType = ref<ResourceType>('Patient')
const searching = ref(false) const searching = ref(false)
const searched = ref(false)
const searchError = ref('') const searchError = ref('')
const searchResults = ref<FhirBundleEntry[]>([]) const searchResults = ref<FhirBundleEntry[]>([])
const searchTotal = ref(0) const searchTotal = ref(0)
@@ -300,6 +411,7 @@ function clearResults(): void {
searchTotal.value = 0 searchTotal.value = 0
selectedResource.value = null selectedResource.value = null
searchError.value = '' searchError.value = ''
searched.value = false
} }
function selectResource(resource: FhirResource | undefined): void { function selectResource(resource: FhirResource | undefined): void {
@@ -374,6 +486,7 @@ function buildSearchParams(): Record<string, string | number | undefined> {
async function runSearch(): Promise<void> { async function runSearch(): Promise<void> {
searching.value = true searching.value = true
searched.value = true
searchError.value = '' searchError.value = ''
selectedResource.value = null selectedResource.value = null

Some files were not shown because too many files have changed in this diff Show More