Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fb9c95dfb | ||
|
|
3680dc5ea7 | ||
|
|
48c5c93358 | ||
|
|
53338906aa | ||
|
|
4c5aafe591 | ||
|
|
00f832aa73 | ||
|
|
4085aaa549 | ||
|
|
5caf928787 | ||
|
|
9811f2a2ed | ||
|
|
56e100a495 | ||
|
|
6e06717b82 | ||
|
|
0c31291b69 | ||
|
|
572cef7b1f | ||
|
|
89da4d094f | ||
|
|
4d95108ac5 | ||
|
|
e25b57c779 | ||
|
|
f2eab26ad0 |
@@ -20,10 +20,11 @@ DASHBOARD_ORIGIN=https://vigilcare-records.vectur45.com
|
||||
# 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"
|
||||
# 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
|
||||
# it needs the externally-routable host:port, not the container network name.
|
||||
# CD copies migrate-api to the deploy host and runs it in a one-shot container on
|
||||
# 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.
|
||||
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" = the service name on the shared Redis compose project.
|
||||
|
||||
@@ -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=
|
||||
@@ -70,6 +70,9 @@ jobs:
|
||||
# Checkout must run on the job host. Build the EF bundle via Dockerfile
|
||||
# --target migrate (context upload), not docker run -v — under act_runner
|
||||
# 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
|
||||
# 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.
|
||||
@@ -85,12 +88,42 @@ jobs:
|
||||
docker rm "$cid"
|
||||
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
|
||||
# migration must be backwards-compatible with the outgoing image
|
||||
# (expand-then-contract). Self-contained linux-x64 binary — runs on the
|
||||
# job host directly.
|
||||
# (expand-then-contract). Connection strings contain `;` / spaces
|
||||
# ("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
|
||||
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:
|
||||
needs: [build-and-push, migrate]
|
||||
|
||||
@@ -14,6 +14,18 @@ on:
|
||||
jobs:
|
||||
backend:
|
||||
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:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -62,6 +74,8 @@ jobs:
|
||||
- name: Test
|
||||
env:
|
||||
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: |
|
||||
dotnet test VigilCareRecords.sln -c Release --no-build \
|
||||
--logger "trx;LogFileName=test-results.trx" \
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
Use Playwright MCP as a visual QA and frontend polish tool for this page.
|
||||
|
||||
Inspect the page at mobile, tablet, and desktop breakpoints, identify styling/layout issues, and fix them directly in the code.
|
||||
|
||||
Focus on:
|
||||
- spacing
|
||||
- sizing
|
||||
- alignment
|
||||
- element positions
|
||||
- typography scaling
|
||||
- card proportions
|
||||
- image/text balance
|
||||
- overflow/clipping
|
||||
- responsiveness
|
||||
|
||||
Requirements:
|
||||
- keep Tailwind CSS
|
||||
- preserve the current design intent
|
||||
- improve the page until it looks polished and production-ready
|
||||
- verify each fix visually with Playwright MCP
|
||||
- iterate until no obvious visual issues remain
|
||||
|
||||
|
||||
Test at:
|
||||
- 375x812
|
||||
- 768x1024
|
||||
- 1280x800
|
||||
|
||||
Use VigilCareRecordsAPI/Data/Seed/DataSeeder.cs to get the logins
|
||||
|
||||
Do not only report problems.
|
||||
Make the fixes, re-test, and then provide a short summary of what was improved.
|
||||
@@ -0,0 +1,84 @@
|
||||
Here's the updated prompt:
|
||||
|
||||
---
|
||||
|
||||
## Role
|
||||
Act as a senior QA engineer testing a Vue 3 + Node.js web application for logic errors, bugs, and edge cases.
|
||||
|
||||
## Output Format
|
||||
A plain text report of all issues found, grouped by file or feature domain, with severity level per issue (Critical / High / Medium / Low).
|
||||
|
||||
## Goal
|
||||
Test the provided functionality, section, or domain by reading the code and mentally executing it across normal, boundary, and failure scenarios. Report every defect found. When testing a view or feature, trace execution downward through all underlying components, composables, and backend routes and controllers that the feature depends on.
|
||||
|
||||
## Grounding Rules
|
||||
- Stay within the confines of the provided code — do not invent features, routes, or behaviors that are not present.
|
||||
- Do not hallucinate API responses, database states, or UI interactions not inferable from the code.
|
||||
- Do not suggest third-party testing tools or libraries unless already present in the codebase.
|
||||
- If a behavior is ambiguous, flag it as a question rather than assuming intent.
|
||||
- When a view or component calls a composable, follow that composable's logic as part of the same test pass.
|
||||
- When a composable or service makes an API call, follow the corresponding backend route, middleware, and controller as part of the same test pass.
|
||||
|
||||
## Instructions
|
||||
Test each provided file or domain in this order of priority:
|
||||
|
||||
1. **Logic correctness** — Does the code do what it is clearly intended to do?
|
||||
2. **Edge cases** — Empty inputs, null/undefined values, empty arrays, zero, negative numbers, max-length strings, concurrent calls.
|
||||
3. **Error handling** — Are errors caught? Are failure states handled gracefully? Do error messages leak sensitive data?
|
||||
4. **Reactivity correctness** (frontend) — Does state update when it should? Can stale state be observed?
|
||||
5. **Data flow** — Are values passed, transformed, or mutated in ways that could produce unexpected results downstream? Trace data from the frontend input all the way to the database query and back.
|
||||
6. **Boundary conditions** — Off-by-one errors, pagination limits, permission boundaries, rate limits.
|
||||
7. **Race conditions** — Async operations that could resolve out of order or leave state inconsistent.
|
||||
8. **Contract mismatches** — Does the frontend expect a response shape the backend does not guarantee? Are required fields missing, optional fields assumed present, or error codes unhandled?
|
||||
9. **Login/Auth Requirements** - If auth or login is required use the following credentials email: bradleystorm.sevt@mockinbox.com and password: Password123!
|
||||
|
||||
**Conflict resolution:** If a behavior could be either a bug or an intentional design choice, report it as a flagged ambiguity rather than a confirmed defect. Do not silently assume either way.
|
||||
|
||||
**Priority hierarchy:** Logic correctness > Error handling > Edge cases > Data flow > Contract mismatches > Boundary conditions > Race conditions > Reactivity.
|
||||
|
||||
## Trace Depth
|
||||
When a file is provided as the entry point for testing, automatically include in scope:
|
||||
- All composables imported and called by that file
|
||||
- All child components rendered by that file
|
||||
- All backend routes, middleware, and controllers called by those composables or services
|
||||
- All database queries executed by those controllers
|
||||
|
||||
Report issues at the layer where they originate, not just where their effect is observed.
|
||||
|
||||
## Examples
|
||||
|
||||
**Bad output (do not produce this):**
|
||||
```
|
||||
- The login form might have issues.
|
||||
- Consider adding more validation.
|
||||
```
|
||||
|
||||
**Good output (produce this):**
|
||||
```
|
||||
FILE: src/composables/useAuth.js
|
||||
SEVERITY: Critical
|
||||
ISSUE: If `refreshToken()` is called while a refresh is already in flight, two concurrent requests are fired. The second response overwrites the token set by the first, leaving the app in a potentially invalid auth state.
|
||||
REPRODUCTION: Trigger two API calls simultaneously on a near-expired token.
|
||||
FIX RECOMMENDATION: Guard the refresh call with an in-flight flag or return the existing promise if one is pending.
|
||||
|
||||
FILE: backend/controllers/authController.js
|
||||
SEVERITY: High
|
||||
ISSUE: The refresh token is not invalidated after use. A leaked token can be replayed indefinitely until expiry.
|
||||
REPRODUCTION: Capture the refresh token from a valid session and reuse it after the session has been refreshed.
|
||||
FIX RECOMMENDATION: Implement refresh token rotation — invalidate the used token and issue a new one on each refresh.
|
||||
```
|
||||
|
||||
## Context / Input
|
||||
Paste files in this order, highest reliability first:
|
||||
1. Backend routes, middleware, and controllers
|
||||
2. Composables and services
|
||||
3. Components and views
|
||||
|
||||
## Final Reminder
|
||||
- Do not fabricate bugs. Every reported issue must be traceable to a specific line or code path in the provided files.
|
||||
- Do not skip files because they look simple — shallow files are common sources of silent failures.
|
||||
- Ambiguity is a valid finding. Flag it rather than resolve it silently.
|
||||
- Always trace execution through the full stack — frontend to composable to backend to database — before closing a test pass on any feature.
|
||||
|
||||
## Output
|
||||
Plain text only. No markdown formatting, no bullet symbols, no headers with hashes. Group findings by file. For each issue state: FILE, SEVERITY, ISSUE, REPRODUCTION STEPS, FIX RECOMMENDATION. If a file has no issues, write the filename followed by "No issues found." Restate this format requirement if the session resets mid-task.
|
||||
@@ -213,7 +213,8 @@ VigilCareRecords/
|
||||
│ ├── run-vigilcare-records-phase-13-verification.sh # OCR config, ocrConfidence API, optional live OCR polling
|
||||
│ └── fixtures/test-scan.pdf # Sample PDF for upload verification scripts
|
||||
└── docs/
|
||||
├── plans/ # Phase 1–13 implementation guides
|
||||
├── plans/ # Phase 14–18 UI redesign guides (1–13 historical; not in repo)
|
||||
├── designs/ # UI/UX design-doc + mockup PNGs
|
||||
├── digitization-workstation-guide.md # Clinical scenarios and clerk workflow reference
|
||||
├── vigilcare-records-gap-analysis.md # Known gaps and hardening backlog
|
||||
└── vigilcare-records-prd.md # Product requirements and phase roadmap
|
||||
|
||||
@@ -9,17 +9,30 @@ public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
|
||||
{
|
||||
// Override configuration to point at a test database — never run tests against
|
||||
// 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)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
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?>
|
||||
{
|
||||
["ConnectionStrings:DefaultConnection"] =
|
||||
"Host=localhost;Port=5437;Database=vigilcare_records_test;Username=postgres;Password=password",
|
||||
["Redis:ConnectionString"] = "localhost:6383,defaultDatabase=1,allowAdmin=true",
|
||||
["Fhir:BaseUrl"] = "http://localhost/fhir"
|
||||
["ConnectionStrings:DefaultConnection"] = connectionString,
|
||||
["Redis:ConnectionString"] = redisConnectionString,
|
||||
["Fhir:BaseUrl"] = fhirBaseUrl
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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="pageSize">Number of events per page. Default 50, max 200.</param>
|
||||
[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<object>), StatusCodes.Status400BadRequest)]
|
||||
[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";
|
||||
}
|
||||
}
|
||||
@@ -45,9 +45,9 @@ RUN apt-get update \
|
||||
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
RUN useradd --uid 1654 --user-group --no-create-home appuser \
|
||||
&& chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
# aspnet:8.0 already ships non-root user `app` (UID/GID 1654).
|
||||
RUN chown -R app:app /app
|
||||
USER app
|
||||
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -6,8 +6,10 @@ using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Minio;
|
||||
using Minio.DataModel.Args;
|
||||
using Prometheus;
|
||||
using Serilog;
|
||||
using StackExchange.Redis;
|
||||
@@ -233,6 +235,11 @@ try
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
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();
|
||||
|
||||
@@ -147,7 +147,7 @@ FROM src AS migrate
|
||||
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`
|
||||
|
||||
@@ -272,7 +272,7 @@ build-and-push ──► migrate ──► deploy (smoke + rollback on failure)
|
||||
|
||||
1. Build `--target migrate`
|
||||
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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
Repo → **Settings** → **Actions**.
|
||||
@@ -53,7 +67,7 @@ Repo → **Settings** → **Actions**.
|
||||
|---|---|
|
||||
| `REGISTRY_USERNAME` | `docker login` |
|
||||
| `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_USER` | SSH / SCP |
|
||||
| `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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 101 KiB |
@@ -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
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
**Phases 1–13 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 14–18 (UI redesign)** are planned: restyle and re-layout `vigilcare-records-web` against [designs/design-doc.md](designs/design-doc.md) without new backend behavior. Implementation guides: [plans/](plans/).
|
||||
|
||||
| Phase | Scope | Status |
|
||||
|---|---|---|
|
||||
| 1 | Schema, auth, roles, batch CRUD, MinIO upload, status machine | Done |
|
||||
@@ -19,8 +21,13 @@
|
||||
| 11 | HL7 FHIR R4 read-only API and FHIR Explorer UI | Done |
|
||||
| 12 | Backend-driven batch-type field requirements (`fieldRequirements` metadata) | Done |
|
||||
| 13 | Optional OCR-assisted draft pre-fill (Azure or Tesseract; disabled by default) | Done |
|
||||
| 14 | UI redesign: design tokens, app shell (sidebar), login | Planned |
|
||||
| 15 | UI redesign: shared UX primitives (status, OCR badges, SoD, sticky actions) | Planned |
|
||||
| 16 | UI redesign: Entry / Verification / Clinical Approval workstation layouts | Planned |
|
||||
| 17 | UI redesign: Intake, Cover Sheets, Live Capture, History, Dashboard, FHIR Explorer | Planned |
|
||||
| 18 | UI redesign: surface unused APIs (batch events, work queues, Users admin) | 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 14–18 verify via Vitest + manual checklists in each plan.
|
||||
|
||||
See [README.md](../README.md) for API reference, quick start, and [digitization-workstation-guide.md](digitization-workstation-guide.md) for operator workflows.
|
||||
|
||||
@@ -568,7 +575,7 @@ SMART on FHIR authorization and FHIR write operations remain out of scope.
|
||||
|
||||
---
|
||||
|
||||
## Digitization Workstation UI — *implemented (Phases 7, 10, 11, 12, 13)*
|
||||
## Digitization Workstation UI — *implemented (Phases 7, 10, 11, 12, 13); redesign Planned (Phases 14–18)*
|
||||
|
||||
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 |
|
||||
| **Queue dashboard** | Administrator | Backlog metrics from work-queue overview |
|
||||
| **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.
|
||||
|
||||
### UI redesign roadmap (Phases 14–18)
|
||||
|
||||
Constrained to **existing API capabilities**. Design source: [designs/design-doc.md](designs/design-doc.md) (section 36 corrections override mockups). Plans: [plans/README.md](plans/README.md).
|
||||
|
||||
| Phase | Focus |
|
||||
|---|---|
|
||||
| 14 | Navy/blue design tokens, role-filtered sidebar shell, split login (no role picker); keep Public Sans |
|
||||
| 15 | Shared primitives: status badges, empty/loading/error, sticky action bar, OCR % badges, SoD banner, confirm dialogs |
|
||||
| 16 | Dense Entry / Verification / Clinical Approval workstation layouts |
|
||||
| 17 | Restyle Intake, Cover Sheets, Live Capture, Patient History, Queue Dashboard, FHIR Explorer |
|
||||
| 18 | Wire unused APIs: batch events panel, `work-queue/*` lists, administrator Users page |
|
||||
|
||||
Deferred mockup items (global search, notifications, Reports/Master Data, SSO, OCR region highlight, infra health widgets) are listed in [plans/README.md](plans/README.md).
|
||||
|
||||
See [digitization-workstation-guide.md](digitization-workstation-guide.md) for operator workflows and clinical scenarios.
|
||||
|
||||
---
|
||||
@@ -666,12 +688,17 @@ If VigilCareClinical is unreachable in split deployment, batch remains `approved
|
||||
| 11 | HL7 FHIR R4 read-only API and FHIR Explorer UI | Done |
|
||||
| 12 | Backend-driven batch-type field requirements | Done |
|
||||
| 13 | Optional OCR-assisted draft pre-fill | Done |
|
||||
| 14 | UI redesign: tokens, app shell, login | Planned |
|
||||
| 15 | UI redesign: shared UX primitives | Planned |
|
||||
| 16 | UI redesign: Entry / Verification / Approval workstation | Planned |
|
||||
| 17 | UI redesign: supporting screens + dashboard | Planned |
|
||||
| 18 | UI redesign: surface unused existing APIs | Done |
|
||||
|
||||
---
|
||||
|
||||
## 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 14–18, 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)
|
||||
|
||||
- **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
|
||||
- [digitization-workstation-guide.md](digitization-workstation-guide.md) — clinical scenarios (backfill, live capture, corrections)
|
||||
- [plans/](plans/) — phase-by-phase implementation guides (Phases 1–13)
|
||||
- [plans/](plans/) — phase implementation guides (Phases 14–18 UI redesign; Phase 1–13 plans not in repo)
|
||||
- [designs/design-doc.md](designs/design-doc.md) — UI/UX design specification for the workstation redesign
|
||||
- [vigilcare-records-gap-analysis.md](vigilcare-records-gap-analysis.md) — post-phase hardening tracker and remaining open items
|
||||
- [vigilcare-clinical-api-prd.md](vigilcare-clinical-api-prd.md) — downstream alerting and observation ingest
|
||||
- [Completed/VigilCareClinicalAPI/VigilCare-Partner-Brief.md](Completed/VigilCareClinicalAPI/VigilCare-Partner-Brief.md) — clinical positioning and scope boundaries
|
||||
|
||||
@@ -579,9 +579,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -599,9 +596,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -619,9 +613,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -639,9 +630,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -659,9 +647,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -679,9 +664,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1415,9 +1397,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
||||
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2509,9 +2491,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2533,9 +2512,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2557,9 +2533,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2581,9 +2554,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2798,9 +2768,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -3035,9 +3005,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -3054,7 +3024,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.17",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -3810,9 +3780,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
|
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, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
const submitBtn = wrapper.find('button')
|
||||
const buttons = wrapper.findAll('button')
|
||||
const submitButton = buttons.find((b) => b.text().includes('Submit for Verification'))
|
||||
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', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
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', () => {
|
||||
@@ -172,7 +198,6 @@ describe('EntryForm', () => {
|
||||
store.currentDraft = emptyDraft('VITALS', {
|
||||
patient: {
|
||||
id: 'dp1',
|
||||
batchId: 'b1',
|
||||
fullName: 'John Doe',
|
||||
dateOfBirth: '1990-05-15',
|
||||
sex: 'male',
|
||||
@@ -188,7 +213,7 @@ describe('EntryForm', () => {
|
||||
})
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -271,7 +296,7 @@ describe('EntryForm', () => {
|
||||
|
||||
const store = useBatchStore()
|
||||
store.submitForVerification = vi.fn().mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
new Promise<void>((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', () => {
|
||||
it('renders observation code options', () => {
|
||||
it('renders observation code options in editable mode', () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: { observation: makeObservation() },
|
||||
})
|
||||
const options = wrapper.findAll('select option')
|
||||
const options = wrapper.findAll<HTMLOptionElement>('select option')
|
||||
const values = options.map((o) => o.element.value)
|
||||
expect(values).toContain('HEART_RATE')
|
||||
expect(values).toContain('TEMP_C')
|
||||
@@ -34,23 +34,31 @@ describe('ObservationRow', () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
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')
|
||||
|
||||
const textInputs = wrapper.findAll('input[type="text"]')
|
||||
const textInputs = wrapper.findAll<HTMLInputElement>('input[type="text"]')
|
||||
const unitInput = textInputs[0]
|
||||
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, {
|
||||
props: { observation: makeObservation() },
|
||||
props: { observation: makeObservation({ unit: '' }) },
|
||||
})
|
||||
const select = wrapper.find('select')
|
||||
await select.setValue('TEMP_C')
|
||||
|
||||
expect(wrapper.emitted('update')).toBeTruthy()
|
||||
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 () => {
|
||||
@@ -86,15 +94,15 @@ describe('ObservationRow', () => {
|
||||
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, {
|
||||
props: { observation: makeObservation(), readonly: true },
|
||||
props: { observation: makeObservation({ value: 88, unit: 'bpm' }), readonly: true },
|
||||
})
|
||||
const select = wrapper.find('select')
|
||||
expect(select.element.disabled).toBe(true)
|
||||
|
||||
const numberInput = wrapper.find('input[type="number"]')
|
||||
expect(numberInput.element.disabled).toBe(true)
|
||||
expect(wrapper.find('select').exists()).toBe(false)
|
||||
expect(wrapper.find('input[type="number"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('Heart Rate')
|
||||
expect(wrapper.text()).toContain('88')
|
||||
expect(wrapper.text()).toContain('bpm')
|
||||
})
|
||||
|
||||
it('hides delete button in readonly mode', () => {
|
||||
@@ -114,7 +122,7 @@ describe('ObservationRow', () => {
|
||||
verified: false,
|
||||
},
|
||||
})
|
||||
const checkbox = wrapper.find('input[type="checkbox"]')
|
||||
const checkbox = wrapper.find<HTMLInputElement>('input[type="checkbox"]')
|
||||
expect(checkbox.exists()).toBe(true)
|
||||
expect(checkbox.element.checked).toBe(false)
|
||||
})
|
||||
@@ -128,7 +136,7 @@ describe('ObservationRow', () => {
|
||||
verified: true,
|
||||
},
|
||||
})
|
||||
const checkbox = wrapper.find('input[type="checkbox"]')
|
||||
const checkbox = wrapper.find<HTMLInputElement>('input[type="checkbox"]')
|
||||
expect(checkbox.element.checked).toBe(true)
|
||||
})
|
||||
|
||||
@@ -150,7 +158,7 @@ describe('ObservationRow', () => {
|
||||
|
||||
it('hides verification checkbox when showVerified is false', () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: { observation: makeObservation(), showVerified: false },
|
||||
props: { observation: makeObservation(), readonly: true, showVerified: false },
|
||||
})
|
||||
const checkbox = wrapper.find('input[type="checkbox"]')
|
||||
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 VerificationForm from '@/components/VerificationForm.vue'
|
||||
import { useBatchStore } from '@/stores/batches'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { BatchDetailResponse } from '@/types'
|
||||
import {
|
||||
emptyDraft,
|
||||
@@ -31,8 +32,44 @@ vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
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 {
|
||||
const batchType = overrides.batchType ?? 'VITALS'
|
||||
return {
|
||||
@@ -63,15 +100,12 @@ function makeBatch(overrides: Partial<BatchDetailResponse> = {}): BatchDetailRes
|
||||
|
||||
function mountWithDraft(batchOverrides: Partial<BatchDetailResponse> = {}) {
|
||||
const batch = makeBatch(batchOverrides)
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch, batchId: 'b1' },
|
||||
})
|
||||
const wrapper = mountForm({ batch, batchId: 'b1' })
|
||||
|
||||
const store = useBatchStore()
|
||||
store.currentDraft = emptyDraft(batch.batchType, {
|
||||
patient: {
|
||||
id: 'dp1',
|
||||
batchId: 'b1',
|
||||
fullName: 'Jane Doe',
|
||||
dateOfBirth: '1990-05-15',
|
||||
sex: 'female',
|
||||
@@ -124,9 +158,7 @@ beforeEach(() => {
|
||||
|
||||
describe('VerificationForm', () => {
|
||||
it('renders verification header', () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
const wrapper = mountForm()
|
||||
expect(wrapper.text()).toContain('Verification Review')
|
||||
expect(wrapper.text()).toContain('Pending Verification')
|
||||
})
|
||||
@@ -139,6 +171,10 @@ describe('VerificationForm', () => {
|
||||
expect(wrapper.text()).toContain('Jane Doe')
|
||||
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"]')
|
||||
expect(checkboxes.length).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -154,20 +190,13 @@ describe('VerificationForm', () => {
|
||||
})
|
||||
|
||||
it('shows rejection reason banner when present', () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: {
|
||||
batch: makeBatch({ rejectionReason: 'Temperature seems incorrect' }),
|
||||
batchId: 'b1',
|
||||
},
|
||||
})
|
||||
const wrapper = mountForm({ batch: makeBatch({ rejectionReason: 'Temperature seems incorrect' }), batchId: 'b1' })
|
||||
expect(wrapper.text()).toContain('Previous Rejection Reason')
|
||||
expect(wrapper.text()).toContain('Temperature seems incorrect')
|
||||
})
|
||||
|
||||
it('does not show rejection banner when no reason', () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch({ rejectionReason: null }), batchId: 'b1' },
|
||||
})
|
||||
const wrapper = mountForm({ batch: makeBatch({ rejectionReason: null }), batchId: 'b1' })
|
||||
expect(wrapper.text()).not.toContain('Previous Rejection Reason')
|
||||
})
|
||||
|
||||
@@ -193,15 +222,23 @@ describe('VerificationForm', () => {
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const { wrapper } = mountWithDraft()
|
||||
await wrapper.vm.$nextTick()
|
||||
await selectPass(wrapper)
|
||||
|
||||
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve'))
|
||||
expect(approveBtn!.element.disabled).toBe(true)
|
||||
const approveBtn = wrapper.find('[data-testid="verify-pass"]')
|
||||
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()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
@@ -209,13 +246,14 @@ describe('VerificationForm', () => {
|
||||
for (const cb of checkboxes) {
|
||||
await cb.setValue(true)
|
||||
}
|
||||
await wrapper.vm.$nextTick()
|
||||
await selectPass(wrapper)
|
||||
|
||||
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve'))
|
||||
expect(approveBtn!.element.disabled).toBe(false)
|
||||
const approveBtn = wrapper.find('[data-testid="verify-pass"]')
|
||||
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()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
@@ -225,10 +263,9 @@ describe('VerificationForm', () => {
|
||||
for (const cb of checkboxes) {
|
||||
await cb.setValue(true)
|
||||
}
|
||||
await wrapper.vm.$nextTick()
|
||||
await selectPass(wrapper)
|
||||
|
||||
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve'))
|
||||
await approveBtn!.trigger('click')
|
||||
await wrapper.find('[data-testid="verify-pass"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(store.verifyBatch).toHaveBeenCalledWith(
|
||||
@@ -242,88 +279,141 @@ describe('VerificationForm', () => {
|
||||
})
|
||||
|
||||
describe('reject flow', () => {
|
||||
it('shows reject dialog when reject button is clicked', async () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
async function selectReject(wrapper: ReturnType<typeof mountForm>) {
|
||||
const rejectRadio = wrapper.find('input[type="radio"][value="reject"]')
|
||||
await rejectRadio.setValue(true)
|
||||
await wrapper.vm.$nextTick()
|
||||
}
|
||||
|
||||
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
|
||||
await rejectBtn!.trigger('click')
|
||||
it('shows Return for Rework and opens confirm dialog', async () => {
|
||||
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()
|
||||
|
||||
expect(wrapper.text()).toContain('Reject Batch')
|
||||
expect(wrapper.text()).toContain('Confirm Rejection')
|
||||
expect(wrapper.text()).toContain('Return for Rework')
|
||||
expect(wrapper.text()).toContain('This returns the batch for rework')
|
||||
})
|
||||
|
||||
it('disables confirm button when reason is empty', async () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
const wrapper = mountForm()
|
||||
await selectReject(wrapper)
|
||||
|
||||
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
|
||||
await rejectBtn!.trigger('click')
|
||||
await wrapper.find('[data-testid="verify-return"]').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection')
|
||||
expect(confirmBtn!.element.disabled).toBe(true)
|
||||
const dialogConfirms = wrapper.findAll('[data-testid="confirm-dialog"] button')
|
||||
.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 () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
const wrapper = mountForm()
|
||||
await selectReject(wrapper)
|
||||
|
||||
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
|
||||
await rejectBtn!.trigger('click')
|
||||
await wrapper.find('[data-testid="verify-return"]').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Temperature value appears incorrect')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection')
|
||||
expect(confirmBtn!.element.disabled).toBe(false)
|
||||
const dialogConfirms = wrapper.findAll('[data-testid="confirm-dialog"] button')
|
||||
.filter((b) => b.text() === 'Return for Rework')
|
||||
expect((dialogConfirms[0].element as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('calls rejectBatch on confirm', async () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
const wrapper = mountForm()
|
||||
|
||||
const store = useBatchStore()
|
||||
store.rejectBatch = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
|
||||
await rejectBtn!.trigger('click')
|
||||
await selectReject(wrapper)
|
||||
await wrapper.find('[data-testid="verify-return"]').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Value incorrect')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection')
|
||||
await confirmBtn!.trigger('click')
|
||||
const dialogConfirms = wrapper.findAll('[data-testid="confirm-dialog"] button')
|
||||
.filter((b) => b.text() === 'Return for Rework')
|
||||
await dialogConfirms[0].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(store.rejectBatch).toHaveBeenCalledWith('b1', 'Value incorrect')
|
||||
})
|
||||
|
||||
it('closes reject dialog on cancel', async () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
const wrapper = mountForm()
|
||||
await selectReject(wrapper)
|
||||
|
||||
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
|
||||
await rejectBtn!.trigger('click')
|
||||
await wrapper.find('[data-testid="verify-return"]').trigger('click')
|
||||
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')
|
||||
await cancelBtn!.trigger('click')
|
||||
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 () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch({ batchType: 'ALLERGY_UPDATE' }), batchId: 'b1' },
|
||||
const wrapper = mountForm({
|
||||
batch: makeBatch({ batchType: 'ALLERGY_UPDATE' }),
|
||||
batchId: 'b1',
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
store.currentDraft = emptyDraft('ALLERGY_UPDATE', {
|
||||
patient: {
|
||||
id: 'dp1',
|
||||
batchId: 'b1',
|
||||
fullName: 'Jane',
|
||||
dateOfBirth: '1990-01-01',
|
||||
sex: 'female',
|
||||
@@ -403,10 +493,10 @@ describe('VerificationForm', () => {
|
||||
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.findAll('button').find((b) => b.text().includes('Approve'))
|
||||
await approveBtn!.trigger('click')
|
||||
await wrapper.find('[data-testid="verify-pass"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
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, 80–94% 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 { setActivePinia, createPinia } from 'pinia'
|
||||
import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router'
|
||||
import type { RouteLocationNormalized } from 'vue-router'
|
||||
import { useAuthStore, getDefaultRouteForRole } from '@/stores/auth'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
@@ -26,8 +26,7 @@ function setupGuard() {
|
||||
const nextCalls: (string | undefined)[] = []
|
||||
const auth = useAuthStore()
|
||||
|
||||
function runGuard(to: RouteLocationNormalized, from?: RouteLocationNormalized) {
|
||||
const _from = from ?? buildRoute('/')
|
||||
function runGuard(to: RouteLocationNormalized) {
|
||||
const next = vi.fn((dest?: string) => {
|
||||
nextCalls.push(dest)
|
||||
})
|
||||
@@ -148,6 +147,15 @@ describe('router navigation guard', () => {
|
||||
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', () => {
|
||||
const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK')
|
||||
const { next } = runGuard(buildRoute('/fhir-explorer', {
|
||||
@@ -157,6 +165,15 @@ describe('router navigation guard', () => {
|
||||
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', () => {
|
||||
const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK')
|
||||
const { next } = runGuard(buildRoute('/cover-sheets', {
|
||||
|
||||
@@ -36,6 +36,11 @@ describe('useBatchStore', () => {
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).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 () => {
|
||||
let resolvePromise: (v: unknown) => void
|
||||
let resolvePromise: (v: any) => void
|
||||
mockedGet.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
@@ -108,7 +113,12 @@ describe('useBatchStore', () => {
|
||||
const store = useBatchStore()
|
||||
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')
|
||||
})
|
||||
|
||||
@@ -410,4 +420,198 @@ describe('useBatchStore', () => {
|
||||
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 />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/TourHelpButton.vue', () => ({
|
||||
default: { template: '<button type="button" data-testid="tour-help" />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/PatientSearch.vue', () => ({
|
||||
default: {
|
||||
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'
|
||||
|
||||
const mockedGet = vi.mocked(get)
|
||||
@@ -75,18 +104,33 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('CoverSheetView', () => {
|
||||
it('renders generate form and cover sheet list', async () => {
|
||||
const wrapper = mount(CoverSheetView)
|
||||
it('renders generate form, preview, and cover sheet list', async () => {
|
||||
const wrapper = mount(CoverSheetView, {
|
||||
global: {
|
||||
stubs: {
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
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.find('.cover-sheet-preview').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="status-badge"]').text()).toBe('UNUSED')
|
||||
expect(wrapper.find('select').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('loads entry clerks and cover sheets on mount', async () => {
|
||||
mount(CoverSheetView)
|
||||
mount(CoverSheetView, {
|
||||
global: {
|
||||
stubs: {
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(mockedGet).toHaveBeenCalledWith('users', { role: 'DATA_ENTRY_CLERK' })
|
||||
@@ -104,7 +148,13 @@ describe('CoverSheetView', () => {
|
||||
error: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(CoverSheetView)
|
||||
const wrapper = mount(CoverSheetView, {
|
||||
global: {
|
||||
stubs: {
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const batchTypeSelect = wrapper.findAll('select')[0]
|
||||
@@ -134,7 +184,13 @@ describe('CoverSheetView', () => {
|
||||
error: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(CoverSheetView)
|
||||
const wrapper = mount(CoverSheetView, {
|
||||
global: {
|
||||
stubs: {
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.findAll('select')[0].setValue('LAB_RESULTS')
|
||||
@@ -142,5 +198,6 @@ describe('CoverSheetView', () => {
|
||||
await flushPromises()
|
||||
|
||||
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', () => ({
|
||||
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', () => ({
|
||||
@@ -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'
|
||||
|
||||
const mockedFhirGet = vi.mocked(fhirGet)
|
||||
@@ -76,6 +96,7 @@ describe('FhirExplorerView', () => {
|
||||
expect(wrapper.text()).toContain('Patient $everything')
|
||||
expect(wrapper.find('select').element).toBeTruthy()
|
||||
expect(wrapper.text()).toContain('MRN (identifier)')
|
||||
expect(wrapper.text()).toContain('CapabilityStatement')
|
||||
})
|
||||
|
||||
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('"id": "patient-1"')
|
||||
expect(wrapper.find('.fhir-json-panel').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('loads Patient $everything bundle grouped by resource type', async () => {
|
||||
|
||||
@@ -18,6 +18,10 @@ vi.mock('@/components/AppHeader.vue', () => ({
|
||||
default: { template: '<div />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/TourHelpButton.vue', () => ({
|
||||
default: { template: '<button type="button" data-testid="tour-help" />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/PatientSearch.vue', () => ({
|
||||
default: {
|
||||
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()
|
||||
|
||||
vi.mock('@/stores/batches', () => ({
|
||||
useBatchStore: () => ({
|
||||
batches: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
listBatches: vi.fn(),
|
||||
uploadBatch: uploadBatchMock,
|
||||
assignBatch: vi.fn(),
|
||||
@@ -89,7 +101,9 @@ describe('IntakeView cover sheet upload', () => {
|
||||
const wrapper = mount(IntakeView)
|
||||
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.attributes('autofocus')).toBeDefined()
|
||||
expect(wrapper.find('button.btn-secondary').text()).toBe('Lookup')
|
||||
@@ -147,7 +161,7 @@ describe('IntakeView cover sheet upload', () => {
|
||||
undefined,
|
||||
'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 () => {
|
||||
@@ -155,6 +169,7 @@ describe('IntakeView cover sheet upload', () => {
|
||||
|
||||
expect(wrapper.text()).toContain('New 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 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 })
|
||||
})
|
||||
})
|
||||
@@ -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 components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--vc-navy: #08254A;
|
||||
--vc-primary: #155EEF;
|
||||
--vc-primary-hover: #0B4FD6;
|
||||
--vc-soft-blue: #EEF4FF;
|
||||
--vc-bg: #F7F9FC;
|
||||
--vc-surface: #FFFFFF;
|
||||
--vc-text-strong: #101828;
|
||||
--vc-text: #344054;
|
||||
--vc-text-secondary: #667085;
|
||||
--vc-text-disabled: #98A2B3;
|
||||
--vc-border: #E4E7EC;
|
||||
--vc-border-strong: #D0D5DD;
|
||||
--vc-success: #079455;
|
||||
--vc-success-bg: #ECFDF3;
|
||||
--vc-warning: #DC6803;
|
||||
--vc-warning-bg: #FFFAEB;
|
||||
--vc-error: #D92D20;
|
||||
--vc-error-bg: #FEF3F2;
|
||||
--vc-critical: #B42318;
|
||||
--vc-critical-bg: #FEE4E2;
|
||||
--vc-radius-input: 8px;
|
||||
--vc-radius-card: 10px;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply font-sans text-ink bg-canvas;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white px-4 py-2 rounded-md
|
||||
@apply bg-primary-600 text-white px-4 py-2 rounded-input
|
||||
hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply bg-white text-primary-700 border border-primary-300 px-4 py-2 rounded-md
|
||||
@apply bg-surface text-primary-700 border border-primary-300 px-4 py-2 rounded-input
|
||||
hover:bg-primary-50 disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply bg-clinical-danger text-white px-4 py-2 rounded-md
|
||||
hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed
|
||||
@apply bg-clinical-danger text-white px-4 py-2 rounded-input
|
||||
hover:bg-clinical-critical disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors;
|
||||
}
|
||||
.form-input {
|
||||
@apply block w-full rounded-md border border-gray-300 px-4 py-2
|
||||
@apply block w-full rounded-input border border-line-strong px-4 py-2 text-ink
|
||||
focus:border-primary-500 focus:ring-2 focus:ring-primary-500
|
||||
disabled:bg-gray-100 disabled:text-gray-500;
|
||||
disabled:bg-canvas disabled:text-ink-disabled;
|
||||
}
|
||||
.page-container {
|
||||
@apply p-4 sm:p-6 lg:p-8 max-w-4xl mx-auto;
|
||||
}
|
||||
.app-header {
|
||||
@apply flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between
|
||||
px-4 py-4 sm:px-6 sm:py-4 bg-white border-b shadow-sm;
|
||||
@apply flex flex-row items-start justify-between gap-3
|
||||
px-4 py-3 sm:px-6 sm:items-center min-h-14 sm:min-h-16 bg-surface border-b border-line;
|
||||
}
|
||||
.app-header-title {
|
||||
@apply flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4;
|
||||
@apply flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-4 min-w-0;
|
||||
}
|
||||
.app-header-actions {
|
||||
@apply flex flex-wrap items-center gap-2 sm:gap-4;
|
||||
@apply flex flex-wrap items-center justify-end gap-2 sm:gap-4 shrink-0;
|
||||
}
|
||||
.card {
|
||||
@apply bg-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 {
|
||||
@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 {
|
||||
@apply text-sm text-gray-500 hover:text-gray-800 transition-colors;
|
||||
@apply text-sm text-ink-secondary hover:text-ink-strong transition-colors;
|
||||
}
|
||||
.nav-link.router-link-active {
|
||||
@apply text-gray-900 font-medium;
|
||||
@apply text-ink-strong font-medium;
|
||||
}
|
||||
.status-badge {
|
||||
@apply px-2 py-1 rounded-full text-xs font-medium;
|
||||
@apply px-2 py-1 rounded-control text-xs font-medium;
|
||||
}
|
||||
.ocr-banner {
|
||||
@apply bg-blue-50 border border-blue-200 rounded-md p-3 text-sm text-blue-800;
|
||||
@apply bg-primary-50 border border-primary-100 rounded-input p-3 text-sm text-primary-800;
|
||||
}
|
||||
.ocr-high {
|
||||
@apply border-l-[3px] border-l-green-500;
|
||||
@apply border-l-[3px] border-l-clinical-safe;
|
||||
}
|
||||
.ocr-medium {
|
||||
@apply border-l-[3px] border-l-yellow-500;
|
||||
@apply border-l-[3px] border-l-clinical-warning;
|
||||
}
|
||||
.ocr-low {
|
||||
@apply border-l-[3px] border-l-red-500;
|
||||
@apply border-l-[3px] border-l-clinical-danger;
|
||||
}
|
||||
.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 +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>
|
||||
<div class="app-header">
|
||||
<div class="app-header-title">
|
||||
<h1 class="text-lg font-semibold">{{ title }}</h1>
|
||||
<nav class="flex gap-3 ml-4">
|
||||
<router-link v-if="auth.canIntake" to="/intake" class="nav-link">Intake</router-link>
|
||||
<router-link v-if="auth.canIntake" to="/cover-sheets" class="nav-link">Cover Sheets</router-link>
|
||||
<router-link v-if="auth.canEntry" to="/entry" class="nav-link">Entry</router-link>
|
||||
<router-link v-if="auth.canVerify" to="/verification" class="nav-link">Verification</router-link>
|
||||
<router-link v-if="auth.canApprove" to="/approval" class="nav-link">Approval</router-link>
|
||||
<router-link v-if="auth.canLiveCapture" to="/live-capture" class="nav-link">Live Capture</router-link>
|
||||
<router-link to="/patients" class="nav-link">History</router-link>
|
||||
<router-link v-if="auth.canSupervise" to="/dashboard" class="nav-link">Dashboard</router-link>
|
||||
<router-link v-if="auth.canSupervise" to="/fhir-explorer" class="nav-link">FHIR Explorer</router-link>
|
||||
</nav>
|
||||
<slot name="subtitle" />
|
||||
<div class="app-header" :data-tour="tourAnchor || undefined">
|
||||
<div class="app-header-title min-w-0 flex-1">
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-lg sm:text-xl font-semibold text-ink-strong leading-tight">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p
|
||||
v-if="description"
|
||||
class="mt-0.5 text-sm text-ink-secondary leading-snug max-w-2xl"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
<slot name="subtitle" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-header-actions">
|
||||
<div class="app-header-actions self-start">
|
||||
<slot name="actions" />
|
||||
<span class="text-sm text-gray-600">{{ auth.userFullName }}</span>
|
||||
<button
|
||||
type="button"
|
||||
@click="auth.logout()"
|
||||
class="text-sm text-gray-500 hover:text-gray-800"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
defineProps<{ title: string }>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
/** Stable walkthrough anchor, e.g. intake-header */
|
||||
tourAnchor?: string
|
||||
}>(),
|
||||
{ description: undefined, tourAnchor: undefined },
|
||||
)
|
||||
</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>
|
||||
<div class="overflow-x-auto">
|
||||
<div v-if="loading" class="text-gray-500 text-center py-4">Loading...</div>
|
||||
<div v-else-if="batches.length === 0" class="text-gray-500 text-center py-4">
|
||||
No batches found.
|
||||
</div>
|
||||
<table v-else class="w-full min-w-[640px] text-sm">
|
||||
<InlineError
|
||||
v-if="error"
|
||||
title="Could not load batches"
|
||||
:message="error"
|
||||
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>
|
||||
<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">Type</th>
|
||||
<th class="py-2 px-4">Track</th>
|
||||
@@ -19,7 +33,7 @@
|
||||
<tr
|
||||
v-for="batch in batches"
|
||||
: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)"
|
||||
>
|
||||
<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">
|
||||
<span
|
||||
:class="batch.track === 'BACKFILL'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-green-100 text-green-800'"
|
||||
? 'bg-primary-50 text-primary-800 border border-primary-100'
|
||||
: 'bg-clinical-safe-bg text-clinical-safe border border-[#ABEFC6]'"
|
||||
class="status-badge"
|
||||
>
|
||||
{{ batch.track === 'BACKFILL' ? 'Backfill' : 'Live' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 px-4">
|
||||
<span
|
||||
:class="statusColor(batch.status)"
|
||||
class="status-badge"
|
||||
>
|
||||
{{ formatStatus(batch.status) }}
|
||||
</span>
|
||||
<StatusBadge :status="batch.status" />
|
||||
</td>
|
||||
<td class="py-2 px-4 text-gray-500">
|
||||
<td class="py-2 px-4 text-ink-secondary">
|
||||
{{ new Date(batch.createdAt).toLocaleString() }}
|
||||
</td>
|
||||
<td v-if="showAssign" class="py-2 px-4">
|
||||
<button
|
||||
v-if="batch.status === 'UPLOADED'"
|
||||
type="button"
|
||||
@click.stop="$emit('assign', batch.id)"
|
||||
class="text-primary-600 hover:text-primary-800 text-xs font-medium"
|
||||
>
|
||||
@@ -62,37 +72,33 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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<{
|
||||
batches: BatchDetailResponse[]
|
||||
loading: boolean
|
||||
showAssign?: boolean
|
||||
}>()
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
batches: BatchDetailResponse[]
|
||||
loading: boolean
|
||||
showAssign?: boolean
|
||||
error?: string | null
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
}>(),
|
||||
{
|
||||
emptyTitle: 'No batches found.',
|
||||
emptyDescription: undefined,
|
||||
}
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
(e: 'select', batchId: string): void
|
||||
(e: 'assign', batchId: string): void
|
||||
(e: 'retry'): void
|
||||
}>()
|
||||
|
||||
function formatBatchType(type: string): string {
|
||||
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
}
|
||||
|
||||
function formatStatus(status: string): string {
|
||||
return status.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
UPLOADED: 'bg-gray-100 text-gray-800',
|
||||
IN_ENTRY: 'bg-yellow-100 text-yellow-800',
|
||||
PENDING_VERIFICATION: 'bg-orange-100 text-orange-800',
|
||||
REJECTED: 'bg-red-100 text-red-800',
|
||||
VERIFIED: 'bg-blue-100 text-blue-800',
|
||||
AWAITING_CLINICAL_APPROVAL: 'bg-purple-100 text-purple-800',
|
||||
APPROVED: 'bg-green-100 text-green-800',
|
||||
PROMOTED: 'bg-emerald-100 text-emerald-800',
|
||||
}
|
||||
return colors[status] ?? 'bg-gray-100 text-gray-800'
|
||||
return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
</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>
|
||||
@@ -1,245 +1,315 @@
|
||||
<template>
|
||||
<div class="h-full overflow-y-auto p-4 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">Data Entry</h2>
|
||||
<span
|
||||
:class="statusColor"
|
||||
class="status-badge"
|
||||
>
|
||||
{{ batch?.status?.replace(/_/g, ' ') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ocrConfidence" class="ocr-banner">
|
||||
Pre-filled by OCR ({{ ocrConfidence.provider }}).
|
||||
Review all values against the scan before submitting.
|
||||
</div>
|
||||
|
||||
<!-- Patient section -->
|
||||
<fieldset class="border border-gray-200 rounded-md p-4">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">Patient Demographics</legend>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="h-full min-h-0 flex flex-col" data-testid="entry-form" data-tour="entry-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>
|
||||
<label class="block text-xs text-gray-500">Full Name</label>
|
||||
<input
|
||||
v-model="patient.fullName"
|
||||
@blur="savePatient"
|
||||
type="text"
|
||||
:class="['form-input', 'text-sm', fieldConfidenceClass('patient.fullName')]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500">Date of Birth</label>
|
||||
<input
|
||||
v-model="patient.dateOfBirth"
|
||||
@blur="savePatient"
|
||||
type="date"
|
||||
:class="['form-input', 'text-sm', fieldConfidenceClass('patient.dateOfBirth')]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500">Sex</label>
|
||||
<select
|
||||
v-model="patient.sex"
|
||||
@change="savePatient"
|
||||
:class="['form-input', 'text-sm', fieldConfidenceClass('patient.sex')]"
|
||||
<p class="evidence-level evidence-level--2 mb-1">
|
||||
<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"
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500">Blood Type</label>
|
||||
<select v-model="patient.bloodType" @change="savePatient" class="form-input text-sm">
|
||||
<option value="">Unknown</option>
|
||||
<option v-for="bt in bloodTypes" :key="bt" :value="bt">{{ bt }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs text-gray-500">Emergency Contact</label>
|
||||
<input
|
||||
v-model="patient.emergencyContact"
|
||||
@blur="savePatient"
|
||||
type="text"
|
||||
class="form-input text-sm"
|
||||
/>
|
||||
{{ saveStatusLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge :status="batch?.status" />
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Allergies section (ALLERGY_UPDATE or MIXED) -->
|
||||
<fieldset v-if="showAllergies" class="border border-gray-200 rounded-md p-4">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">Allergies</legend>
|
||||
<label class="flex items-center gap-2 mb-3 cursor-pointer">
|
||||
<input
|
||||
v-model="patient.noKnownAllergies"
|
||||
@change="onNoKnownAllergiesChange"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 text-clinical-safe rounded"
|
||||
/>
|
||||
<span class="text-sm">No known allergies (NKA)</span>
|
||||
</label>
|
||||
<div v-if="!patient.noKnownAllergies" class="space-y-2">
|
||||
<div
|
||||
v-for="(allergy, idx) in allergies"
|
||||
:key="idx"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<div v-if="ocrConfidence" class="ocr-banner">
|
||||
Pre-filled by OCR ({{ ocrConfidence.provider }}) — review against the scan.
|
||||
OCR is assistive, not authoritative.
|
||||
</div>
|
||||
|
||||
<!-- Patient section -->
|
||||
<fieldset class="workstation-form-section">
|
||||
<legend class="workstation-form-legend">Patient Demographics</legend>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="workstation-field-label">
|
||||
Full Name
|
||||
<OcrConfidenceBadge :label="fieldConfidenceLabel('patient.fullName')" :level="fieldConfidenceLevel('patient.fullName')" />
|
||||
</label>
|
||||
<input
|
||||
v-model="patient.fullName"
|
||||
type="text"
|
||||
:class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('patient.fullName')]"
|
||||
@blur="savePatient"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="workstation-field-label">
|
||||
Date of Birth
|
||||
<OcrConfidenceBadge :label="fieldConfidenceLabel('patient.dateOfBirth')" :level="fieldConfidenceLevel('patient.dateOfBirth')" />
|
||||
</label>
|
||||
<input
|
||||
v-model="patient.dateOfBirth"
|
||||
type="date"
|
||||
:class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('patient.dateOfBirth')]"
|
||||
@blur="savePatient"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="workstation-field-label">
|
||||
Sex
|
||||
<OcrConfidenceBadge :label="fieldConfidenceLabel('patient.sex')" :level="fieldConfidenceLevel('patient.sex')" />
|
||||
</label>
|
||||
<select
|
||||
v-model="patient.sex"
|
||||
:class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('patient.sex')]"
|
||||
@change="savePatient"
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="workstation-field-label">Blood Type</label>
|
||||
<select v-model="patient.bloodType" class="form-input text-sm py-1.5" @change="savePatient">
|
||||
<option value="">Unknown</option>
|
||||
<option v-for="bt in bloodTypes" :key="bt" :value="bt">{{ bt }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="workstation-field-label">Emergency Contact</label>
|
||||
<input
|
||||
v-model="patient.emergencyContact"
|
||||
type="text"
|
||||
class="form-input text-sm py-1.5"
|
||||
@blur="savePatient"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Allergies section (ALLERGY_UPDATE or MIXED) -->
|
||||
<fieldset v-if="showAllergies" class="workstation-form-section">
|
||||
<legend class="workstation-form-legend">Allergies</legend>
|
||||
<label class="flex items-center gap-2 mb-2 cursor-pointer">
|
||||
<input
|
||||
v-model="allergies[idx]"
|
||||
@blur="saveAllergies"
|
||||
type="text"
|
||||
class="form-input text-sm flex-1"
|
||||
placeholder="Allergy (e.g. Penicillin)"
|
||||
v-model="patient.noKnownAllergies"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 text-clinical-safe rounded"
|
||||
@change="onNoKnownAllergiesChange"
|
||||
/>
|
||||
<button
|
||||
@click="removeAllergy(idx)"
|
||||
class="text-clinical-danger hover:text-red-800 text-sm"
|
||||
<span class="text-sm">No known allergies (NKA)</span>
|
||||
</label>
|
||||
<div v-if="!patient.noKnownAllergies" class="space-y-2">
|
||||
<div
|
||||
v-for="(_allergy, idx) in allergies"
|
||||
:key="idx"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
Remove
|
||||
<input
|
||||
v-model="allergies[idx]"
|
||||
type="text"
|
||||
class="form-input text-sm py-1.5 flex-1"
|
||||
placeholder="Allergy (e.g. Penicillin)"
|
||||
@blur="saveAllergies"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="text-clinical-danger hover:text-clinical-critical text-sm"
|
||||
@click="removeAllergy(idx)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="text-sm text-primary-600 hover:text-primary-800" @click="addAllergy">
|
||||
+ Add Allergy
|
||||
</button>
|
||||
</div>
|
||||
<button @click="addAllergy" class="text-sm text-primary-600 hover:text-primary-800">
|
||||
+ Add Allergy
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</fieldset>
|
||||
|
||||
<!-- Medications section (MEDICATION_LIST or MIXED) -->
|
||||
<fieldset v-if="showMedications" class="border border-gray-200 rounded-md p-4">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">Medications</legend>
|
||||
<label class="flex items-center gap-2 mb-3 cursor-pointer">
|
||||
<input
|
||||
v-model="patient.noActiveMedications"
|
||||
@change="onNoActiveMedicationsChange"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 text-clinical-safe rounded"
|
||||
/>
|
||||
<span class="text-sm">No active medications</span>
|
||||
</label>
|
||||
<div v-if="!patient.noActiveMedications" class="space-y-2">
|
||||
<div
|
||||
v-for="(med, idx) in medications"
|
||||
:key="idx"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<!-- Medications section (MEDICATION_LIST or MIXED) -->
|
||||
<fieldset v-if="showMedications" class="workstation-form-section">
|
||||
<legend class="workstation-form-legend">Medications</legend>
|
||||
<label class="flex items-center gap-2 mb-2 cursor-pointer">
|
||||
<input
|
||||
v-model="medications[idx]"
|
||||
@blur="saveMedications"
|
||||
type="text"
|
||||
class="form-input text-sm flex-1"
|
||||
placeholder="Medication (e.g. Metoprolol 50mg BID)"
|
||||
v-model="patient.noActiveMedications"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 text-clinical-safe rounded"
|
||||
@change="onNoActiveMedicationsChange"
|
||||
/>
|
||||
<button
|
||||
@click="removeMedication(idx)"
|
||||
class="text-clinical-danger hover:text-red-800 text-sm"
|
||||
<span class="text-sm">No active medications</span>
|
||||
</label>
|
||||
<div v-if="!patient.noActiveMedications" class="space-y-2">
|
||||
<div
|
||||
v-for="(_med, idx) in medications"
|
||||
:key="idx"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
Remove
|
||||
<input
|
||||
v-model="medications[idx]"
|
||||
type="text"
|
||||
class="form-input text-sm py-1.5 flex-1"
|
||||
placeholder="Medication (e.g. Metoprolol 50mg BID)"
|
||||
@blur="saveMedications"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="text-clinical-danger hover:text-clinical-critical text-sm"
|
||||
@click="removeMedication(idx)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="text-sm text-primary-600 hover:text-primary-800" @click="addMedication">
|
||||
+ Add Medication
|
||||
</button>
|
||||
</div>
|
||||
<button @click="addMedication" class="text-sm text-primary-600 hover:text-primary-800">
|
||||
+ Add Medication
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</fieldset>
|
||||
|
||||
<!-- Encounter section -->
|
||||
<fieldset class="border border-gray-200 rounded-md p-4">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">Encounter Context</legend>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500">Admission Date</label>
|
||||
<input
|
||||
v-model="encounter.admissionDate"
|
||||
@blur="saveEncounter"
|
||||
type="datetime-local"
|
||||
:class="['form-input', 'text-sm', fieldConfidenceClass('encounter.admissionDate')]"
|
||||
/>
|
||||
<!-- Encounter section -->
|
||||
<fieldset class="workstation-form-section">
|
||||
<legend class="workstation-form-legend">Encounter Context</legend>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="workstation-field-label">
|
||||
Admission Date
|
||||
<OcrConfidenceBadge :label="fieldConfidenceLabel('encounter.admissionDate')" :level="fieldConfidenceLevel('encounter.admissionDate')" />
|
||||
</label>
|
||||
<input
|
||||
v-model="encounter.admissionDate"
|
||||
type="datetime-local"
|
||||
:class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('encounter.admissionDate')]"
|
||||
@blur="saveEncounter"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="workstation-field-label">
|
||||
Department
|
||||
<OcrConfidenceBadge :label="fieldConfidenceLabel('encounter.department')" :level="fieldConfidenceLevel('encounter.department')" />
|
||||
</label>
|
||||
<select
|
||||
v-model="encounter.department"
|
||||
:class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('encounter.department')]"
|
||||
@change="saveEncounter"
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option v-for="dept in departments" :key="dept" :value="dept">{{ dept }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="workstation-field-label">
|
||||
Room / Bed
|
||||
<OcrConfidenceBadge :label="fieldConfidenceLabel('encounter.roomBed')" :level="fieldConfidenceLevel('encounter.roomBed')" />
|
||||
</label>
|
||||
<input
|
||||
v-model="encounter.roomBed"
|
||||
type="text"
|
||||
:class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('encounter.roomBed')]"
|
||||
@blur="saveEncounter"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="workstation-field-label">
|
||||
Admission Reason
|
||||
<OcrConfidenceBadge :label="fieldConfidenceLabel('encounter.admissionReason')" :level="fieldConfidenceLevel('encounter.admissionReason')" />
|
||||
</label>
|
||||
<input
|
||||
v-model="encounter.admissionReason"
|
||||
type="text"
|
||||
:class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('encounter.admissionReason')]"
|
||||
@blur="saveEncounter"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showEncounterSummary">
|
||||
<label class="workstation-field-label">Encounter Status</label>
|
||||
<select v-model="encounter.status" class="form-input text-sm py-1.5" @change="saveEncounter">
|
||||
<option value="">—</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="discharged">Discharged</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="showEncounterSummary" class="md:col-span-2">
|
||||
<label class="workstation-field-label">Discharge Diagnosis</label>
|
||||
<textarea
|
||||
v-model="encounter.dischargeDiagnosis"
|
||||
class="form-input text-sm py-1.5"
|
||||
rows="2"
|
||||
placeholder="Discharge diagnosis..."
|
||||
@blur="saveEncounter"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500">Department</label>
|
||||
<select
|
||||
v-model="encounter.department"
|
||||
@change="saveEncounter"
|
||||
:class="['form-input', 'text-sm', fieldConfidenceClass('encounter.department')]"
|
||||
</fieldset>
|
||||
|
||||
<!-- Observations section -->
|
||||
<fieldset class="workstation-form-section">
|
||||
<legend class="workstation-form-legend">Observations</legend>
|
||||
<div class="space-y-3">
|
||||
<ObservationRow
|
||||
v-for="obs in observations"
|
||||
:key="obs.id"
|
||||
:observation="obs"
|
||||
: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)"
|
||||
@delete="handleObsDelete"
|
||||
/>
|
||||
<button type="button" class="btn-secondary text-sm py-1.5 w-full sm:w-auto" @click="addObservation">
|
||||
+ Add Observation
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div v-if="errorMessage" class="text-clinical-danger text-sm" role="alert">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<AuditTrailPanel :batch-id="batchId" />
|
||||
</div>
|
||||
|
||||
<WorkstationActionBar>
|
||||
<template #center>
|
||||
<button
|
||||
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"
|
||||
:disabled="submitting || saveState === 'saving'"
|
||||
@click="submitForVerification"
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option v-for="dept in departments" :key="dept" :value="dept">{{ dept }}</option>
|
||||
</select>
|
||||
{{ submitting ? 'Submitting...' : 'Submit for Verification' }}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500">Room / Bed</label>
|
||||
<input
|
||||
v-model="encounter.roomBed"
|
||||
@blur="saveEncounter"
|
||||
type="text"
|
||||
:class="['form-input', 'text-sm', fieldConfidenceClass('encounter.roomBed')]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500">Admission Reason</label>
|
||||
<input
|
||||
v-model="encounter.admissionReason"
|
||||
@blur="saveEncounter"
|
||||
type="text"
|
||||
:class="['form-input', 'text-sm', fieldConfidenceClass('encounter.admissionReason')]"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showEncounterSummary">
|
||||
<label class="block text-xs text-gray-500">Encounter Status</label>
|
||||
<select v-model="encounter.status" @change="saveEncounter" class="form-input text-sm">
|
||||
<option value="">—</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="discharged">Discharged</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="showEncounterSummary" class="col-span-2">
|
||||
<label class="block text-xs text-gray-500">Discharge Diagnosis</label>
|
||||
<textarea
|
||||
v-model="encounter.dischargeDiagnosis"
|
||||
@blur="saveEncounter"
|
||||
class="form-input text-sm"
|
||||
rows="2"
|
||||
placeholder="Discharge diagnosis..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Observations section -->
|
||||
<fieldset class="border border-gray-200 rounded-md p-4">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">Observations</legend>
|
||||
<div class="space-y-2">
|
||||
<ObservationRow
|
||||
v-for="obs in observations"
|
||||
:key="obs.id"
|
||||
:observation="obs"
|
||||
:value-input-class="observationValueConfidenceClass(obs.observationCode)"
|
||||
@update="(field, value) => handleObsUpdate(obs.id, field, value)"
|
||||
@delete="handleObsDelete"
|
||||
/>
|
||||
<button @click="addObservation" class="btn-primary text-sm">
|
||||
+ Add Observation
|
||||
</template>
|
||||
<template v-if="nextBatchId" #right>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
:disabled="submitting"
|
||||
data-testid="entry-next-batch"
|
||||
@click="emit('open-next', nextBatchId)"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 pt-4 border-t">
|
||||
<button
|
||||
@click="submitForVerification"
|
||||
class="btn-primary"
|
||||
:disabled="submitting"
|
||||
>
|
||||
{{ submitting ? 'Submitting...' : 'Submit for Verification' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="text-clinical-danger text-sm">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</template>
|
||||
</WorkstationActionBar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -249,17 +319,38 @@ import { useBatchStore } from '../stores/batches'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { useOcrFieldConfidence } from '../composables/useOcrFieldConfidence'
|
||||
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'
|
||||
|
||||
const props = defineProps<{
|
||||
batch: BatchDetailResponse | null
|
||||
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 toast = useToast()
|
||||
const submitting = ref(false)
|
||||
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 departments = [
|
||||
@@ -287,7 +378,11 @@ const departments = [
|
||||
|
||||
const fieldReqs = computed(() => batchStore.currentDraft?.fieldRequirements)
|
||||
const ocrConfidence = computed(() => batchStore.currentDraft?.ocrConfidence ?? null)
|
||||
const { fieldConfidenceClass } = useOcrFieldConfidence(ocrConfidence)
|
||||
const {
|
||||
fieldConfidenceClass,
|
||||
fieldConfidenceLabel,
|
||||
fieldConfidenceLevel,
|
||||
} = useOcrFieldConfidence(ocrConfidence)
|
||||
|
||||
function observationValueConfidenceClass(observationCode: string): string {
|
||||
if (!observationCode) return ''
|
||||
@@ -322,19 +417,6 @@ const encounter = reactive({
|
||||
|
||||
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(
|
||||
() => batchStore.currentDraft,
|
||||
(draft) => {
|
||||
@@ -376,18 +458,29 @@ function removeAllergy(idx: number) {
|
||||
saveAllergies()
|
||||
}
|
||||
|
||||
async function saveAllergies() {
|
||||
async function withSaveFeedback(fn: () => Promise<void>) {
|
||||
saveState.value = 'saving'
|
||||
errorMessage.value = ''
|
||||
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, {
|
||||
...patient,
|
||||
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() {
|
||||
@@ -407,17 +500,12 @@ function removeMedication(idx: number) {
|
||||
}
|
||||
|
||||
async function saveMedications() {
|
||||
try {
|
||||
await withSaveFeedback(async () => {
|
||||
await batchStore.saveDraftPatient(props.batchId, {
|
||||
...patient,
|
||||
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() {
|
||||
@@ -428,28 +516,33 @@ function onNoActiveMedicationsChange() {
|
||||
}
|
||||
|
||||
async function savePatient() {
|
||||
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()),
|
||||
})
|
||||
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() {
|
||||
try {
|
||||
await withSaveFeedback(async () => {
|
||||
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
|
||||
toast.error(msg)
|
||||
})
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
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>
|
||||
<div class="flex flex-col lg:flex-row lg:items-start gap-4 p-4 bg-gray-50 rounded-md">
|
||||
<div class="flex-1 grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500">Code</label>
|
||||
<!-- Read-only: activity-list row — bold value, timestamp meta, optional OK -->
|
||||
<div
|
||||
v-if="readonly"
|
||||
class="observation-card observation-card--readonly"
|
||||
:class="{ 'observation-card--verified': showVerified && verified }"
|
||||
data-testid="observation-row"
|
||||
>
|
||||
<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">
|
||||
<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
|
||||
type="checkbox"
|
||||
class="w-3.5 h-3.5 text-clinical-safe rounded"
|
||||
:checked="verified"
|
||||
@change="$emit('verify', observation.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
OK
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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"
|
||||
@change="update('observationCode', ($event.target as HTMLSelectElement).value)"
|
||||
class="form-input text-sm"
|
||||
:disabled="readonly"
|
||||
class="form-input text-sm font-semibold py-1.5"
|
||||
@change="onCodeChange(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
<option value="HEART_RATE">Heart Rate</option>
|
||||
<option value="TEMP_C">Temperature (C)</option>
|
||||
<option value="BP_SYSTOLIC">BP Systolic</option>
|
||||
<option value="BP_DIASTOLIC">BP Diastolic</option>
|
||||
<option value="RESP_RATE">Respiratory Rate</option>
|
||||
<option value="SPO2">SpO2</option>
|
||||
<option value="POTASSIUM_MEQ_L">Potassium</option>
|
||||
<option value="GLUCOSE_MG_DL">Glucose</option>
|
||||
<option value="WBC_K_UL">WBC</option>
|
||||
<option value="LACTATE_MMOL_L">Lactate</option>
|
||||
<option value="">Select code…</option>
|
||||
<option
|
||||
v-for="opt in CODE_OPTIONS"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500">Value</label>
|
||||
<button
|
||||
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)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</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"
|
||||
@change="update('value', parseFloat(($event.target as HTMLInputElement).value))"
|
||||
type="number"
|
||||
step="0.01"
|
||||
:class="['form-input', 'text-sm', valueInputClass]"
|
||||
:disabled="readonly"
|
||||
: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>
|
||||
<label class="block text-xs text-gray-500">Unit</label>
|
||||
<div class="w-[4.75rem] shrink-0">
|
||||
<label class="workstation-field-label">Unit</label>
|
||||
<input
|
||||
:value="observation.unit"
|
||||
@change="update('unit', ($event.target as HTMLInputElement).value)"
|
||||
type="text"
|
||||
class="form-input text-sm"
|
||||
:disabled="readonly"
|
||||
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="block text-xs text-gray-500">Recorded At</label>
|
||||
<label class="workstation-field-label">Recorded at</label>
|
||||
<input
|
||||
:value="observation.recordedAt?.substring(0, 16)"
|
||||
@change="update('recordedAt', ($event.target as HTMLInputElement).value)"
|
||||
type="datetime-local"
|
||||
class="form-input text-sm"
|
||||
:disabled="readonly"
|
||||
class="form-input text-sm py-1.5"
|
||||
@change="update('recordedAt', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500">Note</label>
|
||||
<label class="workstation-field-label">Note</label>
|
||||
<input
|
||||
:value="observation.note"
|
||||
@change="update('note', ($event.target as HTMLInputElement).value)"
|
||||
type="text"
|
||||
class="form-input text-sm"
|
||||
class="form-input text-sm py-1.5"
|
||||
placeholder="Optional"
|
||||
:disabled="readonly"
|
||||
@change="update('note', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Verification checkbox (only in verification mode) -->
|
||||
<div v-if="showVerified" class="flex items-center lg:mt-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="verified"
|
||||
@change="$emit('verify', observation.id, ($event.target as HTMLInputElement).checked)"
|
||||
class="w-4 h-4 text-clinical-safe rounded"
|
||||
/>
|
||||
<span class="ml-2 text-xs text-gray-500">OK</span>
|
||||
</div>
|
||||
|
||||
<!-- Delete button (entry mode only) -->
|
||||
<button
|
||||
v-if="!readonly && !showVerified"
|
||||
@click="$emit('delete', observation.id)"
|
||||
class="lg:mt-8 text-clinical-danger hover:text-red-800 text-sm"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DraftObservation } from '../types'
|
||||
import { computed } from 'vue'
|
||||
import type { OcrConfidenceLevel } from '../composables/useOcrFieldConfidence'
|
||||
import OcrConfidenceBadge from './OcrConfidenceBadge.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
observation: DraftObservation
|
||||
readonly?: boolean
|
||||
showVerified?: boolean
|
||||
verified?: boolean
|
||||
valueInputClass?: string
|
||||
}>()
|
||||
/** Minimal observation shape for editable/readonly cards (DraftObservation satisfies this). */
|
||||
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
|
||||
showVerified?: boolean
|
||||
verified?: boolean
|
||||
canDelete?: boolean
|
||||
valueInputClass?: string
|
||||
ocrLabel?: string | null
|
||||
ocrLevel?: OcrConfidenceLevel | null
|
||||
}>(),
|
||||
{
|
||||
canDelete: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update', field: string, value: unknown): void
|
||||
@@ -105,7 +206,38 @@ const emit = defineEmits<{
|
||||
(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) {
|
||||
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>
|
||||
@@ -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<{
|
||||
/** 0–1 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>
|
||||
<div class="h-full flex flex-col bg-gray-900 rounded-lg overflow-hidden">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex flex-wrap items-center gap-2 p-4 bg-gray-800 text-white text-sm">
|
||||
<button @click="zoomIn" class="px-2 py-1 hover:bg-gray-700 rounded" title="Zoom in">
|
||||
<div
|
||||
class="h-full flex flex-col bg-canvas rounded-card border border-line overflow-hidden"
|
||||
data-testid="scan-viewer"
|
||||
>
|
||||
<!-- 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 @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 @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
|
||||
</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
|
||||
</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>
|
||||
|
||||
<!-- Document area -->
|
||||
<!-- Document surface -->
|
||||
<div
|
||||
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"
|
||||
@mousemove="pan"
|
||||
@mouseup="stopPan"
|
||||
@mouseleave="stopPan"
|
||||
@wheel.prevent="onWheel"
|
||||
>
|
||||
<div
|
||||
:style="{
|
||||
transform: `translate(${panX}px, ${panY}px) scale(${scale}) rotate(${rotation}deg)`,
|
||||
transformOrigin: 'top left',
|
||||
transition: isPanning ? 'none' : 'transform 0.2s',
|
||||
}"
|
||||
>
|
||||
<!-- PDF/image rendered from same-origin blob URL (avoids cross-origin MinIO iframe issues) -->
|
||||
<iframe
|
||||
v-if="isPdf"
|
||||
:src="url"
|
||||
class="w-[800px] h-[1100px] bg-white"
|
||||
frameborder="0"
|
||||
title="Scanned document"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="url"
|
||||
class="max-w-none"
|
||||
draggable="false"
|
||||
@load="onImageLoad"
|
||||
<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
|
||||
:style="{
|
||||
transform: `translate(${panX}px, ${panY}px) scale(${scale}) rotate(${rotation}deg)`,
|
||||
transformOrigin: 'top left',
|
||||
transition: isPanning ? 'none' : 'transform 0.15s ease-out',
|
||||
}"
|
||||
>
|
||||
<!-- PDF/image from same-origin blob URL (avoids cross-origin MinIO iframe issues) -->
|
||||
<iframe
|
||||
v-if="isPdf"
|
||||
:src="url"
|
||||
class="scan-page w-[800px] h-[1100px] bg-white"
|
||||
frameborder="0"
|
||||
title="Scanned document"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="url"
|
||||
class="scan-page max-w-none bg-white"
|
||||
draggable="false"
|
||||
alt="Scanned document"
|
||||
@load="onImageLoad"
|
||||
/>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<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<{
|
||||
url: string
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
url?: string | null
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
}>(),
|
||||
{
|
||||
url: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
}
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
(e: 'retry'): void
|
||||
}>()
|
||||
|
||||
const isPdf = computed(() => {
|
||||
if (!props.url) return false
|
||||
const lower = props.url.toLowerCase()
|
||||
return lower.includes('.pdf') || lower.includes('application/pdf')
|
||||
})
|
||||
@@ -73,23 +166,44 @@ const panY = ref(0)
|
||||
const isPanning = ref(false)
|
||||
const lastX = 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 zoomOut() { scale.value = Math.max(scale.value - 0.25, 0.25) }
|
||||
function zoomIn() {
|
||||
scale.value = Math.min(scale.value + 0.25, 5)
|
||||
}
|
||||
function zoomOut() {
|
||||
scale.value = Math.max(scale.value - 0.25, 0.25)
|
||||
}
|
||||
function resetZoom() {
|
||||
scale.value = 1
|
||||
panX.value = 0
|
||||
panY.value = 0
|
||||
rotation.value = 0
|
||||
}
|
||||
function rotateCw() { rotation.value = (rotation.value + 90) % 360 }
|
||||
function 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) {
|
||||
if (!props.url || props.loading || props.error) return
|
||||
if (e.deltaY < 0) zoomIn()
|
||||
else zoomOut()
|
||||
}
|
||||
|
||||
function startPan(e: MouseEvent) {
|
||||
if (!props.url || props.loading || props.error) return
|
||||
if ((e.target as HTMLElement)?.closest('button')) return
|
||||
isPanning.value = true
|
||||
lastX.value = e.clientX
|
||||
lastY.value = e.clientY
|
||||
@@ -103,12 +217,40 @@ function pan(e: MouseEvent) {
|
||||
lastY.value = e.clientY
|
||||
}
|
||||
|
||||
function stopPan() { isPanning.value = false }
|
||||
function stopPan() {
|
||||
isPanning.value = false
|
||||
}
|
||||
|
||||
function onImageLoad() {
|
||||
// Reset view when a new image loads
|
||||
function onImageLoad(e: Event) {
|
||||
const img = e.target as HTMLImageElement
|
||||
if (img.naturalWidth > 0) {
|
||||
naturalPageWidth.value = img.naturalWidth
|
||||
}
|
||||
scale.value = 1
|
||||
panX.value = 0
|
||||
panY.value = 0
|
||||
nextTick(() => fitWidth())
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.url,
|
||||
(url) => {
|
||||
if (!url) return
|
||||
if (isPdf.value) {
|
||||
naturalPageWidth.value = 800
|
||||
nextTick(() => fitWidth())
|
||||
}
|
||||
}
|
||||
)
|
||||
</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,215 +1,259 @@
|
||||
<template>
|
||||
<div class="h-full overflow-y-auto p-4 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">Verification Review</h2>
|
||||
<span class="bg-orange-100 text-orange-800 status-badge">
|
||||
Pending Verification
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="batch?.rejectionReason" class="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<p class="text-sm font-medium text-red-800">Previous Rejection Reason:</p>
|
||||
<p class="text-sm text-red-700">{{ batch.rejectionReason }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="ocrConfidence" class="ocr-banner">
|
||||
Values pre-filled by OCR ({{ ocrConfidence.provider }}).
|
||||
Colored borders indicate extraction confidence — verify each value against the scan.
|
||||
</div>
|
||||
|
||||
<!-- Patient review -->
|
||||
<fieldset class="border border-gray-200 rounded-md p-4">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">Patient Demographics</legend>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div v-for="field in patientFields" :key="field.path">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="fieldChecks[field.path]"
|
||||
@change="toggleCheck(field.path)"
|
||||
class="w-4 h-4 text-clinical-safe rounded"
|
||||
/>
|
||||
<label class="text-xs text-gray-500">{{ field.label }}</label>
|
||||
</div>
|
||||
<p
|
||||
class="text-sm mt-2 pl-8 font-medium"
|
||||
:class="fieldConfidenceClass(field.path)"
|
||||
>
|
||||
{{ field.value || '(empty)' }}
|
||||
<div
|
||||
class="h-full min-h-0 flex flex-col"
|
||||
data-testid="verification-form"
|
||||
data-tour="verification-form"
|
||||
>
|
||||
<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>
|
||||
</fieldset>
|
||||
|
||||
<!-- Allergies review (ALLERGY_UPDATE or MIXED) -->
|
||||
<fieldset v-if="allergyFields.length > 0" class="border border-gray-200 rounded-md p-4">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">Allergies</legend>
|
||||
<div class="space-y-3">
|
||||
<div v-for="field in allergyFields" :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)' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Medications review (MEDICATION_LIST or MIXED) -->
|
||||
<fieldset v-if="medicationFields.length > 0" class="border border-gray-200 rounded-md p-4">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">Medications</legend>
|
||||
<div class="space-y-3">
|
||||
<div v-for="field in medicationFields" :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)' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Encounter review -->
|
||||
<fieldset class="border border-gray-200 rounded-md p-4">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">Encounter Context</legend>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div v-for="field in encounterFields" :key="field.path">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="fieldChecks[field.path]"
|
||||
@change="toggleCheck(field.path)"
|
||||
class="w-4 h-4 text-clinical-safe rounded"
|
||||
/>
|
||||
<label class="text-xs text-gray-500">{{ field.label }}</label>
|
||||
</div>
|
||||
<p
|
||||
class="text-sm mt-2 pl-8 font-medium"
|
||||
:class="fieldConfidenceClass(field.path)"
|
||||
>
|
||||
{{ field.value || '(empty)' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Observations review -->
|
||||
<fieldset class="border border-gray-200 rounded-md p-4">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">
|
||||
Observations ({{ observations.length }})
|
||||
</legend>
|
||||
<div class="space-y-2">
|
||||
<ObservationRow
|
||||
v-for="(obs, index) in observations"
|
||||
:key="obs.id"
|
||||
:observation="obs"
|
||||
:readonly="true"
|
||||
:show-verified="true"
|
||||
:verified="fieldChecks[`observations[${index}].value`] ?? false"
|
||||
:value-input-class="observationValueConfidenceClass(obs.observationCode)"
|
||||
@verify="(_obsId, passed) => toggleCheck(`observations[${index}].value`, passed)"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Verification progress -->
|
||||
<div class="bg-gray-50 rounded-md p-4">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span>Fields verified:</span>
|
||||
<span :class="allChecked ? 'text-clinical-safe font-bold' : 'text-gray-600'">
|
||||
{{ checkedCount }} / {{ totalFields }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2 mt-2">
|
||||
<div
|
||||
class="bg-clinical-safe h-2 rounded-full transition-all"
|
||||
:style="{ width: `${(checkedCount / Math.max(totalFields, 1)) * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 pt-4 border-t">
|
||||
<button
|
||||
@click="approveVerification"
|
||||
class="btn-primary"
|
||||
:disabled="!allChecked || processing"
|
||||
<div
|
||||
v-if="batch?.rejectionReason"
|
||||
class="rounded-input border border-[#FECDCA] bg-clinical-danger-bg p-3"
|
||||
>
|
||||
{{ processing ? 'Processing...' : 'Approve - Verified' }}
|
||||
</button>
|
||||
<button
|
||||
@click="showRejectDialog = true"
|
||||
class="btn-danger"
|
||||
:disabled="processing"
|
||||
<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 v-if="ocrConfidence" class="ocr-banner">
|
||||
Pre-filled by OCR ({{ ocrConfidence.provider }}) — review against the scan.
|
||||
OCR is assistive, not authoritative. Colored borders and badges indicate extraction confidence only.
|
||||
</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 -->
|
||||
<fieldset v-if="patientFields.length > 0" class="workstation-form-section">
|
||||
<legend class="workstation-form-legend">Patient Demographics</legend>
|
||||
<div class="space-y-2.5">
|
||||
<VerificationFieldCard
|
||||
v-for="field in patientFields"
|
||||
: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>
|
||||
</fieldset>
|
||||
|
||||
<!-- Allergies review -->
|
||||
<fieldset v-if="allergyFields.length > 0" class="workstation-form-section">
|
||||
<legend class="workstation-form-legend">Allergies</legend>
|
||||
<div class="space-y-2.5">
|
||||
<VerificationFieldCard
|
||||
v-for="field in allergyFields"
|
||||
: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>
|
||||
</fieldset>
|
||||
|
||||
<!-- Medications review -->
|
||||
<fieldset v-if="medicationFields.length > 0" class="workstation-form-section">
|
||||
<legend class="workstation-form-legend">Medications</legend>
|
||||
<div class="space-y-2.5">
|
||||
<VerificationFieldCard
|
||||
v-for="field in medicationFields"
|
||||
: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>
|
||||
</fieldset>
|
||||
|
||||
<!-- Encounter review -->
|
||||
<fieldset
|
||||
v-if="showEncounterContext && encounterFields.length > 0"
|
||||
class="workstation-form-section"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<legend class="workstation-form-legend">Encounter Context</legend>
|
||||
<div class="space-y-2.5">
|
||||
<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>
|
||||
</fieldset>
|
||||
|
||||
<!-- Observations review -->
|
||||
<fieldset
|
||||
v-if="showObservations"
|
||||
class="workstation-form-section"
|
||||
>
|
||||
<legend class="workstation-form-legend">
|
||||
Observations ({{ observations.length }})
|
||||
</legend>
|
||||
<p
|
||||
v-if="observations.length === 0"
|
||||
class="text-sm text-ink-secondary"
|
||||
>
|
||||
No observations recorded.
|
||||
</p>
|
||||
<div v-else class="space-y-3">
|
||||
<ObservationRow
|
||||
v-for="(obs, index) in observations"
|
||||
:key="obs.id"
|
||||
:observation="obs"
|
||||
:readonly="true"
|
||||
:show-verified="true"
|
||||
:verified="fieldChecks[`observations[${index}].value`] ?? false"
|
||||
: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)"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Verification progress (field checks only — not analytics chrome) -->
|
||||
<div class="rounded-input border border-line bg-canvas p-3">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-ink-secondary">Fields verified:</span>
|
||||
<span :class="allChecked ? 'text-clinical-safe font-semibold' : 'text-ink'">
|
||||
{{ checkedCount }} / {{ totalFields }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-line rounded-full h-1.5 mt-2">
|
||||
<div
|
||||
class="bg-clinical-safe h-1.5 rounded-full transition-all"
|
||||
:style="{ width: `${(checkedCount / Math.max(totalFields, 1)) * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="text-clinical-danger text-sm" role="alert">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<AuditTrailPanel :batch-id="batchId" />
|
||||
</div>
|
||||
|
||||
<!-- Reject dialog -->
|
||||
<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
|
||||
v-model="rejectionReason"
|
||||
class="form-input"
|
||||
rows="4"
|
||||
placeholder="Reason for rejection (required)..."
|
||||
/>
|
||||
<div class="flex flex-col-reverse sm:flex-row sm:justify-end gap-4 mt-4">
|
||||
<button
|
||||
@click="showRejectDialog = false"
|
||||
class="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="rejectVerification"
|
||||
class="btn-danger"
|
||||
:disabled="!rejectionReason.trim()"
|
||||
>
|
||||
Confirm Rejection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="text-clinical-danger text-sm">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
<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 { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useBatchStore } from '../stores/batches'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { useOcrFieldConfidence } from '../composables/useOcrFieldConfidence'
|
||||
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'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -217,6 +261,7 @@ const props = defineProps<{
|
||||
batchId: string
|
||||
}>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
const batchStore = useBatchStore()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
@@ -224,6 +269,8 @@ const processing = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const showRejectDialog = ref(false)
|
||||
const rejectionReason = ref('')
|
||||
const sodBlocked = ref(false)
|
||||
const decision = ref<'pass' | 'reject' | null>(null)
|
||||
|
||||
const fieldChecks = ref<Record<string, boolean>>({})
|
||||
const observations = ref<DraftObservation[]>([])
|
||||
@@ -241,7 +288,11 @@ const encounterFields = ref<FieldInfo[]>([])
|
||||
|
||||
const fieldReqs = computed(() => batchStore.currentDraft?.fieldRequirements)
|
||||
const ocrConfidence = computed(() => batchStore.currentDraft?.ocrConfidence ?? null)
|
||||
const { fieldConfidenceClass } = useOcrFieldConfidence(ocrConfidence)
|
||||
const {
|
||||
fieldConfidenceClass,
|
||||
fieldConfidenceLabel,
|
||||
fieldConfidenceLevel,
|
||||
} = useOcrFieldConfidence(ocrConfidence)
|
||||
|
||||
function observationValueConfidenceClass(observationCode: string): string {
|
||||
if (!observationCode) return ''
|
||||
@@ -251,16 +302,15 @@ function observationValueConfidenceClass(observationCode: string): string {
|
||||
const showAllergies = computed(() => fieldReqs.value?.showAllergies ?? false)
|
||||
const showMedications = computed(() => fieldReqs.value?.showMedications ?? false)
|
||||
const showEncounterSummary = computed(() => fieldReqs.value?.showEncounterSummaryFields ?? false)
|
||||
const showEncounterContext = computed(() => fieldReqs.value?.showEncounterContext ?? false)
|
||||
const showObservations = computed(() => fieldReqs.value?.showObservations ?? false)
|
||||
|
||||
function parseJsonList(json: string | null): string[] {
|
||||
if (!json) return []
|
||||
try {
|
||||
const parsed = JSON.parse(json)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
const enteredByDisplayName = computed(() => {
|
||||
const name = props.batch?.enteredByUserName?.trim()
|
||||
if (name) return name
|
||||
const fromQueue = batchStore.batches.find((b) => b.id === props.batchId)
|
||||
return fromQueue?.enteredByUserName?.trim() || null
|
||||
})
|
||||
|
||||
watch(
|
||||
() => batchStore.currentDraft,
|
||||
@@ -269,7 +319,6 @@ watch(
|
||||
|
||||
observations.value = draft.observations ?? []
|
||||
|
||||
// Build patient field list
|
||||
if (draft.patient) {
|
||||
patientFields.value = [
|
||||
{ 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 ?? '' },
|
||||
]
|
||||
|
||||
// Build allergy fields
|
||||
allergyFields.value = []
|
||||
if (showAllergies.value) {
|
||||
if (draft.patient.noKnownAllergies) {
|
||||
@@ -301,7 +349,6 @@ watch(
|
||||
}
|
||||
}
|
||||
|
||||
// Build medication fields
|
||||
medicationFields.value = []
|
||||
if (showMedications.value) {
|
||||
if (draft.patient.noActiveMedications) {
|
||||
@@ -322,10 +369,13 @@ watch(
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
patientFields.value = []
|
||||
allergyFields.value = []
|
||||
medicationFields.value = []
|
||||
}
|
||||
|
||||
// Build encounter field list
|
||||
if (draft.encounter) {
|
||||
if (draft.encounter && showEncounterContext.value) {
|
||||
encounterFields.value = [
|
||||
{ path: 'encounter.admissionDate', label: 'Admission Date', value: draft.encounter.admissionDate ?? '' },
|
||||
{ path: 'encounter.department', label: 'Department', value: draft.encounter.department ?? '' },
|
||||
@@ -338,9 +388,14 @@ watch(
|
||||
{ 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 = {}
|
||||
patientFields.value.forEach((f) => { fieldChecks.value[f.path] = false })
|
||||
allergyFields.value.forEach((f) => { fieldChecks.value[f.path] = false })
|
||||
@@ -351,6 +406,10 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(sodBlocked, (blocked) => {
|
||||
if (blocked) decision.value = null
|
||||
})
|
||||
|
||||
const totalFields = computed(
|
||||
() => patientFields.value.length + allergyFields.value.length + medicationFields.value.length
|
||||
+ encounterFields.value.length + observations.value.length
|
||||
@@ -365,6 +424,7 @@ function toggleCheck(path: string, value?: boolean) {
|
||||
}
|
||||
|
||||
async function approveVerification() {
|
||||
if (sodBlocked.value || decision.value !== 'pass') return
|
||||
processing.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
@@ -385,12 +445,13 @@ async function approveVerification() {
|
||||
}
|
||||
|
||||
async function rejectVerification() {
|
||||
if (sodBlocked.value) return
|
||||
processing.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
await batchStore.rejectBatch(props.batchId, rejectionReason.value)
|
||||
showRejectDialog.value = false
|
||||
toast.warning('Batch rejected')
|
||||
toast.warning('Batch returned for rework')
|
||||
router.push('/verification')
|
||||
} catch (e: unknown) {
|
||||
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 { OcrConfidenceMap } from '../types'
|
||||
|
||||
/** Design-doc §14: 95–100% high, 80–94% 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(
|
||||
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 {
|
||||
if (!ocrConfidence.value) return ''
|
||||
const confidence = ocrConfidence.value.fieldConfidences[fieldPath]
|
||||
if (confidence === undefined) return ''
|
||||
if (confidence >= 0.85) return 'ocr-high'
|
||||
if (confidence >= 0.7) return 'ocr-medium'
|
||||
const level = fieldConfidenceLevel(fieldPath)
|
||||
if (!level) return ''
|
||||
if (level === 'high') return 'ocr-high'
|
||||
if (level === 'medium') return 'ocr-medium'
|
||||
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 documentUrl = ref<string | null>(null)
|
||||
const documentError = ref<string | null>(null)
|
||||
const documentLoading = ref(false)
|
||||
|
||||
let currentBlobUrl: string | null = null
|
||||
|
||||
@@ -21,8 +22,12 @@ export function usePresignedUrl(batchId: Ref<string | undefined>) {
|
||||
documentUrl.value = null
|
||||
documentError.value = null
|
||||
|
||||
if (!batchId.value) return
|
||||
if (!batchId.value) {
|
||||
documentLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
documentLoading.value = true
|
||||
try {
|
||||
await batchStore.getBatch(batchId.value)
|
||||
const blob = await getBlob(`digitization-batches/${batchId.value}/document`)
|
||||
@@ -33,6 +38,8 @@ export function usePresignedUrl(batchId: Ref<string | undefined>) {
|
||||
documentUrl.value = currentBlobUrl
|
||||
} catch (e: unknown) {
|
||||
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()
|
||||
documentUrl.value = null
|
||||
documentError.value = null
|
||||
documentLoading.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -52,5 +60,5 @@ export function usePresignedUrl(batchId: Ref<string | undefined>) {
|
||||
|
||||
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>
|
||||
@@ -8,102 +8,124 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('../views/LoginView.vue'),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/intake',
|
||||
name: 'Intake',
|
||||
component: () => import('../views/IntakeView.vue'),
|
||||
meta: { requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] },
|
||||
},
|
||||
{
|
||||
path: '/cover-sheets',
|
||||
name: 'CoverSheets',
|
||||
component: () => import('../views/CoverSheetView.vue'),
|
||||
meta: { requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] },
|
||||
},
|
||||
{
|
||||
path: '/entry',
|
||||
name: 'EntryQueue',
|
||||
component: () => import('../views/EntryView.vue'),
|
||||
meta: { requiresAuth: true, roles: ['DATA_ENTRY_CLERK', 'ADMINISTRATOR'] },
|
||||
},
|
||||
{
|
||||
path: '/entry/:batchId',
|
||||
name: 'EntryBatch',
|
||||
component: () => import('../views/EntryView.vue'),
|
||||
meta: { requiresAuth: true, roles: ['DATA_ENTRY_CLERK', 'ADMINISTRATOR'] },
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/verification',
|
||||
name: 'VerificationQueue',
|
||||
component: () => import('../views/VerificationView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
roles: ['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/verification/:batchId',
|
||||
name: 'VerificationBatch',
|
||||
component: () => import('../views/VerificationView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
roles: ['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'],
|
||||
},
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/approval',
|
||||
name: 'ApprovalQueue',
|
||||
component: () => import('../views/ApprovalView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
roles: ['CLINICAL_APPROVER', 'ADMINISTRATOR'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/approval/:batchId',
|
||||
name: 'ApprovalBatch',
|
||||
component: () => import('../views/ApprovalView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
roles: ['CLINICAL_APPROVER', 'ADMINISTRATOR'],
|
||||
},
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/patients/:patientId/history',
|
||||
name: 'PatientHistory',
|
||||
component: () => import('../views/PatientHistoryView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/patients',
|
||||
name: 'PatientSearch',
|
||||
component: () => import('../views/PatientHistoryView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/live-capture',
|
||||
name: 'LiveCapture',
|
||||
component: () => import('../views/LiveCaptureView.vue'),
|
||||
meta: { requiresAuth: true, roles: ['CLINICIAN', 'ADMINISTRATOR'] },
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('../views/QueueDashboardView.vue'),
|
||||
meta: { requiresAuth: true, roles: ['ADMINISTRATOR'] },
|
||||
},
|
||||
{
|
||||
path: '/fhir-explorer',
|
||||
name: 'FhirExplorer',
|
||||
component: () => import('../views/FhirExplorerView.vue'),
|
||||
meta: { requiresAuth: true, roles: ['ADMINISTRATOR'] },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../layouts/AuthenticatedLayout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: () => {
|
||||
const auth = useAuthStore()
|
||||
return auth.isAuthenticated
|
||||
? getDefaultRouteForRole(auth.userRole)
|
||||
: '/login'
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'intake',
|
||||
name: 'Intake',
|
||||
component: () => import('../views/IntakeView.vue'),
|
||||
meta: { requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] },
|
||||
},
|
||||
{
|
||||
path: 'cover-sheets',
|
||||
name: 'CoverSheets',
|
||||
component: () => import('../views/CoverSheetView.vue'),
|
||||
meta: { requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] },
|
||||
},
|
||||
{
|
||||
path: 'entry',
|
||||
name: 'EntryQueue',
|
||||
component: () => import('../views/EntryView.vue'),
|
||||
meta: { requiresAuth: true, roles: ['DATA_ENTRY_CLERK', 'ADMINISTRATOR'] },
|
||||
},
|
||||
{
|
||||
path: 'entry/:batchId',
|
||||
name: 'EntryBatch',
|
||||
component: () => import('../views/EntryView.vue'),
|
||||
meta: { requiresAuth: true, roles: ['DATA_ENTRY_CLERK', 'ADMINISTRATOR'] },
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: 'verification',
|
||||
name: 'VerificationQueue',
|
||||
component: () => import('../views/VerificationView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
roles: ['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'verification/:batchId',
|
||||
name: 'VerificationBatch',
|
||||
component: () => import('../views/VerificationView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
roles: ['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'],
|
||||
},
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: 'approval',
|
||||
name: 'ApprovalQueue',
|
||||
component: () => import('../views/ApprovalView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
roles: ['CLINICAL_APPROVER', 'ADMINISTRATOR'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'approval/:batchId',
|
||||
name: 'ApprovalBatch',
|
||||
component: () => import('../views/ApprovalView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
roles: ['CLINICAL_APPROVER', 'ADMINISTRATOR'],
|
||||
},
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: 'patients/:patientId/history',
|
||||
name: 'PatientHistory',
|
||||
component: () => import('../views/PatientHistoryView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: 'patients',
|
||||
name: 'PatientSearch',
|
||||
component: () => import('../views/PatientHistoryView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'live-capture',
|
||||
name: 'LiveCapture',
|
||||
component: () => import('../views/LiveCaptureView.vue'),
|
||||
meta: { requiresAuth: true, roles: ['CLINICIAN', 'ADMINISTRATOR'] },
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('../views/QueueDashboardView.vue'),
|
||||
meta: { requiresAuth: true, roles: ['ADMINISTRATOR'] },
|
||||
},
|
||||
{
|
||||
path: 'fhir-explorer',
|
||||
name: 'FhirExplorer',
|
||||
component: () => import('../views/FhirExplorerView.vue'),
|
||||
meta: { requiresAuth: true, roles: ['ADMINISTRATOR'] },
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'Users',
|
||||
component: () => import('../views/UsersView.vue'),
|
||||
meta: { requiresAuth: true, roles: ['ADMINISTRATOR'] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/login',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -8,10 +8,53 @@ import type {
|
||||
DraftEncounter,
|
||||
DraftObservation,
|
||||
BatchListResponse,
|
||||
BatchEventResponse,
|
||||
CursorPagedResult,
|
||||
FieldCheck,
|
||||
PatientDigitizationHistoryResponse,
|
||||
WorkQueueItemResponse,
|
||||
WorkQueueResponse,
|
||||
} 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 {
|
||||
if (value == null) return null
|
||||
const trimmed = value.trim()
|
||||
@@ -66,6 +109,12 @@ export const useBatchStore = defineStore('batches', () => {
|
||||
const loading = ref(false)
|
||||
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)
|
||||
|
||||
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> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
@@ -102,7 +226,16 @@ export const useBatchStore = defineStore('batches', () => {
|
||||
`digitization-batches/${id}`
|
||||
)
|
||||
if (response.success && response.data) {
|
||||
currentBatch.value = response.data
|
||||
const fromQueue = batches.value.find((b) => b.id === id)
|
||||
currentBatch.value = {
|
||||
...response.data,
|
||||
enteredByUserName:
|
||||
response.data.enteredByUserName ?? fromQueue?.enteredByUserName ?? null,
|
||||
verifiedByUserName:
|
||||
response.data.verifiedByUserName ?? fromQueue?.verifiedByUserName ?? null,
|
||||
approvedByUserName:
|
||||
response.data.approvedByUserName ?? fromQueue?.approvedByUserName ?? null,
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load batch'
|
||||
@@ -276,7 +409,18 @@ async function assignBatch(batchId: string, entryClerkUserId: string): Promise<v
|
||||
totalCount,
|
||||
loading,
|
||||
error,
|
||||
events,
|
||||
eventsLoading,
|
||||
eventsError,
|
||||
eventsHasMore,
|
||||
eventsNextCursor,
|
||||
listBatches,
|
||||
listWorkQueue,
|
||||
listEntryQueue,
|
||||
listVerificationQueue,
|
||||
listClinicalApprovalQueue,
|
||||
fetchEvents,
|
||||
clearEvents,
|
||||
getBatch,
|
||||
uploadBatch,
|
||||
assignBatch,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -63,8 +63,12 @@ export interface BatchDetailResponse {
|
||||
documentUrl: string | null
|
||||
enableRetroactiveAlerts: boolean
|
||||
enteredByUserId: string | null
|
||||
/** Present on work-queue items; optional on batch detail until API includes it */
|
||||
enteredByUserName?: string | null
|
||||
verifiedByUserId: string | null
|
||||
verifiedByUserName?: string | null
|
||||
approvedByUserId: string | null
|
||||
approvedByUserName?: string | null
|
||||
rejectionReason: string | null
|
||||
promotedAt: string | null
|
||||
promotionEncounterId: string | null
|
||||
@@ -177,6 +181,36 @@ export interface UserSummary {
|
||||
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 {
|
||||
id: string
|
||||
code: string
|
||||
@@ -209,6 +243,49 @@ export interface DigitizationEventSummary {
|
||||
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 {
|
||||
batchId: 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',
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,289 +1,100 @@
|
||||
<template>
|
||||
<div class="min-h-screen lg:h-screen flex flex-col">
|
||||
<AppHeader title="Clinical Approval">
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<AppHeader title="Clinical Approval" tour-anchor="approval-header">
|
||||
<template #subtitle>
|
||||
<span v-if="currentBatch" class="text-sm text-gray-500">
|
||||
Batch: {{ currentBatch.id.substring(0, 8) }}...
|
||||
| Type: {{ formatBatchType(currentBatch.batchType) }}
|
||||
<span v-if="currentBatch" class="text-sm text-ink-secondary">
|
||||
Batch {{ currentBatch.id.substring(0, 8) }}…
|
||||
· {{ formatBatchType(currentBatch.batchType) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<TourHelpButton />
|
||||
</template>
|
||||
</AppHeader>
|
||||
|
||||
<!-- Queue view (no batch selected) -->
|
||||
<div v-if="!batchId" class="flex-1 p-4 sm:p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Clinical Approval Queue</h2>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
Verified batches awaiting clinical sign-off before promotion to live tables.
|
||||
</p>
|
||||
<BatchList
|
||||
:batches="batchStore.batches"
|
||||
:loading="batchStore.loading"
|
||||
@select="openBatch"
|
||||
/>
|
||||
</div>
|
||||
<WorkstationLayout :has-batch="!!batchId">
|
||||
<template #queue>
|
||||
<h2 class="text-xl font-semibold mb-4 text-ink-strong">Clinical Approval Queue</h2>
|
||||
<p class="text-sm text-ink-secondary mb-4">
|
||||
Verified batches awaiting clinical sign-off before promotion to live tables.
|
||||
</p>
|
||||
<BatchList
|
||||
:batches="batchStore.batches"
|
||||
: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"
|
||||
@retry="loadQueue"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Split pane (batch selected) -->
|
||||
<div v-else class="flex-1 split-pane">
|
||||
<ScanViewer
|
||||
v-if="documentUrl"
|
||||
:url="documentUrl"
|
||||
/>
|
||||
<div v-else class="flex items-center justify-center bg-gray-100 rounded-lg">
|
||||
<p class="text-gray-500">{{ documentError ?? 'Loading document...' }}</p>
|
||||
</div>
|
||||
<template #rail>
|
||||
<WorkstationQueueRail
|
||||
title="Approval queue"
|
||||
:batches="batchStore.batches"
|
||||
:selected-id="batchId"
|
||||
:loading="batchStore.loading"
|
||||
@select="openBatch"
|
||||
@back="router.push('/approval')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Approval panel -->
|
||||
<div class="h-full overflow-y-auto p-4 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">Clinical Review</h2>
|
||||
<span class="bg-purple-100 text-purple-800 status-badge">
|
||||
Awaiting Clinical Approval
|
||||
</span>
|
||||
</div>
|
||||
<template #scan>
|
||||
<ScanViewer
|
||||
:url="documentUrl"
|
||||
:loading="documentLoading"
|
||||
:error="documentError"
|
||||
@retry="refreshUrl"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Supersession info -->
|
||||
<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">
|
||||
No observations recorded.
|
||||
</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>
|
||||
<template #form>
|
||||
<ApprovalForm
|
||||
v-if="batchId"
|
||||
:batch="currentBatch"
|
||||
:batch-id="batchId"
|
||||
:draft="draft"
|
||||
@back="router.push('/approval')"
|
||||
@view-history="(patientId) => router.push(`/patients/${patientId}/history`)"
|
||||
@create-correction="createCorrection"
|
||||
@rejected="router.push('/approval')"
|
||||
/>
|
||||
</template>
|
||||
</WorkstationLayout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useBatchStore } from '../stores/batches'
|
||||
import { usePresignedUrl } from '../composables/usePresignedUrl'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import TourHelpButton from '../components/TourHelpButton.vue'
|
||||
import ScanViewer from '../components/ScanViewer.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 batchStore = useBatchStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined))
|
||||
const currentBatch = computed(() => batchStore.currentBatch)
|
||||
const draft = computed(() => batchStore.currentDraft)
|
||||
const { documentUrl, documentError } = 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)
|
||||
const { documentUrl, documentError, documentLoading, refreshUrl } = usePresignedUrl(batchId)
|
||||
|
||||
function openBatch(id: string) {
|
||||
router.push(`/approval/${id}`)
|
||||
}
|
||||
|
||||
function formatBatchType(type: string): string {
|
||||
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
|
||||
}
|
||||
return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
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(
|
||||
batchId,
|
||||
async (id) => {
|
||||
@@ -328,12 +121,13 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!batchId.value) {
|
||||
await batchStore.listBatches({
|
||||
status: 'AWAITING_CLINICAL_APPROVAL',
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
})
|
||||
}
|
||||
await loadQueue()
|
||||
})
|
||||
|
||||
async function loadQueue() {
|
||||
await batchStore.listClinicalApprovalQueue({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,211 +1,324 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<AppHeader title="Cover Sheets" />
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<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">
|
||||
<h1 class="text-2xl font-bold mb-6">Cover Sheet Management</h1>
|
||||
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
||||
<div class="max-w-7xl mx-auto space-y-6 lg:space-y-8">
|
||||
<!-- Top: 50 / 50 generation + preview -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:gap-8 items-start">
|
||||
<section class="card" data-tour="cover-sheets-generate">
|
||||
<h2 class="text-base font-semibold text-ink-strong mb-4">Generate Cover Sheets</h2>
|
||||
|
||||
<!-- Generate -->
|
||||
<div class="card mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Generate Cover Sheets</h2>
|
||||
<form @submit.prevent="handleGenerate" class="space-y-4">
|
||||
<div>
|
||||
<label for="cs-count" class="block text-sm font-semibold text-ink mb-2">
|
||||
Quantity (1–100)
|
||||
</label>
|
||||
<input
|
||||
id="cs-count"
|
||||
v-model.number="count"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
class="form-input max-w-xs"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleGenerate" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Count (1–100)
|
||||
</label>
|
||||
<input
|
||||
v-model.number="count"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
class="form-input max-w-xs"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="cs-batch-type" class="block text-sm font-semibold text-ink mb-2">
|
||||
Batch Type
|
||||
</label>
|
||||
<select id="cs-batch-type" v-model="batchType" class="form-input" required>
|
||||
<option value="">Select batch type...</option>
|
||||
<option value="PATIENT_REGISTRATION">Patient Registration</option>
|
||||
<option value="ENCOUNTER_SUMMARY">Encounter Summary</option>
|
||||
<option value="VITALS_SHEET">Vitals Sheet</option>
|
||||
<option value="LAB_RESULTS">Lab Results</option>
|
||||
<option value="MEDICATION_LIST">Medication List</option>
|
||||
<option value="ALLERGY_UPDATE">Allergy Update</option>
|
||||
<option value="MIXED">Mixed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Batch Type</label>
|
||||
<select v-model="batchType" class="form-input" required>
|
||||
<option value="">Select batch type...</option>
|
||||
<option value="PATIENT_REGISTRATION">Patient Registration</option>
|
||||
<option value="ENCOUNTER_SUMMARY">Encounter Summary</option>
|
||||
<option value="VITALS_SHEET">Vitals Sheet</option>
|
||||
<option value="LAB_RESULTS">Lab Results</option>
|
||||
<option value="MEDICATION_LIST">Medication List</option>
|
||||
<option value="ALLERGY_UPDATE">Allergy Update</option>
|
||||
<option value="MIXED">Mixed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="cs-track" class="block text-sm font-semibold text-ink mb-2">Track</label>
|
||||
<select id="cs-track" v-model="track" class="form-input">
|
||||
<option value="BACKFILL">Backfill (Track A)</option>
|
||||
<option value="LIVE_CAPTURE">Live Capture (Track B)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Track</label>
|
||||
<select v-model="track" class="form-input">
|
||||
<option value="BACKFILL">Backfill (Track A)</option>
|
||||
<option value="LIVE_CAPTURE">Live Capture (Track B)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-ink mb-2">
|
||||
Patient
|
||||
<span class="font-normal text-ink-secondary">(optional)</span>
|
||||
</label>
|
||||
<PatientSearch v-model="patientId" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Patient (optional)
|
||||
</label>
|
||||
<PatientSearch v-model="patientId" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="cs-clerk" class="block text-sm font-semibold text-ink mb-2">
|
||||
Entry Clerk
|
||||
<span class="font-normal text-ink-secondary">(optional)</span>
|
||||
</label>
|
||||
<select
|
||||
id="cs-clerk"
|
||||
v-model="assignToUserId"
|
||||
class="form-input"
|
||||
:disabled="clerksLoading"
|
||||
>
|
||||
<option value="">No pre-assignment</option>
|
||||
<option v-for="clerk in clerks" :key="clerk.id" :value="clerk.id">
|
||||
{{ clerk.fullName }} ({{ clerk.username }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Assign to Clerk (optional)
|
||||
</label>
|
||||
<select v-model="assignToUserId" class="form-input" :disabled="clerksLoading">
|
||||
<option value="">No pre-assignment</option>
|
||||
<option v-for="clerk in clerks" :key="clerk.id" :value="clerk.id">
|
||||
{{ clerk.fullName }} ({{ clerk.username }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<InlineError
|
||||
v-if="generateError"
|
||||
title="Generation failed"
|
||||
:message="generateError"
|
||||
preserved="Your form values were kept."
|
||||
retry-label="Try again"
|
||||
@retry="handleGenerate"
|
||||
/>
|
||||
|
||||
<div v-if="generateError" class="text-clinical-danger text-sm">
|
||||
{{ generateError }}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3 pt-1">
|
||||
<button type="submit" class="btn-primary" :disabled="generating || !batchType">
|
||||
{{ generating ? 'Generating...' : 'Generate Cover Sheets' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="lastGeneratedIds.length > 0"
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
:disabled="printing"
|
||||
@click="printLastGenerated"
|
||||
>
|
||||
{{ printing ? 'Opening PDF...' : 'Print Cover Sheets' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button type="submit" class="btn-primary" :disabled="generating || !batchType">
|
||||
{{ generating ? 'Generating...' : 'Generate' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="lastGeneratedIds.length > 0"
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
:disabled="printing"
|
||||
@click="printLastGenerated"
|
||||
>
|
||||
{{ printing ? 'Opening PDF...' : 'Print Cover Sheets' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<div class="card">
|
||||
<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>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Status</label>
|
||||
<select v-model="statusFilter" class="form-input" @change="onFiltersChanged">
|
||||
<option value="all">All</option>
|
||||
<option value="unused">Unused</option>
|
||||
<option value="used">Used</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="min-w-[240px]">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Patient</label>
|
||||
<PatientSearch v-model="filterPatientId" @update:model-value="onFiltersChanged" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="listError" class="text-clinical-danger text-sm mb-4">
|
||||
{{ listError }}
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<div v-if="listLoading" class="text-gray-500 text-center py-4">Loading...</div>
|
||||
<div v-else-if="coverSheets.length === 0" class="text-gray-500 text-center py-4">
|
||||
No cover sheets found.
|
||||
</div>
|
||||
<table v-else class="w-full min-w-[800px] text-sm">
|
||||
<thead>
|
||||
<tr class="border-b text-left text-gray-600">
|
||||
<th class="py-2 px-4">Code</th>
|
||||
<th class="py-2 px-4">Batch Type</th>
|
||||
<th class="py-2 px-4">Track</th>
|
||||
<th class="py-2 px-4">Patient</th>
|
||||
<th class="py-2 px-4">Assigned To</th>
|
||||
<th class="py-2 px-4">Status</th>
|
||||
<th class="py-2 px-4">Linked Batch</th>
|
||||
<th class="py-2 px-4">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="sheet in coverSheets"
|
||||
:key="sheet.id"
|
||||
class="border-b hover:bg-gray-50"
|
||||
<p
|
||||
v-if="lastGeneratedIds.length > 0"
|
||||
class="text-sm text-ink-secondary"
|
||||
>
|
||||
<td class="py-2 px-4 font-mono text-xs">{{ sheet.code }}</td>
|
||||
<td class="py-2 px-4">{{ formatBatchType(sheet.batchType) }}</td>
|
||||
<td class="py-2 px-4">
|
||||
Last run: {{ lastGeneratedIds.length }} cover sheet(s) ready to print.
|
||||
</p>
|
||||
</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 class="flex-1 flex flex-col items-center justify-center px-6 py-8 gap-5">
|
||||
<!-- 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
|
||||
:class="sheet.track === 'BACKFILL'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-green-100 text-green-800'"
|
||||
class="status-badge"
|
||||
>
|
||||
{{ sheet.track === 'BACKFILL' ? 'Backfill' : 'Live' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 px-4">
|
||||
<template v-if="sheet.patientName">
|
||||
{{ sheet.patientName }}
|
||||
<span v-if="sheet.patientMrn" class="text-gray-500">({{ sheet.patientMrn }})</span>
|
||||
</template>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</td>
|
||||
<td class="py-2 px-4">
|
||||
{{ sheet.assignToUserName ?? '—' }}
|
||||
</td>
|
||||
<td class="py-2 px-4">
|
||||
<span
|
||||
: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 class="py-2 px-4">
|
||||
<router-link
|
||||
v-if="sheet.batchId"
|
||||
:to="{ path: '/intake', query: { batchId: sheet.batchId } }"
|
||||
class="font-mono text-xs text-primary-600 hover:text-primary-800"
|
||||
>
|
||||
{{ sheet.batchId.substring(0, 8) }}...
|
||||
</router-link>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</td>
|
||||
<td class="py-2 px-4 text-gray-500">
|
||||
{{ formatDate(sheet.createdAt) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
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>
|
||||
|
||||
<div class="flex items-center justify-between mt-4 pt-4 border-t">
|
||||
<p class="text-sm text-gray-500">Page {{ page }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary text-sm"
|
||||
:disabled="page <= 1 || listLoading"
|
||||
@click="goToPage(page - 1)"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary text-sm"
|
||||
:disabled="!hasNextPage || listLoading"
|
||||
@click="goToPage(page + 1)"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<!-- 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">
|
||||
<h2 class="text-base font-semibold text-ink-strong">Existing Cover Sheets</h2>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<div>
|
||||
<label for="cs-status-filter" class="block text-sm font-semibold text-ink mb-2">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="cs-status-filter"
|
||||
v-model="statusFilter"
|
||||
class="form-input"
|
||||
@change="onFiltersChanged"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="unused">Unused</option>
|
||||
<option value="used">Used</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="min-w-[240px]">
|
||||
<label class="block text-sm font-semibold text-ink mb-2">Patient</label>
|
||||
<PatientSearch v-model="filterPatientId" @update:model-value="onFiltersChanged" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InlineError
|
||||
v-if="listError"
|
||||
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">
|
||||
<SkeletonBlock v-if="listLoading" variant="table" :rows="5" />
|
||||
<EmptyState
|
||||
v-else-if="coverSheets.length === 0"
|
||||
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">
|
||||
<thead>
|
||||
<tr class="border-b text-left text-ink-secondary">
|
||||
<th class="py-2 px-4 font-medium">Code</th>
|
||||
<th class="py-2 px-4 font-medium">Batch Type</th>
|
||||
<th class="py-2 px-4 font-medium">Track</th>
|
||||
<th class="py-2 px-4 font-medium">Patient</th>
|
||||
<th class="py-2 px-4 font-medium">Assigned To</th>
|
||||
<th class="py-2 px-4 font-medium">Status</th>
|
||||
<th class="py-2 px-4 font-medium">Linked Batch</th>
|
||||
<th class="py-2 px-4 font-medium">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="sheet in coverSheets"
|
||||
:key="sheet.id"
|
||||
class="border-b hover:bg-primary-50"
|
||||
>
|
||||
<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">
|
||||
<span
|
||||
:class="sheet.track === 'BACKFILL'
|
||||
? 'bg-primary-50 text-primary-800 border border-primary-100'
|
||||
: 'bg-clinical-safe-bg text-clinical-safe border border-[#ABEFC6]'"
|
||||
class="status-badge"
|
||||
>
|
||||
{{ sheet.track === 'BACKFILL' ? 'Backfill' : 'Live' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 px-4">
|
||||
<template v-if="sheet.patientName">
|
||||
{{ sheet.patientName }}
|
||||
<span v-if="sheet.patientMrn" class="text-ink-secondary">
|
||||
({{ sheet.patientMrn }})
|
||||
</span>
|
||||
</template>
|
||||
<span v-else class="text-ink-disabled">—</span>
|
||||
</td>
|
||||
<td class="py-2 px-4">
|
||||
{{ sheet.assignToUserName ?? '—' }}
|
||||
</td>
|
||||
<td class="py-2 px-4">
|
||||
<StatusBadge :status="sheet.isUsed ? 'USED' : 'UNUSED'" />
|
||||
</td>
|
||||
<td class="py-2 px-4">
|
||||
<router-link
|
||||
v-if="sheet.batchId"
|
||||
:to="{ path: '/intake', query: { batchId: sheet.batchId } }"
|
||||
class="font-mono text-xs text-primary-600 hover:text-primary-800"
|
||||
>
|
||||
{{ sheet.batchId.substring(0, 8) }}…
|
||||
</router-link>
|
||||
<span v-else class="text-ink-disabled">—</span>
|
||||
</td>
|
||||
<td class="py-2 px-4 text-ink-secondary">
|
||||
{{ formatDate(sheet.createdAt) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-4 pt-4 border-t border-line">
|
||||
<p class="text-sm text-ink-secondary">Page {{ page }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary text-sm"
|
||||
:disabled="page <= 1 || listLoading"
|
||||
@click="goToPage(page - 1)"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary text-sm"
|
||||
:disabled="!hasNextPage || listLoading"
|
||||
@click="goToPage(page + 1)"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,7 +329,12 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { get, post, postBlob } from '../api/client'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import TourHelpButton from '../components/TourHelpButton.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'
|
||||
|
||||
const toast = useToast()
|
||||
@@ -244,6 +362,25 @@ const filterPatientId = ref<string | undefined>()
|
||||
|
||||
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 {
|
||||
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()
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
clerksLoading.value = true
|
||||
try {
|
||||
|
||||
@@ -1,68 +1,105 @@
|
||||
<template>
|
||||
<div class="min-h-screen lg:h-screen flex flex-col">
|
||||
<AppHeader title="Data Entry">
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<AppHeader title="Data Entry" tour-anchor="entry-header">
|
||||
<template #subtitle>
|
||||
<span v-if="currentBatch" class="text-sm text-gray-500">
|
||||
Batch: {{ currentBatch.id.substring(0, 8) }}...
|
||||
| Type: {{ currentBatch.batchType.replace(/_/g, ' ') }}
|
||||
<span v-if="currentBatch" class="text-sm text-ink-secondary">
|
||||
Batch {{ currentBatch.id.substring(0, 8) }}…
|
||||
· {{ currentBatch.batchType.replace(/_/g, ' ') }}
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<TourHelpButton />
|
||||
</template>
|
||||
</AppHeader>
|
||||
|
||||
<!-- Queue view (no batch selected) -->
|
||||
<div v-if="!batchId" class="flex-1 p-4 sm:p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Entry Queue</h2>
|
||||
<BatchList
|
||||
:batches="batchStore.batches"
|
||||
:loading="batchStore.loading"
|
||||
@select="openBatch"
|
||||
/>
|
||||
</div>
|
||||
<WorkstationLayout :has-batch="!!batchId">
|
||||
<template #queue>
|
||||
<h2 class="text-xl font-semibold mb-4 text-ink-strong">Data Entry Queue</h2>
|
||||
<BatchList
|
||||
:batches="batchStore.batches"
|
||||
: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"
|
||||
@retry="loadQueue"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Split pane (batch selected) -->
|
||||
<div v-else class="flex-1 split-pane">
|
||||
<ScanViewer
|
||||
v-if="documentUrl"
|
||||
:url="documentUrl"
|
||||
/>
|
||||
<div v-else class="flex items-center justify-center bg-gray-100 rounded-lg">
|
||||
<p class="text-gray-500">{{ documentError ?? 'Loading document...' }}</p>
|
||||
</div>
|
||||
<template #rail>
|
||||
<WorkstationQueueRail
|
||||
title="Entry queue"
|
||||
:batches="batchStore.batches"
|
||||
:selected-id="batchId"
|
||||
:loading="batchStore.loading"
|
||||
@select="openBatch"
|
||||
@back="router.push('/entry')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<EntryForm
|
||||
:batch="currentBatch"
|
||||
:batch-id="batchId"
|
||||
/>
|
||||
</div>
|
||||
<template #scan>
|
||||
<ScanViewer
|
||||
:url="documentUrl"
|
||||
:loading="documentLoading"
|
||||
:error="documentError"
|
||||
@retry="refreshUrl"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #form>
|
||||
<EntryForm
|
||||
v-if="batchId"
|
||||
:batch="currentBatch"
|
||||
:batch-id="batchId"
|
||||
:next-batch-id="nextBatchId"
|
||||
@open-next="openBatch"
|
||||
/>
|
||||
</template>
|
||||
</WorkstationLayout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useBatchStore } from '../stores/batches'
|
||||
import { usePresignedUrl } from '../composables/usePresignedUrl'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import TourHelpButton from '../components/TourHelpButton.vue'
|
||||
import ScanViewer from '../components/ScanViewer.vue'
|
||||
import EntryForm from '../components/EntryForm.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 auth = useAuthStore()
|
||||
const batchStore = useBatchStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined))
|
||||
const currentBatch = computed(() => batchStore.currentBatch)
|
||||
const { documentUrl, 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) {
|
||||
router.push(`/entry/${id}`)
|
||||
}
|
||||
|
||||
async function loadQueue() {
|
||||
await batchStore.listEntryQueue({ page: 1, pageSize: 50 })
|
||||
}
|
||||
|
||||
watch(
|
||||
batchId,
|
||||
async (id) => {
|
||||
@@ -74,12 +111,6 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!batchId.value) {
|
||||
await batchStore.listBatches({
|
||||
assignedTo: auth.userId,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
})
|
||||
}
|
||||
await loadQueue()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,223 +1,330 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<AppHeader title="FHIR Explorer" />
|
||||
|
||||
<div class="p-4 sm:p-6 lg:p-8 max-w-6xl mx-auto flex-1 w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="text-2xl font-bold">FHIR R4 Explorer</h1>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<AppHeader
|
||||
title="FHIR Explorer"
|
||||
description="Read-only inspection of exposed FHIR resources for administrators and integration staff."
|
||||
tour-anchor="fhir-header"
|
||||
>
|
||||
<template #actions>
|
||||
<TourHelpButton />
|
||||
<button type="button" class="btn-secondary text-sm" @click="openMetadata">
|
||||
Open CapabilityStatement (/fhir/metadata)
|
||||
CapabilityStatement
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</AppHeader>
|
||||
|
||||
<!-- Resource browser -->
|
||||
<div class="card">
|
||||
<h2 class="text-lg font-semibold mb-4">Resource Browser</h2>
|
||||
<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 -->
|
||||
<section class="card space-y-5">
|
||||
<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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Resource Type</label>
|
||||
<select v-model="resourceType" class="form-input" @change="clearResults">
|
||||
<option value="Patient">Patient</option>
|
||||
<option value="Encounter">Encounter</option>
|
||||
<option value="Observation">Observation</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<template v-if="resourceType === 'Patient'">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Name</label>
|
||||
<input v-model="patientSearch.name" type="text" class="form-input" placeholder="e.g. Santos" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Birth Date</label>
|
||||
<input v-model="patientSearch.birthdate" type="date" class="form-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">MRN (identifier)</label>
|
||||
<input v-model="patientSearch.identifier" type="text" class="form-input" placeholder="VCR-000001" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="resourceType === 'Encounter'">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Patient ID</label>
|
||||
<input v-model="encounterSearch.patient" type="text" class="form-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Status</label>
|
||||
<select v-model="encounterSearch.status" class="form-input">
|
||||
<option value="">Any</option>
|
||||
<option value="in-progress">in-progress</option>
|
||||
<option value="finished">finished</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Date</label>
|
||||
<input v-model="encounterSearch.date" type="date" class="form-input" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Patient ID</label>
|
||||
<input v-model="observationSearch.patient" type="text" class="form-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">LOINC Code</label>
|
||||
<input v-model="observationSearch.code" type="text" class="form-input" placeholder="8867-4" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Category</label>
|
||||
<select v-model="observationSearch.category" class="form-input">
|
||||
<option value="">Any</option>
|
||||
<option value="vital-signs">vital-signs</option>
|
||||
<option value="laboratory">laboratory</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Date (FHIR prefix)</label>
|
||||
<input
|
||||
v-model="observationSearch.date"
|
||||
type="text"
|
||||
<label for="fhir-resource-type" class="block text-sm font-semibold text-ink mb-2">
|
||||
Resource Type
|
||||
</label>
|
||||
<select
|
||||
id="fhir-resource-type"
|
||||
v-model="resourceType"
|
||||
class="form-input"
|
||||
placeholder="ge2026-06-20"
|
||||
/>
|
||||
@change="clearResults"
|
||||
>
|
||||
<option value="Patient">Patient</option>
|
||||
<option value="Encounter">Encounter</option>
|
||||
<option value="Observation">Observation</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-primary" :disabled="searching" @click="runSearch">
|
||||
{{ searching ? 'Searching...' : 'Search' }}
|
||||
</button>
|
||||
<template v-if="resourceType === 'Patient'">
|
||||
<div>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-ink mb-2">Birth Date</label>
|
||||
<input v-model="patientSearch.birthdate" type="date" class="form-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-ink mb-2">MRN (identifier)</label>
|
||||
<input
|
||||
v-model="patientSearch.identifier"
|
||||
type="text"
|
||||
class="form-input font-mono"
|
||||
placeholder="VCR-000001"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-if="searchError" class="text-clinical-danger text-sm mt-3">{{ searchError }}</p>
|
||||
<template v-else-if="resourceType === 'Encounter'">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-ink mb-2">Patient ID</label>
|
||||
<input
|
||||
v-model="encounterSearch.patient"
|
||||
type="text"
|
||||
class="form-input font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-ink mb-2">Status</label>
|
||||
<select v-model="encounterSearch.status" class="form-input">
|
||||
<option value="">Any</option>
|
||||
<option value="in-progress">in-progress</option>
|
||||
<option value="finished">finished</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-ink mb-2">Date</label>
|
||||
<input v-model="encounterSearch.date" type="date" class="form-input" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="searchResults.length > 0" class="mt-6 overflow-x-auto">
|
||||
<p class="text-sm text-gray-600 mb-2">
|
||||
{{ searchTotal }} result(s)
|
||||
</p>
|
||||
<table class="min-w-full text-sm border border-gray-200 rounded-md overflow-hidden">
|
||||
<thead class="bg-gray-50 text-left">
|
||||
<tr>
|
||||
<th class="px-3 py-2">ID</th>
|
||||
<th v-if="resourceType === 'Patient'" class="px-3 py-2">Name</th>
|
||||
<th v-if="resourceType === 'Patient'" class="px-3 py-2">MRN</th>
|
||||
<th v-if="resourceType === 'Patient'" class="px-3 py-2">DOB</th>
|
||||
<th v-if="resourceType === 'Encounter'" class="px-3 py-2">Status</th>
|
||||
<th v-if="resourceType === 'Encounter'" class="px-3 py-2">Patient</th>
|
||||
<th v-if="resourceType === 'Observation'" class="px-3 py-2">Code</th>
|
||||
<th v-if="resourceType === 'Observation'" class="px-3 py-2">Value</th>
|
||||
<th v-if="resourceType === 'Observation'" class="px-3 py-2">Recorded</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="entry in searchResults"
|
||||
:key="String(entry.resource?.id)"
|
||||
class="border-t border-gray-100 hover:bg-primary-50 cursor-pointer"
|
||||
:class="{ 'bg-primary-50': selectedResource?.id === entry.resource?.id }"
|
||||
@click="selectResource(entry.resource)"
|
||||
>
|
||||
<td class="px-3 py-2 font-mono text-xs">{{ entry.resource?.id }}</td>
|
||||
<td v-if="resourceType === 'Patient'" class="px-3 py-2">
|
||||
{{ patientDisplayName(entry.resource) }}
|
||||
</td>
|
||||
<td v-if="resourceType === 'Patient'" class="px-3 py-2">
|
||||
{{ patientMrn(entry.resource) }}
|
||||
</td>
|
||||
<td v-if="resourceType === 'Patient'" class="px-3 py-2">
|
||||
{{ entry.resource?.birthDate ?? '—' }}
|
||||
</td>
|
||||
<td v-if="resourceType === 'Encounter'" class="px-3 py-2">
|
||||
{{ entry.resource?.status ?? '—' }}
|
||||
</td>
|
||||
<td v-if="resourceType === 'Encounter'" class="px-3 py-2">
|
||||
{{ subjectReference(entry.resource) }}
|
||||
</td>
|
||||
<td v-if="resourceType === 'Observation'" class="px-3 py-2">
|
||||
{{ observationCodeDisplay(entry.resource) }}
|
||||
</td>
|
||||
<td v-if="resourceType === 'Observation'" class="px-3 py-2">
|
||||
{{ observationValueDisplay(entry.resource) }}
|
||||
</td>
|
||||
<td v-if="resourceType === 'Observation'" class="px-3 py-2">
|
||||
{{ observationEffective(entry.resource) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedResource" class="mt-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-2">Resource JSON</h3>
|
||||
<pre class="bg-gray-900 text-green-100 text-xs p-4 rounded-md overflow-x-auto max-h-96">{{ formattedSelectedResource }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Patient $everything -->
|
||||
<div class="card">
|
||||
<h2 class="text-lg font-semibold mb-4">Patient $everything</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Load all FHIR resources for a patient in one Bundle.
|
||||
</p>
|
||||
|
||||
<div class="max-w-md mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Patient</label>
|
||||
<PatientSearch v-model="everythingPatientId" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
:disabled="!everythingPatientId || everythingLoading"
|
||||
@click="loadEverything"
|
||||
>
|
||||
{{ everythingLoading ? 'Loading...' : 'Load All Data' }}
|
||||
</button>
|
||||
|
||||
<p v-if="everythingError" class="text-clinical-danger text-sm mt-3">{{ everythingError }}</p>
|
||||
|
||||
<div v-if="everythingBundle" class="mt-6 space-y-6">
|
||||
<div v-if="everythingPatient" class="card bg-primary-50 border border-primary-100">
|
||||
<h3 class="font-semibold mb-2">Patient</h3>
|
||||
<p class="text-sm"><span class="text-gray-500">Name:</span> {{ patientDisplayName(everythingPatient) }}</p>
|
||||
<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>
|
||||
<p class="text-sm"><span class="text-gray-500">Gender:</span> {{ everythingPatient.gender ?? '—' }}</p>
|
||||
<template v-else>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-ink mb-2">Patient ID</label>
|
||||
<input
|
||||
v-model="observationSearch.patient"
|
||||
type="text"
|
||||
class="form-input font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-ink mb-2">LOINC Code</label>
|
||||
<input
|
||||
v-model="observationSearch.code"
|
||||
type="text"
|
||||
class="form-input font-mono"
|
||||
placeholder="8867-4"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-ink mb-2">Category</label>
|
||||
<select v-model="observationSearch.category" class="form-input">
|
||||
<option value="">Any</option>
|
||||
<option value="vital-signs">vital-signs</option>
|
||||
<option value="laboratory">laboratory</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-ink mb-2">Date (FHIR prefix)</label>
|
||||
<input
|
||||
v-model="observationSearch.date"
|
||||
type="text"
|
||||
class="form-input font-mono"
|
||||
placeholder="ge2026-06-20"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="everythingEncounters.length > 0">
|
||||
<h3 class="font-semibold mb-2">Encounters ({{ everythingEncounters.length }})</h3>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="enc in everythingEncounters"
|
||||
:key="String(enc.id)"
|
||||
class="text-sm border border-gray-200 rounded-md px-3 py-2"
|
||||
>
|
||||
<span class="font-mono text-xs text-gray-500">{{ enc.id }}</span>
|
||||
— {{ enc.status }}
|
||||
<span v-if="enc.period?.start"> · {{ enc.period.start }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
:disabled="searching"
|
||||
@click="runSearch"
|
||||
>
|
||||
{{ searching ? 'Searching...' : 'Search' }}
|
||||
</button>
|
||||
|
||||
<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="overflow-x-auto">
|
||||
<p class="text-sm text-ink-secondary mb-2">
|
||||
{{ searchTotal }} result(s)
|
||||
</p>
|
||||
<table class="w-full min-w-[640px] text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line text-left text-ink-secondary">
|
||||
<th class="py-2 px-3 font-medium">ID</th>
|
||||
<th v-if="resourceType === 'Patient'" class="py-2 px-3 font-medium">Name</th>
|
||||
<th v-if="resourceType === 'Patient'" class="py-2 px-3 font-medium">MRN</th>
|
||||
<th v-if="resourceType === 'Patient'" class="py-2 px-3 font-medium">DOB</th>
|
||||
<th v-if="resourceType === 'Encounter'" class="py-2 px-3 font-medium">Status</th>
|
||||
<th v-if="resourceType === 'Encounter'" class="py-2 px-3 font-medium">Patient</th>
|
||||
<th v-if="resourceType === 'Observation'" class="py-2 px-3 font-medium">Code</th>
|
||||
<th v-if="resourceType === 'Observation'" class="py-2 px-3 font-medium">Value</th>
|
||||
<th v-if="resourceType === 'Observation'" class="py-2 px-3 font-medium">Recorded</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="entry in searchResults"
|
||||
:key="String(entry.resource?.id)"
|
||||
class="border-b border-line hover:bg-primary-50 cursor-pointer"
|
||||
:class="{ 'bg-primary-50': selectedResource?.id === entry.resource?.id }"
|
||||
@click="selectResource(entry.resource)"
|
||||
>
|
||||
<td class="py-2 px-3 font-mono text-xs text-ink-strong">
|
||||
{{ entry.resource?.id }}
|
||||
</td>
|
||||
<td v-if="resourceType === 'Patient'" class="py-2 px-3">
|
||||
{{ patientDisplayName(entry.resource) }}
|
||||
</td>
|
||||
<td v-if="resourceType === 'Patient'" class="py-2 px-3 font-mono text-xs">
|
||||
{{ patientMrn(entry.resource) }}
|
||||
</td>
|
||||
<td v-if="resourceType === 'Patient'" class="py-2 px-3">
|
||||
{{ entry.resource?.birthDate ?? '—' }}
|
||||
</td>
|
||||
<td v-if="resourceType === 'Encounter'" class="py-2 px-3">
|
||||
{{ entry.resource?.status ?? '—' }}
|
||||
</td>
|
||||
<td v-if="resourceType === 'Encounter'" class="py-2 px-3 font-mono text-xs">
|
||||
{{ subjectReference(entry.resource) }}
|
||||
</td>
|
||||
<td v-if="resourceType === 'Observation'" class="py-2 px-3">
|
||||
{{ observationCodeDisplay(entry.resource) }}
|
||||
</td>
|
||||
<td v-if="resourceType === 'Observation'" class="py-2 px-3">
|
||||
{{ observationValueDisplay(entry.resource) }}
|
||||
</td>
|
||||
<td v-if="resourceType === 'Observation'" class="py-2 px-3 text-ink-secondary">
|
||||
{{ observationEffective(entry.resource) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="everythingObservations.length > 0">
|
||||
<h3 class="font-semibold mb-2">Observations ({{ everythingObservations.length }})</h3>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="obs in everythingObservations"
|
||||
:key="String(obs.id)"
|
||||
class="text-sm border border-gray-200 rounded-md px-3 py-2 flex flex-wrap gap-x-3"
|
||||
>
|
||||
<span class="font-mono text-xs text-gray-500">{{ obs.id }}</span>
|
||||
<span>{{ observationCodeDisplay(obs) }}</span>
|
||||
<span>{{ observationValueDisplay(obs) }}</span>
|
||||
<span class="text-gray-500">{{ observationEffective(obs) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<EmptyState
|
||||
v-else-if="searched && !searching && !searchError"
|
||||
title="No matching FHIR resources."
|
||||
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 -->
|
||||
<section class="card space-y-4">
|
||||
<header>
|
||||
<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.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="max-w-md">
|
||||
<label class="block text-sm font-semibold text-ink mb-2">Patient</label>
|
||||
<PatientSearch v-model="everythingPatientId" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
:disabled="!everythingPatientId || everythingLoading"
|
||||
@click="loadEverything"
|
||||
>
|
||||
{{ everythingLoading ? 'Loading...' : 'Load All Data' }}
|
||||
</button>
|
||||
|
||||
<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="space-y-5 pt-2">
|
||||
<div
|
||||
v-if="everythingPatient"
|
||||
class="rounded-input border border-primary-100 bg-primary-50 p-4"
|
||||
>
|
||||
<h3 class="text-sm font-semibold text-ink-strong mb-2">Patient</h3>
|
||||
<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 v-if="everythingEncounters.length > 0">
|
||||
<h3 class="text-sm font-semibold text-ink-strong mb-2">
|
||||
Encounters ({{ everythingEncounters.length }})
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="enc in everythingEncounters"
|
||||
:key="String(enc.id)"
|
||||
class="text-sm border border-line rounded-input px-3 py-2 bg-surface"
|
||||
>
|
||||
<span class="font-mono text-xs text-ink-secondary">{{ enc.id }}</span>
|
||||
— {{ enc.status }}
|
||||
<span v-if="enc.period?.start" class="text-ink-secondary">
|
||||
· {{ enc.period.start }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="everythingObservations.length > 0">
|
||||
<h3 class="text-sm font-semibold text-ink-strong mb-2">
|
||||
Observations ({{ everythingObservations.length }})
|
||||
</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"
|
||||
:key="String(obs.id)"
|
||||
class="border-b border-line"
|
||||
>
|
||||
<td class="py-2 px-2 font-mono text-xs text-ink-secondary">{{ obs.id }}</td>
|
||||
<td class="py-2 px-2">{{ observationCodeDisplay(obs) }}</td>
|
||||
<td class="py-2 px-2">{{ observationValueDisplay(obs) }}</td>
|
||||
<td class="py-2 px-2 text-ink-secondary">
|
||||
{{ observationEffective(obs) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -226,7 +333,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import TourHelpButton from '../components/TourHelpButton.vue'
|
||||
import PatientSearch from '../components/PatientSearch.vue'
|
||||
import InlineError from '../components/InlineError.vue'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
import {
|
||||
fhirGet,
|
||||
openFhirJsonInNewTab,
|
||||
@@ -239,6 +349,7 @@ type ResourceType = 'Patient' | 'Encounter' | 'Observation'
|
||||
|
||||
const resourceType = ref<ResourceType>('Patient')
|
||||
const searching = ref(false)
|
||||
const searched = ref(false)
|
||||
const searchError = ref('')
|
||||
const searchResults = ref<FhirBundleEntry[]>([])
|
||||
const searchTotal = ref(0)
|
||||
@@ -300,6 +411,7 @@ function clearResults(): void {
|
||||
searchTotal.value = 0
|
||||
selectedResource.value = null
|
||||
searchError.value = ''
|
||||
searched.value = false
|
||||
}
|
||||
|
||||
function selectResource(resource: FhirResource | undefined): void {
|
||||
@@ -374,6 +486,7 @@ function buildSearchParams(): Record<string, string | number | undefined> {
|
||||
|
||||
async function runSearch(): Promise<void> {
|
||||
searching.value = true
|
||||
searched.value = true
|
||||
searchError.value = ''
|
||||
selectedResource.value = null
|
||||
|
||||
|
||||