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