Add: No toast notification system or success feedback + No corrections/supersession UI
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A clinical records digitization platform built with ASP.NET Core 8, PostgreSQL, MinIO, Redis, and a Vue 3 workstation UI. The domain models the scan-to-approved lifecycle at the center of any paper chart digitization system: scanned document upload, human data entry, dual-human verification with separation of duties, site-configurable clinical approval routing, atomic promotion to live clinical tables, and governed correction via supersession.
|
A clinical records digitization platform built with ASP.NET Core 8, PostgreSQL, MinIO, Redis, and a Vue 3 workstation UI. The domain models the scan-to-approved lifecycle at the center of any paper chart digitization system: scanned document upload, human data entry, dual-human verification with separation of duties, site-configurable clinical approval routing, atomic promotion to live clinical tables, and governed correction via supersession.
|
||||||
|
|
||||||
**Implementation status:** Phases 1–9 are complete. Phase 7 delivers the digitization workstation UI (`vigilcare-records-web`). Phase 8 adds Prometheus metrics, supervisor work-queue overview, cursor-paginated batch audit trails, promotion deferral with exponential backoff retry, and a Docker monitoring stack (Prometheus on **9095**, Grafana on **3013**). Phase 9 adds extended demo seed data (10 batches across all types, tracks, and statuses), the E2E verification script (`scripts/run-vigilcare-records-verification-p9.sh`), and clinical scenario documentation ([digitization-workstation-guide.md](docs/digitization-workstation-guide.md)). Post-phase hardening includes health check endpoints, user management APIs, auth rate limiting, document access audit events, batch cancellation, unified promotion retry logic, normalized patient deduplication, and assignment-time `IN_ENTRY` transitions. See [Implemented Phases](#implemented-phases) and the [gap analysis](docs/vigilcare-records-gap-analysis.md) for remaining work.
|
**Implementation status:** Phases 1–9 are complete. Phase 7 delivers the digitization workstation UI (`vigilcare-records-web`) with role-specific views for intake, entry, verification, clinical approval, live capture, patient history, and supervisor dashboard. Phase 8 adds Prometheus metrics, supervisor work-queue overview, cursor-paginated batch audit trails, promotion deferral with exponential backoff retry, and a Docker monitoring stack (Prometheus on **9095**, Grafana on **3013**). Phase 9 adds extended demo seed data (10 batches across all types, tracks, and statuses), the E2E verification script (`scripts/run-vigilcare-records-verification-p9.sh`), and clinical scenario documentation ([digitization-workstation-guide.md](docs/digitization-workstation-guide.md)). Post-phase hardening includes health check endpoints, user management APIs, auth rate limiting, document access audit events, batch cancellation, list/queue sorting (`sortBy`/`sortDirection`), unified promotion retry logic, normalized patient deduplication, and assignment-time `IN_ENTRY` transitions. See [Implemented Phases](#implemented-phases) and the [gap analysis](docs/vigilcare-records-gap-analysis.md) for remaining work.
|
||||||
|
|
||||||
## Domain Model — How It Maps to a Real Clinical System
|
## Domain Model — How It Maps to a Real Clinical System
|
||||||
|
|
||||||
@@ -57,11 +57,11 @@ Append-only audit log entry for every state transition, field-level correction,
|
|||||||
- **Corrections and Supersession** — approved live observations are never silently edited; a correction uploads a new batch with `supersedesBatchId`, goes through the full entry → verify → approve cycle, and on promotion marks the original batch's `live_observations` rows as `is_superseded` (append-only — never deleted); `422 SUPERSEDED_BATCH_NOT_PROMOTED`, `409 BATCH_ALREADY_SUPERSEDED`, and `404 SUPERSEDED_BATCH_NOT_FOUND` guard invalid supersession targets; correction batches inherit patient context and reuse the original promotion encounter
|
- **Corrections and Supersession** — approved live observations are never silently edited; a correction uploads a new batch with `supersedesBatchId`, goes through the full entry → verify → approve cycle, and on promotion marks the original batch's `live_observations` rows as `is_superseded` (append-only — never deleted); `422 SUPERSEDED_BATCH_NOT_PROMOTED`, `409 BATCH_ALREADY_SUPERSEDED`, and `404 SUPERSEDED_BATCH_NOT_FOUND` guard invalid supersession targets; correction batches inherit patient context and reuse the original promotion encounter
|
||||||
- **Patient Digitization History** — `GET /patients/:id/digitization-history` returns all batches for a patient with correction chain metadata (`isCorrection`, `hasBeenSuperseded`, `supersededByBatchId`), live vs superseded observation counts, summary totals, and per-batch audit trails; `404 PATIENT_HISTORY_NOT_FOUND` when no batches exist for the patient
|
- **Patient Digitization History** — `GET /patients/:id/digitization-history` returns all batches for a patient with correction chain metadata (`isCorrection`, `hasBeenSuperseded`, `supersededByBatchId`), live vs superseded observation counts, summary totals, and per-batch audit trails; `404 PATIENT_HISTORY_NOT_FOUND` when no batches exist for the patient
|
||||||
- **Patient Registry Search** — `GET /patients/search?q=` searches live patients by MRN or full name (minimum 2 characters); used by the intake workstation to link uploads to existing patients
|
- **Patient Registry Search** — `GET /patients/search?q=` searches live patients by MRN or full name (minimum 2 characters); used by the intake workstation to link uploads to existing patients
|
||||||
- **Work Queues** — `GET /work-queue/verification` (batches in `PENDING_VERIFICATION`, sorted by submission time ASC); `GET /work-queue/entry` (batches in `UPLOADED`, `IN_ENTRY`, or `REJECTED`); `GET /work-queue/clinical-approval` (batches in `AWAITING_CLINICAL_APPROVAL`); `GET /work-queue/overview` (aggregate status counts, average queue age, reject rate, oldest pending verification — administrator only); role-restricted access
|
- **Work Queues** — `GET /work-queue/verification` (batches in `PENDING_VERIFICATION`); `GET /work-queue/entry` (batches in `UPLOADED`, `IN_ENTRY`, or `REJECTED`); `GET /work-queue/clinical-approval` (batches in `AWAITING_CLINICAL_APPROVAL`); `GET /work-queue/overview` (aggregate status counts, average queue age, reject rate, oldest pending verification — administrator only); all queue and batch list endpoints support `sortBy` and `sortDirection`; role-restricted access
|
||||||
- **Batch Audit Trail API** — `GET /digitization-batches/:id/events` returns cursor-paginated digitization events with actor username and full name; accessible by administrator, verifier, and clinical approver roles
|
- **Batch Audit Trail API** — `GET /digitization-batches/:id/events` returns cursor-paginated digitization events with actor username and full name; accessible by administrator, verifier, and clinical approver roles
|
||||||
- **User Directory and Management** — `GET /users?role=` lists active users for batch assignment; administrators can `POST /users` (create), `PATCH /users/:id` (update name, role, active flag), `POST /users/:id/reset-password`, and any authenticated user can `POST /users/me/change-password` with current-password verification
|
- **User Directory and Management** — `GET /users?role=` lists active users for batch assignment; administrators can `POST /users` (create), `PATCH /users/:id` (update name, role, active flag), `POST /users/:id/reset-password`, and any authenticated user can `POST /users/me/change-password` with current-password verification
|
||||||
- **Document Access Audit** — `GET /digitization-batches/:id` writes a `document_accessed` digitization event (deduplicated per user within 5 minutes) whenever a presigned scan URL is issued
|
- **Document Access Audit** — `GET /digitization-batches/:id` writes a `document_accessed` digitization event (deduplicated per user within 5 minutes) whenever a presigned scan URL is issued
|
||||||
- **Digitization Workstation UI** — Vue 3 SPA at `vigilcare-records-web`: role-based routing (intake, entry, verification, supervisor dashboard), split-pane scan viewer with zoom/pan/rotate, draft entry with auto-save, field-level verification checkboxes, presigned URL refresh for long sessions, JWT refresh interceptor
|
- **Digitization Workstation UI** — Vue 3 SPA at `vigilcare-records-web`: role-based routing and nav (intake, entry, verification, clinical approval, live capture, patient history, supervisor dashboard); split-pane scan viewer with zoom/pan/rotate; draft entry with auto-save and batch-type-aware fields (allergies, medications, discharge diagnosis); field-level verification checkboxes; clinical approval queue with scan review, approve/reject, and retroactive alert toggle; live capture form for new or existing encounters with attestation and password confirm; patient history timeline with correction chain and audit trail; presigned URL refresh for long sessions; JWT refresh interceptor and toast notifications
|
||||||
- **Live Capture (Track B)** — `POST /live-capture/encounters/{encounterId}/observations` and `POST /live-capture/encounters` for credentialed clinicians entering vitals at bedside; clinician attestation + password re-confirm replaces the dual-human verification gate; observations promote synchronously to live tables in a single transaction with `source = live_capture`; critical threshold evaluation runs before the response returns, with inline `criticalAlert` per observation, committed `ClinicalAlert` rows, and `observation.recorded` / `alert.generated` outbox events; `422 ATTESTATION_REQUIRED`, `422 PASSWORD_CONFIRM_INVALID`, `422 EMPTY_OBSERVATIONS`, `409 ENCOUNTER_NOT_ACTIVE`, `409 ACTIVE_ENCOUNTER_EXISTS`
|
- **Live Capture (Track B)** — `POST /live-capture/encounters/{encounterId}/observations` and `POST /live-capture/encounters` for credentialed clinicians entering vitals at bedside; clinician attestation + password re-confirm replaces the dual-human verification gate; observations promote synchronously to live tables in a single transaction with `source = live_capture`; critical threshold evaluation runs before the response returns, with inline `criticalAlert` per observation, committed `ClinicalAlert` rows, and `observation.recorded` / `alert.generated` outbox events; `422 ATTESTATION_REQUIRED`, `422 PASSWORD_CONFIRM_INVALID`, `422 EMPTY_OBSERVATIONS`, `409 ENCOUNTER_NOT_ACTIVE`, `409 ACTIVE_ENCOUNTER_EXISTS`
|
||||||
- **Batch Status Machine** — explicit transition matrix enforced in the service layer; illegal transitions return `409`; `PROMOTED` and `CANCELLED` are terminal — corrections require a new batch with `supersedesBatchId`
|
- **Batch Status Machine** — explicit transition matrix enforced in the service layer; illegal transitions return `409`; `PROMOTED` and `CANCELLED` are terminal — corrections require a new batch with `supersedesBatchId`
|
||||||
- **JWT Authentication** — `POST /auth/login` returns access token (15 min) and refresh token (7 days); `POST /auth/refresh` rotates tokens; `POST /auth/logout` revokes server-side; `GET /auth/me` returns authenticated user profile; BCrypt password hashing; login and refresh rate-limited to 10 requests per 5 minutes per client (`429`)
|
- **JWT Authentication** — `POST /auth/login` returns access token (15 min) and refresh token (7 days); `POST /auth/refresh` rotates tokens; `POST /auth/logout` revokes server-side; `GET /auth/me` returns authenticated user profile; BCrypt password hashing; login and refresh rate-limited to 10 requests per 5 minutes per client (`429`)
|
||||||
@@ -173,11 +173,11 @@ VigilCareRecords/
|
|||||||
├── vigilcare-records-web/ # Vue 3 digitization workstation UI
|
├── vigilcare-records-web/ # Vue 3 digitization workstation UI
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── api/client.ts # Axios client with JWT interceptor and proactive refresh
|
│ │ ├── api/client.ts # Axios client with JWT interceptor and proactive refresh
|
||||||
│ │ ├── stores/ # Pinia: auth (login, roles, routing), batches (CRUD, draft, verify)
|
│ │ ├── stores/ # Pinia: auth, batches, liveCapture
|
||||||
│ │ ├── router/index.ts # Role-based routes and navigation guards
|
│ │ ├── router/index.ts # Role-based routes and navigation guards
|
||||||
│ │ ├── views/ # Login, Intake, Entry, Verification, QueueDashboard
|
│ │ ├── views/ # Login, Intake, Entry, Verification, Approval, LiveCapture, PatientHistory, QueueDashboard
|
||||||
│ │ ├── components/ # ScanViewer, EntryForm, VerificationForm, BatchList, PatientSearch, AppHeader, AssignClerkDialog
|
│ │ ├── components/ # ScanViewer, EntryForm, VerificationForm, BatchList, PatientSearch, AppHeader, ToastContainer, AssignClerkDialog
|
||||||
│ │ ├── composables/usePresignedUrl.ts # Refreshes presigned document URLs before 15-minute expiry
|
│ │ ├── composables/ # usePresignedUrl (URL refresh), useToast (notifications)
|
||||||
│ │ └── types/index.ts # TypeScript interfaces matching API response shapes
|
│ │ └── types/index.ts # TypeScript interfaces matching API response shapes
|
||||||
│ ├── vite.config.ts # Dev server on port 3028; proxies /api → localhost:5217
|
│ ├── vite.config.ts # Dev server on port 3028; proxies /api → localhost:5217
|
||||||
│ └── tailwind.config.js # Clinical color palette and layout component classes
|
│ └── tailwind.config.js # Clinical color palette and layout component classes
|
||||||
@@ -366,8 +366,12 @@ Open `http://localhost:3028`. The Vite dev server proxies `/api` requests to the
|
|||||||
| `intake1` | `password` | `/intake` — upload scans, assign entry clerks |
|
| `intake1` | `password` | `/intake` — upload scans, assign entry clerks |
|
||||||
| `entry1` | `password` | `/entry` — data entry queue and split-pane form |
|
| `entry1` | `password` | `/entry` — data entry queue and split-pane form |
|
||||||
| `verifier1` | `password` | `/verification` — field-level verification |
|
| `verifier1` | `password` | `/verification` — field-level verification |
|
||||||
|
| `approver1` | `password` | `/approval` — clinical sign-off before promotion |
|
||||||
|
| `clinician1` | `password` | `/live-capture` — bedside vitals with attestation |
|
||||||
| `admin1` | `password` | `/dashboard` — supervisor queue overview |
|
| `admin1` | `password` | `/dashboard` — supervisor queue overview |
|
||||||
|
|
||||||
|
All roles can access `/patients` for patient search and digitization history.
|
||||||
|
|
||||||
See [docs/digitization-workstation-guide.md](docs/digitization-workstation-guide.md) for clinical scenarios (backfill, live capture, corrections) and the full clerk workflow.
|
See [docs/digitization-workstation-guide.md](docs/digitization-workstation-guide.md) for clinical scenarios (backfill, live capture, corrections) and the full clerk workflow.
|
||||||
|
|
||||||
**Paper originals:** The scanned document is the working reference for entry and verification. The physical chart remains the legal original until jurisdiction-specific retention rules apply. Scans are never deleted on batch rejection.
|
**Paper originals:** The scanned document is the working reference for entry and verification. The physical chart remains the legal original until jurisdiction-specific retention rules apply. Scans are never deleted on batch rejection.
|
||||||
@@ -379,6 +383,8 @@ cd vigilcare-records-web
|
|||||||
npm run build # output in dist/
|
npm run build # output in dist/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For production deployment where the UI and API are on different origins, add the frontend URL to `Cors:AllowedOrigins` in `appsettings.json` (default: `http://localhost:3028` for local dev).
|
||||||
|
|
||||||
### Run Tests
|
### Run Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -395,6 +401,7 @@ Integration tests use `WebApplicationFactory` with PostgreSQL, Redis, and MinIO
|
|||||||
| `CorrectionSupersessionTests` | 5 | Correction batch supersession, validation guards (non-promoted, already superseded), patient digitization history, unknown patient 404 |
|
| `CorrectionSupersessionTests` | 5 | Correction batch supersession, validation guards (non-promoted, already superseded), patient digitization history, unknown patient 404 |
|
||||||
| `LiveCaptureIntegrationTests` | 6 | Attestation and password confirm, synchronous promotion, critical low/high potassium alerts, mixed-batch alerting, open encounter + vitals, role and validation guards, audit events |
|
| `LiveCaptureIntegrationTests` | 6 | Attestation and password confirm, synchronous promotion, critical low/high potassium alerts, mixed-batch alerting, open encounter + vitals, role and validation guards, audit events |
|
||||||
| `UserManagementTests` | 9 | User create/update/deactivate, admin password reset, self-service password change, duplicate username guard, weak password validation |
|
| `UserManagementTests` | 9 | User create/update/deactivate, admin password reset, self-service password change, duplicate username guard, weak password validation |
|
||||||
|
| `BatchOperationsTests` | — | Batch cancellation (status guards, Redis lock release), list/queue `sortBy`/`sortDirection` validation |
|
||||||
|
|
||||||
### Verification Scripts
|
### Verification Scripts
|
||||||
|
|
||||||
@@ -484,7 +491,7 @@ Error response:
|
|||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| POST | `/digitization-batches` | Upload a scanned document and create a batch (multipart/form-data) |
|
| POST | `/digitization-batches` | Upload a scanned document and create a batch (multipart/form-data) |
|
||||||
| GET | `/digitization-batches` | List batches; optional `status`, `batchType`, `assignedTo`, `track` filters; paginated |
|
| GET | `/digitization-batches` | List batches; optional `status`, `batchType`, `assignedTo`, `track` filters; paginated and sortable (`sortBy`, `sortDirection`; default `createdAt desc`) |
|
||||||
| GET | `/digitization-batches/{id}` | Batch detail with presigned document URL (15-minute expiry); audits `document_accessed` |
|
| GET | `/digitization-batches/{id}` | Batch detail with presigned document URL (15-minute expiry); audits `document_accessed` |
|
||||||
| PATCH | `/digitization-batches/{id}/assign` | Assign batch to an entry clerk (Redis lock; transitions to `IN_ENTRY`) |
|
| PATCH | `/digitization-batches/{id}/assign` | Assign batch to an entry clerk (Redis lock; transitions to `IN_ENTRY`) |
|
||||||
| POST | `/digitization-batches/{id}/cancel` | Cancel batch permanently (administrator only; `UPLOADED`, `IN_ENTRY`, or `REJECTED`) |
|
| POST | `/digitization-batches/{id}/cancel` | Cancel batch permanently (administrator only; `UPLOADED`, `IN_ENTRY`, or `REJECTED`) |
|
||||||
@@ -640,12 +647,12 @@ Error response:
|
|||||||
|
|
||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| GET | `/work-queue/verification` | Verifier, Clinical Approver, Administrator | Batches in `PENDING_VERIFICATION`, sorted by submission time |
|
| GET | `/work-queue/verification` | Verifier, Clinical Approver, Administrator | Batches in `PENDING_VERIFICATION` |
|
||||||
| GET | `/work-queue/entry` | Data Entry Clerk, Administrator | Batches awaiting or in entry (`UPLOADED`, `IN_ENTRY`, `REJECTED`) |
|
| GET | `/work-queue/entry` | Data Entry Clerk, Administrator | Batches awaiting or in entry (`UPLOADED`, `IN_ENTRY`, `REJECTED`) |
|
||||||
| GET | `/work-queue/clinical-approval` | Clinical Approver, Administrator | Batches in `AWAITING_CLINICAL_APPROVAL` |
|
| GET | `/work-queue/clinical-approval` | Clinical Approver, Administrator | Batches in `AWAITING_CLINICAL_APPROVAL` |
|
||||||
| GET | `/work-queue/overview` | Administrator | Aggregate metrics: status counts, average queue age, 24h reject rate, oldest pending verification |
|
| GET | `/work-queue/overview` | Administrator | Aggregate metrics: status counts, average queue age, 24h reject rate, oldest pending verification |
|
||||||
|
|
||||||
All work queue endpoints support pagination via `?page=1&pageSize=20` (except `overview`).
|
All work queue endpoints support pagination via `?page=1&pageSize=20` and sorting via `?sortBy=updatedAt&sortDirection=asc` (except `overview`). Allowed sort fields: `createdAt`, `updatedAt`, `status`, `batchType`, `track`. Invalid `sortBy` returns `422 INVALID_SORT_FIELD`.
|
||||||
|
|
||||||
### Batch Audit Trail
|
### Batch Audit Trail
|
||||||
|
|
||||||
@@ -1101,6 +1108,10 @@ List endpoints (batch list, work queues) use offset pagination:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `page` | 1 | Page number (1-based) |
|
| `page` | 1 | Page number (1-based) |
|
||||||
| `pageSize` | 20 | Items per page |
|
| `pageSize` | 20 | Items per page |
|
||||||
|
| `sortBy` | `createdAt` (batch list) or `updatedAt` (work queues) | Sort field: `createdAt`, `updatedAt`, `status`, `batchType`, `track` |
|
||||||
|
| `sortDirection` | `desc` (batch list) or `asc` (work queues) | `asc` or `desc` |
|
||||||
|
|
||||||
|
Invalid `sortBy` values return `422` with code `INVALID_SORT_FIELD`.
|
||||||
|
|
||||||
Response shape:
|
Response shape:
|
||||||
|
|
||||||
@@ -1148,7 +1159,7 @@ Phases 1–9 are fully implemented and verified via integration tests and per-ph
|
|||||||
| 4 | Approval with three-person separation of duties (entry ≠ verifier ≠ approver), atomic promotion to live `patients`/`encounters`/`observations` tables in a single PostgreSQL transaction, PostgreSQL sequence-backed MRN generation (`VCR-NNNNNN`), patient dedup by normalized name + DOB, encounter matching by patient + department + active status, transactional outbox (`observation.created` events), retroactive alert policy per batch, `Idempotency-Key` header with 24h TTL for safe retries, `PromotionTests` integration tests, Phase 4 verification script | Done |
|
| 4 | Approval with three-person separation of duties (entry ≠ verifier ≠ approver), atomic promotion to live `patients`/`encounters`/`observations` tables in a single PostgreSQL transaction, PostgreSQL sequence-backed MRN generation (`VCR-NNNNNN`), patient dedup by normalized name + DOB, encounter matching by patient + department + active status, transactional outbox (`observation.created` events), retroactive alert policy per batch, `Idempotency-Key` header with 24h TTL for safe retries, `PromotionTests` integration tests, Phase 4 verification script | Done |
|
||||||
| 5 | Correction batches via `supersedesBatchId`, supersession validation on create (`422`/`404`/`409`), append-only `live_observations` supersession flags (`is_superseded`, `superseded_by_batch_id`, `superseded_at`), correction promotion reuses original encounter and linked patient, `correction_uploaded`/`correction_promoted`/`superseded` audit events, `GET /patients/:id/digitization-history` with correction chain and per-batch audit trails, `CorrectionSupersessionTests` integration tests, Phase 5 verification script | Done |
|
| 5 | Correction batches via `supersedesBatchId`, supersession validation on create (`422`/`404`/`409`), append-only `live_observations` supersession flags (`is_superseded`, `superseded_by_batch_id`, `superseded_at`), correction promotion reuses original encounter and linked patient, `correction_uploaded`/`correction_promoted`/`superseded` audit events, `GET /patients/:id/digitization-history` with correction chain and per-batch audit trails, `CorrectionSupersessionTests` integration tests, Phase 5 verification script | Done |
|
||||||
| 6 | Track B live capture: `LiveCaptureController` with clinician-only endpoints, `AttestationService` (role + password re-confirm), synchronous promotion via `LiveCaptureService`, Redis-backed critical threshold evaluation, inline critical alerts + committed `ClinicalAlert` rows, `observation.recorded` and `alert.generated` outbox events, open-encounter + vitals outpatient workflow, `LiveCaptureIntegrationTests`, Phase 6 verification script | Done |
|
| 6 | Track B live capture: `LiveCaptureController` with clinician-only endpoints, `AttestationService` (role + password re-confirm), synchronous promotion via `LiveCaptureService`, Redis-backed critical threshold evaluation, inline critical alerts + committed `ClinicalAlert` rows, `observation.recorded` and `alert.generated` outbox events, open-encounter + vitals outpatient workflow, `LiveCaptureIntegrationTests`, Phase 6 verification script | Done |
|
||||||
| 7 | Digitization workstation UI (`vigilcare-records-web`): Vue 3 + Pinia + Tailwind, role-based routing, split-pane scan viewer, draft entry with auto-save, verification checkboxes, patient search, assign-clerk dialog, presigned URL refresh, supervisor dashboard shell | Done |
|
| 7 | Digitization workstation UI (`vigilcare-records-web`): Vue 3 + Pinia + Tailwind, role-based routing and nav, split-pane scan viewer, batch-type-aware draft entry (allergies, medications, discharge diagnosis), verification checkboxes, clinical approval view, live capture view (new/existing encounter), patient history timeline, patient search, assign-clerk dialog, presigned URL refresh, supervisor dashboard, toast notifications | Done |
|
||||||
| 8 | Prometheus metrics (`GET /metrics`), custom digitization gauges/histograms/counters, `MetricsCollectorService`, supervisor `GET /work-queue/overview`, cursor-paginated `GET /digitization-batches/:id/events`, promotion deferral (202 / `PROMOTION_DEFERRED`), `PromotionRetryService` with exponential backoff, Docker Prometheus (9095) + Grafana (3013), Phase 8 verification script | Done |
|
| 8 | Prometheus metrics (`GET /metrics`), custom digitization gauges/histograms/counters, `MetricsCollectorService`, supervisor `GET /work-queue/overview`, cursor-paginated `GET /digitization-batches/:id/events`, promotion deferral (202 / `PROMOTION_DEFERRED`), `PromotionRetryService` with exponential backoff, Docker Prometheus (9095) + Grafana (3013), Phase 8 verification script | Done |
|
||||||
| 9 | Extended seed data (10 demo batches across all types/tracks/statuses), E2E verification script (`run-vigilcare-records-verification-p9.sh`), clinical scenario docs (`docs/digitization-workstation-guide.md`), `UserManagementTests` | Done |
|
| 9 | Extended seed data (10 demo batches across all types/tracks/statuses), E2E verification script (`run-vigilcare-records-verification-p9.sh`), clinical scenario docs (`docs/digitization-workstation-guide.md`), `UserManagementTests` | Done |
|
||||||
| — | Health probes (`/health/live`, `/health/ready`, `/health/startup`), user management CRUD + password endpoints, auth rate limiting, document access audit, batch cancellation, unified `ExecutePromotionCoreAsync` for approve + retry promote, assignment-time `IN_ENTRY` transition | Done |
|
| — | Health probes (`/health/live`, `/health/ready`, `/health/startup`), user management CRUD + password endpoints, auth rate limiting, document access audit, batch cancellation, list/queue sorting, unified `ExecutePromotionCoreAsync` for approve + retry promote, assignment-time `IN_ENTRY` transition, CORS policy for production frontend origins | Done |
|
||||||
|
|||||||
@@ -604,7 +604,7 @@ Two of the seven batch types (`ALLERGY_UPDATE` and `MEDICATION_LIST`) cannot be
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## P4 — No corrections/supersession UI
|
## ~~P4 — No corrections/supersession UI~~ DONE
|
||||||
|
|
||||||
### Problem
|
### Problem
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<router-view />
|
<router-view />
|
||||||
|
<ToastContainer />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// App shell — routing handles all layout
|
import ToastContainer from './components/ToastContainer.vue'
|
||||||
</script>
|
</script>
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
<router-link v-if="auth.canVerify" to="/verification" class="nav-link">Verification</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.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 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="/dashboard" class="nav-link">Dashboard</router-link>
|
||||||
</nav>
|
</nav>
|
||||||
<slot name="subtitle" />
|
<slot name="subtitle" />
|
||||||
|
|||||||
@@ -232,6 +232,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, watch } from 'vue'
|
import { ref, reactive, computed, watch } from 'vue'
|
||||||
import { useBatchStore } from '../stores/batches'
|
import { useBatchStore } from '../stores/batches'
|
||||||
|
import { useToast } from '../composables/useToast'
|
||||||
import ObservationRow from '../components/ObservationRow.vue'
|
import ObservationRow from '../components/ObservationRow.vue'
|
||||||
import type { BatchDetailResponse, DraftObservation } from '../types'
|
import type { BatchDetailResponse, DraftObservation } from '../types'
|
||||||
|
|
||||||
@@ -241,6 +242,7 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const batchStore = useBatchStore()
|
const batchStore = useBatchStore()
|
||||||
|
const toast = useToast()
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
|
|
||||||
@@ -363,8 +365,11 @@ async function saveAllergies() {
|
|||||||
...patient,
|
...patient,
|
||||||
allergiesJson: JSON.stringify(allergies.value.filter(a => a.trim())),
|
allergiesJson: JSON.stringify(allergies.value.filter(a => a.trim())),
|
||||||
})
|
})
|
||||||
|
toast.success('Allergies saved')
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
errorMessage.value = e instanceof Error ? e.message : 'Failed to save allergies'
|
const msg = e instanceof Error ? e.message : 'Failed to save allergies'
|
||||||
|
errorMessage.value = msg
|
||||||
|
toast.error(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,8 +395,11 @@ async function saveMedications() {
|
|||||||
...patient,
|
...patient,
|
||||||
medicationsJson: JSON.stringify(medications.value.filter(m => m.trim())),
|
medicationsJson: JSON.stringify(medications.value.filter(m => m.trim())),
|
||||||
})
|
})
|
||||||
|
toast.success('Medications saved')
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
errorMessage.value = e instanceof Error ? e.message : 'Failed to save medications'
|
const msg = e instanceof Error ? e.message : 'Failed to save medications'
|
||||||
|
errorMessage.value = msg
|
||||||
|
toast.error(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,16 +417,22 @@ async function savePatient() {
|
|||||||
allergiesJson: patient.noKnownAllergies ? null : JSON.stringify(allergies.value.filter(a => a.trim())),
|
allergiesJson: patient.noKnownAllergies ? null : JSON.stringify(allergies.value.filter(a => a.trim())),
|
||||||
medicationsJson: patient.noActiveMedications ? null : JSON.stringify(medications.value.filter(m => m.trim())),
|
medicationsJson: patient.noActiveMedications ? null : JSON.stringify(medications.value.filter(m => m.trim())),
|
||||||
})
|
})
|
||||||
|
toast.success('Patient demographics saved')
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
errorMessage.value = e instanceof Error ? e.message : 'Failed to save patient'
|
const msg = e instanceof Error ? e.message : 'Failed to save patient'
|
||||||
|
errorMessage.value = msg
|
||||||
|
toast.error(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveEncounter() {
|
async function saveEncounter() {
|
||||||
try {
|
try {
|
||||||
await batchStore.saveDraftEncounter(props.batchId, encounter)
|
await batchStore.saveDraftEncounter(props.batchId, encounter)
|
||||||
|
toast.success('Encounter context saved')
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
errorMessage.value = e instanceof Error ? e.message : 'Failed to save encounter'
|
const msg = e instanceof Error ? e.message : 'Failed to save encounter'
|
||||||
|
errorMessage.value = msg
|
||||||
|
toast.error(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,8 +464,11 @@ async function submitForVerification() {
|
|||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
try {
|
try {
|
||||||
await batchStore.submitForVerification(props.batchId)
|
await batchStore.submitForVerification(props.batchId)
|
||||||
|
toast.success('Batch submitted for verification')
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
errorMessage.value = e instanceof Error ? e.message : 'Submit failed'
|
const msg = e instanceof Error ? e.message : 'Submit failed'
|
||||||
|
errorMessage.value = msg
|
||||||
|
toast.error(msg)
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
|
||||||
|
<transition-group name="toast">
|
||||||
|
<div
|
||||||
|
v-for="toast in toasts"
|
||||||
|
:key="toast.id"
|
||||||
|
class="pointer-events-auto rounded-md shadow-lg px-4 py-3 text-sm font-medium flex items-start gap-2"
|
||||||
|
:class="toastClasses[toast.type]"
|
||||||
|
>
|
||||||
|
<span class="flex-1">{{ toast.message }}</span>
|
||||||
|
<button
|
||||||
|
@click="dismiss(toast.id)"
|
||||||
|
class="opacity-60 hover:opacity-100 text-current ml-2 shrink-0"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</transition-group>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { toasts } from '../composables/useToast'
|
||||||
|
|
||||||
|
const toastClasses: Record<string, string> = {
|
||||||
|
success: 'bg-green-600 text-white',
|
||||||
|
error: 'bg-red-600 text-white',
|
||||||
|
warning: 'bg-yellow-500 text-white',
|
||||||
|
info: 'bg-blue-600 text-white',
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss(id: number) {
|
||||||
|
toasts.value = toasts.value.filter(t => t.id !== id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toast-enter-active {
|
||||||
|
transition: all 0.3s ease-out;
|
||||||
|
}
|
||||||
|
.toast-leave-active {
|
||||||
|
transition: all 0.2s ease-in;
|
||||||
|
}
|
||||||
|
.toast-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
.toast-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -189,6 +189,7 @@
|
|||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useBatchStore } from '../stores/batches'
|
import { useBatchStore } from '../stores/batches'
|
||||||
|
import { useToast } from '../composables/useToast'
|
||||||
import ObservationRow from '../components/ObservationRow.vue'
|
import ObservationRow from '../components/ObservationRow.vue'
|
||||||
import type { BatchDetailResponse, DraftObservation } from '../types'
|
import type { BatchDetailResponse, DraftObservation } from '../types'
|
||||||
|
|
||||||
@@ -199,6 +200,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const batchStore = useBatchStore()
|
const batchStore = useBatchStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
const processing = ref(false)
|
const processing = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const showRejectDialog = ref(false)
|
const showRejectDialog = ref(false)
|
||||||
@@ -344,9 +346,12 @@ async function approveVerification() {
|
|||||||
passed,
|
passed,
|
||||||
}))
|
}))
|
||||||
await batchStore.verifyBatch(props.batchId, checks, true)
|
await batchStore.verifyBatch(props.batchId, checks, true)
|
||||||
|
toast.success('Batch verified successfully')
|
||||||
router.push('/verification')
|
router.push('/verification')
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
errorMessage.value = e instanceof Error ? e.message : 'Verification failed'
|
const msg = e instanceof Error ? e.message : 'Verification failed'
|
||||||
|
errorMessage.value = msg
|
||||||
|
toast.error(msg)
|
||||||
} finally {
|
} finally {
|
||||||
processing.value = false
|
processing.value = false
|
||||||
}
|
}
|
||||||
@@ -358,9 +363,12 @@ async function rejectVerification() {
|
|||||||
try {
|
try {
|
||||||
await batchStore.rejectBatch(props.batchId, rejectionReason.value)
|
await batchStore.rejectBatch(props.batchId, rejectionReason.value)
|
||||||
showRejectDialog.value = false
|
showRejectDialog.value = false
|
||||||
|
toast.warning('Batch rejected')
|
||||||
router.push('/verification')
|
router.push('/verification')
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
errorMessage.value = e instanceof Error ? e.message : 'Rejection failed'
|
const msg = e instanceof Error ? e.message : 'Rejection failed'
|
||||||
|
errorMessage.value = msg
|
||||||
|
toast.error(msg)
|
||||||
} finally {
|
} finally {
|
||||||
processing.value = false
|
processing.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number
|
||||||
|
message: string
|
||||||
|
type: ToastType
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 0
|
||||||
|
export const toasts = ref<Toast[]>([])
|
||||||
|
|
||||||
|
function addToast(message: string, type: ToastType, duration = 4000) {
|
||||||
|
const id = nextId++
|
||||||
|
toasts.value.push({ id, message, type, duration })
|
||||||
|
setTimeout(() => {
|
||||||
|
toasts.value = toasts.value.filter(t => t.id !== id)
|
||||||
|
}, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
return {
|
||||||
|
success: (message: string) => addToast(message, 'success'),
|
||||||
|
error: (message: string) => addToast(message, 'error', 6000),
|
||||||
|
warning: (message: string) => addToast(message, 'warning', 5000),
|
||||||
|
info: (message: string) => addToast(message, 'info'),
|
||||||
|
toasts,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,19 @@ const routes: RouteRecordRaw[] = [
|
|||||||
},
|
},
|
||||||
props: true,
|
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',
|
path: '/live-capture',
|
||||||
name: 'LiveCapture',
|
name: 'LiveCapture',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
DraftObservation,
|
DraftObservation,
|
||||||
BatchListResponse,
|
BatchListResponse,
|
||||||
FieldCheck,
|
FieldCheck,
|
||||||
|
PatientDigitizationHistoryResponse,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
|
||||||
export const useBatchStore = defineStore('batches', () => {
|
export const useBatchStore = defineStore('batches', () => {
|
||||||
@@ -68,13 +69,15 @@ export const useBatchStore = defineStore('batches', () => {
|
|||||||
file: File,
|
file: File,
|
||||||
batchType: string,
|
batchType: string,
|
||||||
track: string,
|
track: string,
|
||||||
patientId?: string
|
patientId?: string,
|
||||||
|
supersedesBatchId?: string,
|
||||||
): Promise<BatchDetailResponse | null> {
|
): Promise<BatchDetailResponse | null> {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const fields: Record<string, string> = { batchType, track }
|
const fields: Record<string, string> = { batchType, track }
|
||||||
if (patientId) fields.patientId = patientId
|
if (patientId) fields.patientId = patientId
|
||||||
|
if (supersedesBatchId) fields.supersedesBatchId = supersedesBatchId
|
||||||
|
|
||||||
const response = await uploadFile<BatchDetailResponse>(
|
const response = await uploadFile<BatchDetailResponse>(
|
||||||
'digitization-batches',
|
'digitization-batches',
|
||||||
@@ -194,6 +197,25 @@ async function assignBatch(batchId: string, entryClerkUserId: string): Promise<v
|
|||||||
return { data: response.data ?? undefined }
|
return { data: response.data ?? undefined }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getPatientHistory(patientId: string): Promise<PatientDigitizationHistoryResponse | null> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const response = await get<PatientDigitizationHistoryResponse>(
|
||||||
|
`patients/${patientId}/digitization-history`,
|
||||||
|
)
|
||||||
|
if (response.success && response.data) {
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to load patient history'
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
batches,
|
batches,
|
||||||
currentBatch,
|
currentBatch,
|
||||||
@@ -216,5 +238,6 @@ async function assignBatch(batchId: string, entryClerkUserId: string): Promise<v
|
|||||||
verifyBatch,
|
verifyBatch,
|
||||||
rejectBatch,
|
rejectBatch,
|
||||||
approveBatch,
|
approveBatch,
|
||||||
|
getPatientHistory,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -146,6 +146,47 @@ export interface UserSummary {
|
|||||||
role: string
|
role: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DigitizationEventSummary {
|
||||||
|
eventType: string
|
||||||
|
occurredAt: string
|
||||||
|
actorUserId: string
|
||||||
|
actorName: string
|
||||||
|
metadataJson: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigitizationHistoryEntry {
|
||||||
|
batchId: string
|
||||||
|
status: string
|
||||||
|
batchType: string
|
||||||
|
track: string
|
||||||
|
supersedesBatchId: string | null
|
||||||
|
isCorrection: boolean
|
||||||
|
hasBeenSuperseded: boolean
|
||||||
|
supersededByBatchId: string | null
|
||||||
|
draftObservationCount: number
|
||||||
|
liveObservationCount: number
|
||||||
|
supersededObservationCount: number
|
||||||
|
createdAt: string
|
||||||
|
promotedAt: string | null
|
||||||
|
promotionEncounterId: string | null
|
||||||
|
enteredByUserId: string | null
|
||||||
|
enteredByUserName: string | null
|
||||||
|
verifiedByUserId: string | null
|
||||||
|
verifiedByUserName: string | null
|
||||||
|
approvedByUserId: string | null
|
||||||
|
approvedByUserName: string | null
|
||||||
|
auditTrail: DigitizationEventSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatientDigitizationHistoryResponse {
|
||||||
|
patientId: string
|
||||||
|
totalBatches: number
|
||||||
|
promotedBatches: number
|
||||||
|
supersededBatches: number
|
||||||
|
pendingBatches: number
|
||||||
|
entries: DigitizationHistoryEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface LiveCaptureObservationInput {
|
export interface LiveCaptureObservationInput {
|
||||||
observationCode: string
|
observationCode: string
|
||||||
value: number | null
|
value: number | null
|
||||||
|
|||||||
@@ -41,6 +41,25 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 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) -->
|
<!-- Patient summary (read-only) -->
|
||||||
<fieldset v-if="draft?.patient" class="border border-gray-200 rounded-md p-4">
|
<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>
|
<legend class="text-sm font-medium text-gray-700 px-2">Patient Demographics</legend>
|
||||||
@@ -134,6 +153,21 @@
|
|||||||
<p class="text-green-700 mt-1">Patient MRN: {{ promotionResult.mrn }}</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">Encounter: {{ promotionResult.encounterId?.substring(0, 8) }}...</p>
|
||||||
<p class="text-green-700">Observations promoted: {{ promotionResult.observationIds?.length }}</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>
|
</div>
|
||||||
|
|
||||||
<!-- Deferred banner -->
|
<!-- Deferred banner -->
|
||||||
@@ -188,6 +222,7 @@ import { ref, computed, onMounted, watch } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useBatchStore } from '../stores/batches'
|
import { useBatchStore } from '../stores/batches'
|
||||||
import { usePresignedUrl } from '../composables/usePresignedUrl'
|
import { usePresignedUrl } from '../composables/usePresignedUrl'
|
||||||
|
import { useToast } from '../composables/useToast'
|
||||||
import AppHeader from '../components/AppHeader.vue'
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
import ScanViewer from '../components/ScanViewer.vue'
|
import ScanViewer from '../components/ScanViewer.vue'
|
||||||
import BatchList from '../components/BatchList.vue'
|
import BatchList from '../components/BatchList.vue'
|
||||||
@@ -198,6 +233,7 @@ const props = defineProps<{ batchId?: string }>()
|
|||||||
const batchStore = useBatchStore()
|
const batchStore = useBatchStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined))
|
const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined))
|
||||||
const currentBatch = computed(() => batchStore.currentBatch)
|
const currentBatch = computed(() => batchStore.currentBatch)
|
||||||
@@ -231,21 +267,37 @@ async function approve() {
|
|||||||
const response = await batchStore.approveBatch(batchId.value, enableRetroactiveAlerts.value)
|
const response = await batchStore.approveBatch(batchId.value, enableRetroactiveAlerts.value)
|
||||||
if (response?.status === 202) {
|
if (response?.status === 202) {
|
||||||
deferred.value = true
|
deferred.value = true
|
||||||
|
toast.info('Approved. Promotion will be retried automatically.')
|
||||||
} else if (response?.data) {
|
} else if (response?.data) {
|
||||||
promotionResult.value = response.data
|
promotionResult.value = response.data
|
||||||
|
toast.success('Batch approved and promoted successfully')
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const msg = e instanceof Error ? e.message : 'Approval failed'
|
const msg = e instanceof Error ? e.message : 'Approval failed'
|
||||||
if (msg.includes('PROMOTION_DEFERRED')) {
|
if (msg.includes('PROMOTION_DEFERRED')) {
|
||||||
deferred.value = true
|
deferred.value = true
|
||||||
|
toast.info('Approved. Promotion will be retried automatically.')
|
||||||
} else {
|
} else {
|
||||||
errorMessage.value = msg
|
errorMessage.value = msg
|
||||||
|
toast.error(msg)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
processing.value = false
|
processing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createCorrection() {
|
||||||
|
if (!batchId.value) return
|
||||||
|
router.push({
|
||||||
|
path: '/intake',
|
||||||
|
query: {
|
||||||
|
supersedesBatchId: batchId.value,
|
||||||
|
patientId: currentBatch.value?.patientId ?? undefined,
|
||||||
|
batchType: currentBatch.value?.batchType ?? undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function reject() {
|
async function reject() {
|
||||||
if (!batchId.value) return
|
if (!batchId.value) return
|
||||||
processing.value = true
|
processing.value = true
|
||||||
@@ -253,9 +305,12 @@ async function reject() {
|
|||||||
try {
|
try {
|
||||||
await batchStore.rejectBatch(batchId.value, rejectionReason.value)
|
await batchStore.rejectBatch(batchId.value, rejectionReason.value)
|
||||||
showRejectDialog.value = false
|
showRejectDialog.value = false
|
||||||
|
toast.warning('Batch rejected')
|
||||||
router.push('/approval')
|
router.push('/approval')
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
errorMessage.value = e instanceof Error ? e.message : 'Rejection failed'
|
const msg = e instanceof Error ? e.message : 'Rejection failed'
|
||||||
|
errorMessage.value = msg
|
||||||
|
toast.error(msg)
|
||||||
} finally {
|
} finally {
|
||||||
processing.value = false
|
processing.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,21 @@
|
|||||||
<PatientSearch v-model="patientId" />
|
<PatientSearch v-model="patientId" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Supersession (correction) -->
|
||||||
|
<div v-if="supersedesBatchId" class="bg-blue-50 border border-blue-200 rounded-md p-4">
|
||||||
|
<p class="text-sm font-medium text-blue-800">Correction Batch</p>
|
||||||
|
<p class="text-sm text-blue-700 mt-1">
|
||||||
|
This upload will supersede batch
|
||||||
|
<span class="font-mono">{{ supersedesBatchId.substring(0, 8) }}...</span>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
@click="clearCorrection"
|
||||||
|
class="text-xs text-blue-600 hover:text-blue-800 mt-2"
|
||||||
|
>
|
||||||
|
Cancel correction (upload as new batch)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="uploadError" class="text-clinical-danger text-sm">
|
<div v-if="uploadError" class="text-clinical-danger text-sm">
|
||||||
{{ uploadError }}
|
{{ uploadError }}
|
||||||
</div>
|
</div>
|
||||||
@@ -104,18 +119,23 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import { useBatchStore } from '../stores/batches'
|
import { useBatchStore } from '../stores/batches'
|
||||||
|
import { useToast } from '../composables/useToast'
|
||||||
import AppHeader from '../components/AppHeader.vue'
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
import PatientSearch from '../components/PatientSearch.vue'
|
import PatientSearch from '../components/PatientSearch.vue'
|
||||||
import BatchList from '../components/BatchList.vue'
|
import BatchList from '../components/BatchList.vue'
|
||||||
import AssignClerkDialog from '../components/AssignClerkDialog.vue'
|
import AssignClerkDialog from '../components/AssignClerkDialog.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
const batchStore = useBatchStore()
|
const batchStore = useBatchStore()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
const selectedFile = ref<File | null>(null)
|
const selectedFile = ref<File | null>(null)
|
||||||
const batchType = ref('')
|
const batchType = ref((route.query.batchType as string) || '')
|
||||||
const track = ref('BACKFILL')
|
const track = ref('BACKFILL')
|
||||||
const patientId = ref<string | undefined>(undefined)
|
const patientId = ref<string | undefined>((route.query.patientId as string) || undefined)
|
||||||
|
const supersedesBatchId = ref<string | undefined>((route.query.supersedesBatchId as string) || undefined)
|
||||||
const uploadError = ref('')
|
const uploadError = ref('')
|
||||||
const assignError = ref('')
|
const assignError = ref('')
|
||||||
const assignDialogOpen = ref(false)
|
const assignDialogOpen = ref(false)
|
||||||
@@ -135,20 +155,30 @@ async function handleUpload() {
|
|||||||
selectedFile.value,
|
selectedFile.value,
|
||||||
batchType.value,
|
batchType.value,
|
||||||
track.value,
|
track.value,
|
||||||
patientId.value
|
patientId.value,
|
||||||
|
supersedesBatchId.value,
|
||||||
)
|
)
|
||||||
if (batch) {
|
if (batch) {
|
||||||
|
const isCorrection = !!supersedesBatchId.value
|
||||||
selectedFile.value = null
|
selectedFile.value = null
|
||||||
batchType.value = ''
|
batchType.value = ''
|
||||||
track.value = 'BACKFILL'
|
track.value = 'BACKFILL'
|
||||||
patientId.value = undefined
|
patientId.value = undefined
|
||||||
|
supersedesBatchId.value = undefined
|
||||||
|
toast.success(isCorrection ? 'Correction batch created' : 'Batch uploaded successfully')
|
||||||
await loadRecent()
|
await loadRecent()
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
uploadError.value = e instanceof Error ? e.message : 'Upload failed'
|
const msg = e instanceof Error ? e.message : 'Upload failed'
|
||||||
|
uploadError.value = msg
|
||||||
|
toast.error(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearCorrection() {
|
||||||
|
supersedesBatchId.value = undefined
|
||||||
|
}
|
||||||
|
|
||||||
function openAssignDialog(batchId: string) {
|
function openAssignDialog(batchId: string) {
|
||||||
assignError.value = ''
|
assignError.value = ''
|
||||||
assignBatchId.value = batchId
|
assignBatchId.value = batchId
|
||||||
@@ -165,9 +195,12 @@ async function handleAssign(batchId: string, clerkUserId: string) {
|
|||||||
try {
|
try {
|
||||||
await batchStore.assignBatch(batchId, clerkUserId)
|
await batchStore.assignBatch(batchId, clerkUserId)
|
||||||
closeAssignDialog()
|
closeAssignDialog()
|
||||||
|
toast.success('Batch assigned to entry clerk')
|
||||||
await loadRecent()
|
await loadRecent()
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
assignError.value = e instanceof Error ? e.message : 'Assignment failed'
|
const msg = e instanceof Error ? e.message : 'Assignment failed'
|
||||||
|
assignError.value = msg
|
||||||
|
toast.error(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -241,11 +241,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useLiveCaptureStore } from '../stores/liveCapture'
|
import { useLiveCaptureStore } from '../stores/liveCapture'
|
||||||
|
import { useToast } from '../composables/useToast'
|
||||||
import AppHeader from '../components/AppHeader.vue'
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
import PatientSearch from '../components/PatientSearch.vue'
|
import PatientSearch from '../components/PatientSearch.vue'
|
||||||
import type { LiveCaptureObservationInput } from '../types'
|
import type { LiveCaptureObservationInput } from '../types'
|
||||||
|
|
||||||
const store = useLiveCaptureStore()
|
const store = useLiveCaptureStore()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
const mode = ref<'new' | 'existing'>('new')
|
const mode = ref<'new' | 'existing'>('new')
|
||||||
const patientId = ref<string | undefined>()
|
const patientId = ref<string | undefined>()
|
||||||
@@ -370,8 +372,16 @@ async function submit() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
passwordConfirm.value = ''
|
passwordConfirm.value = ''
|
||||||
|
const result = store.lastResult
|
||||||
|
if (result && result.criticalAlertCount > 0) {
|
||||||
|
toast.warning(`Vitals recorded — ${result.criticalAlertCount} CRITICAL alert(s) generated`)
|
||||||
|
} else {
|
||||||
|
toast.success('Vitals recorded and promoted successfully')
|
||||||
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
errorMessage.value = e instanceof Error ? e.message : 'Submission failed'
|
const msg = e instanceof Error ? e.message : 'Submission failed'
|
||||||
|
errorMessage.value = msg
|
||||||
|
toast.error(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex flex-col">
|
||||||
|
<AppHeader title="Patient History" />
|
||||||
|
|
||||||
|
<div class="flex-1 p-4 sm:p-6 lg:p-8 max-w-5xl mx-auto w-full">
|
||||||
|
<!-- Search when no patient selected -->
|
||||||
|
<div v-if="!patientId" class="card">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">Find Patient</h2>
|
||||||
|
<PatientSearch v-model="selectedPatientId" />
|
||||||
|
<button
|
||||||
|
v-if="selectedPatientId"
|
||||||
|
@click="router.push(`/patients/${selectedPatientId}/history`)"
|
||||||
|
class="btn-primary mt-4"
|
||||||
|
>
|
||||||
|
View History
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- History view -->
|
||||||
|
<div v-else>
|
||||||
|
<div v-if="batchStore.loading" class="text-gray-500 text-center py-8">
|
||||||
|
Loading history...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="batchStore.error" class="text-clinical-danger text-center py-8">
|
||||||
|
{{ batchStore.error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="history">
|
||||||
|
<!-- Summary stats -->
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div class="card text-center">
|
||||||
|
<p class="text-2xl font-bold">{{ history.totalBatches }}</p>
|
||||||
|
<p class="text-xs text-gray-500">Total Batches</p>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center">
|
||||||
|
<p class="text-2xl font-bold text-green-700">{{ history.promotedBatches }}</p>
|
||||||
|
<p class="text-xs text-gray-500">Promoted</p>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center">
|
||||||
|
<p class="text-2xl font-bold text-orange-600">{{ history.pendingBatches }}</p>
|
||||||
|
<p class="text-xs text-gray-500">Pending</p>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center">
|
||||||
|
<p class="text-2xl font-bold text-gray-400">{{ history.supersededBatches }}</p>
|
||||||
|
<p class="text-xs text-gray-500">Superseded</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="entry in history.entries"
|
||||||
|
:key="entry.batchId"
|
||||||
|
:data-batch-id="entry.batchId"
|
||||||
|
class="card relative transition-all"
|
||||||
|
:class="{
|
||||||
|
'border-l-4 border-l-green-500': entry.status === 'PROMOTED' && !entry.hasBeenSuperseded,
|
||||||
|
'border-l-4 border-l-gray-300': entry.hasBeenSuperseded,
|
||||||
|
'border-l-4 border-l-blue-500': entry.isCorrection && !entry.hasBeenSuperseded,
|
||||||
|
'border-l-4 border-l-yellow-500': !['PROMOTED', 'CANCELLED'].includes(entry.status) && !entry.hasBeenSuperseded,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<!-- Header row -->
|
||||||
|
<div class="flex flex-wrap items-center gap-2 mb-3">
|
||||||
|
<span class="font-mono text-xs text-gray-600">
|
||||||
|
{{ entry.batchId.substring(0, 8) }}...
|
||||||
|
</span>
|
||||||
|
<span :class="statusBadgeClass(entry.status)" class="status-badge">
|
||||||
|
{{ formatStatus(entry.status) }}
|
||||||
|
</span>
|
||||||
|
<span class="status-badge bg-gray-100 text-gray-700">
|
||||||
|
{{ formatBatchType(entry.batchType) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="entry.isCorrection"
|
||||||
|
class="status-badge bg-blue-100 text-blue-800"
|
||||||
|
>
|
||||||
|
Correction
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="entry.hasBeenSuperseded"
|
||||||
|
class="status-badge bg-gray-200 text-gray-500"
|
||||||
|
>
|
||||||
|
Superseded
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Supersession chain info -->
|
||||||
|
<div
|
||||||
|
v-if="entry.isCorrection && entry.supersedesBatchId"
|
||||||
|
class="text-xs text-blue-700 bg-blue-50 rounded px-3 py-2 mb-3"
|
||||||
|
>
|
||||||
|
Corrects batch
|
||||||
|
<button
|
||||||
|
@click="scrollToBatch(entry.supersedesBatchId!)"
|
||||||
|
class="font-mono underline hover:text-blue-900"
|
||||||
|
>
|
||||||
|
{{ entry.supersedesBatchId.substring(0, 8) }}...
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="entry.hasBeenSuperseded && entry.supersededByBatchId"
|
||||||
|
class="text-xs text-gray-500 bg-gray-50 rounded px-3 py-2 mb-3"
|
||||||
|
>
|
||||||
|
Superseded by
|
||||||
|
<button
|
||||||
|
@click="scrollToBatch(entry.supersededByBatchId!)"
|
||||||
|
class="font-mono underline hover:text-gray-700"
|
||||||
|
>
|
||||||
|
{{ entry.supersededByBatchId.substring(0, 8) }}...
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Observation counts -->
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm mb-3">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Draft obs:</span>
|
||||||
|
<span class="font-medium ml-1">{{ entry.draftObservationCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Live obs:</span>
|
||||||
|
<span class="font-medium ml-1" :class="{ 'line-through text-gray-400': entry.hasBeenSuperseded }">
|
||||||
|
{{ entry.liveObservationCount }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="entry.supersededObservationCount > 0">
|
||||||
|
<span class="text-gray-500">Superseded obs:</span>
|
||||||
|
<span class="font-medium ml-1 text-gray-400">{{ entry.supersededObservationCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="entry.promotionEncounterId">
|
||||||
|
<span class="text-gray-500">Encounter:</span>
|
||||||
|
<span class="font-mono text-xs ml-1">{{ entry.promotionEncounterId.substring(0, 8) }}...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- People & dates -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-xs text-gray-500">
|
||||||
|
<div>Created: {{ new Date(entry.createdAt).toLocaleString() }}</div>
|
||||||
|
<div v-if="entry.promotedAt">
|
||||||
|
Promoted: {{ new Date(entry.promotedAt).toLocaleString() }}
|
||||||
|
</div>
|
||||||
|
<div v-if="entry.enteredByUserName">
|
||||||
|
Entered by: {{ entry.enteredByUserName }}
|
||||||
|
</div>
|
||||||
|
<div v-if="entry.verifiedByUserName">
|
||||||
|
Verified by: {{ entry.verifiedByUserName }}
|
||||||
|
</div>
|
||||||
|
<div v-if="entry.approvedByUserName">
|
||||||
|
Approved by: {{ entry.approvedByUserName }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Audit trail (collapsible) -->
|
||||||
|
<details v-if="entry.auditTrail.length > 0" class="mt-3">
|
||||||
|
<summary class="text-xs text-primary-600 cursor-pointer hover:text-primary-800">
|
||||||
|
Audit trail ({{ entry.auditTrail.length }} events)
|
||||||
|
</summary>
|
||||||
|
<div class="mt-2 space-y-1 pl-3 border-l-2 border-gray-200">
|
||||||
|
<div
|
||||||
|
v-for="(event, idx) in entry.auditTrail"
|
||||||
|
:key="idx"
|
||||||
|
class="text-xs text-gray-600"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{{ formatEventType(event.eventType) }}</span>
|
||||||
|
<span class="text-gray-400 ml-2">
|
||||||
|
{{ new Date(event.occurredAt).toLocaleString() }}
|
||||||
|
</span>
|
||||||
|
<span class="ml-2">by {{ event.actorName }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- Create Correction button for promoted, non-superseded batches -->
|
||||||
|
<div
|
||||||
|
v-if="entry.status === 'PROMOTED' && !entry.hasBeenSuperseded"
|
||||||
|
class="mt-3 pt-3 border-t border-gray-100"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
@click="createCorrection(entry)"
|
||||||
|
class="text-sm text-primary-600 hover:text-primary-800 font-medium"
|
||||||
|
>
|
||||||
|
Create Correction
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="history.entries.length === 0" class="text-gray-500 text-center py-8">
|
||||||
|
No digitization history for this patient.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useBatchStore } from '../stores/batches'
|
||||||
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
|
import PatientSearch from '../components/PatientSearch.vue'
|
||||||
|
import type { PatientDigitizationHistoryResponse, DigitizationHistoryEntry } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{ patientId?: string }>()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const batchStore = useBatchStore()
|
||||||
|
|
||||||
|
const patientId = ref(props.patientId ?? (route.params.patientId as string | undefined))
|
||||||
|
const selectedPatientId = ref<string | undefined>()
|
||||||
|
const history = ref<PatientDigitizationHistoryResponse | null>(null)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.patientId ?? (route.params.patientId as string | undefined),
|
||||||
|
async (id) => {
|
||||||
|
patientId.value = id
|
||||||
|
if (id) {
|
||||||
|
history.value = await batchStore.getPatientHistory(id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
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 formatEventType(type: string): string {
|
||||||
|
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadgeClass(status: string): string {
|
||||||
|
const classes: 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',
|
||||||
|
CANCELLED: 'bg-gray-200 text-gray-500',
|
||||||
|
}
|
||||||
|
return classes[status] ?? 'bg-gray-100 text-gray-800'
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBatch(batchId: string) {
|
||||||
|
const el = document.querySelector(`[data-batch-id="${batchId}"]`)
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
el.classList.add('ring-2', 'ring-primary-400')
|
||||||
|
setTimeout(() => el.classList.remove('ring-2', 'ring-primary-400'), 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCorrection(entry: DigitizationHistoryEntry) {
|
||||||
|
router.push({
|
||||||
|
path: '/intake',
|
||||||
|
query: {
|
||||||
|
supersedesBatchId: entry.batchId,
|
||||||
|
patientId: patientId.value,
|
||||||
|
batchType: entry.batchType,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user