feature: Digitization Workstation UI
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
# VigilCare Records API
|
# VigilCare Records
|
||||||
|
|
||||||
A clinical records intake backend built with ASP.NET Core 8, PostgreSQL, MinIO, and Redis. 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:** Six planned phases are complete through Phase 6 — from schema, authentication, and batch CRUD through draft data entry, verification/rejection with separation of duties, approval with atomic promotion to VigilCareClinical live tables, correction batches that supersede erroneous promoted observations without silent edits, and Track B live capture with clinician attestation and synchronous critical alerting. See [Implemented Phases](#implemented-phases) for the full breakdown.
|
**Implementation status:** Phases 1–6 are complete (API core through Track B live capture). Phase 7 delivers the digitization workstation UI (`vigilcare-records-web`). Phase 8 is partially implemented — work queue overview endpoint and supervisor dashboard UI are in place; Prometheus metrics and promotion retry remain planned. See [Implemented Phases](#implemented-phases) for the full breakdown.
|
||||||
|
|
||||||
## Domain Model — How It Maps to a Real Clinical System
|
## Domain Model — How It Maps to a Real Clinical System
|
||||||
|
|
||||||
@@ -55,7 +55,10 @@ Append-only audit log entry for every state transition, field-level correction,
|
|||||||
- **Promotion Result Query** — `GET /digitization-batches/:id/promotion-result` returns live entity IDs (patient, MRN, encounter, observations) created during promotion
|
- **Promotion Result Query** — `GET /digitization-batches/:id/promotion-result` returns live entity IDs (patient, MRN, encounter, observations) created during promotion
|
||||||
- **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
|
||||||
- **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`); role-restricted access
|
- **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
|
||||||
|
- **User Directory** — `GET /users?role=` lists active users for batch assignment (intake clerks assign entry clerks via the workstation UI)
|
||||||
|
- **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
|
||||||
- **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` is terminal — corrections require a new batch with `supersedesBatchId`
|
- **Batch Status Machine** — explicit transition matrix enforced in the service layer; illegal transitions return `409`; `PROMOTED` is 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
|
- **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
|
||||||
@@ -85,7 +88,9 @@ HTTP request
|
|||||||
├── DigitizationHistoryService (patient digitization history with correction chain and audit trails)
|
├── DigitizationHistoryService (patient digitization history with correction chain and audit trails)
|
||||||
├── IdempotencyService (Idempotency-Key storage + replay for safe promotion retries)
|
├── IdempotencyService (Idempotency-Key storage + replay for safe promotion retries)
|
||||||
├── MrnGenerator (PostgreSQL sequence-backed MRN generation: VCR-000001)
|
├── MrnGenerator (PostgreSQL sequence-backed MRN generation: VCR-000001)
|
||||||
├── WorkQueueService (verification, entry, clinical approval queues)
|
├── WorkQueueService (verification, entry, clinical approval queues, supervisor overview metrics)
|
||||||
|
├── PatientRegistryService (live patient search by MRN or name)
|
||||||
|
├── UserDirectoryService (active user listing for batch assignment)
|
||||||
├── AttestationService (clinician role + password re-confirm for live capture)
|
├── AttestationService (clinician role + password re-confirm for live capture)
|
||||||
├── LiveCaptureService (Track B synchronous promotion + critical alert evaluation)
|
├── LiveCaptureService (Track B synchronous promotion + critical alert evaluation)
|
||||||
├── DocumentStorageService (MinIO upload, SHA-256, presigned URLs)
|
├── DocumentStorageService (MinIO upload, SHA-256, presigned URLs)
|
||||||
@@ -122,6 +127,7 @@ HTTP request
|
|||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Server | ASP.NET Core 8 (.NET 8.0) |
|
| Server | ASP.NET Core 8 (.NET 8.0) |
|
||||||
|
| Frontend | Vue 3, Vite, Pinia, Vue Router, Axios, Tailwind CSS, VueUse |
|
||||||
| Database | PostgreSQL 16 with EF Core 8 (code-first migrations) |
|
| Database | PostgreSQL 16 with EF Core 8 (code-first migrations) |
|
||||||
| Cache / locking | Redis 7 (batch assignment locks, alert threshold cache for live capture) |
|
| Cache / locking | Redis 7 (batch assignment locks, alert threshold cache for live capture) |
|
||||||
| Object storage | MinIO (scanned documents — PDF, JPEG, PNG) |
|
| Object storage | MinIO (scanned documents — PDF, JPEG, PNG) |
|
||||||
@@ -136,133 +142,49 @@ HTTP request
|
|||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
VigilCareRecordsAPI/
|
VigilCareRecords/
|
||||||
├── Program.cs # Service registration, middleware, seed on startup
|
├── VigilCareRecordsAPI/
|
||||||
├── appsettings.json # Connection strings, Redis, MinIO, Seq, JWT, site config
|
│ ├── Program.cs # Service registration, middleware, seed on startup
|
||||||
├── Controllers/
|
│ ├── appsettings.json # Connection strings, Redis, MinIO, Seq, JWT, site config
|
||||||
│ ├── ApprovalController.cs # Batch approval and promotion to live clinical tables
|
│ ├── Controllers/
|
||||||
│ ├── AuthController.cs # JWT login, token refresh, logout, authenticated user profile
|
│ │ ├── ApprovalController.cs # Batch approval and promotion to live clinical tables
|
||||||
│ ├── DigitizationBatchesController.cs # Batch CRUD, document upload, assignment
|
│ │ ├── AuthController.cs # JWT login, token refresh, logout, authenticated user profile
|
||||||
│ ├── DraftController.cs # Draft data entry: patient, encounter, observations, submit
|
│ │ ├── DigitizationBatchesController.cs # Batch CRUD, document upload, assignment
|
||||||
│ ├── PatientsController.cs # Patient digitization history with correction chain
|
│ │ ├── DraftController.cs # Draft data entry: patient, encounter, observations, submit
|
||||||
│ ├── LiveCaptureController.cs # Track B live capture: record vitals, open encounter + vitals
|
│ │ ├── PatientsController.cs # Patient search and digitization history
|
||||||
│ ├── VerificationController.cs # Batch verification and rejection with separation of duties
|
│ │ ├── UsersController.cs # User directory for batch assignment
|
||||||
│ └── WorkQueueController.cs # Work queues: verification, entry, clinical approval
|
│ │ ├── LiveCaptureController.cs # Track B live capture: record vitals, open encounter + vitals
|
||||||
├── Domain/
|
│ │ ├── VerificationController.cs # Batch verification and rejection with separation of duties
|
||||||
│ ├── Entities/
|
│ │ └── WorkQueueController.cs # Work queues and supervisor overview
|
||||||
│ │ ├── Clinical/
|
│ ├── Domain/ … # Entities, enums (batch, draft, clinical, user)
|
||||||
│ │ │ ├── Patient.cs # Live patient record with MRN (promoted from draft)
|
│ ├── Services/ … # Auth, batch, draft, verification, promotion, work queue, patient registry, user directory, live capture
|
||||||
│ │ │ ├── Encounter.cs # Live encounter (promoted from draft)
|
│ ├── Models/Records/ … # Request/response DTOs
|
||||||
│ │ │ ├── Observation.cs # Live observation with source traceability (batchId, draftObsId)
|
│ ├── Data/ … # EF Core context, configurations, migrations, seed
|
||||||
│ │ │ ├── OutboxEvent.cs # Transactional outbox for downstream consumers (eventType, aggregateType, payloadJson)
|
│ └── … # Middleware, Common, Infrastructure
|
||||||
│ │ │ ├── AlertThreshold.cs # Critical/warning bounds per observation code (live capture alerting)
|
├── vigilcare-records-web/ # Vue 3 digitization workstation UI
|
||||||
│ │ │ ├── ClinicalAlert.cs # Synchronous critical alerts (AlertType, Severity, Details)
|
│ ├── src/
|
||||||
│ │ │ └── IdempotencyRecord.cs # Idempotency-Key storage for safe promotion retries
|
│ │ ├── api/client.ts # Axios client with JWT interceptor and proactive refresh
|
||||||
│ │ ├── Draft/
|
│ │ ├── stores/ # Pinia: auth (login, roles, routing), batches (CRUD, draft, verify)
|
||||||
│ │ │ ├── DraftPatient.cs # Structured patient demographics from paper chart
|
│ │ ├── router/index.ts # Role-based routes and navigation guards
|
||||||
│ │ │ ├── DraftEncounter.cs # Encounter context: admission, department, room/bed
|
│ │ ├── views/ # Login, Intake, Entry, Verification, QueueDashboard
|
||||||
│ │ │ └── DraftObservation.cs # Single measurement: code, value, unit, recordedAt
|
│ │ ├── components/ # ScanViewer, EntryForm, VerificationForm, BatchList, PatientSearch, AppHeader, AssignClerkDialog
|
||||||
│ │ ├── DigitizationBatch.cs # Central workflow entity with status machine
|
│ │ ├── composables/usePresignedUrl.ts # Refreshes presigned document URLs before 15-minute expiry
|
||||||
│ │ ├── LiveEncounter.cs # Live encounter mirror for supersession / history queries
|
│ │ └── types/index.ts # TypeScript interfaces matching API response shapes
|
||||||
│ │ ├── LiveObservation.cs # Live observation with supersession flags (is_superseded, superseded_by_batch_id)
|
│ ├── vite.config.ts # Dev server on port 3028; proxies /api → localhost:5217
|
||||||
│ │ ├── ScannedDocument.cs # MinIO object reference with SHA-256 integrity hash
|
│ └── tailwind.config.js # Clinical color palette and layout component classes
|
||||||
│ │ ├── DigitizationEvent.cs # Append-only audit trail for every status transition
|
├── tests/
|
||||||
│ │ ├── User.cs # Username, BCrypt hash, full name, role, active flag
|
│ └── VigilCareRecordsAPI.Tests/ # Integration tests (Phases 1–6)
|
||||||
│ │ ├── RefreshToken.cs # Opaque refresh token with user FK, expiry, revocation
|
├── scripts/
|
||||||
│ │ └── AuthAuditEvent.cs # Auth event audit: login, logout, refresh, failed attempts
|
│ ├── run-vigilcare-records-verification.sh # Phase 1
|
||||||
│ └── Enums/
|
│ ├── run-vigilcare-records-phase-2-verification.sh
|
||||||
│ ├── BatchStatus.cs # Uploaded → InEntry → PendingVerification → Verified/AwaitingClinicalApproval → Approved → Promoted
|
│ ├── run-vigilcare-records-phase-3-verification.sh
|
||||||
│ ├── BatchType.cs # PatientRegistration, VitalsSheet, LabResults, EncounterSummary, MedicationList, AllergyUpdate, Mixed
|
│ ├── run-vigilcare-records-phase-4-verification.sh
|
||||||
│ ├── BatchTrack.cs # Backfill (Track A) or LiveCapture (Track B)
|
│ ├── run-vigilcare-records-phase-5-verification.sh
|
||||||
│ ├── UserRole.cs # IntakeClerk, DataEntryClerk, Verifier, ClinicalApprover, Clinician, Administrator
|
│ ├── run-vigilcare-records-phase-6-verification.sh
|
||||||
│ ├── DigitizationEventType.cs # 18 event types covering full lifecycle + corrections + promotion retry
|
│ └── run-vigilcare-records-verification-p9.sh # E2E workflow + work queue overview smoke test
|
||||||
│ ├── BloodType.cs # A+, A-, B+, B-, AB+, AB-, O+, O-
|
└── docs/
|
||||||
│ ├── Department.cs # Clinical departments
|
├── plans/ # Phase 1–9 implementation guides
|
||||||
│ ├── AlertType.cs # Critical/warning alert types (ported from VigilCareClinical)
|
├── digitization-workstation-guide.md # Clerk workflow and UI reference
|
||||||
│ ├── AlertSeverity.cs # WARNING, CRITICAL
|
|
||||||
│ └── AlertStatus.cs # OPEN, ACKNOWLEDGED, RESOLVED, ESCALATED
|
|
||||||
├── Services/
|
|
||||||
│ ├── Interfaces/ # IAuthService, IBatchService, IDraftService, IVerificationService, IWorkQueueService, IDocumentStorageService, IPromotionService, IIdempotencyService, IMrnGenerator, IDigitizationHistoryService, ILiveCaptureService, IAttestationService
|
|
||||||
│ ├── AuthService.cs # Login (BCrypt verify), JWT generation, refresh rotation, logout, audit logging
|
|
||||||
│ ├── BatchService.cs # Batch CRUD, status machine transitions, Redis assignment lock, duplicate detection, supersession validation on create
|
|
||||||
│ ├── DraftService.cs # Draft CRUD with plausibility validation, batch-type completeness on submit (relaxed for correction batches)
|
|
||||||
│ ├── VerificationService.cs # Verify/reject with separation of duties, site-config clinical approval routing
|
|
||||||
│ ├── PromotionService.cs # Atomic approve + promote: draft → live tables in single transaction; supersede original observations on correction promotion
|
|
||||||
│ ├── DigitizationHistoryService.cs # Patient history with correction chain, observation counts, audit trails
|
|
||||||
│ ├── IdempotencyService.cs # Idempotency-Key record storage + replay (24h TTL)
|
|
||||||
│ ├── MrnGenerator.cs # PostgreSQL sequence-backed MRN generation (VCR-000001)
|
|
||||||
│ ├── WorkQueueService.cs # Verification, entry, and clinical approval work queues
|
|
||||||
│ ├── DocumentStorageService.cs # MinIO upload with SHA-256, presigned URL generation
|
|
||||||
│ ├── AttestationService.cs # Clinician role + password re-confirm for live capture
|
|
||||||
│ ├── Interfaces/LiveCaptureService.cs # Track B synchronous promotion + critical alert evaluation
|
|
||||||
│ └── PlausibilityValidator.cs # Per-code numeric range guard (reused from VigilCareClinical)
|
|
||||||
├── Configurations/
|
|
||||||
│ ├── JwtOptions.cs # Issuer, audience, signing key, access/refresh token expiration
|
|
||||||
│ ├── MinioOptions.cs # Endpoint, credentials, bucket, presigned URL expiry
|
|
||||||
│ └── SiteConfigOptions.cs # ClinicalApprovalRequired map per batch type
|
|
||||||
├── Models/Records/
|
|
||||||
│ ├── Auth/ # LoginRequest, LoginResponse, RefreshRequest, LogoutRequest, TokenResponse, UserProfileResponse
|
|
||||||
│ ├── Batch/ # CreateBatchForm, CreateBatchRequest, AssignBatchRequest, BatchDetailResponse, SupersessionInfo, PatientDigitizationHistoryResponse, DigitizationHistoryEntry, ...
|
|
||||||
│ ├── Promotion/ # ApproveRequest, PromotionResultResponse
|
|
||||||
│ ├── DraftPatient/ # DraftPatientDto, UpsertDraftPatientRequest
|
|
||||||
│ ├── DraftEncounter/ # DraftEncounterDto, UpsertDraftEncounterRequest
|
|
||||||
│ ├── DraftObservation/ # DraftObservationDto, CreateDraftObservationRequest, UpdateDraftObservationRequest
|
|
||||||
│ ├── WorkQueue/ # WorkQueueResponse, WorkQueueItemResponse
|
|
||||||
│ ├── LiveCapture/ # LiveCaptureResponse, RecordObservationsRequest, ThresholdCacheEntry, ...
|
|
||||||
│ └── Common/ # PagedResult
|
|
||||||
├── Data/
|
|
||||||
│ ├── AppDbContext.cs # EF Core context — entity configs, indexes, constraints
|
|
||||||
│ ├── Configurations/
|
|
||||||
│ │ ├── Clinical/ # PatientConfiguration, EncounterConfiguration, ObservationConfiguration, OutboxEventConfiguration, AlertThresholdConfiguration, ClinicalAlertConfiguration, IdempotencyRecordConfiguration
|
|
||||||
│ │ ├── Draft/ # DraftPatientConfiguration, DraftEncounterConfiguration, DraftObservationConfiguration
|
|
||||||
│ │ ├── LiveEncounterConfiguration.cs # live_encounters table mapping
|
|
||||||
│ │ ├── LiveObservationConfiguration.cs # live_observations with supersession columns and partial index
|
|
||||||
│ │ └── ... # DigitizationBatch, DigitizationEvent, ScannedDocument, User, RefreshToken, AuthAuditEvent configs
|
|
||||||
│ ├── Seed/
|
|
||||||
│ │ └── DataSeeder.cs # Seeds 12 demo users (two per role) on startup
|
|
||||||
│ └── Migrations/ # InitialCreate through AddAlertThresholdsAndClinicalAlerts
|
|
||||||
├── Common/
|
|
||||||
│ ├── ApiResponse.cs # { success, statusCode, data, error } envelope
|
|
||||||
│ └── Exceptions/
|
|
||||||
│ ├── NotFoundException.cs
|
|
||||||
│ ├── ConflictException.cs # Status machine, separation of duties, assignment conflicts
|
|
||||||
│ ├── BadRequestException.cs
|
|
||||||
│ ├── DomainException.cs
|
|
||||||
│ └── ValidationException.cs
|
|
||||||
├── Middleware/
|
|
||||||
│ ├── CorrelationIdMiddleware.cs # Per-request correlation IDs
|
|
||||||
│ └── ExceptionHandlerMiddleware.cs # Consistent error responses
|
|
||||||
└── Infrastructure/
|
|
||||||
└── OpenApi/
|
|
||||||
└── SwaggerServiceCollectionExtensions.cs
|
|
||||||
|
|
||||||
tests/
|
|
||||||
└── VigilCareRecordsAPI.Tests/
|
|
||||||
├── DraftEntryTests.cs # Draft CRUD, plausibility validation, submit-for-verification completeness
|
|
||||||
├── VerificationTests.cs # Verification, rejection, separation of duties, clinical approval routing
|
|
||||||
├── PromotionTests.cs # Approval, atomic promotion, idempotency, separation of duties, retroactive alerts, patient dedup
|
|
||||||
├── CorrectionSupersessionTests.cs # Correction batch supersession, validation guards, patient digitization history
|
|
||||||
├── LiveCaptureIntegrationTests.cs # Track B attestation, synchronous promotion, critical alerts, open encounter workflow
|
|
||||||
├── Fixtures/
|
|
||||||
│ ├── ApiFixture.cs # WebApplicationFactory with PostgreSQL, Redis, MinIO containers
|
|
||||||
│ └── DatabaseCollection.cs # Shared test collection
|
|
||||||
└── Helpers/
|
|
||||||
├── AuthHelper.cs # JWT token generation for test users
|
|
||||||
├── BatchSeedHelper.cs # Creates seeded batches at various lifecycle stages
|
|
||||||
├── BatchPipelineHelper.cs # End-to-end batch pipeline: upload → entry → verify → ready for approval
|
|
||||||
├── CorrectionPipelineHelper.cs # Lab correction pipeline: promote original → promote correction batch
|
|
||||||
└── DbResetHelper.cs # Database cleanup between tests
|
|
||||||
|
|
||||||
scripts/
|
|
||||||
├── run-vigilcare-records-verification.sh # Phase 1 — schema, auth, roles, batch CRUD, MinIO, status machine
|
|
||||||
├── run-vigilcare-records-phase-2-verification.sh # Phase 2 — draft entry, plausibility, submit-for-verification
|
|
||||||
├── run-vigilcare-records-phase-3-verification.sh # Phase 3 — verification, rejection, separation of duties, work queues
|
|
||||||
├── run-vigilcare-records-phase-4-verification.sh # Phase 4 — approval, atomic promotion, idempotency, patient dedup, outbox events
|
|
||||||
├── run-vigilcare-records-phase-5-verification.sh # Phase 5 — corrections, supersession, digitization history, audit trail
|
|
||||||
└── run-vigilcare-records-phase-6-verification.sh # Phase 6 — live capture, attestation, synchronous alerts, outbox events
|
|
||||||
|
|
||||||
docs/
|
|
||||||
├── plans/ # Phase 1–9 implementation and verification guides
|
|
||||||
└── vigilcare-records-prd.md # Product requirements and phase roadmap
|
└── vigilcare-records-prd.md # Product requirements and phase roadmap
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -378,6 +300,7 @@ Approved live observations are never mutated or deleted. When a transcription er
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- .NET 8 SDK
|
- .NET 8 SDK
|
||||||
|
- Node.js 20+ and npm (for the workstation UI)
|
||||||
- Docker and Docker Compose
|
- Docker and Docker Compose
|
||||||
|
|
||||||
### Start Infrastructure
|
### Start Infrastructure
|
||||||
@@ -407,6 +330,34 @@ On startup the application:
|
|||||||
|
|
||||||
Swagger UI is available at `http://localhost:5217/swagger` in Development.
|
Swagger UI is available at `http://localhost:5217/swagger` in Development.
|
||||||
|
|
||||||
|
### Run the Workstation UI
|
||||||
|
|
||||||
|
With the API running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd vigilcare-records-web
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:3028`. The Vite dev server proxies `/api` requests to the API on port 5217.
|
||||||
|
|
||||||
|
| Username | Password | Default route |
|
||||||
|
|---|---|---|
|
||||||
|
| `intake1` | `password` | `/intake` — upload scans, assign entry clerks |
|
||||||
|
| `entry1` | `password` | `/entry` — data entry queue and split-pane form |
|
||||||
|
| `verifier1` | `password` | `/verification` — field-level verification |
|
||||||
|
| `admin1` | `password` | `/dashboard` — supervisor queue overview |
|
||||||
|
|
||||||
|
See [docs/digitization-workstation-guide.md](docs/digitization-workstation-guide.md) for the full clerk workflow.
|
||||||
|
|
||||||
|
Production build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd vigilcare-records-web
|
||||||
|
npm run build # output in dist/
|
||||||
|
```
|
||||||
|
|
||||||
### Run Tests
|
### Run Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -434,6 +385,7 @@ With the API running (`dotnet run`) and Docker Compose up:
|
|||||||
./scripts/run-vigilcare-records-phase-4-verification.sh # Phase 4 — approval, promotion, idempotency, patient dedup
|
./scripts/run-vigilcare-records-phase-4-verification.sh # Phase 4 — approval, promotion, idempotency, patient dedup
|
||||||
./scripts/run-vigilcare-records-phase-5-verification.sh # Phase 5 — corrections, supersession, digitization history
|
./scripts/run-vigilcare-records-phase-5-verification.sh # Phase 5 — corrections, supersession, digitization history
|
||||||
./scripts/run-vigilcare-records-phase-6-verification.sh # Phase 6 — live capture, attestation, critical alerts
|
./scripts/run-vigilcare-records-phase-6-verification.sh # Phase 6 — live capture, attestation, critical alerts
|
||||||
|
./scripts/run-vigilcare-records-verification-p9.sh # E2E workflow + work queue overview smoke test
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -657,8 +609,40 @@ Error response:
|
|||||||
| 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`, sorted by submission time |
|
||||||
| 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 |
|
||||||
|
|
||||||
All work queue endpoints support pagination via `?page=1&pageSize=20`.
|
All work queue endpoints support pagination via `?page=1&pageSize=20` (except `overview`).
|
||||||
|
|
||||||
|
**GET `/work-queue/overview` response:**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `statusCounts` | object | Batch count per status (all 8 statuses present) |
|
||||||
|
| `averageTimeInQueueMinutes` | number | Average age of batches in `PENDING_VERIFICATION` |
|
||||||
|
| `rejectRate` | number | Rejections / (rejections + verifications) over the last 24 hours (0.0–1.0) |
|
||||||
|
| `oldestPendingVerificationMinutes` | number | Age of the oldest batch in `PENDING_VERIFICATION` |
|
||||||
|
|
||||||
|
### Patient Registry
|
||||||
|
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/patients/search?q=` | Intake Clerk, Administrator | Search live patients by MRN or full name (min 2 characters); returns up to 20 matches |
|
||||||
|
|
||||||
|
**Search result object:**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | Guid | Patient ID |
|
||||||
|
| `fullName` | string | Patient full name |
|
||||||
|
| `mrn` | string | Medical record number |
|
||||||
|
|
||||||
|
### User Directory
|
||||||
|
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/users?role=` | Intake Clerk, Administrator | List active users; optional `role` filter (e.g. `DATA_ENTRY_CLERK`) |
|
||||||
|
|
||||||
|
Used by the intake workstation assign-clerk dialog.
|
||||||
|
|
||||||
### Patient Digitization History
|
### Patient Digitization History
|
||||||
|
|
||||||
@@ -1034,7 +1018,7 @@ Response shape:
|
|||||||
|
|
||||||
## Implemented Phases
|
## Implemented Phases
|
||||||
|
|
||||||
Six phases from the project roadmap are implemented and verified. Integration tests (`dotnet test`) and per-phase verification scripts cover Phases 1–6.
|
Phases 1–6 are fully implemented and verified via integration tests and per-phase scripts. Phase 7 (workstation UI) and parts of Phase 8 (supervisor overview) are implemented.
|
||||||
|
|
||||||
| Phase | Feature | Status |
|
| Phase | Feature | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -1044,6 +1028,6 @@ Six phases from the project roadmap are implemented and verified. Integration te
|
|||||||
| 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 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 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 (Vue 3 side-by-side scan viewer + entry form) | Planned |
|
| 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 |
|
||||||
| 8 | Prometheus metrics, supervisor dashboard, promotion retry job | Planned |
|
| 8 | `GET /work-queue/overview`, supervisor dashboard UI, patient search API, user directory API | Partial — Prometheus metrics and promotion retry job planned |
|
||||||
| 9 | Seed data, E2E verification script, clinical scenario documentation | Planned |
|
| 9 | E2E verification script (`run-vigilcare-records-verification-p9.sh`), clinical scenario documentation | Partial |
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Patient-scoped endpoints for digitization history and audit trail.
|
/// Patient registry search and digitization history.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/patients")]
|
[Route("api/v1/patients")]
|
||||||
@@ -12,9 +11,27 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
public class PatientsController : ControllerBase
|
public class PatientsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IDigitizationHistoryService _history;
|
private readonly IDigitizationHistoryService _history;
|
||||||
|
private readonly IPatientRegistryService _patients;
|
||||||
|
|
||||||
public PatientsController(IDigitizationHistoryService history) =>
|
public PatientsController(
|
||||||
|
IDigitizationHistoryService history,
|
||||||
|
IPatientRegistryService patients)
|
||||||
|
{
|
||||||
_history = history;
|
_history = history;
|
||||||
|
_patients = patients;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches live patients by MRN or full name (minimum 2 characters).
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("search")]
|
||||||
|
[Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<PatientSearchResult>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> Search([FromQuery] string q)
|
||||||
|
{
|
||||||
|
var results = await _patients.SearchAsync(q ?? string.Empty);
|
||||||
|
return Ok(ApiResponse<IReadOnlyList<PatientSearchResult>>.Ok(results));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the complete digitization history for a patient, including all
|
/// Returns the complete digitization history for a patient, including all
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User directory for batch assignment and operational lookups.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/users")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
[Authorize]
|
||||||
|
public class UsersController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IUserDirectoryService _users;
|
||||||
|
|
||||||
|
public UsersController(IUserDirectoryService users) => _users = users;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lists active users, optionally filtered by role.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize(Roles = "INTAKE_CLERK,ADMINISTRATOR")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<UserSummaryResponse>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> List([FromQuery] string? role)
|
||||||
|
{
|
||||||
|
UserRole? parsedRole = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(role))
|
||||||
|
parsedRole = UserRoleExtensions.FromDbString(role);
|
||||||
|
|
||||||
|
var results = await _users.ListByRoleAsync(parsedRole);
|
||||||
|
return Ok(ApiResponse<IReadOnlyList<UserSummaryResponse>>.Ok(results));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,4 +68,17 @@ public class WorkQueueController : ControllerBase
|
|||||||
var result = await _workQueue.GetClinicalApprovalQueueAsync(page, pageSize);
|
var result = await _workQueue.GetClinicalApprovalQueueAsync(page, pageSize);
|
||||||
return Ok(ApiResponse<WorkQueueResponse>.Ok(result));
|
return Ok(ApiResponse<WorkQueueResponse>.Ok(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns aggregate work queue health metrics for the supervisor dashboard.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("overview")]
|
||||||
|
[Authorize(Roles = "ADMINISTRATOR")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<WorkQueueOverviewResponse>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status403Forbidden)]
|
||||||
|
public async Task<IActionResult> GetOverview()
|
||||||
|
{
|
||||||
|
var overview = await _workQueue.GetOverviewAsync();
|
||||||
|
return Ok(ApiResponse<WorkQueueOverviewResponse>.Ok(overview));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/// <summary>Patient match returned by GET /api/v1/patients/search.</summary>
|
||||||
|
public record PatientSearchResult(
|
||||||
|
Guid Id,
|
||||||
|
string FullName,
|
||||||
|
string Mrn
|
||||||
|
);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/// <summary>User summary for assignment and directory lookups.</summary>
|
||||||
|
public record UserSummaryResponse(
|
||||||
|
Guid Id,
|
||||||
|
string Username,
|
||||||
|
string FullName,
|
||||||
|
string Role
|
||||||
|
);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/// <summary>
|
||||||
|
/// Aggregate work queue health metrics for the supervisor dashboard.
|
||||||
|
/// Returned by GET /api/v1/work-queue/overview.
|
||||||
|
/// </summary>
|
||||||
|
public record WorkQueueOverviewResponse(
|
||||||
|
Dictionary<string, int> StatusCounts,
|
||||||
|
double AverageTimeInQueueMinutes,
|
||||||
|
double RejectRate,
|
||||||
|
double OldestPendingVerificationMinutes
|
||||||
|
);
|
||||||
@@ -62,6 +62,8 @@ try
|
|||||||
builder.Services.AddScoped<IDocumentStorageService, DocumentStorageService>();
|
builder.Services.AddScoped<IDocumentStorageService, DocumentStorageService>();
|
||||||
builder.Services.AddScoped<IVerificationService, VerificationService>();
|
builder.Services.AddScoped<IVerificationService, VerificationService>();
|
||||||
builder.Services.AddScoped<IWorkQueueService, WorkQueueService>();
|
builder.Services.AddScoped<IWorkQueueService, WorkQueueService>();
|
||||||
|
builder.Services.AddScoped<IPatientRegistryService, PatientRegistryService>();
|
||||||
|
builder.Services.AddScoped<IUserDirectoryService, UserDirectoryService>();
|
||||||
builder.Services.AddScoped<IPromotionService, PromotionService>();
|
builder.Services.AddScoped<IPromotionService, PromotionService>();
|
||||||
builder.Services.AddScoped<IIdempotencyService, IdempotencyService>();
|
builder.Services.AddScoped<IIdempotencyService, IdempotencyService>();
|
||||||
builder.Services.AddScoped<IMrnGenerator, MrnGenerator>();
|
builder.Services.AddScoped<IMrnGenerator, MrnGenerator>();
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
public interface IPatientRegistryService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<PatientSearchResult>> SearchAsync(string query, int limit = 20);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
public interface IUserDirectoryService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<UserSummaryResponse>> ListByRoleAsync(UserRole? role);
|
||||||
|
}
|
||||||
@@ -24,4 +24,9 @@ public interface IWorkQueueService
|
|||||||
/// This is the clinical approver's work queue.
|
/// This is the clinical approver's work queue.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<WorkQueueResponse> GetClinicalApprovalQueueAsync(int page, int pageSize);
|
Task<WorkQueueResponse> GetClinicalApprovalQueueAsync(int page, int pageSize);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns aggregate work queue health metrics for the supervisor dashboard.
|
||||||
|
/// </summary>
|
||||||
|
Task<WorkQueueOverviewResponse> GetOverviewAsync();
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
public class PatientRegistryService : IPatientRegistryService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
public PatientRegistryService(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<PatientSearchResult>> SearchAsync(string query, int limit = 20)
|
||||||
|
{
|
||||||
|
var trimmed = query.Trim();
|
||||||
|
if (trimmed.Length < 2)
|
||||||
|
return Array.Empty<PatientSearchResult>();
|
||||||
|
|
||||||
|
var pattern = $"%{trimmed}%";
|
||||||
|
|
||||||
|
return await _db.Patients
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(p =>
|
||||||
|
EF.Functions.ILike(p.FullName, pattern) ||
|
||||||
|
EF.Functions.ILike(p.Mrn, pattern))
|
||||||
|
.OrderBy(p => p.FullName)
|
||||||
|
.Take(limit)
|
||||||
|
.Select(p => new PatientSearchResult(p.Id, p.FullName, p.Mrn))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
public class UserDirectoryService : IUserDirectoryService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
public UserDirectoryService(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<UserSummaryResponse>> ListByRoleAsync(UserRole? role)
|
||||||
|
{
|
||||||
|
var query = _db.Users.AsNoTracking().Where(u => u.IsActive);
|
||||||
|
|
||||||
|
if (role.HasValue)
|
||||||
|
query = query.Where(u => u.Role == role.Value);
|
||||||
|
|
||||||
|
return await query
|
||||||
|
.OrderBy(u => u.FullName)
|
||||||
|
.Select(u => new UserSummaryResponse(
|
||||||
|
u.Id,
|
||||||
|
u.Username,
|
||||||
|
u.FullName,
|
||||||
|
u.Role.ToDbString()))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,10 +9,12 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
public class WorkQueueService : IWorkQueueService
|
public class WorkQueueService : IWorkQueueService
|
||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ILogger<WorkQueueService> _logger;
|
||||||
|
|
||||||
public WorkQueueService(AppDbContext db)
|
public WorkQueueService(AppDbContext db, ILogger<WorkQueueService> logger)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WorkQueueResponse> GetVerificationQueueAsync(int page, int pageSize)
|
public async Task<WorkQueueResponse> GetVerificationQueueAsync(int page, int pageSize)
|
||||||
@@ -80,4 +82,77 @@ public class WorkQueueService : IWorkQueueService
|
|||||||
|
|
||||||
return new WorkQueueResponse(queueName, items, page, pageSize, totalCount, totalPages);
|
return new WorkQueueResponse(queueName, items, page, pageSize, totalCount, totalPages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<WorkQueueOverviewResponse> GetOverviewAsync()
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
var statusGroups = await _db.DigitizationBatches
|
||||||
|
.AsNoTracking()
|
||||||
|
.GroupBy(b => b.Status)
|
||||||
|
.Select(g => new { Status = g.Key, Count = g.Count() })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var statusCounts = new Dictionary<string, int>();
|
||||||
|
foreach (var status in Enum.GetValues<BatchStatus>())
|
||||||
|
{
|
||||||
|
var count = statusGroups.FirstOrDefault(g => g.Status == status)?.Count ?? 0;
|
||||||
|
statusCounts[status.ToDbString()] = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pendingBatches = await _db.DigitizationBatches
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(b => b.Status == BatchStatus.PendingVerification)
|
||||||
|
.Select(b => b.UpdatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
double avgTimeInQueueMinutes = 0;
|
||||||
|
double oldestPendingMinutes = 0;
|
||||||
|
|
||||||
|
if (pendingBatches.Count > 0)
|
||||||
|
{
|
||||||
|
var ages = pendingBatches
|
||||||
|
.Select(updatedAt => (now - updatedAt).TotalMinutes)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
avgTimeInQueueMinutes = ages.Average();
|
||||||
|
oldestPendingMinutes = ages.Max();
|
||||||
|
}
|
||||||
|
|
||||||
|
var cutoff = now.AddHours(-24);
|
||||||
|
|
||||||
|
var recentEvents = await _db.DigitizationEvents
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(e => e.OccurredAt >= cutoff)
|
||||||
|
.Where(e => e.EventType == DigitizationEventType.Rejected
|
||||||
|
|| e.EventType == DigitizationEventType.Verified
|
||||||
|
|| e.EventType == DigitizationEventType.VerifiedPendingClinical)
|
||||||
|
.GroupBy(e => e.EventType)
|
||||||
|
.Select(g => new { EventType = g.Key, Count = g.Count() })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var rejections = recentEvents
|
||||||
|
.Where(e => e.EventType == DigitizationEventType.Rejected)
|
||||||
|
.Sum(e => e.Count);
|
||||||
|
|
||||||
|
var verifications = recentEvents
|
||||||
|
.Where(e => e.EventType == DigitizationEventType.Verified
|
||||||
|
|| e.EventType == DigitizationEventType.VerifiedPendingClinical)
|
||||||
|
.Sum(e => e.Count);
|
||||||
|
|
||||||
|
var totalDecisions = rejections + verifications;
|
||||||
|
var rejectRate = totalDecisions > 0
|
||||||
|
? (double)rejections / totalDecisions
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Work queue overview: {PendingCount} pending, avg queue {AvgMinutes:F1}m, reject rate {RejectRate:P1}",
|
||||||
|
pendingBatches.Count, avgTimeInQueueMinutes, rejectRate);
|
||||||
|
|
||||||
|
return new WorkQueueOverviewResponse(
|
||||||
|
StatusCounts: statusCounts,
|
||||||
|
AverageTimeInQueueMinutes: Math.Round(avgTimeInQueueMinutes, 1),
|
||||||
|
RejectRate: Math.Round(rejectRate, 4),
|
||||||
|
OldestPendingVerificationMinutes: Math.Round(oldestPendingMinutes, 1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
```markdown
|
||||||
|
# VigilCare Records — Digitization Workstation Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This guide describes three primary clinical workflows supported by the VigilCare
|
||||||
|
Records digitization platform: historical backfill, live bedside capture, and
|
||||||
|
corrections. Each workflow maps to a real operational scenario in a paper-based
|
||||||
|
hospital or clinic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow 1: Historical Backfill (Track A)
|
||||||
|
|
||||||
|
**Scenario:** District General Hospital has 200 active patients with paper charts.
|
||||||
|
The facility is deploying VigilCareClinical for real-time alerting. Before alerts
|
||||||
|
can fire, historical vital signs and lab results must be digitized into the system.
|
||||||
|
|
||||||
|
**Actors:** Intake Clerk, Data Entry Clerk, Verifier, Clinical Approver (for
|
||||||
|
high-stakes batch types)
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
### 1. Intake (Intake Clerk)
|
||||||
|
|
||||||
|
The intake clerk receives a stack of patient charts from the ward. For each chart
|
||||||
|
section:
|
||||||
|
|
||||||
|
1. Scan the page(s) using a flatbed scanner or document camera
|
||||||
|
2. Log in to the workstation as `intake1`
|
||||||
|
3. Navigate to **Intake** view
|
||||||
|
4. Upload the scan (PDF, JPEG, or PNG; max 25 MB)
|
||||||
|
5. Select the batch type:
|
||||||
|
- **Vitals Sheet** — vital signs from nursing observation charts
|
||||||
|
- **Lab Results** — laboratory test result reports
|
||||||
|
- **Patient Registration** — face sheet with demographics
|
||||||
|
- **Encounter Summary** — admission/discharge summaries
|
||||||
|
- **Allergy Update** — allergy documentation
|
||||||
|
- **Medication List** — current medication records
|
||||||
|
6. Set track to **Backfill** (default)
|
||||||
|
7. Optionally link to an existing patient by searching MRN or name
|
||||||
|
8. Click **Upload and Create Batch**
|
||||||
|
|
||||||
|
The batch is created in `uploaded` status. The SHA-256 hash prevents duplicate
|
||||||
|
uploads of the same document for the same patient within 24 hours.
|
||||||
|
|
||||||
|
### 2. Data Entry (Data Entry Clerk)
|
||||||
|
|
||||||
|
The entry clerk opens the batch from their **Entry** queue:
|
||||||
|
|
||||||
|
1. Log in as `entry1`
|
||||||
|
2. Click the batch to open the split-pane workstation
|
||||||
|
3. **Left pane:** The scanned document is displayed with zoom, pan, and rotate
|
||||||
|
controls. The clerk reads the handwritten or printed values from the scan.
|
||||||
|
4. **Right pane:** Structured form fields for:
|
||||||
|
- Patient demographics (name, DOB, sex, blood type, emergency contact)
|
||||||
|
- Encounter context (admission date, department, room/bed, admission reason)
|
||||||
|
- Observations (one row per vital sign or lab result)
|
||||||
|
|
||||||
|
**Observation entry rules:**
|
||||||
|
- Each observation requires a code (e.g., `HEART_RATE`), numeric value, unit, and
|
||||||
|
`recordedAt` timestamp taken from the chart (not scan time)
|
||||||
|
- Plausibility validation fires on save:
|
||||||
|
- Heart rate: 20-300 bpm
|
||||||
|
- Temperature: 25-45 C
|
||||||
|
- SpO2: 0-100 %
|
||||||
|
- Potassium: 1.5-10.0 mEq/L
|
||||||
|
- Glucose: 20-800 mg/dL
|
||||||
|
- Out-of-range values return `422 OBSERVATION_OUT_OF_PLAUSIBLE_RANGE`
|
||||||
|
- This catches the most common digitization error: decimal misplacement (5.2 vs 52)
|
||||||
|
|
||||||
|
5. Click **Submit for Verification** when all fields are complete
|
||||||
|
6. The batch transitions to `pending_verification`
|
||||||
|
|
||||||
|
### 3. Verification (Verifier)
|
||||||
|
|
||||||
|
The verifier reviews the entry against the original scan:
|
||||||
|
|
||||||
|
1. Log in as `verifier1` (must be a different person than the entry clerk)
|
||||||
|
2. Open the batch from the **Verification** queue
|
||||||
|
3. **Left pane:** Same scan viewer
|
||||||
|
4. **Right pane:** Each field has a verification checkbox
|
||||||
|
- Compare each entered value against the scan
|
||||||
|
- Check the checkbox when the value matches
|
||||||
|
- Progress bar shows completion percentage
|
||||||
|
5. If all fields match: click **Approve - Verified**
|
||||||
|
6. If any field is wrong: click **Reject** with a mandatory reason
|
||||||
|
|
||||||
|
**Separation of duties:** The system enforces that `enteredByUserId !== verifiedByUserId`.
|
||||||
|
If the entry clerk tries to verify their own batch, the API returns `409
|
||||||
|
SEPARATION_OF_DUTIES_VIOLATION`.
|
||||||
|
|
||||||
|
**On rejection:** The batch returns to `rejected` status. The entry clerk sees the
|
||||||
|
rejection reason and can correct the draft, then resubmit for verification.
|
||||||
|
|
||||||
|
### 4. Approval and Promotion
|
||||||
|
|
||||||
|
Track A has **two human gates** before live records exist:
|
||||||
|
|
||||||
|
1. **Verifier** — scan comparison (`verify` / `reject` on `pending_verification`)
|
||||||
|
2. **Clinical approver** — promotion authorization (`approve` on `verified` or `awaiting_clinical_approval`)
|
||||||
|
|
||||||
|
After verification passes, site configuration routes the batch:
|
||||||
|
|
||||||
|
| batchType | Default: next status after verify |
|
||||||
|
|---|---|
|
||||||
|
| `patient_registration`, `allergy_update` | `verified` → clinical approver calls `approve` |
|
||||||
|
| `encounter_summary`, `vitals_sheet`, `lab_results`, `medication_list`, `mixed` | `awaiting_clinical_approval` → clinical approver reviews in **Clinical Approval** queue, then calls `approve` |
|
||||||
|
|
||||||
|
Log in as `approver1` for the approval step. Verifiers (`verifier1`) never call `approve`.
|
||||||
|
|
||||||
|
On approval:
|
||||||
|
1. The promotion service runs atomically:
|
||||||
|
- Create or update Patient in VigilCareClinical
|
||||||
|
- Create or match Encounter
|
||||||
|
- Insert each DraftObservation as a live Observation
|
||||||
|
- Write outbox events (alerting suppressed for backfill unless
|
||||||
|
`enableRetroactiveAlerts: true`)
|
||||||
|
2. Batch status transitions to `promoted`
|
||||||
|
3. All observations are now visible in VigilCareClinical's ward dashboard
|
||||||
|
|
||||||
|
**Alert suppression for backfill:** By default, backfilled observations do not
|
||||||
|
trigger real-time alerts. A historical potassium of 6.2 mEq/L from three days ago
|
||||||
|
should not page the on-call physician today. The facility can override this per
|
||||||
|
batch by setting `enableRetroactiveAlerts: true`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow 2: Live Bedside Capture (Track B)
|
||||||
|
|
||||||
|
**Scenario:** A nurse or physician enters vital signs at the bedside using a tablet.
|
||||||
|
The observation needs to reach VigilCareClinical's alert pipeline immediately.
|
||||||
|
|
||||||
|
**Actor:** Clinician
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
1. Log in as `clinician1`
|
||||||
|
2. Navigate to the live capture endpoint
|
||||||
|
3. Select or create the encounter
|
||||||
|
4. Enter observation values with `clinicianAttestation: true`
|
||||||
|
5. Confirm with password re-entry or PIN
|
||||||
|
|
||||||
|
**What happens:**
|
||||||
|
- A `DigitizationBatch` is created with `track: live_capture` and lands in `promoted` immediately (workflow states skipped, audit unit retained)
|
||||||
|
- `DraftObservation` rows and `DigitizationEvent` entries (`live_capture_attested`, `promoted`) are written in the same transaction as live observations
|
||||||
|
- No verification queue — the clinician's attestation replaces verify + approve for that batch only
|
||||||
|
- The batch is promoted synchronously
|
||||||
|
- If a critical value is entered (e.g., potassium 6.8 mEq/L), the synchronous
|
||||||
|
alert fires before the response returns
|
||||||
|
- The observation appears in VigilCareClinical's ward dashboard immediately
|
||||||
|
|
||||||
|
**When to use Track B vs Track A:**
|
||||||
|
- Track B: Current patient encounter, values just measured, clinician is at bedside
|
||||||
|
- Track A: Historical charts, bulk digitization, values from past encounters
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow 3: Corrections
|
||||||
|
|
||||||
|
**Scenario:** After promotion, a reviewer discovers that the SpO2 value was entered
|
||||||
|
as 94% but the chart actually shows 95%. The promoted observation must be corrected.
|
||||||
|
|
||||||
|
**Rule:** Approved records are never silently edited. Corrections go through the
|
||||||
|
full pipeline.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
1. Create a new batch with `supersedesBatchId` pointing to the original batch
|
||||||
|
2. Re-enter the corrected values
|
||||||
|
3. Submit for verification (new entry, new verifier review)
|
||||||
|
4. On approval and promotion:
|
||||||
|
- The corrected observations are inserted as new live records
|
||||||
|
- The original observations are marked `superseded` (soft flag, not deleted)
|
||||||
|
- The audit trail shows: original values, correction request, new values,
|
||||||
|
who made each change and when
|
||||||
|
|
||||||
|
**Why not just edit the original?** Clinical audit integrity. A regulator must be
|
||||||
|
able to see what was originally entered, when it was corrected, and by whom. Silent
|
||||||
|
edits destroy this chain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Staffing Considerations
|
||||||
|
|
||||||
|
- In a small facility, one person may serve as both intake clerk and data entry clerk
|
||||||
|
- The system **never** allows one person to both enter and verify the same batch,
|
||||||
|
even if they hold both roles
|
||||||
|
- Minimum staff for full workflow: 2 people (one enters, one verifies)
|
||||||
|
- The system tracks clerk throughput via the work queue overview endpoint and
|
||||||
|
Prometheus metrics
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Issues and Resolution
|
||||||
|
|
||||||
|
| Issue | Cause | Resolution |
|
||||||
|
|---|---|---|
|
||||||
|
| `409 DUPLICATE_DOCUMENT` | Same PDF uploaded for same patient within 24h | Check if batch already exists; use a different scan if needed |
|
||||||
|
| `409 SEPARATION_OF_DUTIES_VIOLATION` | Entry clerk trying to verify own batch | Assign to a different verifier |
|
||||||
|
| `422 OBSERVATION_OUT_OF_PLAUSIBLE_RANGE` | Value outside allowed range | Check for decimal misplacement (52 vs 5.2) |
|
||||||
|
| `409 ILLEGAL_STATUS_TRANSITION` | Trying to skip a workflow step | Follow the status machine: uploaded -> in_entry -> pending_verification -> verified -> approved -> promoted |
|
||||||
|
| High rejection rate (>15%) | Scan quality or training issues | Review rejection reasons; improve scanner resolution or provide entry clerk training |
|
||||||
|
| Batch stuck in `approved` | VigilCareClinical unreachable | Promotion retry worker handles this automatically with exponential backoff |
|
||||||
|
```
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# VigilCare Records — End-to-End Verification Script
|
||||||
|
# Tests the full digitization workflow: upload → entry → verify → approve → promote
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# - API running on http://localhost:5217
|
||||||
|
# - docker compose up -d (PostgreSQL, Redis, MinIO, Seq)
|
||||||
|
# - Seed data applied (dotnet run applies seed on startup)
|
||||||
|
# - A test PDF file at ./scripts/test-scan.pdf (create with: echo "test" | enscript -o - | ps2pdf - scripts/test-scan.pdf)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# chmod +x scripts/run-vigilcare-records-verification.sh
|
||||||
|
# ./scripts/run-vigilcare-records-verification.sh
|
||||||
|
|
||||||
|
API="http://localhost:5217/api/v1"
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
TEST_FILE="${1:-scripts/test-scan.pdf}"
|
||||||
|
|
||||||
|
# Colors for terminal output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
log_pass() { PASS=$((PASS + 1)); echo -e "${GREEN} PASS${NC} $1"; }
|
||||||
|
log_fail() { FAIL=$((FAIL + 1)); echo -e "${RED} FAIL${NC} $1"; }
|
||||||
|
log_step() { echo -e "\n${YELLOW}── $1 ──${NC}"; }
|
||||||
|
|
||||||
|
# Create a minimal test PDF if it doesn't exist
|
||||||
|
if [ ! -f "$TEST_FILE" ]; then
|
||||||
|
echo "Creating test PDF at $TEST_FILE..."
|
||||||
|
mkdir -p "$(dirname "$TEST_FILE")"
|
||||||
|
echo "%PDF-1.0
|
||||||
|
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj 2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj 3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R/Resources<<>>>>endobj
|
||||||
|
xref
|
||||||
|
0 4
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000009 00000 n
|
||||||
|
0000000058 00000 n
|
||||||
|
0000000115 00000 n
|
||||||
|
trailer<</Size 4/Root 1 0 R>>
|
||||||
|
startxref
|
||||||
|
206
|
||||||
|
%%EOF" > "$TEST_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Phase 1: Authentication
|
||||||
|
# ============================================================================
|
||||||
|
log_step "1. Authentication"
|
||||||
|
|
||||||
|
# Login as intake clerk
|
||||||
|
INTAKE_LOGIN=$(curl -s -X POST "$API/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"intake1","password":"password"}')
|
||||||
|
INTAKE_TOKEN=$(echo "$INTAKE_LOGIN" | jq -r '.data.token')
|
||||||
|
INTAKE_REFRESH=$(echo "$INTAKE_LOGIN" | jq -r '.data.refreshToken')
|
||||||
|
|
||||||
|
if [ "$INTAKE_TOKEN" != "null" ] && [ -n "$INTAKE_TOKEN" ]; then
|
||||||
|
log_pass "Intake clerk login returned JWT"
|
||||||
|
else
|
||||||
|
log_fail "Intake clerk login failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$INTAKE_REFRESH" != "null" ] && [ -n "$INTAKE_REFRESH" ]; then
|
||||||
|
log_pass "Intake clerk login returned refresh token"
|
||||||
|
else
|
||||||
|
log_fail "Intake clerk login missing refresh token"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Login as entry clerk
|
||||||
|
ENTRY_TOKEN=$(curl -s -X POST "$API/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"entry1","password":"password"}' | jq -r '.data.token')
|
||||||
|
|
||||||
|
if [ "$ENTRY_TOKEN" != "null" ] && [ -n "$ENTRY_TOKEN" ]; then
|
||||||
|
log_pass "Entry clerk login returned JWT"
|
||||||
|
else
|
||||||
|
log_fail "Entry clerk login failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Login as verifier (must be different from entry clerk for separation of duties)
|
||||||
|
VERIFIER_TOKEN=$(curl -s -X POST "$API/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"verifier1","password":"password"}' | jq -r '.data.token')
|
||||||
|
|
||||||
|
if [ "$VERIFIER_TOKEN" != "null" ] && [ -n "$VERIFIER_TOKEN" ]; then
|
||||||
|
log_pass "Verifier login returned JWT"
|
||||||
|
else
|
||||||
|
log_fail "Verifier login failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Login as approver
|
||||||
|
APPROVER_TOKEN=$(curl -s -X POST "$API/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"approver1","password":"password"}' | jq -r '.data.token')
|
||||||
|
|
||||||
|
if [ "$APPROVER_TOKEN" != "null" ] && [ -n "$APPROVER_TOKEN" ]; then
|
||||||
|
log_pass "Approver login returned JWT"
|
||||||
|
else
|
||||||
|
log_fail "Approver login failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify /me endpoint
|
||||||
|
ME_ROLE=$(curl -s "$API/auth/me" \
|
||||||
|
-H "Authorization: Bearer $INTAKE_TOKEN" | jq -r '.data.role')
|
||||||
|
|
||||||
|
if [ "$ME_ROLE" = "INTAKE_CLERK" ]; then
|
||||||
|
log_pass "/me returns correct role: INTAKE_CLERK"
|
||||||
|
else
|
||||||
|
log_fail "/me returned unexpected role: $ME_ROLE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify 401 without token
|
||||||
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$API/auth/me")
|
||||||
|
if [ "$HTTP_CODE" = "401" ]; then
|
||||||
|
log_pass "Unauthenticated /me returns 401"
|
||||||
|
else
|
||||||
|
log_fail "Unauthenticated /me returned $HTTP_CODE (expected 401)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Refresh token rotation
|
||||||
|
REFRESH_RESPONSE=$(curl -s -X POST "$API/auth/refresh" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"refreshToken\":\"$INTAKE_REFRESH\"}")
|
||||||
|
NEW_TOKEN=$(echo "$REFRESH_RESPONSE" | jq -r '.data.token')
|
||||||
|
NEW_REFRESH=$(echo "$REFRESH_RESPONSE" | jq -r '.data.refreshToken')
|
||||||
|
|
||||||
|
if [ "$NEW_TOKEN" != "null" ] && [ -n "$NEW_TOKEN" ] && [ "$NEW_REFRESH" != "$INTAKE_REFRESH" ]; then
|
||||||
|
log_pass "Refresh rotated tokens successfully"
|
||||||
|
INTAKE_TOKEN="$NEW_TOKEN"
|
||||||
|
INTAKE_REFRESH="$NEW_REFRESH"
|
||||||
|
else
|
||||||
|
log_fail "Token refresh failed: $REFRESH_RESPONSE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Logout revokes refresh token
|
||||||
|
LOGOUT_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$API/auth/logout" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"refreshToken\":\"$INTAKE_REFRESH\"}")
|
||||||
|
if [ "$LOGOUT_CODE" = "204" ]; then
|
||||||
|
log_pass "Logout revoked refresh token (204)"
|
||||||
|
else
|
||||||
|
log_fail "Logout returned $LOGOUT_CODE (expected 204)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Re-login for subsequent test phases
|
||||||
|
INTAKE_LOGIN=$(curl -s -X POST "$API/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"intake1","password":"password"}')
|
||||||
|
INTAKE_TOKEN=$(echo "$INTAKE_LOGIN" | jq -r '.data.token')
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Phase 2: Upload and Batch Creation
|
||||||
|
# ============================================================================
|
||||||
|
log_step "2. Upload and Batch Creation"
|
||||||
|
|
||||||
|
UPLOAD_RESPONSE=$(curl -s -X POST "$API/digitization-batches" \
|
||||||
|
-H "Authorization: Bearer $INTAKE_TOKEN" \
|
||||||
|
-F "file=@$TEST_FILE" \
|
||||||
|
-F "batchType=VITALS_SHEET" \
|
||||||
|
-F "track=BACKFILL")
|
||||||
|
|
||||||
|
BATCH_ID=$(echo "$UPLOAD_RESPONSE" | jq -r '.data.id')
|
||||||
|
BATCH_STATUS=$(echo "$UPLOAD_RESPONSE" | jq -r '.data.status')
|
||||||
|
|
||||||
|
if [ "$BATCH_ID" != "null" ] && [ -n "$BATCH_ID" ]; then
|
||||||
|
log_pass "Batch created: $BATCH_ID"
|
||||||
|
else
|
||||||
|
log_fail "Batch creation failed: $UPLOAD_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$BATCH_STATUS" = "UPLOADED" ]; then
|
||||||
|
log_pass "Initial status is UPLOADED"
|
||||||
|
else
|
||||||
|
log_fail "Initial status is $BATCH_STATUS (expected UPLOADED)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify presigned URL
|
||||||
|
DOC_URL=$(curl -s "$API/digitization-batches/$BATCH_ID" \
|
||||||
|
-H "Authorization: Bearer $INTAKE_TOKEN" | jq -r '.data.documentUrl')
|
||||||
|
|
||||||
|
if [ "$DOC_URL" != "null" ] && [ -n "$DOC_URL" ]; then
|
||||||
|
log_pass "Presigned URL returned for document"
|
||||||
|
else
|
||||||
|
log_fail "No presigned URL returned"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Phase 3: Assign and Begin Entry
|
||||||
|
# ============================================================================
|
||||||
|
log_step "3. Assign and Begin Entry"
|
||||||
|
|
||||||
|
# Get entry clerk user ID
|
||||||
|
ENTRY_USER_ID=$(curl -s "$API/auth/me" \
|
||||||
|
-H "Authorization: Bearer $ENTRY_TOKEN" | jq -r '.data.id')
|
||||||
|
|
||||||
|
# Assign batch to entry clerk
|
||||||
|
ASSIGN_RESPONSE=$(curl -s -X PATCH "$API/digitization-batches/$BATCH_ID/assign" \
|
||||||
|
-H "Authorization: Bearer $INTAKE_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"entryClerkUserId\":\"$ENTRY_USER_ID\"}")
|
||||||
|
|
||||||
|
ASSIGN_SUCCESS=$(echo "$ASSIGN_RESPONSE" | jq -r '.success')
|
||||||
|
if [ "$ASSIGN_SUCCESS" = "true" ]; then
|
||||||
|
log_pass "Batch assigned to entry clerk"
|
||||||
|
else
|
||||||
|
log_fail "Batch assignment failed: $(echo "$ASSIGN_RESPONSE" | jq -r '.error.message')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Phase 4: Draft Data Entry
|
||||||
|
# ============================================================================
|
||||||
|
log_step "4. Draft Data Entry"
|
||||||
|
|
||||||
|
# Save draft patient
|
||||||
|
PATIENT_RESPONSE=$(curl -s -X PUT "$API/digitization-batches/$BATCH_ID/draft/patient" \
|
||||||
|
-H "Authorization: Bearer $ENTRY_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"fullName": "E2E Test Patient",
|
||||||
|
"dateOfBirth": "1985-06-15",
|
||||||
|
"sex": "male",
|
||||||
|
"bloodType": "A+",
|
||||||
|
"emergencyContact": "Test Contact - 555-9999",
|
||||||
|
"noKnownAllergies": true
|
||||||
|
}')
|
||||||
|
|
||||||
|
if [ "$(echo "$PATIENT_RESPONSE" | jq -r '.success')" = "true" ]; then
|
||||||
|
log_pass "Draft patient saved"
|
||||||
|
else
|
||||||
|
log_fail "Draft patient save failed: $(echo "$PATIENT_RESPONSE" | jq -r '.error.message')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Save draft encounter
|
||||||
|
ENCOUNTER_RESPONSE=$(curl -s -X PUT "$API/digitization-batches/$BATCH_ID/draft/encounter" \
|
||||||
|
-H "Authorization: Bearer $ENTRY_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"admissionDate": "2026-06-20T08:00:00Z",
|
||||||
|
"department": "Emergency Department",
|
||||||
|
"roomBed": "ED-12",
|
||||||
|
"admissionReason": "Chest pain, rule out MI"
|
||||||
|
}')
|
||||||
|
|
||||||
|
if [ "$(echo "$ENCOUNTER_RESPONSE" | jq -r '.success')" = "true" ]; then
|
||||||
|
log_pass "Draft encounter saved"
|
||||||
|
else
|
||||||
|
log_fail "Draft encounter save failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add observations
|
||||||
|
for OBS in \
|
||||||
|
'{"observationCode":"HEART_RATE","value":95,"unit":"bpm","recordedAt":"2026-06-20T08:15:00Z"}' \
|
||||||
|
'{"observationCode":"TEMP_C","value":37.1,"unit":"C","recordedAt":"2026-06-20T08:15:00Z"}' \
|
||||||
|
'{"observationCode":"BP_SYSTOLIC","value":142,"unit":"mmHg","recordedAt":"2026-06-20T08:15:00Z"}' \
|
||||||
|
'{"observationCode":"BP_DIASTOLIC","value":88,"unit":"mmHg","recordedAt":"2026-06-20T08:15:00Z"}' \
|
||||||
|
'{"observationCode":"RESP_RATE","value":20,"unit":"breaths/min","recordedAt":"2026-06-20T08:15:00Z"}' \
|
||||||
|
'{"observationCode":"SPO2","value":97,"unit":"%","recordedAt":"2026-06-20T08:15:00Z"}'
|
||||||
|
do
|
||||||
|
OBS_RESULT=$(curl -s -X POST "$API/digitization-batches/$BATCH_ID/draft/observations" \
|
||||||
|
-H "Authorization: Bearer $ENTRY_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$OBS")
|
||||||
|
OBS_CODE=$(echo "$OBS" | jq -r '.observationCode')
|
||||||
|
if [ "$(echo "$OBS_RESULT" | jq -r '.success')" = "true" ]; then
|
||||||
|
log_pass "Observation $OBS_CODE added"
|
||||||
|
else
|
||||||
|
log_fail "Observation $OBS_CODE failed: $(echo "$OBS_RESULT" | jq -r '.error.message')"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Verify draft is complete
|
||||||
|
DRAFT_RESPONSE=$(curl -s "$API/digitization-batches/$BATCH_ID/draft" \
|
||||||
|
-H "Authorization: Bearer $ENTRY_TOKEN")
|
||||||
|
|
||||||
|
OBS_COUNT=$(echo "$DRAFT_RESPONSE" | jq -r '.data.observations | length')
|
||||||
|
if [ "$OBS_COUNT" = "6" ]; then
|
||||||
|
log_pass "Draft has 6 observations"
|
||||||
|
else
|
||||||
|
log_fail "Draft has $OBS_COUNT observations (expected 6)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Submit for verification
|
||||||
|
SUBMIT_RESPONSE=$(curl -s -X POST "$API/digitization-batches/$BATCH_ID/submit-for-verification" \
|
||||||
|
-H "Authorization: Bearer $ENTRY_TOKEN")
|
||||||
|
|
||||||
|
if [ "$(echo "$SUBMIT_RESPONSE" | jq -r '.success')" = "true" ]; then
|
||||||
|
log_pass "Submitted for verification"
|
||||||
|
else
|
||||||
|
log_fail "Submit failed: $(echo "$SUBMIT_RESPONSE" | jq -r '.error.message')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify status is now PENDING_VERIFICATION
|
||||||
|
CURRENT_STATUS=$(curl -s "$API/digitization-batches/$BATCH_ID" \
|
||||||
|
-H "Authorization: Bearer $ENTRY_TOKEN" | jq -r '.data.batch.status')
|
||||||
|
|
||||||
|
if [ "$CURRENT_STATUS" = "PENDING_VERIFICATION" ]; then
|
||||||
|
log_pass "Status is PENDING_VERIFICATION"
|
||||||
|
else
|
||||||
|
log_fail "Status is $CURRENT_STATUS (expected PENDING_VERIFICATION)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Phase 5: Separation of Duties Check
|
||||||
|
# ============================================================================
|
||||||
|
log_step "5. Separation of Duties"
|
||||||
|
|
||||||
|
# Entry clerk tries to verify their own batch — should get 409
|
||||||
|
SOD_RESPONSE=$(curl -s -X POST "$API/digitization-batches/$BATCH_ID/verify" \
|
||||||
|
-H "Authorization: Bearer $ENTRY_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"fieldChecks":[],"passed":true}')
|
||||||
|
|
||||||
|
SOD_CODE=$(echo "$SOD_RESPONSE" | jq -r '.error.code')
|
||||||
|
if [ "$SOD_CODE" = "SEPARATION_OF_DUTIES_VIOLATION" ]; then
|
||||||
|
log_pass "Entry clerk cannot verify own batch (409 SEPARATION_OF_DUTIES_VIOLATION)"
|
||||||
|
else
|
||||||
|
log_fail "Separation of duties not enforced: $SOD_CODE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Phase 6: Verification
|
||||||
|
# ============================================================================
|
||||||
|
log_step "6. Verification (by different user)"
|
||||||
|
|
||||||
|
VERIFY_RESPONSE=$(curl -s -X POST "$API/digitization-batches/$BATCH_ID/verify" \
|
||||||
|
-H "Authorization: Bearer $VERIFIER_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"fieldChecks": [
|
||||||
|
{"fieldPath": "patient.fullName", "passed": true},
|
||||||
|
{"fieldPath": "patient.dateOfBirth", "passed": true},
|
||||||
|
{"fieldPath": "observations[0].value", "passed": true},
|
||||||
|
{"fieldPath": "observations[1].value", "passed": true},
|
||||||
|
{"fieldPath": "observations[2].value", "passed": true},
|
||||||
|
{"fieldPath": "observations[3].value", "passed": true},
|
||||||
|
{"fieldPath": "observations[4].value", "passed": true},
|
||||||
|
{"fieldPath": "observations[5].value", "passed": true}
|
||||||
|
],
|
||||||
|
"passed": true
|
||||||
|
}')
|
||||||
|
|
||||||
|
if [ "$(echo "$VERIFY_RESPONSE" | jq -r '.success')" = "true" ]; then
|
||||||
|
log_pass "Verification passed"
|
||||||
|
else
|
||||||
|
log_fail "Verification failed: $(echo "$VERIFY_RESPONSE" | jq -r '.error.message')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
VERIFIED_STATUS=$(curl -s "$API/digitization-batches/$BATCH_ID" \
|
||||||
|
-H "Authorization: Bearer $VERIFIER_TOKEN" | jq -r '.data.batch.status')
|
||||||
|
|
||||||
|
if [ "$VERIFIED_STATUS" = "VERIFIED" ] || [ "$VERIFIED_STATUS" = "AWAITING_CLINICAL_APPROVAL" ]; then
|
||||||
|
log_pass "Status after verification: $VERIFIED_STATUS"
|
||||||
|
else
|
||||||
|
log_fail "Status after verification: $VERIFIED_STATUS (expected VERIFIED or AWAITING_CLINICAL_APPROVAL)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Phase 7: Approval and Promotion
|
||||||
|
# ============================================================================
|
||||||
|
log_step "7. Approval and Promotion"
|
||||||
|
|
||||||
|
APPROVE_RESPONSE=$(curl -s -X POST "$API/digitization-batches/$BATCH_ID/approve" \
|
||||||
|
-H "Authorization: Bearer $APPROVER_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Idempotency-Key: e2e-test-$(date +%s)")
|
||||||
|
|
||||||
|
if [ "$(echo "$APPROVE_RESPONSE" | jq -r '.success')" = "true" ]; then
|
||||||
|
log_pass "Batch approved and promoted"
|
||||||
|
else
|
||||||
|
log_fail "Approval failed: $(echo "$APPROVE_RESPONSE" | jq -r '.error.message')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify final status is PROMOTED
|
||||||
|
FINAL_STATUS=$(curl -s "$API/digitization-batches/$BATCH_ID" \
|
||||||
|
-H "Authorization: Bearer $APPROVER_TOKEN" | jq -r '.data.batch.status')
|
||||||
|
|
||||||
|
if [ "$FINAL_STATUS" = "PROMOTED" ]; then
|
||||||
|
log_pass "Final status is PROMOTED"
|
||||||
|
else
|
||||||
|
log_fail "Final status is $FINAL_STATUS (expected PROMOTED)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify promotedAt is set
|
||||||
|
PROMOTED_AT=$(curl -s "$API/digitization-batches/$BATCH_ID" \
|
||||||
|
-H "Authorization: Bearer $APPROVER_TOKEN" | jq -r '.data.batch.promotedAt')
|
||||||
|
|
||||||
|
if [ "$PROMOTED_AT" != "null" ] && [ -n "$PROMOTED_AT" ]; then
|
||||||
|
log_pass "promotedAt timestamp set: $PROMOTED_AT"
|
||||||
|
else
|
||||||
|
log_fail "promotedAt is null"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Phase 8: Illegal Transition Check
|
||||||
|
# ============================================================================
|
||||||
|
log_step "8. Illegal Transitions"
|
||||||
|
|
||||||
|
# Try to re-approve a promoted batch — should get 409
|
||||||
|
ILLEGAL_RESPONSE=$(curl -s -X POST "$API/digitization-batches/$BATCH_ID/approve" \
|
||||||
|
-H "Authorization: Bearer $APPROVER_TOKEN" \
|
||||||
|
-H "Content-Type: application/json")
|
||||||
|
|
||||||
|
ILLEGAL_CODE=$(echo "$ILLEGAL_RESPONSE" | jq -r '.error.code')
|
||||||
|
if [ "$ILLEGAL_CODE" = "ILLEGAL_STATUS_TRANSITION" ]; then
|
||||||
|
log_pass "Cannot approve promoted batch (409 ILLEGAL_STATUS_TRANSITION)"
|
||||||
|
else
|
||||||
|
log_fail "Illegal transition not blocked: $ILLEGAL_CODE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Phase 9: Work Queue Overview
|
||||||
|
# ============================================================================
|
||||||
|
log_step "9. Work Queue Overview"
|
||||||
|
|
||||||
|
OVERVIEW_RESPONSE=$(curl -s "$API/work-queue/overview" \
|
||||||
|
-H "Authorization: Bearer $APPROVER_TOKEN")
|
||||||
|
|
||||||
|
OVERVIEW_SUCCESS=$(echo "$OVERVIEW_RESPONSE" | jq -r '.success')
|
||||||
|
if [ "$OVERVIEW_SUCCESS" = "true" ]; then
|
||||||
|
log_pass "Work queue overview returned successfully"
|
||||||
|
echo " Status counts: $(echo "$OVERVIEW_RESPONSE" | jq -c '.data.statusCounts')"
|
||||||
|
echo " Reject rate: $(echo "$OVERVIEW_RESPONSE" | jq -r '.data.rejectRate')"
|
||||||
|
else
|
||||||
|
log_fail "Work queue overview failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Phase 10: Metrics Endpoint
|
||||||
|
# ============================================================================
|
||||||
|
log_step "10. Prometheus Metrics"
|
||||||
|
|
||||||
|
METRICS=$(curl -s http://localhost:5217/metrics)
|
||||||
|
|
||||||
|
if echo "$METRICS" | grep -q "digitization_batches_by_status"; then
|
||||||
|
log_pass "digitization_batches_by_status metric present"
|
||||||
|
else
|
||||||
|
log_fail "digitization_batches_by_status metric missing"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if echo "$METRICS" | grep -q "digitization_promotion_duration_seconds"; then
|
||||||
|
log_pass "digitization_promotion_duration_seconds metric present"
|
||||||
|
else
|
||||||
|
log_fail "digitization_promotion_duration_seconds metric missing"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if echo "$METRICS" | grep -q "digitization_rejection_total"; then
|
||||||
|
log_pass "digitization_rejection_total metric present"
|
||||||
|
else
|
||||||
|
log_fail "digitization_rejection_total metric missing"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Summary
|
||||||
|
# ============================================================================
|
||||||
|
echo ""
|
||||||
|
echo "============================================"
|
||||||
|
echo " E2E Verification Complete"
|
||||||
|
echo "============================================"
|
||||||
|
echo -e " ${GREEN}Passed: $PASS${NC}"
|
||||||
|
echo -e " ${RED}Failed: $FAIL${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ "$FAIL" -eq 0 ]; then
|
||||||
|
echo -e " ${GREEN}ALL TESTS PASSED${NC}"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo -e " ${RED}$FAIL TEST(S) FAILED${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + TypeScript + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>vigilcare-records-web</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2716
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "vigilcare-records-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vueuse/core": "^14.3.0",
|
||||||
|
"axios": "1.7",
|
||||||
|
"pinia": "^2.3.1",
|
||||||
|
"vue": "^3.5.38",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.13.2",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"autoprefixer": "^10.5.2",
|
||||||
|
"postcss": "^8.5.15",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.1.0",
|
||||||
|
"vue-tsc": "^3.3.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// App shell — routing handles all layout
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'
|
||||||
|
import type { ApiResponse, TokenResponse } from '../types'
|
||||||
|
|
||||||
|
const REFRESH_BUFFER_MS = 60_000 // refresh 1 minute before access token expiry
|
||||||
|
|
||||||
|
const apiClient: AxiosInstance = axios.create({
|
||||||
|
baseURL: '/api/v1',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
timeout: 30000,
|
||||||
|
})
|
||||||
|
|
||||||
|
function getStoredAccessToken(): string | null {
|
||||||
|
return localStorage.getItem('vigilcare_token')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoredRefreshToken(): string | null {
|
||||||
|
return localStorage.getItem('vigilcare_refresh_token')
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStoredTokens(accessToken: string, refreshToken: string): void {
|
||||||
|
localStorage.setItem('vigilcare_token', accessToken)
|
||||||
|
localStorage.setItem('vigilcare_refresh_token', refreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStoredAuth(): void {
|
||||||
|
localStorage.removeItem('vigilcare_token')
|
||||||
|
localStorage.removeItem('vigilcare_refresh_token')
|
||||||
|
localStorage.removeItem('vigilcare_user')
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeJwtExpiry(token: string): number | null {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]!))
|
||||||
|
return typeof payload.exp === 'number' ? payload.exp * 1000 : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshPromise: Promise<string | null> | null = null
|
||||||
|
|
||||||
|
async function refreshAccessToken(): Promise<string | null> {
|
||||||
|
const refreshToken = getStoredRefreshToken()
|
||||||
|
if (!refreshToken) return null
|
||||||
|
|
||||||
|
if (!refreshPromise) {
|
||||||
|
refreshPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post<ApiResponse<TokenResponse>>(
|
||||||
|
'/api/v1/auth/refresh',
|
||||||
|
{ refreshToken }
|
||||||
|
)
|
||||||
|
const data = response.data.data
|
||||||
|
if (!response.data.success || !data) return null
|
||||||
|
|
||||||
|
setStoredTokens(data.token, data.refreshToken)
|
||||||
|
scheduleProactiveRefresh(data.token)
|
||||||
|
return data.token
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
refreshPromise = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
return refreshPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function scheduleProactiveRefresh(accessToken: string): void {
|
||||||
|
if (refreshTimer) clearTimeout(refreshTimer)
|
||||||
|
|
||||||
|
const expiresAt = decodeJwtExpiry(accessToken)
|
||||||
|
if (!expiresAt) return
|
||||||
|
|
||||||
|
const delay = Math.max(expiresAt - Date.now() - REFRESH_BUFFER_MS, 0)
|
||||||
|
refreshTimer = setTimeout(() => {
|
||||||
|
void refreshAccessToken()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initializeAuthRefresh(): void {
|
||||||
|
const token = getStoredAccessToken()
|
||||||
|
if (token) scheduleProactiveRefresh(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectToLogin(): void {
|
||||||
|
clearStoredAuth()
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request interceptor: attach JWT from localStorage
|
||||||
|
apiClient.interceptors.request.use(
|
||||||
|
async (config: InternalAxiosRequestConfig) => {
|
||||||
|
const token = getStoredAccessToken()
|
||||||
|
if (token && config.headers) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Response interceptor: retry once on 401 after refresh
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||||
|
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true
|
||||||
|
const newToken = await refreshAccessToken()
|
||||||
|
if (newToken && originalRequest.headers) {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||||
|
return apiClient(originalRequest)
|
||||||
|
}
|
||||||
|
redirectToLogin()
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Typed API helpers
|
||||||
|
export async function get<T>(url: string, params?: Record<string, unknown>): Promise<ApiResponse<T>> {
|
||||||
|
const response = await apiClient.get<ApiResponse<T>>(url, { params })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function post<T>(url: string, data?: unknown): Promise<ApiResponse<T>> {
|
||||||
|
const response = await apiClient.post<ApiResponse<T>>(url, data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function put<T>(url: string, data?: unknown): Promise<ApiResponse<T>> {
|
||||||
|
const response = await apiClient.put<ApiResponse<T>>(url, data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patch<T>(url: string, data?: unknown): Promise<ApiResponse<T>> {
|
||||||
|
const response = await apiClient.patch<ApiResponse<T>>(url, data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function del<T>(url: string): Promise<ApiResponse<T>> {
|
||||||
|
const response = await apiClient.delete<ApiResponse<T>>(url)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multipart upload helper — used by IntakeView for document upload
|
||||||
|
export async function uploadFile<T>(
|
||||||
|
url: string,
|
||||||
|
file: File,
|
||||||
|
fields: Record<string, string>
|
||||||
|
): Promise<ApiResponse<T>> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
Object.entries(fields).forEach(([key, value]) => {
|
||||||
|
formData.append(key, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await apiClient.post<ApiResponse<T>>(url, formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
timeout: 60000, // uploads may be larger — extend timeout
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export default apiClient
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,44 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn-primary {
|
||||||
|
@apply bg-primary-600 text-white px-4 py-2 rounded-md
|
||||||
|
hover:bg-primary-700 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
|
||||||
|
transition-colors;
|
||||||
|
}
|
||||||
|
.form-input {
|
||||||
|
@apply block w-full rounded-md border border-gray-300 px-4 py-2
|
||||||
|
focus:border-primary-500 focus:ring-2 focus:ring-primary-500
|
||||||
|
disabled:bg-gray-100 disabled:text-gray-500;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.app-header-title {
|
||||||
|
@apply flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4;
|
||||||
|
}
|
||||||
|
.app-header-actions {
|
||||||
|
@apply flex flex-wrap items-center gap-2 sm:gap-4;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
@apply bg-white rounded-lg shadow-md p-4 sm:p-6;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.status-badge {
|
||||||
|
@apply px-2 py-1 rounded-full text-xs font-medium;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-header">
|
||||||
|
<div class="app-header-title">
|
||||||
|
<h1 class="text-lg font-semibold">{{ title }}</h1>
|
||||||
|
<slot name="subtitle" />
|
||||||
|
</div>
|
||||||
|
<div class="app-header-actions">
|
||||||
|
<slot name="actions" />
|
||||||
|
<span class="text-sm text-gray-600">{{ auth.userFullName }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="auth.logout()"
|
||||||
|
class="text-sm text-gray-500 hover:text-gray-800"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
defineProps<{ title: string }>()
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="show"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 px-4"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-lg p-6 max-w-md w-full">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">Assign Entry Clerk</h3>
|
||||||
|
|
||||||
|
<p v-if="loading" class="text-sm text-gray-500 mb-4">Loading clerks...</p>
|
||||||
|
<div v-else class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Entry Clerk</label>
|
||||||
|
<select v-model="selectedClerkId" class="form-input">
|
||||||
|
<option value="">Select entry clerk...</option>
|
||||||
|
<option v-for="clerk in clerks" :key="clerk.id" :value="clerk.id">
|
||||||
|
{{ clerk.fullName }} ({{ clerk.username }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMessage" class="text-clinical-danger text-sm mb-4">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col-reverse sm:flex-row sm:justify-end gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="emit('close')"
|
||||||
|
class="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="confirm"
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="!selectedClerkId || loading"
|
||||||
|
>
|
||||||
|
Assign Batch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { get } from '../api/client'
|
||||||
|
import type { UserSummary } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
batchId: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
assigned: [batchId: string, clerkUserId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const clerks = ref<UserSummary[]>([])
|
||||||
|
const selectedClerkId = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
async (visible) => {
|
||||||
|
if (!visible) return
|
||||||
|
|
||||||
|
selectedClerkId.value = ''
|
||||||
|
errorMessage.value = ''
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await get<UserSummary[]>('users', { role: 'DATA_ENTRY_CLERK' })
|
||||||
|
if (response.success && response.data) {
|
||||||
|
clerks.value = response.data
|
||||||
|
} else {
|
||||||
|
clerks.value = []
|
||||||
|
errorMessage.value = response.error?.message ?? 'Failed to load entry clerks'
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
clerks.value = []
|
||||||
|
errorMessage.value = e instanceof Error ? e.message : 'Failed to load entry clerks'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function confirm(): void {
|
||||||
|
if (!props.batchId || !selectedClerkId.value) return
|
||||||
|
emit('assigned', props.batchId, selectedClerkId.value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<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">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b text-left text-gray-600">
|
||||||
|
<th class="py-2 px-4">ID</th>
|
||||||
|
<th class="py-2 px-4">Type</th>
|
||||||
|
<th class="py-2 px-4">Track</th>
|
||||||
|
<th class="py-2 px-4">Status</th>
|
||||||
|
<th class="py-2 px-4">Created</th>
|
||||||
|
<th v-if="showAssign" class="py-2 px-4">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="batch in batches"
|
||||||
|
:key="batch.id"
|
||||||
|
class="border-b hover:bg-gray-50 cursor-pointer"
|
||||||
|
@click="$emit('select', batch.id)"
|
||||||
|
>
|
||||||
|
<td class="py-2 px-4 font-mono text-xs">{{ batch.id.substring(0, 8) }}...</td>
|
||||||
|
<td class="py-2 px-4">{{ formatBatchType(batch.batchType) }}</td>
|
||||||
|
<td class="py-2 px-4">
|
||||||
|
<span
|
||||||
|
:class="batch.track === 'BACKFILL'
|
||||||
|
? 'bg-blue-100 text-blue-800'
|
||||||
|
: 'bg-green-100 text-green-800'"
|
||||||
|
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>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-4 text-gray-500">
|
||||||
|
{{ new Date(batch.createdAt).toLocaleString() }}
|
||||||
|
</td>
|
||||||
|
<td v-if="showAssign" class="py-2 px-4">
|
||||||
|
<button
|
||||||
|
v-if="batch.status === 'UPLOADED'"
|
||||||
|
@click.stop="$emit('assign', batch.id)"
|
||||||
|
class="text-primary-600 hover:text-primary-800 text-xs font-medium"
|
||||||
|
>
|
||||||
|
Assign
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { BatchDetailResponse } from '../types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
batches: BatchDetailResponse[]
|
||||||
|
loading: boolean
|
||||||
|
showAssign?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'select', batchId: string): void
|
||||||
|
(e: 'assign', batchId: string): 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'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500">Sex</label>
|
||||||
|
<select v-model="patient.sex" @change="savePatient" class="form-input text-sm">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500">Department</label>
|
||||||
|
<select v-model="encounter.department" @change="saveEncounter" class="form-input text-sm">
|
||||||
|
<option value="">—</option>
|
||||||
|
<option v-for="dept in departments" :key="dept" :value="dept">{{ dept }}</option>
|
||||||
|
</select>
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
@update="(field, value) => handleObsUpdate(obs.id, field, value)"
|
||||||
|
@delete="handleObsDelete"
|
||||||
|
/>
|
||||||
|
<button @click="addObservation" class="btn-primary text-sm">
|
||||||
|
+ Add Observation
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, watch } from 'vue'
|
||||||
|
import { useBatchStore } from '../stores/batches'
|
||||||
|
import ObservationRow from '../components/ObservationRow.vue'
|
||||||
|
import type { BatchDetailResponse, DraftObservation } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
batch: BatchDetailResponse | null
|
||||||
|
batchId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const batchStore = useBatchStore()
|
||||||
|
const submitting = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
const bloodTypes = ['A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-']
|
||||||
|
const departments = [
|
||||||
|
'Emergency Department',
|
||||||
|
'Internal Medicine',
|
||||||
|
'General Medicine',
|
||||||
|
'Surgery',
|
||||||
|
'ICU',
|
||||||
|
'NICU',
|
||||||
|
'Medical-Surgical',
|
||||||
|
'Outpatient Clinic',
|
||||||
|
'Pediatrics',
|
||||||
|
'Obstetrics & Gynecology',
|
||||||
|
'Labor & Delivery',
|
||||||
|
'Cardiology',
|
||||||
|
'Orthopedics',
|
||||||
|
'Neurology',
|
||||||
|
'Oncology',
|
||||||
|
'Radiology',
|
||||||
|
'Laboratory',
|
||||||
|
'Psychiatry',
|
||||||
|
'Physical Therapy',
|
||||||
|
'Anesthesiology',
|
||||||
|
]
|
||||||
|
|
||||||
|
const patient = reactive({
|
||||||
|
fullName: '',
|
||||||
|
dateOfBirth: '',
|
||||||
|
sex: '',
|
||||||
|
bloodType: '',
|
||||||
|
emergencyContact: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const encounter = reactive({
|
||||||
|
admissionDate: '',
|
||||||
|
department: '',
|
||||||
|
roomBed: '',
|
||||||
|
admissionReason: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const observations = ref<DraftObservation[]>([])
|
||||||
|
|
||||||
|
const statusColor = ref('bg-gray-100 text-gray-800')
|
||||||
|
|
||||||
|
// Load draft data when batch changes
|
||||||
|
watch(
|
||||||
|
() => batchStore.currentDraft,
|
||||||
|
(draft) => {
|
||||||
|
if (!draft) return
|
||||||
|
if (draft.patient) {
|
||||||
|
Object.assign(patient, {
|
||||||
|
fullName: draft.patient.fullName ?? '',
|
||||||
|
dateOfBirth: draft.patient.dateOfBirth ?? '',
|
||||||
|
sex: draft.patient.sex ?? '',
|
||||||
|
bloodType: draft.patient.bloodType ?? '',
|
||||||
|
emergencyContact: draft.patient.emergencyContact ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (draft.encounter) {
|
||||||
|
Object.assign(encounter, {
|
||||||
|
admissionDate: draft.encounter.admissionDate?.substring(0, 16) ?? '',
|
||||||
|
department: draft.encounter.department ?? '',
|
||||||
|
roomBed: draft.encounter.roomBed ?? '',
|
||||||
|
admissionReason: draft.encounter.admissionReason ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
observations.value = draft.observations ?? []
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
async function savePatient() {
|
||||||
|
try {
|
||||||
|
await batchStore.saveDraftPatient(props.batchId, patient)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
errorMessage.value = e instanceof Error ? e.message : 'Failed to save patient'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEncounter() {
|
||||||
|
try {
|
||||||
|
await batchStore.saveDraftEncounter(props.batchId, encounter)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
errorMessage.value = e instanceof Error ? e.message : 'Failed to save encounter'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addObservation() {
|
||||||
|
await batchStore.addObservation(props.batchId, {
|
||||||
|
observationCode: '',
|
||||||
|
value: 0,
|
||||||
|
unit: '',
|
||||||
|
recordedAt: new Date().toISOString(),
|
||||||
|
note: null,
|
||||||
|
})
|
||||||
|
observations.value = batchStore.currentDraft?.observations ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleObsUpdate(obsId: string, field: string, value: unknown) {
|
||||||
|
const obs = observations.value.find((o) => o.id === obsId)
|
||||||
|
if (!obs) return
|
||||||
|
;(obs as Record<string, unknown>)[field] = value
|
||||||
|
await batchStore.updateObservation(props.batchId, obsId, obs)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleObsDelete(obsId: string) {
|
||||||
|
await batchStore.deleteObservation(props.batchId, obsId)
|
||||||
|
observations.value = batchStore.currentDraft?.observations ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForVerification() {
|
||||||
|
submitting.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
await batchStore.submitForVerification(props.batchId)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
errorMessage.value = e instanceof Error ? e.message : 'Submit failed'
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<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>
|
||||||
|
<select
|
||||||
|
:value="observation.observationCode"
|
||||||
|
@change="update('observationCode', ($event.target as HTMLSelectElement).value)"
|
||||||
|
class="form-input text-sm"
|
||||||
|
:disabled="readonly"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500">Value</label>
|
||||||
|
<input
|
||||||
|
:value="observation.value"
|
||||||
|
@change="update('value', parseFloat(($event.target as HTMLInputElement).value))"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
class="form-input text-sm"
|
||||||
|
:disabled="readonly"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500">Unit</label>
|
||||||
|
<input
|
||||||
|
:value="observation.unit"
|
||||||
|
@change="update('unit', ($event.target as HTMLInputElement).value)"
|
||||||
|
type="text"
|
||||||
|
class="form-input text-sm"
|
||||||
|
:disabled="readonly"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500">Recorded At</label>
|
||||||
|
<input
|
||||||
|
:value="observation.recordedAt?.substring(0, 16)"
|
||||||
|
@change="update('recordedAt', ($event.target as HTMLInputElement).value)"
|
||||||
|
type="datetime-local"
|
||||||
|
class="form-input text-sm"
|
||||||
|
:disabled="readonly"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500">Note</label>
|
||||||
|
<input
|
||||||
|
:value="observation.note"
|
||||||
|
@change="update('note', ($event.target as HTMLInputElement).value)"
|
||||||
|
type="text"
|
||||||
|
class="form-input text-sm"
|
||||||
|
placeholder="Optional"
|
||||||
|
:disabled="readonly"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
observation: DraftObservation
|
||||||
|
readonly?: boolean
|
||||||
|
showVerified?: boolean
|
||||||
|
verified?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update', field: string, value: unknown): void
|
||||||
|
(e: 'delete', obsId: string): void
|
||||||
|
(e: 'verify', obsId: string, passed: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function update(field: string, value: unknown) {
|
||||||
|
emit('update', field, value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
@input="debouncedSearch"
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Search by MRN or patient name..."
|
||||||
|
/>
|
||||||
|
<ul
|
||||||
|
v-if="results.length > 0"
|
||||||
|
class="absolute z-10 w-full bg-white border border-gray-200 rounded-md
|
||||||
|
shadow-lg mt-2 max-h-48 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="patient in results"
|
||||||
|
:key="patient.id"
|
||||||
|
@click="selectPatient(patient)"
|
||||||
|
class="px-4 py-2 hover:bg-primary-50 cursor-pointer text-sm"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{{ patient.fullName }}</span>
|
||||||
|
<span class="text-gray-500 ml-2">MRN: {{ patient.mrn }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-if="selectedPatient" class="text-sm text-clinical-safe mt-2">
|
||||||
|
Selected: {{ selectedPatient.fullName }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { get } from '../api/client'
|
||||||
|
import type { PatientSearchResult } from '../types'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'update:modelValue', value: string | undefined): void }>()
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const results = ref<PatientSearchResult[]>([])
|
||||||
|
const selectedPatient = ref<PatientSearchResult | null>(null)
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout>
|
||||||
|
|
||||||
|
function debouncedSearch() {
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(search, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
if (searchQuery.value.length < 2) {
|
||||||
|
results.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await get<PatientSearchResult[]>('patients/search', {
|
||||||
|
q: searchQuery.value,
|
||||||
|
})
|
||||||
|
if (response.success && response.data) {
|
||||||
|
results.value = response.data
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
results.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPatient(patient: PatientSearchResult) {
|
||||||
|
selectedPatient.value = patient
|
||||||
|
searchQuery.value = patient.fullName
|
||||||
|
results.value = []
|
||||||
|
emit('update:modelValue', patient.id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<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">
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button @click="zoomOut" class="px-2 py-1 hover:bg-gray-700 rounded" title="Zoom out">
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<button @click="resetZoom" class="px-2 py-1 hover:bg-gray-700 rounded" title="Reset">
|
||||||
|
1:1
|
||||||
|
</button>
|
||||||
|
<button @click="rotateCw" class="px-2 py-1 hover:bg-gray-700 rounded" title="Rotate 90">
|
||||||
|
Rotate
|
||||||
|
</button>
|
||||||
|
<span class="ml-auto text-gray-400 text-xs">{{ Math.round(scale * 100) }}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Document area -->
|
||||||
|
<div
|
||||||
|
ref="viewerContainer"
|
||||||
|
class="flex-1 overflow-auto 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 rendering via iframe for simplicity; production would use pdf.js -->
|
||||||
|
<iframe
|
||||||
|
v-if="isPdf"
|
||||||
|
:src="url"
|
||||||
|
class="w-[800px] h-[1100px] bg-white"
|
||||||
|
frameborder="0"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-else
|
||||||
|
:src="url"
|
||||||
|
class="max-w-none"
|
||||||
|
draggable="false"
|
||||||
|
@load="onImageLoad"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
url: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isPdf = computed(() => {
|
||||||
|
const lower = props.url.toLowerCase()
|
||||||
|
return lower.includes('.pdf') || lower.includes('application/pdf')
|
||||||
|
})
|
||||||
|
|
||||||
|
const scale = ref(1)
|
||||||
|
const rotation = ref(0)
|
||||||
|
const panX = ref(0)
|
||||||
|
const panY = ref(0)
|
||||||
|
const isPanning = ref(false)
|
||||||
|
const lastX = ref(0)
|
||||||
|
const lastY = ref(0)
|
||||||
|
|
||||||
|
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 onWheel(e: WheelEvent) {
|
||||||
|
if (e.deltaY < 0) zoomIn()
|
||||||
|
else zoomOut()
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPan(e: MouseEvent) {
|
||||||
|
isPanning.value = true
|
||||||
|
lastX.value = e.clientX
|
||||||
|
lastY.value = e.clientY
|
||||||
|
}
|
||||||
|
|
||||||
|
function pan(e: MouseEvent) {
|
||||||
|
if (!isPanning.value) return
|
||||||
|
panX.value += e.clientX - lastX.value
|
||||||
|
panY.value += e.clientY - lastY.value
|
||||||
|
lastX.value = e.clientX
|
||||||
|
lastY.value = e.clientY
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPan() { isPanning.value = false }
|
||||||
|
|
||||||
|
function onImageLoad() {
|
||||||
|
// Reset view when a new image loads
|
||||||
|
scale.value = 1
|
||||||
|
panX.value = 0
|
||||||
|
panY.value = 0
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 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">
|
||||||
|
{{ 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">
|
||||||
|
{{ 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"
|
||||||
|
@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"
|
||||||
|
>
|
||||||
|
{{ processing ? 'Processing...' : 'Approve - Verified' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="showRejectDialog = true"
|
||||||
|
class="btn-danger"
|
||||||
|
:disabled="processing"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</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="rejectVerification"
|
||||||
|
class="btn-danger"
|
||||||
|
:disabled="!rejectionReason.trim()"
|
||||||
|
>
|
||||||
|
Confirm Rejection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMessage" class="text-clinical-danger text-sm">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useBatchStore } from '../stores/batches'
|
||||||
|
import ObservationRow from '../components/ObservationRow.vue'
|
||||||
|
import type { BatchDetailResponse, DraftObservation } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
batch: BatchDetailResponse | null
|
||||||
|
batchId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const batchStore = useBatchStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const processing = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const showRejectDialog = ref(false)
|
||||||
|
const rejectionReason = ref('')
|
||||||
|
|
||||||
|
const fieldChecks = ref<Record<string, boolean>>({})
|
||||||
|
const observations = ref<DraftObservation[]>([])
|
||||||
|
|
||||||
|
interface FieldInfo {
|
||||||
|
path: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const patientFields = ref<FieldInfo[]>([])
|
||||||
|
const encounterFields = ref<FieldInfo[]>([])
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => batchStore.currentDraft,
|
||||||
|
(draft) => {
|
||||||
|
if (!draft) return
|
||||||
|
|
||||||
|
observations.value = draft.observations ?? []
|
||||||
|
|
||||||
|
// Build patient field list
|
||||||
|
if (draft.patient) {
|
||||||
|
patientFields.value = [
|
||||||
|
{ path: 'patient.fullName', label: 'Full Name', value: draft.patient.fullName ?? '' },
|
||||||
|
{ path: 'patient.dateOfBirth', label: 'Date of Birth', value: draft.patient.dateOfBirth ?? '' },
|
||||||
|
{ path: 'patient.sex', label: 'Sex', value: draft.patient.sex ?? '' },
|
||||||
|
{ path: 'patient.bloodType', label: 'Blood Type', value: draft.patient.bloodType ?? '' },
|
||||||
|
{ path: 'patient.emergencyContact', label: 'Emergency Contact', value: draft.patient.emergencyContact ?? '' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build encounter field list
|
||||||
|
if (draft.encounter) {
|
||||||
|
encounterFields.value = [
|
||||||
|
{ path: 'encounter.admissionDate', label: 'Admission Date', value: draft.encounter.admissionDate ?? '' },
|
||||||
|
{ path: 'encounter.department', label: 'Department', value: draft.encounter.department ?? '' },
|
||||||
|
{ path: 'encounter.roomBed', label: 'Room / Bed', value: draft.encounter.roomBed ?? '' },
|
||||||
|
{ path: 'encounter.admissionReason', label: 'Admission Reason', value: draft.encounter.admissionReason ?? '' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize all checks to false
|
||||||
|
fieldChecks.value = {}
|
||||||
|
patientFields.value.forEach((f) => { fieldChecks.value[f.path] = false })
|
||||||
|
encounterFields.value.forEach((f) => { fieldChecks.value[f.path] = false })
|
||||||
|
observations.value.forEach((_, i) => { fieldChecks.value[`observations[${i}].value`] = false })
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalFields = computed(
|
||||||
|
() => patientFields.value.length + encounterFields.value.length + observations.value.length
|
||||||
|
)
|
||||||
|
const checkedCount = computed(
|
||||||
|
() => Object.values(fieldChecks.value).filter(Boolean).length
|
||||||
|
)
|
||||||
|
const allChecked = computed(() => checkedCount.value === totalFields.value && totalFields.value > 0)
|
||||||
|
|
||||||
|
function toggleCheck(path: string, value?: boolean) {
|
||||||
|
fieldChecks.value[path] = value ?? !fieldChecks.value[path]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approveVerification() {
|
||||||
|
processing.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
const checks = Object.entries(fieldChecks.value).map(([fieldPath, passed]) => ({
|
||||||
|
fieldPath,
|
||||||
|
passed,
|
||||||
|
}))
|
||||||
|
await batchStore.verifyBatch(props.batchId, checks, true)
|
||||||
|
router.push('/verification')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
errorMessage.value = e instanceof Error ? e.message : 'Verification failed'
|
||||||
|
} finally {
|
||||||
|
processing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejectVerification() {
|
||||||
|
processing.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
await batchStore.rejectBatch(props.batchId, rejectionReason.value)
|
||||||
|
showRejectDialog.value = false
|
||||||
|
router.push('/verification')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
errorMessage.value = e instanceof Error ? e.message : 'Rejection failed'
|
||||||
|
} finally {
|
||||||
|
processing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { computed, watch, onUnmounted, type Ref } from 'vue'
|
||||||
|
import { useIntervalFn } from '@vueuse/core'
|
||||||
|
import { useBatchStore } from '../stores/batches'
|
||||||
|
|
||||||
|
const PRESIGNED_URL_TTL_MS = 15 * 60 * 1000
|
||||||
|
const REFRESH_BUFFER_MS = 60_000
|
||||||
|
|
||||||
|
export function usePresignedUrl(batchId: Ref<string | undefined>) {
|
||||||
|
const batchStore = useBatchStore()
|
||||||
|
|
||||||
|
const documentUrl = computed(() => batchStore.documentUrl)
|
||||||
|
|
||||||
|
async function refreshUrl(): Promise<void> {
|
||||||
|
if (batchId.value) {
|
||||||
|
await batchStore.getBatch(batchId.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pause, resume } = useIntervalFn(
|
||||||
|
() => { void refreshUrl() },
|
||||||
|
PRESIGNED_URL_TTL_MS - REFRESH_BUFFER_MS,
|
||||||
|
{ immediate: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
batchId,
|
||||||
|
async (id) => {
|
||||||
|
pause()
|
||||||
|
if (id) {
|
||||||
|
await refreshUrl()
|
||||||
|
resume()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onUnmounted(pause)
|
||||||
|
|
||||||
|
return { documentUrl, refreshUrl }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router/index.ts'
|
||||||
|
import { initializeAuthRefresh } from './api/client.ts'
|
||||||
|
import { useAuthStore } from './stores/auth.ts'
|
||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
|
app.use(pinia)
|
||||||
|
app.use(router)
|
||||||
|
|
||||||
|
initializeAuthRefresh()
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
if (auth.isAuthenticated) {
|
||||||
|
void auth.fetchCurrentUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||||
|
import { useAuthStore, getDefaultRouteForRole } from '../stores/auth'
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
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: '/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: '/dashboard',
|
||||||
|
name: 'Dashboard',
|
||||||
|
component: () => import('../views/QueueDashboardView.vue'),
|
||||||
|
meta: { requiresAuth: true, roles: ['ADMINISTRATOR'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
redirect: '/login',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Navigation guard: check auth and role
|
||||||
|
router.beforeEach((to, _from, next) => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
if (to.path === '/login' && auth.isAuthenticated) {
|
||||||
|
return next(getDefaultRouteForRole(auth.userRole))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||||
|
return next('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.meta.roles && Array.isArray(to.meta.roles)) {
|
||||||
|
const allowedRoles = to.meta.roles as string[]
|
||||||
|
if (!allowedRoles.includes(auth.userRole)) {
|
||||||
|
const fallback = getDefaultRouteForRole(auth.userRole)
|
||||||
|
if (fallback !== '/login' && fallback !== to.path) {
|
||||||
|
return next(fallback)
|
||||||
|
}
|
||||||
|
return next('/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { post, get } from '../api/client'
|
||||||
|
import type { User, LoginRequest, LoginResponse, UserProfileResponse } from '../types'
|
||||||
|
import router from '../router'
|
||||||
|
|
||||||
|
export function getDefaultRouteForRole(role: string): string {
|
||||||
|
switch (role) {
|
||||||
|
case 'INTAKE_CLERK':
|
||||||
|
return '/intake'
|
||||||
|
case 'DATA_ENTRY_CLERK':
|
||||||
|
return '/entry'
|
||||||
|
case 'VERIFIER':
|
||||||
|
case 'CLINICAL_APPROVER':
|
||||||
|
return '/verification'
|
||||||
|
case 'ADMINISTRATOR':
|
||||||
|
return '/dashboard'
|
||||||
|
default:
|
||||||
|
return '/login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const token = ref<string | null>(localStorage.getItem('vigilcare_token'))
|
||||||
|
const refreshToken = ref<string | null>(localStorage.getItem('vigilcare_refresh_token'))
|
||||||
|
const user = ref<User | null>(
|
||||||
|
JSON.parse(localStorage.getItem('vigilcare_user') || 'null')
|
||||||
|
)
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => !!token.value)
|
||||||
|
const userRole = computed(() => user.value?.role ?? '')
|
||||||
|
const userId = computed(() => user.value?.id ?? '')
|
||||||
|
const userFullName = computed(() => user.value?.fullName ?? '')
|
||||||
|
|
||||||
|
// Role-based view access
|
||||||
|
const canIntake = computed(() =>
|
||||||
|
['INTAKE_CLERK', 'ADMINISTRATOR'].includes(userRole.value)
|
||||||
|
)
|
||||||
|
const canEntry = computed(() =>
|
||||||
|
['DATA_ENTRY_CLERK', 'ADMINISTRATOR'].includes(userRole.value)
|
||||||
|
)
|
||||||
|
const canVerify = computed(() =>
|
||||||
|
['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'].includes(userRole.value)
|
||||||
|
)
|
||||||
|
const canSupervise = computed(() =>
|
||||||
|
['ADMINISTRATOR'].includes(userRole.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
async function login(credentials: LoginRequest): Promise<void> {
|
||||||
|
const response = await post<LoginResponse>('auth/login', credentials)
|
||||||
|
|
||||||
|
if (!response.success || !response.data) {
|
||||||
|
throw new Error(response.error?.message ?? 'Login failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginData = response.data
|
||||||
|
|
||||||
|
token.value = loginData.token
|
||||||
|
refreshToken.value = loginData.refreshToken
|
||||||
|
user.value = {
|
||||||
|
id: loginData.userId,
|
||||||
|
username: loginData.username,
|
||||||
|
fullName: loginData.fullName,
|
||||||
|
role: loginData.role,
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('vigilcare_token', loginData.token)
|
||||||
|
localStorage.setItem('vigilcare_refresh_token', loginData.refreshToken)
|
||||||
|
localStorage.setItem('vigilcare_user', JSON.stringify(user.value))
|
||||||
|
|
||||||
|
router.push(getDefaultRouteForRole(loginData.role))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCurrentUser(): Promise<void> {
|
||||||
|
const response = await get<UserProfileResponse>('auth/me')
|
||||||
|
if (response.success && response.data) {
|
||||||
|
user.value = {
|
||||||
|
id: response.data.id,
|
||||||
|
username: response.data.username,
|
||||||
|
fullName: response.data.fullName,
|
||||||
|
role: response.data.role,
|
||||||
|
}
|
||||||
|
localStorage.setItem('vigilcare_user', JSON.stringify(user.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout(): Promise<void> {
|
||||||
|
if (refreshToken.value) {
|
||||||
|
try {
|
||||||
|
await post<void>('auth/logout', { refreshToken: refreshToken.value })
|
||||||
|
} catch {
|
||||||
|
// Still clear local session if server revoke fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
token.value = null
|
||||||
|
refreshToken.value = null
|
||||||
|
user.value = null
|
||||||
|
localStorage.removeItem('vigilcare_token')
|
||||||
|
localStorage.removeItem('vigilcare_refresh_token')
|
||||||
|
localStorage.removeItem('vigilcare_user')
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
refreshToken,
|
||||||
|
user,
|
||||||
|
isAuthenticated,
|
||||||
|
userRole,
|
||||||
|
userId,
|
||||||
|
userFullName,
|
||||||
|
canIntake,
|
||||||
|
canEntry,
|
||||||
|
canVerify,
|
||||||
|
canSupervise,
|
||||||
|
login,
|
||||||
|
fetchCurrentUser,
|
||||||
|
logout,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { get, post, put, del, patch, uploadFile } from '../api/client'
|
||||||
|
import type {
|
||||||
|
BatchDetailResponse,
|
||||||
|
BatchDraft,
|
||||||
|
DraftPatient,
|
||||||
|
DraftEncounter,
|
||||||
|
DraftObservation,
|
||||||
|
BatchListResponse,
|
||||||
|
FieldCheck,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
export const useBatchStore = defineStore('batches', () => {
|
||||||
|
const batches = ref<BatchDetailResponse[]>([])
|
||||||
|
const currentBatch = ref<BatchDetailResponse | null>(null)
|
||||||
|
const currentDraft = ref<BatchDraft | null>(null)
|
||||||
|
const totalCount = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const documentUrl = computed(() => currentBatch.value?.documentUrl ?? null)
|
||||||
|
|
||||||
|
async function listBatches(params: {
|
||||||
|
status?: string
|
||||||
|
batchType?: string
|
||||||
|
assignedTo?: string
|
||||||
|
track?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const response = await get<BatchListResponse>(
|
||||||
|
'digitization-batches',
|
||||||
|
params as Record<string, unknown>
|
||||||
|
)
|
||||||
|
if (response.success && response.data) {
|
||||||
|
batches.value = response.data.items
|
||||||
|
totalCount.value = response.data.totalCount
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to load batches'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBatch(id: string): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const response = await get<BatchDetailResponse>(
|
||||||
|
`digitization-batches/${id}`
|
||||||
|
)
|
||||||
|
if (response.success && response.data) {
|
||||||
|
currentBatch.value = response.data
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to load batch'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadBatch(
|
||||||
|
file: File,
|
||||||
|
batchType: string,
|
||||||
|
track: string,
|
||||||
|
patientId?: string
|
||||||
|
): Promise<BatchDetailResponse | null> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const fields: Record<string, string> = { batchType, track }
|
||||||
|
if (patientId) fields.patientId = patientId
|
||||||
|
|
||||||
|
const response = await uploadFile<BatchDetailResponse>(
|
||||||
|
'digitization-batches',
|
||||||
|
file,
|
||||||
|
fields
|
||||||
|
)
|
||||||
|
if (response.success && response.data) {
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Upload failed'
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assignBatch(batchId: string, entryClerkUserId: string): Promise<void> {
|
||||||
|
const response = await patch<BatchDetailResponse>(`digitization-batches/${batchId}/assign`, {
|
||||||
|
entryClerkUserId,
|
||||||
|
})
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.error?.message ?? 'Assignment failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDraft(batchId: string): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await get<BatchDraft>(
|
||||||
|
`digitization-batches/${batchId}/draft`
|
||||||
|
)
|
||||||
|
if (response.success && response.data) {
|
||||||
|
currentDraft.value = response.data
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDraftPatient(
|
||||||
|
batchId: string,
|
||||||
|
patient: Partial<DraftPatient>
|
||||||
|
): Promise<void> {
|
||||||
|
await put<DraftPatient>(
|
||||||
|
`digitization-batches/${batchId}/draft/patient`,
|
||||||
|
patient
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDraftEncounter(
|
||||||
|
batchId: string,
|
||||||
|
encounter: Partial<DraftEncounter>
|
||||||
|
): Promise<void> {
|
||||||
|
await put<DraftEncounter>(
|
||||||
|
`digitization-batches/${batchId}/draft/encounter`,
|
||||||
|
encounter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addObservation(
|
||||||
|
batchId: string,
|
||||||
|
observation: Partial<DraftObservation>
|
||||||
|
): Promise<void> {
|
||||||
|
await post<DraftObservation>(
|
||||||
|
`digitization-batches/${batchId}/draft/observations`,
|
||||||
|
observation
|
||||||
|
)
|
||||||
|
await getDraft(batchId) // refresh draft to get new observation ID
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateObservation(
|
||||||
|
batchId: string,
|
||||||
|
obsId: string,
|
||||||
|
observation: Partial<DraftObservation>
|
||||||
|
): Promise<void> {
|
||||||
|
await put<DraftObservation>(
|
||||||
|
`digitization-batches/${batchId}/draft/observations/${obsId}`,
|
||||||
|
observation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteObservation(batchId: string, obsId: string): Promise<void> {
|
||||||
|
await del<void>(`digitization-batches/${batchId}/draft/observations/${obsId}`)
|
||||||
|
await getDraft(batchId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForVerification(batchId: string): Promise<void> {
|
||||||
|
await post<void>(`digitization-batches/${batchId}/submit-for-verification`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyBatch(
|
||||||
|
batchId: string,
|
||||||
|
fieldChecks: FieldCheck[],
|
||||||
|
passed: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
await post<void>(`digitization-batches/${batchId}/verify`, {
|
||||||
|
fieldChecks,
|
||||||
|
passed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejectBatch(batchId: string, reason: string): Promise<void> {
|
||||||
|
await post<void>(`digitization-batches/${batchId}/reject`, { reason })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approveBatch(batchId: string): Promise<void> {
|
||||||
|
await post<void>(`digitization-batches/${batchId}/approve`, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
batches,
|
||||||
|
currentBatch,
|
||||||
|
currentDraft,
|
||||||
|
documentUrl,
|
||||||
|
totalCount,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
listBatches,
|
||||||
|
getBatch,
|
||||||
|
uploadBatch,
|
||||||
|
assignBatch,
|
||||||
|
getDraft,
|
||||||
|
saveDraftPatient,
|
||||||
|
saveDraftEncounter,
|
||||||
|
addObservation,
|
||||||
|
updateObservation,
|
||||||
|
deleteObservation,
|
||||||
|
submitForVerification,
|
||||||
|
verifyBatch,
|
||||||
|
rejectBatch,
|
||||||
|
approveBatch,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
fullName: string
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string
|
||||||
|
refreshToken: string
|
||||||
|
userId: string
|
||||||
|
username: string
|
||||||
|
fullName: string
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenResponse {
|
||||||
|
token: string
|
||||||
|
refreshToken: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
success: boolean
|
||||||
|
statusCode: number
|
||||||
|
data: T | null
|
||||||
|
error: ApiError | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
message: string
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchDetailResponse {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
batchType: string
|
||||||
|
track: string
|
||||||
|
patientId: string | null
|
||||||
|
documentRef: string
|
||||||
|
documentUrl: string | null
|
||||||
|
enableRetroactiveAlerts: boolean
|
||||||
|
enteredByUserId: string | null
|
||||||
|
verifiedByUserId: string | null
|
||||||
|
approvedByUserId: string | null
|
||||||
|
rejectionReason: string | null
|
||||||
|
promotedAt: string | null
|
||||||
|
promotionEncounterId: string | null
|
||||||
|
supersedesBatchId: string | null
|
||||||
|
clinicianAttestation: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchListResponse {
|
||||||
|
items: BatchDetailResponse[]
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
totalCount: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfileResponse {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
fullName: string
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DraftPatient {
|
||||||
|
id: string
|
||||||
|
batchId: string
|
||||||
|
fullName: string | null
|
||||||
|
dateOfBirth: string | null
|
||||||
|
sex: string | null
|
||||||
|
bloodType: string | null
|
||||||
|
emergencyContact: string | null
|
||||||
|
allergiesJson: string | null
|
||||||
|
noKnownAllergies: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DraftEncounter {
|
||||||
|
id: string
|
||||||
|
batchId: string
|
||||||
|
admissionDate: string | null
|
||||||
|
department: string | null
|
||||||
|
roomBed: string | null
|
||||||
|
admissionReason: string | null
|
||||||
|
dischargeDiagnosis: string | null
|
||||||
|
status: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DraftObservation {
|
||||||
|
id: string
|
||||||
|
batchId: string
|
||||||
|
observationCode: string
|
||||||
|
value: number
|
||||||
|
unit: string
|
||||||
|
recordedAt: string
|
||||||
|
note: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchDraft {
|
||||||
|
patient: DraftPatient | null
|
||||||
|
encounter: DraftEncounter | null
|
||||||
|
observations: DraftObservation[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldCheck {
|
||||||
|
fieldPath: string
|
||||||
|
passed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedResult<T> {
|
||||||
|
items: T[]
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
totalCount: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkQueueOverview {
|
||||||
|
statusCounts: Record<string, number>
|
||||||
|
averageTimeInQueueMinutes: number
|
||||||
|
rejectRate: number
|
||||||
|
oldestPendingVerificationMinutes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PatientSearchResult {
|
||||||
|
id: string
|
||||||
|
fullName: string
|
||||||
|
mrn: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserSummary {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
fullName: string
|
||||||
|
role: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen lg:h-screen flex flex-col">
|
||||||
|
<AppHeader title="Data Entry">
|
||||||
|
<template #subtitle>
|
||||||
|
<span v-if="currentBatch" class="text-sm text-gray-500">
|
||||||
|
Batch: {{ currentBatch.id.substring(0, 8) }}...
|
||||||
|
| Type: {{ currentBatch.batchType.replace(/_/g, ' ') }}
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- 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">Loading document...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EntryForm
|
||||||
|
:batch="currentBatch"
|
||||||
|
:batch-id="batchId"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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 ScanViewer from '../components/ScanViewer.vue'
|
||||||
|
import EntryForm from '../components/EntryForm.vue'
|
||||||
|
import BatchList from '../components/BatchList.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 } = usePresignedUrl(batchId)
|
||||||
|
|
||||||
|
async function openBatch(id: string) {
|
||||||
|
router.push(`/entry/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
batchId,
|
||||||
|
async (id) => {
|
||||||
|
if (id) {
|
||||||
|
await batchStore.getDraft(id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!batchId.value) {
|
||||||
|
await batchStore.listBatches({
|
||||||
|
assignedTo: auth.userId,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex flex-col">
|
||||||
|
<AppHeader title="Intake" />
|
||||||
|
|
||||||
|
<div class="page-container flex-1">
|
||||||
|
<h1 class="text-2xl font-bold mb-6">Upload Scanned Document</h1>
|
||||||
|
|
||||||
|
<!-- Upload form -->
|
||||||
|
<div class="card mb-6">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">New Batch</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleUpload" class="space-y-4">
|
||||||
|
<!-- File input -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Scanned Document (PDF, JPEG, PNG — max 25 MB)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref="fileInput"
|
||||||
|
@change="onFileChange"
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png"
|
||||||
|
class="block w-full text-sm text-gray-500
|
||||||
|
file:mr-4 file:py-2 file:px-4
|
||||||
|
file:rounded-md file:border-0
|
||||||
|
file:text-sm file:font-semibold
|
||||||
|
file:bg-primary-50 file:text-primary-700
|
||||||
|
hover:file:bg-primary-100"
|
||||||
|
/>
|
||||||
|
<p v-if="selectedFile" class="text-sm text-gray-500 mt-2">
|
||||||
|
{{ selectedFile.name }} ({{ (selectedFile.size / 1024 / 1024).toFixed(2) }} MB)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Batch type -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Track -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Patient search -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Patient (optional — can link later)
|
||||||
|
</label>
|
||||||
|
<PatientSearch v-model="patientId" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="uploadError" class="text-clinical-danger text-sm">
|
||||||
|
{{ uploadError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="!selectedFile || !batchType || batchStore.loading"
|
||||||
|
>
|
||||||
|
{{ batchStore.loading ? 'Uploading...' : 'Upload and Create Batch' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent uploads -->
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="text-lg font-semibold mb-4">Recent Uploads</h2>
|
||||||
|
<div v-if="assignError" class="text-clinical-danger text-sm mb-4">
|
||||||
|
{{ assignError }}
|
||||||
|
</div>
|
||||||
|
<BatchList
|
||||||
|
:batches="batchStore.batches"
|
||||||
|
:loading="batchStore.loading"
|
||||||
|
show-assign
|
||||||
|
@assign="openAssignDialog"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AssignClerkDialog
|
||||||
|
:show="assignDialogOpen"
|
||||||
|
:batch-id="assignBatchId"
|
||||||
|
@close="closeAssignDialog"
|
||||||
|
@assigned="handleAssign"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useBatchStore } from '../stores/batches'
|
||||||
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
|
import PatientSearch from '../components/PatientSearch.vue'
|
||||||
|
import BatchList from '../components/BatchList.vue'
|
||||||
|
import AssignClerkDialog from '../components/AssignClerkDialog.vue'
|
||||||
|
|
||||||
|
const batchStore = useBatchStore()
|
||||||
|
|
||||||
|
const selectedFile = ref<File | null>(null)
|
||||||
|
const batchType = ref('')
|
||||||
|
const track = ref('BACKFILL')
|
||||||
|
const patientId = ref<string | undefined>(undefined)
|
||||||
|
const uploadError = ref('')
|
||||||
|
const assignError = ref('')
|
||||||
|
const assignDialogOpen = ref(false)
|
||||||
|
const assignBatchId = ref<string | null>(null)
|
||||||
|
|
||||||
|
function onFileChange(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
selectedFile.value = input.files?.[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload() {
|
||||||
|
if (!selectedFile.value || !batchType.value) return
|
||||||
|
|
||||||
|
uploadError.value = ''
|
||||||
|
try {
|
||||||
|
const batch = await batchStore.uploadBatch(
|
||||||
|
selectedFile.value,
|
||||||
|
batchType.value,
|
||||||
|
track.value,
|
||||||
|
patientId.value
|
||||||
|
)
|
||||||
|
if (batch) {
|
||||||
|
selectedFile.value = null
|
||||||
|
batchType.value = ''
|
||||||
|
track.value = 'BACKFILL'
|
||||||
|
patientId.value = undefined
|
||||||
|
await loadRecent()
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
uploadError.value = e instanceof Error ? e.message : 'Upload failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAssignDialog(batchId: string) {
|
||||||
|
assignError.value = ''
|
||||||
|
assignBatchId.value = batchId
|
||||||
|
assignDialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAssignDialog() {
|
||||||
|
assignDialogOpen.value = false
|
||||||
|
assignBatchId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAssign(batchId: string, clerkUserId: string) {
|
||||||
|
assignError.value = ''
|
||||||
|
try {
|
||||||
|
await batchStore.assignBatch(batchId, clerkUserId)
|
||||||
|
closeAssignDialog()
|
||||||
|
await loadRecent()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
assignError.value = e instanceof Error ? e.message : 'Assignment failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecent() {
|
||||||
|
await batchStore.listBatches({ status: 'UPLOADED', page: 1, pageSize: 20 })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadRecent)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-gray-50 px-4 py-8">
|
||||||
|
<div class="max-w-md w-full bg-white rounded-lg shadow-md p-6 sm:p-8">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">VigilCare Records</h1>
|
||||||
|
<p class="text-gray-600 mt-2">Digitization Workstation</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleLogin" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Username</label>
|
||||||
|
<input
|
||||||
|
v-model="username"
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="e.g. intake1, entry1, verifier1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Password</label>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
class="form-input"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMessage" class="text-clinical-danger text-sm">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-primary w-full" :disabled="loading">
|
||||||
|
{{ loading ? 'Signing in...' : 'Sign In' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
loading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
await auth.login({ username: username.value, password: password.value })
|
||||||
|
} catch (e: unknown) {
|
||||||
|
errorMessage.value = e instanceof Error ? e.message : 'Login failed'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen lg:h-screen flex flex-col">
|
||||||
|
<AppHeader title="Supervisor Dashboard">
|
||||||
|
<template #actions>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="refreshData"
|
||||||
|
class="text-sm text-primary-600 hover:text-primary-800"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</AppHeader>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 space-y-6 lg:space-y-8">
|
||||||
|
<!-- Summary cards -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 lg:gap-8">
|
||||||
|
<div class="card">
|
||||||
|
<p class="text-sm text-gray-500">Pending Entry</p>
|
||||||
|
<p class="text-3xl font-bold text-yellow-600">
|
||||||
|
{{ overview?.statusCounts?.UPLOADED ?? 0 }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<p class="text-sm text-gray-500">In Entry</p>
|
||||||
|
<p class="text-3xl font-bold text-blue-600">
|
||||||
|
{{ overview?.statusCounts?.IN_ENTRY ?? 0 }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<p class="text-sm text-gray-500">Pending Verification</p>
|
||||||
|
<p class="text-3xl font-bold text-orange-600">
|
||||||
|
{{ overview?.statusCounts?.PENDING_VERIFICATION ?? 0 }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<p class="text-sm text-gray-500">Reject Rate</p>
|
||||||
|
<p
|
||||||
|
class="text-3xl font-bold"
|
||||||
|
:class="(overview?.rejectRate ?? 0) > 15
|
||||||
|
? 'text-clinical-danger'
|
||||||
|
: 'text-clinical-safe'"
|
||||||
|
>
|
||||||
|
{{ ((overview?.rejectRate ?? 0) * 100).toFixed(1) }}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Queue age and throughput -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-8">
|
||||||
|
<div class="card">
|
||||||
|
<h3 class="text-sm font-medium text-gray-700 mb-2">Average Time in Queue</h3>
|
||||||
|
<p class="text-2xl font-bold">
|
||||||
|
{{ formatMinutes(overview?.averageTimeInQueueMinutes ?? 0) }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-2">
|
||||||
|
Target: < 24 hours
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3 class="text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Oldest Pending Verification
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
class="text-2xl font-bold"
|
||||||
|
:class="(overview?.oldestPendingVerificationMinutes ?? 0) > 1440
|
||||||
|
? 'text-clinical-danger'
|
||||||
|
: 'text-gray-900'"
|
||||||
|
>
|
||||||
|
{{ formatMinutes(overview?.oldestPendingVerificationMinutes ?? 0) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status breakdown -->
|
||||||
|
<div class="card">
|
||||||
|
<h3 class="text-sm font-medium text-gray-700 mb-4">Batches by Status</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(count, status) in overview?.statusCounts ?? {}"
|
||||||
|
:key="status"
|
||||||
|
class="flex items-center gap-4"
|
||||||
|
>
|
||||||
|
<span class="w-40 shrink-0 text-sm text-gray-600">
|
||||||
|
{{ String(status).replace(/_/g, ' ') }}
|
||||||
|
</span>
|
||||||
|
<div class="flex-1 bg-gray-100 rounded-full h-4">
|
||||||
|
<div
|
||||||
|
class="h-4 rounded-full bg-primary-500"
|
||||||
|
:style="{ width: `${(Number(count) / maxCount) * 100}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium w-8 text-right">{{ count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent activity -->
|
||||||
|
<div class="card">
|
||||||
|
<h3 class="text-sm font-medium text-gray-700 mb-4">All Batches</h3>
|
||||||
|
<BatchList
|
||||||
|
:batches="batchStore.batches"
|
||||||
|
:loading="batchStore.loading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useBatchStore } from '../stores/batches'
|
||||||
|
import { get } from '../api/client'
|
||||||
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
|
import BatchList from '../components/BatchList.vue'
|
||||||
|
import type { WorkQueueOverview } from '../types'
|
||||||
|
|
||||||
|
const batchStore = useBatchStore()
|
||||||
|
const overview = ref<WorkQueueOverview | null>(null)
|
||||||
|
|
||||||
|
const maxCount = computed(() => {
|
||||||
|
if (!overview.value?.statusCounts) return 1
|
||||||
|
return Math.max(...Object.values(overview.value.statusCounts).map(Number), 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatMinutes(minutes: number): string {
|
||||||
|
if (minutes < 60) return `${Math.round(minutes)}m`
|
||||||
|
if (minutes < 1440) return `${(minutes / 60).toFixed(1)}h`
|
||||||
|
return `${(minutes / 1440).toFixed(1)}d`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshData() {
|
||||||
|
const response = await get<WorkQueueOverview>('work-queue/overview')
|
||||||
|
if (response.success && response.data) {
|
||||||
|
overview.value = response.data
|
||||||
|
}
|
||||||
|
await batchStore.listBatches({ page: 1, pageSize: 50 })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(refreshData)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen lg:h-screen flex flex-col">
|
||||||
|
<AppHeader title="Verification">
|
||||||
|
<template #subtitle>
|
||||||
|
<span v-if="currentBatch" class="text-sm text-gray-500">
|
||||||
|
Batch: {{ currentBatch.id.substring(0, 8) }}...
|
||||||
|
| Entered by: {{ currentBatch.enteredByUserId?.substring(0, 8) }}...
|
||||||
|
</span>
|
||||||
|
</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">Verification Queue</h2>
|
||||||
|
<p class="text-sm text-gray-500 mb-4">
|
||||||
|
Batches pending verification, sorted by submission time (oldest first).
|
||||||
|
</p>
|
||||||
|
<BatchList
|
||||||
|
:batches="batchStore.batches"
|
||||||
|
:loading="batchStore.loading"
|
||||||
|
@select="openBatch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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">Loading document...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VerificationForm
|
||||||
|
:batch="currentBatch"
|
||||||
|
:batch-id="batchId"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useBatchStore } from '../stores/batches'
|
||||||
|
import { usePresignedUrl } from '../composables/usePresignedUrl'
|
||||||
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
|
import ScanViewer from '../components/ScanViewer.vue'
|
||||||
|
import VerificationForm from '../components/VerificationForm.vue'
|
||||||
|
import BatchList from '../components/BatchList.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ batchId?: string }>()
|
||||||
|
|
||||||
|
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 } = usePresignedUrl(batchId)
|
||||||
|
|
||||||
|
function openBatch(id: string) {
|
||||||
|
router.push(`/verification/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
batchId,
|
||||||
|
async (id) => {
|
||||||
|
if (id) {
|
||||||
|
await batchStore.getDraft(id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!batchId.value) {
|
||||||
|
await batchStore.listBatches({
|
||||||
|
status: 'PENDING_VERIFICATION',
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#eff6ff',
|
||||||
|
100: '#dbeafe',
|
||||||
|
500: '#3b82f6',
|
||||||
|
600: '#2563eb',
|
||||||
|
700: '#1d4ed8',
|
||||||
|
},
|
||||||
|
clinical: {
|
||||||
|
safe: '#16a34a',
|
||||||
|
warning: '#eab308',
|
||||||
|
danger: '#dc2626',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
// Rule of 8 — prefer these multiples of 8px in component spacing
|
||||||
|
18: '4.5rem',
|
||||||
|
22: '5.5rem',
|
||||||
|
},
|
||||||
|
screens: {
|
||||||
|
xs: '480px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3028,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:5217',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user