From d55c88c99936303344f6c6c7c19a01dcad3d3847 Mon Sep 17 00:00:00 2001 From: kirukib Date: Mon, 10 Aug 2026 16:41:37 +0300 Subject: [PATCH] Add prescription review flagging with Admin UI and specs. Pharmacists can flag and resolve Rx risk/ops issues on the queue and detail pages; master and admin backend sheets document the flags contract (Mob still TBD). Co-authored-by: Cursor --- docs/GISHEN-MASTER-SPEC.md | 20 +- docs/admin-backend-spec.md | 31 +- src/lib/prescriptionFlags.ts | 76 ++ src/mocks/data.ts | 91 ++ .../prescriptions/CreatePrescriptionPage.tsx | 69 +- .../prescriptions/PrescriptionDetailPage.tsx | 812 ++++++++++++------ src/pages/prescriptions/PrescriptionsPage.tsx | 85 +- src/types/index.ts | 33 + 8 files changed, 940 insertions(+), 277 deletions(-) create mode 100644 src/lib/prescriptionFlags.ts diff --git a/docs/GISHEN-MASTER-SPEC.md b/docs/GISHEN-MASTER-SPEC.md index 4018742..10fc4f2 100644 --- a/docs/GISHEN-MASTER-SPEC.md +++ b/docs/GISHEN-MASTER-SPEC.md @@ -7,7 +7,7 @@ | **Home repo (this file)** | `Gishen-Admin` | | **Sibling root** | `/Users/kirukib/Desktop/Yaltopia Project/` | | **Shared API base (TBD)** | Prefer `https://api.gishenpharmacy.org/v1` (B2B draft / Admin alignment). Legacy sheets still show `api.gishen.example` — unify TBD | -| **Last reconciled** | 2026-08-08 | +| **Last reconciled** | 2026-08-10 | --- @@ -15,6 +15,7 @@ | Date | Change | | --- | --- | +| 2026-08-10 | **Admin Rx flagging:** prescription `flags[]` (code, severity, note, open/resolved) with list badges/filters + detail add/resolve. Mob Rx Care has **no** pharmacist flagging surface yet — customer visibility / sync **TBD**. Specs updated in Admin backend sheet. | | 2026-08-08 | **Entity schemas (platform pack):** Expanded [`docs/schemas.md`](./schemas.md) to cover shared primitives, Admin ops backend DTOs, B2B portal entities, Ecom/retail DTOs, event catalog, and path-prefix ownership. §2.9 index updated. | | 2026-08-08 | **Entity schemas:** Added shared schema summary (§2.9) + link to Admin [`docs/schemas.md`](./schemas.md) for full field tables on Money, Staff, Org/Commercial, Doctor, Rx, Order, Item/UOM, Stock, Customer, Settlement, Dispatch. | | 2026-08-08 | **Org/Money alignment:** Canonical org status `pending_activation`; Admin activate path + `CommercialTerms`/`Money`; B2B register `POST /v1/org/register` + `org.activated`/`org.suspended`. Resolved Money default (decimal major ETB). Refreshed divergence table + Sources stamp after sibling B2B/Ecom skim. | @@ -42,12 +43,12 @@ Update these dates whenever you reconcile against a sibling. | Source | Path (sibling area) | Found | Spec / README | Last skimmed | | --- | --- | --- | --- | --- | -| **Admin** | `Gishen-Admin/` | ✅ | [`docs/admin-backend-spec.md`](./admin-backend-spec.md) + [`docs/schemas.md`](./schemas.md) | 2026-08-08 | -| **Mob** | `Gishen-Mob/` | ✅ | [`docs/BACKEND_SPEC.md`](../../Gishen-Mob/docs/BACKEND_SPEC.md) (relative from sibling root) | 2026-08-07 | -| **Ecom** | `Gishen-Ecom/` | ✅ | [`docs/backend.md`](../../Gishen-Ecom/docs/backend.md) | 2026-08-08 | -| **B2B** | `Gishen-B2B/` | ✅ | [`docs/backend/OVERVIEW.md`](../../Gishen-B2B/docs/backend/OVERVIEW.md) + coordination | 2026-08-08 | -| **Dispatch Bot** | `Gishen-Dispatch-Bot/` | ✅ (related) | `docs/BACKEND_SPEC.md` | 2026-08-07 | -| **Telegram Mini App** | `Gishen-Telegram-MiniApp/` | ⚠️ empty scaffold | — | 2026-08-07 | +| **Admin** | `Gishen-Admin/` | ✅ | [`docs/admin-backend-spec.md`](./admin-backend-spec.md) + [`docs/schemas.md`](./schemas.md) | 2026-08-10 | +| **Mob** | `Gishen-Mob/` | ✅ | [`docs/BACKEND_SPEC.md`](../../Gishen-Mob/docs/BACKEND_SPEC.md) (relative from sibling root) | 2026-08-10 | +| **Ecom** | `Gishen-Ecom/` | ✅ | [`docs/backend.md`](../../Gishen-Ecom/docs/backend.md) | 2026-08-10 | +| **B2B** | `Gishen-B2B/` | ✅ | [`docs/backend/OVERVIEW.md`](../../Gishen-B2B/docs/backend/OVERVIEW.md) + coordination | 2026-08-10 | +| **Dispatch Bot** | `Gishen-Dispatch-Bot/` | ✅ (related) | `docs/BACKEND_SPEC.md` | 2026-08-10 | +| **Telegram Mini App** | `Gishen-Telegram-MiniApp/` | ⚠️ empty scaffold | — | 2026-08-10 | ### Sources not found @@ -151,12 +152,13 @@ See [§3 Auth matrix](#3-auth-provider-matrix). | Layer | Contract | | --- | --- | | Admin line item | `frequency?`: `QD`\|`BID`\|`TID`\|`QID`\|`QXH`\|`custom`; `intervalHours?` (QXH); `times?`: `HH:mm`[] | +| Admin review flags | Rx header `flags[]`: `code` (reason enum), `severity` info\|warning\|critical, `note`, open/resolved + resolution trail; queue filter + badges; add/resolve in Admin detail — **staff-only** | | Mob reminders | `POST /health/reminders` with `times[]`, grace, escalate; calendar UI; confirm/snooze | | Sync intent | Persist pharmacist final `times` (+ interval) on approve/dispense so Mob can seed reminders without re-derive — **when** to push still open (Admin Q13) | -| Customer Rx Care | Mob/Ecom upload + `prescriptions/check` stock match statuses | +| Customer Rx Care | Mob/Ecom upload + `prescriptions/check` stock match statuses — **no** pharmacist flagging API/UI in Mob yet (**TBD** if customers ever see staff flags) | | B2B | Member submits Rx; HR/Finance never see clinical lines | -Admin helper: `src/lib/dosingSchedule.ts`. +Admin helpers: `src/lib/dosingSchedule.ts`, `src/lib/prescriptionFlags.ts`. ### 2.6 Catalog / Items + multi-UOM + storefront PDP diff --git a/docs/admin-backend-spec.md b/docs/admin-backend-spec.md index d4d765e..1c82308 100644 --- a/docs/admin-backend-spec.md +++ b/docs/admin-backend-spec.md @@ -14,6 +14,7 @@ | Date | Change | | --- | --- | +| 2026-08-10 | **Rx prescription flags:** `flags[]` on Rx; add/resolve endpoints; list filters `flagged` / `flagCode`; Admin queue badges + detail Flags section; optional initial flag on intake | | 2026-08-08 | **Entity schemas pack (platform):** [`schemas.md`](./schemas.md) covers shared + Admin + B2B + Ecom/backend DTOs; linked from Conventions + master | | 2026-08-08 | **B2B org alignment:** `pending_activation`, `POST .../activate` + `CommercialTerms`/`Money`, events `org.activated`/`org.suspended`; B2B register path `POST /v1/org/register` | | 2026-08-07 | Pointer to cross-app [`GISHEN-MASTER-SPEC.md`](./GISHEN-MASTER-SPEC.md) + pre-push refresh rule | @@ -253,15 +254,37 @@ UI: `/orders/new` (Guest customer vs Registered customer toggle), `/orders/:id` | Method | Path | Roles | Description | | --- | --- | --- | --- | -| GET | `/pharmacy/prescriptions` | pharmacist, super_admin | Branch-scoped queue; filters: `status`, `controlled`, `q` | -| POST | `/pharmacy/prescriptions` | pharmacist, super_admin, **doctor** | Staff Rx intake or doctor-authored Rx; body includes `doctorId` when attributed | +| GET | `/pharmacy/prescriptions` | pharmacist, super_admin | Branch-scoped queue; filters: `status`, `controlled`, `flagged` (`open`\|`none`), `flagCode`, `q` | +| POST | `/pharmacy/prescriptions` | pharmacist, super_admin, **doctor** | Staff Rx intake or doctor-authored Rx; body includes `doctorId` when attributed; optional `flags[]` seed on create | | POST | `/admin/prescriptions/:id/verify` | pharmacist, super_admin | `{ status: approved\|queried\|rejected, qtyAdjustments?, substitute?, notes? }` | | POST | `/pharmacy/prescriptions/:id/dispense` | pharmacist | `{ batch, quantity }` stamped pharmacistId + time | | GET | `/pharmacy/prescriptions/controlled-report` | pharmacist, super_admin | Controlled-substance report | -| GET | `/pharmacy/prescriptions/:id` | pharmacist, super_admin, doctor (own) | Detail: items (incl. dosing schedule), controlled flags, review trail, linked doctor | +| GET | `/pharmacy/prescriptions/:id` | pharmacist, super_admin, doctor (own) | Detail: items (incl. dosing schedule), `flags[]`, controlled line flags, review trail, linked doctor | +| POST | `/pharmacy/prescriptions/:id/flags` | pharmacist, super_admin | Add flag `{ code, severity?, note }` → opens review marker (defaults severity from code) | +| POST | `/pharmacy/prescriptions/:id/flags/:flagId/resolve` | pharmacist, super_admin | `{ resolutionNote }` → `status=resolved`, stamps `resolvedBy` / `resolvedAt` | | GET | `/pharmacy/refills` | pharmacist, super_admin | Days-of-supply / script expiry oversight | -UI: `/prescriptions/new` (doctor select + per-line dosing schedule), `/prescriptions/:id` — Approve is the sole primary header action; Reject / Query live in an overflow menu and as an inline footer on the verification section. Medications table shows interval/frequency and dose times. +UI: `/prescriptions` (open-flag count badge + Filters: Flags / Flag type), `/prescriptions/new` (doctor select + per-line dosing + optional initial flag), `/prescriptions/:id` — Approve is the sole primary header action; Reject / Query live in an overflow menu and as an inline footer on the verification section; **Flags** section supports add + resolve with history. Medications table shows interval/frequency and dose times. + +### Prescription flags (review markers) + +Operational / clinical **review flags** on an Rx header — distinct from line-item `controlled` and workflow `status`. Staff add flags during intake or review; open flags surface on the queue until resolved. Resolved flags remain for audit. + +| Field | Type | Notes | +| --- | --- | --- | +| `id` | string | Flag id | +| `code` | enum | `controlled_substance` \| `interaction_concern` \| `incomplete_rx` \| `fraud_suspicion` \| `stock_shortage` \| `needs_clarification` \| `allergy_concern` \| `dosing_concern` \| `other` | +| `severity` | `info` \| `warning` \| `critical` | Defaults from `code` when omitted on create | +| `note` | string | Why this Rx needs attention | +| `createdBy` | string | Staff display name (or id + denorm) | +| `createdAt` | ISO datetime | | +| `status` | `open` \| `resolved` | | +| `resolvedBy?` | string | Set on resolve | +| `resolvedAt?` | ISO datetime | Set on resolve | +| `resolutionNote?` | string | How it was cleared | + +Rx list may expose derived helpers: `openFlagCount`, highest open severity (for badge colour). +Helper (Admin): `src/lib/prescriptionFlags.ts`. Mock seeds: `rx-1001`–`1003`, `rx-1005`, `rx-1008`. ### Prescription medication line items (schedule) diff --git a/src/lib/prescriptionFlags.ts b/src/lib/prescriptionFlags.ts new file mode 100644 index 0000000..4747c08 --- /dev/null +++ b/src/lib/prescriptionFlags.ts @@ -0,0 +1,76 @@ +import type { + Prescription, + PrescriptionFlag, + PrescriptionFlagCode, + PrescriptionFlagSeverity, +} from '@/types' + +export const PRESCRIPTION_FLAG_CODES: { + value: PrescriptionFlagCode + label: string + defaultSeverity: PrescriptionFlagSeverity +}[] = [ + { value: 'controlled_substance', label: 'Controlled substance', defaultSeverity: 'warning' }, + { value: 'interaction_concern', label: 'Interaction concern', defaultSeverity: 'critical' }, + { value: 'incomplete_rx', label: 'Incomplete Rx', defaultSeverity: 'warning' }, + { value: 'fraud_suspicion', label: 'Fraud suspicion', defaultSeverity: 'critical' }, + { value: 'stock_shortage', label: 'Stock shortage', defaultSeverity: 'warning' }, + { value: 'needs_clarification', label: 'Needs doctor clarification', defaultSeverity: 'warning' }, + { value: 'allergy_concern', label: 'Allergy concern', defaultSeverity: 'critical' }, + { value: 'dosing_concern', label: 'Dosing concern', defaultSeverity: 'warning' }, + { value: 'other', label: 'Other', defaultSeverity: 'info' }, +] + +const labelByCode = Object.fromEntries( + PRESCRIPTION_FLAG_CODES.map((c) => [c.value, c.label]), +) as Record + +const defaultSeverityByCode = Object.fromEntries( + PRESCRIPTION_FLAG_CODES.map((c) => [c.value, c.defaultSeverity]), +) as Record + +export function flagCodeLabel(code: PrescriptionFlagCode): string { + return labelByCode[code] ?? code +} + +export function defaultSeverityForFlag(code: PrescriptionFlagCode): PrescriptionFlagSeverity { + return defaultSeverityByCode[code] ?? 'info' +} + +export function severityBadgeVariant( + severity: PrescriptionFlagSeverity, +): 'muted' | 'warning' | 'danger' { + if (severity === 'critical') return 'danger' + if (severity === 'warning') return 'warning' + return 'muted' +} + +export function openFlags(rx: Prescription | undefined | null): PrescriptionFlag[] { + return (rx?.flags ?? []).filter((f) => f.status === 'open') +} + +export function openFlagCount(rx: Prescription | undefined | null): number { + return openFlags(rx).length +} + +export function hasOpenFlags(rx: Prescription | undefined | null): boolean { + return openFlagCount(rx) > 0 +} + +export function highestOpenSeverity( + rx: Prescription | undefined | null, +): PrescriptionFlagSeverity | null { + const open = openFlags(rx) + if (open.some((f) => f.severity === 'critical')) return 'critical' + if (open.some((f) => f.severity === 'warning')) return 'warning' + if (open.length > 0) return 'info' + return null +} + +/** Newest first; open flags before resolved when timestamps tie. */ +export function sortFlags(flags: PrescriptionFlag[]): PrescriptionFlag[] { + return [...flags].sort((a, b) => { + if (a.status !== b.status) return a.status === 'open' ? -1 : 1 + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + }) +} diff --git a/src/mocks/data.ts b/src/mocks/data.ts index e386fcd..c206db4 100644 --- a/src/mocks/data.ts +++ b/src/mocks/data.ts @@ -47,6 +47,17 @@ export let prescriptions: Prescription[] = [ ], submittedAt: '2026-08-06T08:10:00Z', prescriber: 'Dr. Selam', + flags: [ + { + id: 'rxf-1001-1', + code: 'interaction_concern', + severity: 'critical', + note: 'Amoxicillin + high-dose Paracetamol — confirm hepatic risk with patient history.', + createdBy: 'Hana Pharmacist', + createdAt: '2026-08-06T08:25:00Z', + status: 'open', + }, + ], }, { id: 'rx-1002', @@ -64,6 +75,17 @@ export let prescriptions: Prescription[] = [ ], submittedAt: '2026-08-06T09:20:00Z', prescriber: 'Dr. Yohannes', + flags: [ + { + id: 'rxf-1002-1', + code: 'incomplete_rx', + severity: 'warning', + note: 'Duration / days supply missing from scan crop.', + createdBy: 'Hana Pharmacist', + createdAt: '2026-08-06T09:35:00Z', + status: 'open', + }, + ], }, { id: 'rx-1003', @@ -82,6 +104,26 @@ export let prescriptions: Prescription[] = [ submittedAt: '2026-08-06T07:45:00Z', prescriber: 'Dr. Helen', reviewStartedAt: '2026-08-06T08:05:00Z', + flags: [ + { + id: 'rxf-1003-1', + code: 'controlled_substance', + severity: 'warning', + note: 'Schedule stamp + ID check required before dispense.', + createdBy: 'Rediet Tesfa', + createdAt: '2026-08-06T08:08:00Z', + status: 'open', + }, + { + id: 'rxf-1003-2', + code: 'stock_shortage', + severity: 'warning', + note: 'CMC has 8 tablets on hand — restock or transfer from Bole.', + createdBy: 'Rediet Tesfa', + createdAt: '2026-08-06T08:12:00Z', + status: 'open', + }, + ], }, { id: 'rx-1004', @@ -111,6 +153,29 @@ export let prescriptions: Prescription[] = [ reviewedBy: 'Hana Pharmacist', reviewedAt: '2026-08-05T12:05:00Z', decisionNote: 'Prescriber signature is illegible — a clearer scan was requested.', + flags: [ + { + id: 'rxf-1005-1', + code: 'needs_clarification', + severity: 'warning', + note: 'Signature illegible — contacted Dr. Yohannes for confirmation.', + createdBy: 'Hana Pharmacist', + createdAt: '2026-08-05T11:50:00Z', + status: 'open', + }, + { + id: 'rxf-1005-2', + code: 'incomplete_rx', + severity: 'info', + note: 'Initial crop missed the footer stamp; customer re-uploaded.', + createdBy: 'Hana Pharmacist', + createdAt: '2026-08-05T11:42:00Z', + status: 'resolved', + resolvedBy: 'Hana Pharmacist', + resolvedAt: '2026-08-05T12:00:00Z', + resolutionNote: 'Clearer scan received; signature still queried separately.', + }, + ], }, { id: 'rx-1006', @@ -159,6 +224,32 @@ export let prescriptions: Prescription[] = [ reviewedBy: 'Hana Pharmacist', reviewedAt: '2026-08-02T11:22:00Z', decisionNote: 'Prescriber is not on the national register.', + flags: [ + { + id: 'rxf-1008-1', + code: 'fraud_suspicion', + severity: 'critical', + note: 'Prescriber name not on national register; possible forged script.', + createdBy: 'Hana Pharmacist', + createdAt: '2026-08-02T11:05:00Z', + status: 'resolved', + resolvedBy: 'Hana Pharmacist', + resolvedAt: '2026-08-02T11:22:00Z', + resolutionNote: 'Rx rejected; incident logged for compliance.', + }, + { + id: 'rxf-1008-2', + code: 'controlled_substance', + severity: 'warning', + note: 'Diazepam — do not dispense pending fraud check.', + createdBy: 'Hana Pharmacist', + createdAt: '2026-08-02T11:02:00Z', + status: 'resolved', + resolvedBy: 'Hana Pharmacist', + resolvedAt: '2026-08-02T11:22:00Z', + resolutionNote: 'Closed with reject decision.', + }, + ], }, { id: 'rx-1009', diff --git a/src/pages/prescriptions/CreatePrescriptionPage.tsx b/src/pages/prescriptions/CreatePrescriptionPage.tsx index fdadb31..acfc949 100644 --- a/src/pages/prescriptions/CreatePrescriptionPage.tsx +++ b/src/pages/prescriptions/CreatePrescriptionPage.tsx @@ -18,8 +18,12 @@ import { DOSING_FREQUENCY_OPTIONS, defaultTimesForFrequency, } from '@/lib/dosingSchedule' +import { + PRESCRIPTION_FLAG_CODES, + defaultSeverityForFlag, +} from '@/lib/prescriptionFlags' import { branches, doctors, prescriptions } from '@/mocks/data' -import type { DosingFrequency, Prescription } from '@/types' +import type { DosingFrequency, Prescription, PrescriptionFlagCode } from '@/types' export function CreatePrescriptionPage() { const navigate = useNavigate() @@ -38,6 +42,9 @@ export function CreatePrescriptionPage() { frequency: 'BID' as DosingFrequency, intervalHours: '8', times: defaultTimesForFrequency('BID'), + attachFlag: false, + flagCode: 'needs_clarification' as PrescriptionFlagCode, + flagNote: '', }) const selectedDoctor = activeDoctors.find((d) => d.id === form.doctorId) @@ -100,6 +107,11 @@ export function CreatePrescriptionPage() { toast.error('Enter hours between doses') return } + if (form.attachFlag && !form.flagNote.trim()) { + toast.error('Add a note for the initial flag') + return + } + const now = new Date().toISOString() const rx: Prescription = { id: `rx-${Date.now().toString().slice(-6)}`, customerName: form.customerName, @@ -115,10 +127,25 @@ export function CreatePrescriptionPage() { times: cleanedTimes, }, ], - submittedAt: new Date().toISOString(), + submittedAt: now, prescriber: selectedDoctor.name, doctorId: selectedDoctor.id, orgId: selectedDoctor.orgId, + ...(form.attachFlag + ? { + flags: [ + { + id: `rxf-${Date.now().toString(36)}`, + code: form.flagCode, + severity: defaultSeverityForFlag(form.flagCode), + note: form.flagNote.trim(), + createdBy: user?.name ?? 'Pharmacist', + createdAt: now, + status: 'open' as const, + }, + ], + } + : {}), } prescriptions.unshift(rx) toast.success('Prescription intake created') @@ -290,6 +317,44 @@ export function CreatePrescriptionPage() { /> Controlled substance +
+ + {form.attachFlag ? ( +
+
+ + + setForm({ ...form, flagCode: e.target.value as PrescriptionFlagCode }) + } + > + {PRESCRIPTION_FLAG_CODES.map((opt) => ( + + ))} + +
+
+ + setForm({ ...form, flagNote: e.target.value })} + placeholder="Why this Rx needs attention" + /> +
+
+ ) : null} +
) diff --git a/src/pages/prescriptions/PrescriptionDetailPage.tsx b/src/pages/prescriptions/PrescriptionDetailPage.tsx index 7e02692..1fdc1c3 100644 --- a/src/pages/prescriptions/PrescriptionDetailPage.tsx +++ b/src/pages/prescriptions/PrescriptionDetailPage.tsx @@ -1,29 +1,111 @@ -import { useMemo, useState } from 'react' +import { useState, type FormEvent } from 'react' import { Link, useParams } from 'react-router-dom' import { toast } from 'sonner' -import { Check, ClipboardCopy, HelpCircle, X } from 'lucide-react' +import { Check, ClipboardCopy, Flag, HelpCircle, Plus, X } from 'lucide-react' import { DetailPageLayout } from '@/layouts/DetailPageLayout' -import { ActivityTimeline, DetailSection } from '@/components/detail/ActivityTimeline' +import { ActivityTimeline, DetailSection, type TimelineEvent } from '@/components/detail/ActivityTimeline' import { ApprovalStepper, type ApprovalStep } from '@/components/detail/ApprovalStepper' import { KeyValueGrid } from '@/components/detail/KeyValueGrid' import { RowActions, type RowAction } from '@/components/table/RowActions' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { SelectNative } from '@/components/ui/select-native' import { StatusBadge, statusLabel } from '@/components/ui/status-badge' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { Textarea } from '@/components/ui/textarea' import { branches, doctors, prescriptions } from '@/mocks/data' import { useAuthStore } from '@/zustand/authStore' import { formatDoseTimes, formatFrequencyLabel } from '@/lib/dosingSchedule' +import { + PRESCRIPTION_FLAG_CODES, + defaultSeverityForFlag, + flagCodeLabel, + openFlagCount, + severityBadgeVariant, + sortFlags, +} from '@/lib/prescriptionFlags' import { formatDateTime } from '@/lib/utils' -import type { Prescription } from '@/types' +import type { + Prescription, + PrescriptionFlag, + PrescriptionFlagCode, + PrescriptionFlagSeverity, +} from '@/types' const OPEN_STATUSES: Prescription['status'][] = ['submitted', 'under_review', 'queried'] +function buildSteps(rx: Prescription): ApprovalStep[] { + const decided = rx.status === 'approved' || rx.status === 'rejected' + const reviewSeen = decided || rx.status === 'queried' + + return [ + { + key: 'submitted', + label: 'Submitted', + state: 'done', + actor: rx.customerName, + at: rx.submittedAt, + }, + { + key: 'review', + label: 'Pharmacist review', + state: reviewSeen ? 'done' : 'current', + actor: reviewSeen ? rx.reviewedBy : rx.reviewStartedAt ? 'Review in progress' : undefined, + at: rx.reviewStartedAt, + hint: rx.status === 'submitted' ? 'Waiting to be picked up from the queue' : undefined, + }, + { + key: 'decision', + label: + rx.status === 'approved' + ? 'Approved for dispensing' + : rx.status === 'rejected' + ? 'Rejected' + : rx.status === 'queried' + ? 'Queried with customer' + : 'Decision', + state: + rx.status === 'approved' + ? 'done' + : rx.status === 'rejected' + ? 'failed' + : rx.status === 'queried' + ? 'current' + : 'upcoming', + actor: + rx.status === 'queried' || rx.status === 'approved' || rx.status === 'rejected' + ? rx.reviewedBy + : undefined, + at: rx.status === 'submitted' || rx.status === 'under_review' ? undefined : rx.reviewedAt, + hint: rx.status === 'queried' ? 'Awaiting a clearer scan from the customer' : undefined, + }, + ] +} + export function PrescriptionDetailPage() { const { id } = useParams() const user = useAuthStore((s) => s.user) const seed = prescriptions.find((p) => p.id === id) - const [rx, setRx] = useState(seed ? { ...seed, items: [...seed.items] } : undefined) + const [rx, setRx] = useState( + seed ? { ...seed, items: [...seed.items], flags: seed.flags ? [...seed.flags] : [] } : undefined, + ) + const [addOpen, setAddOpen] = useState(false) + const [resolveTarget, setResolveTarget] = useState(null) + const [addForm, setAddForm] = useState({ + code: 'needs_clarification' as PrescriptionFlagCode, + severity: 'warning' as PrescriptionFlagSeverity, + note: '', + }) + const [resolutionNote, setResolutionNote] = useState('') if (!rx) { return ( @@ -39,6 +121,15 @@ export function PrescriptionDetailPage() { const doctor = rx.doctorId ? doctors.find((d) => d.id === rx.doctorId) : undefined const controlled = rx.items.some((i) => i.controlled) const isOpen = OPEN_STATUSES.includes(rx.status) + const flags = sortFlags(rx.flags ?? []) + const openCount = openFlagCount(rx) + const steps = buildSteps(rx) + + const persistFlags = (nextFlags: PrescriptionFlag[]) => { + setRx((prev) => (prev ? { ...prev, flags: nextFlags } : prev)) + const target = prescriptions.find((p) => p.id === rx.id) + if (target) target.flags = nextFlags + } const act = (next: Prescription['status']) => { const patch = { @@ -57,6 +148,61 @@ export function PrescriptionDetailPage() { toast.message(`${rx.id} copied to clipboard`) } + const openAddDialog = () => { + setAddForm({ + code: 'needs_clarification', + severity: defaultSeverityForFlag('needs_clarification'), + note: '', + }) + setAddOpen(true) + } + + const onAddFlag = (e: FormEvent) => { + e.preventDefault() + const note = addForm.note.trim() + if (!note) { + toast.error('Add a short note for the flag') + return + } + const flag: PrescriptionFlag = { + id: `rxf-${Date.now().toString(36)}`, + code: addForm.code, + severity: addForm.severity, + note, + createdBy: user?.name ?? 'Pharmacist', + createdAt: new Date().toISOString(), + status: 'open', + } + persistFlags([flag, ...(rx.flags ?? [])]) + setAddOpen(false) + toast.success('Flag added') + } + + const onResolveFlag = (e: FormEvent) => { + e.preventDefault() + if (!resolveTarget) return + const note = resolutionNote.trim() + if (!note) { + toast.error('Add a resolution note') + return + } + const nextFlags = (rx.flags ?? []).map((f) => + f.id === resolveTarget.id + ? { + ...f, + status: 'resolved' as const, + resolvedBy: user?.name ?? 'Pharmacist', + resolvedAt: new Date().toISOString(), + resolutionNote: note, + } + : f, + ) + persistFlags(nextFlags) + setResolveTarget(null) + setResolutionNote('') + toast.success('Flag resolved') + } + const overflow: RowAction[] = [ ...(isOpen ? [ @@ -71,51 +217,6 @@ export function PrescriptionDetailPage() { { label: 'Copy Rx ID', icon: , onClick: copyId }, ] - const steps = useMemo(() => { - const decided = rx.status === 'approved' || rx.status === 'rejected' - const reviewSeen = decided || rx.status === 'queried' - - return [ - { - key: 'submitted', - label: 'Submitted', - state: 'done', - actor: rx.customerName, - at: rx.submittedAt, - }, - { - key: 'review', - label: 'Pharmacist review', - state: reviewSeen ? 'done' : 'current', - actor: reviewSeen ? rx.reviewedBy : rx.reviewStartedAt ? 'Review in progress' : undefined, - at: rx.reviewStartedAt, - hint: rx.status === 'submitted' ? 'Waiting to be picked up from the queue' : undefined, - }, - { - key: 'decision', - label: - rx.status === 'approved' - ? 'Approved for dispensing' - : rx.status === 'rejected' - ? 'Rejected' - : rx.status === 'queried' - ? 'Queried with customer' - : 'Decision', - state: - rx.status === 'approved' - ? 'done' - : rx.status === 'rejected' - ? 'failed' - : rx.status === 'queried' - ? 'current' - : 'upcoming', - actor: rx.status === 'queried' || rx.status === 'approved' || rx.status === 'rejected' ? rx.reviewedBy : undefined, - at: rx.status === 'submitted' || rx.status === 'under_review' ? undefined : rx.reviewedAt, - hint: rx.status === 'queried' ? 'Awaiting a clearer scan from the customer' : undefined, - }, - ] - }, [rx]) - const decisionCluster = (
{isOpen ? ( @@ -146,220 +247,415 @@ export function PrescriptionDetailPage() {
) + const trailEvents: TimelineEvent[] = [] + trailEvents.push({ + id: '1', + at: rx.submittedAt, + title: 'Submitted by customer', + detail: branch?.name, + tone: 'default', + }) + if (rx.reviewStartedAt) { + trailEvents.push({ + id: '2', + at: rx.reviewStartedAt, + title: 'Picked up for pharmacist review', + detail: branch?.name, + tone: 'warning', + }) + } else { + trailEvents.push({ + id: '2', + at: rx.submittedAt, + title: 'Queued for pharmacist review', + tone: 'warning', + }) + } + if (rx.status === 'approved') { + trailEvents.push({ + id: '3', + at: rx.reviewedAt ?? rx.submittedAt, + title: 'Approved for dispensing', + detail: rx.reviewedBy, + tone: 'success', + }) + } else if (rx.status === 'queried') { + trailEvents.push({ + id: '3', + at: rx.reviewedAt ?? rx.submittedAt, + title: 'Query sent to customer', + detail: rx.reviewedBy, + tone: 'warning', + }) + } else if (rx.status === 'rejected') { + trailEvents.push({ + id: '3', + at: rx.reviewedAt ?? rx.submittedAt, + title: 'Rejected', + detail: rx.reviewedBy, + tone: 'danger', + }) + } + for (const f of flags) { + trailEvents.push({ + id: `flag-${f.id}`, + at: f.status === 'resolved' && f.resolvedAt ? f.resolvedAt : f.createdAt, + title: + f.status === 'resolved' + ? `Resolved flag · ${flagCodeLabel(f.code)}` + : `Flagged · ${flagCodeLabel(f.code)}`, + detail: + f.status === 'resolved' + ? `${f.resolvedBy ?? 'Staff'}: ${f.resolutionNote ?? f.note}` + : `${f.createdBy}: ${f.note}`, + tone: f.status === 'resolved' ? 'success' : f.severity === 'critical' ? 'danger' : 'warning', + }) + } + trailEvents.sort((a, b) => new Date(a.at).getTime() - new Date(b.at).getTime()) + return ( - - - {controlled ? Controlled : null} - - } - actions={ - <> - {isOpen ? ( - <> - + + + ) : null} + + + } + sidebar={ + + + + } + > +
+ + +
+

+ {rx.status === 'approved' + ? 'Approved and ready to dispense' + : rx.status === 'rejected' + ? 'Rejected — nothing will be dispensed' + : rx.status === 'queried' + ? 'Waiting on the customer' + : rx.status === 'under_review' + ? 'Open with a pharmacist' + : 'Waiting for a pharmacist'} +

+

+ {rx.reviewedBy && rx.reviewedAt + ? `${statusLabel('prescription', rx.status)} by ${rx.reviewedBy} · ${formatDateTime(rx.reviewedAt)}` + : `Submitted ${formatDateTime(rx.submittedAt)} · no decision recorded yet`} +

+ {rx.decisionNote ? ( +

{rx.decisionNote}

+ ) : null} +
+
+ + + + Add flag - - - ) : null} - - - } - sidebar={ - - - - } - > -
- - -
-

- {rx.status === 'approved' - ? 'Approved and ready to dispense' - : rx.status === 'rejected' - ? 'Rejected — nothing will be dispensed' - : rx.status === 'queried' - ? 'Waiting on the customer' - : rx.status === 'under_review' - ? 'Open with a pharmacist' - : 'Waiting for a pharmacist'} -

-

- {rx.reviewedBy && rx.reviewedAt - ? `${statusLabel('prescription', rx.status)} by ${rx.reviewedBy} · ${formatDateTime(rx.reviewedAt)}` - : `Submitted ${formatDateTime(rx.submittedAt)} · no decision recorded yet`} -

- {rx.decisionNote ? ( -

{rx.decisionNote}

- ) : null} -
-
+ {flags.length === 0 ? ( +

No flags on this prescription yet.

+ ) : ( +
    + {flags.map((flag) => ( +
  • +
    +
    +
    + + + {flag.severity} + + {flagCodeLabel(flag.code)} + + {flag.status === 'open' ? 'Open' : 'Resolved'} + +
    +

    {flag.note}

    +

    + Added by {flag.createdBy} · {formatDateTime(flag.createdAt)} +

    + {flag.status === 'resolved' ? ( +

    + Resolved by {flag.resolvedBy} + {flag.resolvedAt ? ` · ${formatDateTime(flag.resolvedAt)}` : ''} + {flag.resolutionNote ? ` — ${flag.resolutionNote}` : ''} +

    + ) : null} +
    + {flag.status === 'open' ? ( + + ) : null} +
    +
  • + ))} +
+ )} + - - - {doctor.name} - - ) : ( - rx.prescriber ?? '—' - ), - }, - { label: 'Hospital', value: doctor?.orgName ?? '—' }, - { label: 'License', value: doctor?.licenseNumber ?? '—' }, - { label: 'Submitted', value: formatDateTime(rx.submittedAt) }, - { label: 'Items', value: String(rx.items.length) }, - { label: 'Controlled items', value: controlled ? 'Yes — stamp required' : 'None' }, - { label: 'Reviewed by', value: rx.reviewedBy ?? 'Not reviewed yet' }, - ]} - /> - + + + {doctor.name} + + ) : ( + rx.prescriber ?? '—' + ), + }, + { label: 'Hospital', value: doctor?.orgName ?? '—' }, + { label: 'License', value: doctor?.licenseNumber ?? '—' }, + { label: 'Submitted', value: formatDateTime(rx.submittedAt) }, + { label: 'Items', value: String(rx.items.length) }, + { label: 'Controlled items', value: controlled ? 'Yes — stamp required' : 'None' }, + { label: 'Open flags', value: String(openCount) }, + { label: 'Reviewed by', value: rx.reviewedBy ?? 'Not reviewed yet' }, + ]} + /> + - - - - - Medication - Qty - Interval - Dose times - Flags - - - - {rx.items.map((item) => ( - - {item.name} - {item.qty} - - {formatFrequencyLabel(item.frequency, item.intervalHours)} - {item.frequency ? ( - - {item.frequency} - {item.frequency === 'QXH' && item.intervalHours - ? ` · ${item.intervalHours}h` - : ''} - - ) : null} - - - {formatDoseTimes(item.times)} - - - {item.controlled ? ( - Controlled - ) : ( - OTC/Rx - )} - + +
+ + + Medication + Qty + Interval + Dose times + Flags - ))} - -
-
-

- {isOpen - ? controlled - ? 'Approving stamps a dispensing record and logs the controlled item against your licence.' - : 'Approving stamps a dispensing record against this branch’s stock.' - : `Decision recorded by ${rx.reviewedBy ?? 'a pharmacist'}.`} -

- {decisionCluster} -
-
-
- + + + {rx.items.map((item) => ( + + {item.name} + {item.qty} + + {formatFrequencyLabel(item.frequency, item.intervalHours)} + {item.frequency ? ( + + {item.frequency} + {item.frequency === 'QXH' && item.intervalHours + ? ` · ${item.intervalHours}h` + : ''} + + ) : null} + + {formatDoseTimes(item.times)} + + {item.controlled ? ( + Controlled + ) : ( + OTC/Rx + )} + + + ))} + + +
+

+ {isOpen + ? controlled + ? 'Approving stamps a dispensing record and logs the controlled item against your licence.' + : 'Approving stamps a dispensing record against this branch’s stock.' + : `Decision recorded by ${rx.reviewedBy ?? 'a pharmacist'}.`} +

+ {decisionCluster} +
+
+
+
+ + + + + Add prescription flag + + Mark a review, risk, or operational issue. Open flags show on the queue until someone resolves them. + + +
+
+ + { + const code = e.target.value as PrescriptionFlagCode + setAddForm((prev) => ({ + ...prev, + code, + severity: defaultSeverityForFlag(code), + })) + }} + > + {PRESCRIPTION_FLAG_CODES.map((opt) => ( + + ))} + +
+
+ + + setAddForm((prev) => ({ + ...prev, + severity: e.target.value as PrescriptionFlagSeverity, + })) + } + > + + + + +
+
+ +