Compare commits

...

2 Commits

Author SHA1 Message Date
12a4aa8a9f Document PrescriptionFlag on the platform schemas sheet.
Keep entity schemas aligned with Admin Rx flagging after merging remote schema pack.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 17:07:15 +03:00
d55c88c999 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 <cursoragent@cursor.com>
2026-08-10 17:05:35 +03:00
9 changed files with 969 additions and 277 deletions

View File

@ -7,7 +7,7 @@
| **Home repo (this file)** | `Gishen-Admin` | | **Home repo (this file)** | `Gishen-Admin` |
| **Sibling root** | `/Users/kirukib/Desktop/Yaltopia Project/` | | **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 | | **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 | | 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 (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 | **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. | | 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 | | 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 | | **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-07 | | **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-08 | | **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-08 | | **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-07 | | **Dispatch Bot** | `Gishen-Dispatch-Bot/` | ✅ (related) | `docs/BACKEND_SPEC.md` | 2026-08-10 |
| **Telegram Mini App** | `Gishen-Telegram-MiniApp/` | ⚠️ empty scaffold | — | 2026-08-07 | | **Telegram Mini App** | `Gishen-Telegram-MiniApp/` | ⚠️ empty scaffold | — | 2026-08-10 |
### Sources not found ### Sources not found
@ -151,12 +152,13 @@ See [§3 Auth matrix](#3-auth-provider-matrix).
| Layer | Contract | | Layer | Contract |
| --- | --- | | --- | --- |
| Admin line item | `frequency?`: `QD`\|`BID`\|`TID`\|`QID`\|`QXH`\|`custom`; `intervalHours?` (QXH); `times?`: `HH:mm`[] | | 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 | | 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) | | 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 | | 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 ### 2.6 Catalog / Items + multi-UOM + storefront PDP

View File

@ -14,6 +14,7 @@
| Date | Change | | 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 | **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-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 | | 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 | | Method | Path | Roles | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| GET | `/pharmacy/prescriptions` | pharmacist, super_admin | Branch-scoped queue; filters: `status`, `controlled`, `q` | | 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 | | 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 | `/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 | | 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/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 | | 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) ### Prescription medication line items (schedule)

View File

@ -339,6 +339,34 @@ interface PrescriptionItem {
instructions?: string instructions?: string
} }
/** Staff review markers — distinct from line `controlled` and workflow status. */
type PrescriptionFlagCode =
| 'controlled_substance'
| 'interaction_concern'
| 'incomplete_rx'
| 'fraud_suspicion'
| 'stock_shortage'
| 'needs_clarification'
| 'allergy_concern'
| 'dosing_concern'
| 'other'
type PrescriptionFlagSeverity = 'info' | 'warning' | 'critical'
type PrescriptionFlagStatus = 'open' | 'resolved'
interface PrescriptionFlag {
id: string
code: PrescriptionFlagCode
severity: PrescriptionFlagSeverity
note: string
createdBy: string
createdAt: Timestamp
status: PrescriptionFlagStatus
resolvedBy?: string
resolvedAt?: Timestamp
resolutionNote?: string
}
interface Prescription { interface Prescription {
id: string id: string
customerName: string customerName: string
@ -359,6 +387,7 @@ interface Prescription {
rejection_reason?: string rejection_reason?: string
days_supply?: number days_supply?: number
refill_due_at?: DateOnly refill_due_at?: DateOnly
flags?: PrescriptionFlag[]
} }
type PrescriptionVerifyBody = { type PrescriptionVerifyBody = {

View File

@ -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<PrescriptionFlagCode, string>
const defaultSeverityByCode = Object.fromEntries(
PRESCRIPTION_FLAG_CODES.map((c) => [c.value, c.defaultSeverity]),
) as Record<PrescriptionFlagCode, PrescriptionFlagSeverity>
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()
})
}

View File

@ -47,6 +47,17 @@ export let prescriptions: Prescription[] = [
], ],
submittedAt: '2026-08-06T08:10:00Z', submittedAt: '2026-08-06T08:10:00Z',
prescriber: 'Dr. Selam', 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', id: 'rx-1002',
@ -64,6 +75,17 @@ export let prescriptions: Prescription[] = [
], ],
submittedAt: '2026-08-06T09:20:00Z', submittedAt: '2026-08-06T09:20:00Z',
prescriber: 'Dr. Yohannes', 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', id: 'rx-1003',
@ -82,6 +104,26 @@ export let prescriptions: Prescription[] = [
submittedAt: '2026-08-06T07:45:00Z', submittedAt: '2026-08-06T07:45:00Z',
prescriber: 'Dr. Helen', prescriber: 'Dr. Helen',
reviewStartedAt: '2026-08-06T08:05:00Z', 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', id: 'rx-1004',
@ -111,6 +153,29 @@ export let prescriptions: Prescription[] = [
reviewedBy: 'Hana Pharmacist', reviewedBy: 'Hana Pharmacist',
reviewedAt: '2026-08-05T12:05:00Z', reviewedAt: '2026-08-05T12:05:00Z',
decisionNote: 'Prescriber signature is illegible — a clearer scan was requested.', 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', id: 'rx-1006',
@ -159,6 +224,32 @@ export let prescriptions: Prescription[] = [
reviewedBy: 'Hana Pharmacist', reviewedBy: 'Hana Pharmacist',
reviewedAt: '2026-08-02T11:22:00Z', reviewedAt: '2026-08-02T11:22:00Z',
decisionNote: 'Prescriber is not on the national register.', 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', id: 'rx-1009',

View File

@ -18,8 +18,12 @@ import {
DOSING_FREQUENCY_OPTIONS, DOSING_FREQUENCY_OPTIONS,
defaultTimesForFrequency, defaultTimesForFrequency,
} from '@/lib/dosingSchedule' } from '@/lib/dosingSchedule'
import {
PRESCRIPTION_FLAG_CODES,
defaultSeverityForFlag,
} from '@/lib/prescriptionFlags'
import { branches, doctors, prescriptions } from '@/mocks/data' import { branches, doctors, prescriptions } from '@/mocks/data'
import type { DosingFrequency, Prescription } from '@/types' import type { DosingFrequency, Prescription, PrescriptionFlagCode } from '@/types'
export function CreatePrescriptionPage() { export function CreatePrescriptionPage() {
const navigate = useNavigate() const navigate = useNavigate()
@ -38,6 +42,9 @@ export function CreatePrescriptionPage() {
frequency: 'BID' as DosingFrequency, frequency: 'BID' as DosingFrequency,
intervalHours: '8', intervalHours: '8',
times: defaultTimesForFrequency('BID'), times: defaultTimesForFrequency('BID'),
attachFlag: false,
flagCode: 'needs_clarification' as PrescriptionFlagCode,
flagNote: '',
}) })
const selectedDoctor = activeDoctors.find((d) => d.id === form.doctorId) const selectedDoctor = activeDoctors.find((d) => d.id === form.doctorId)
@ -100,6 +107,11 @@ export function CreatePrescriptionPage() {
toast.error('Enter hours between doses') toast.error('Enter hours between doses')
return 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 = { const rx: Prescription = {
id: `rx-${Date.now().toString().slice(-6)}`, id: `rx-${Date.now().toString().slice(-6)}`,
customerName: form.customerName, customerName: form.customerName,
@ -115,10 +127,25 @@ export function CreatePrescriptionPage() {
times: cleanedTimes, times: cleanedTimes,
}, },
], ],
submittedAt: new Date().toISOString(), submittedAt: now,
prescriber: selectedDoctor.name, prescriber: selectedDoctor.name,
doctorId: selectedDoctor.id, doctorId: selectedDoctor.id,
orgId: selectedDoctor.orgId, 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) prescriptions.unshift(rx)
toast.success('Prescription intake created') toast.success('Prescription intake created')
@ -290,6 +317,44 @@ export function CreatePrescriptionPage() {
/> />
Controlled substance Controlled substance
</label> </label>
<div className={cn('space-y-3 rounded-xl border border-dashed p-4', createFieldFull)}>
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
checked={form.attachFlag}
onChange={(e) => setForm({ ...form, attachFlag: e.target.checked })}
/>
Attach an initial review flag
</label>
{form.attachFlag ? (
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>Flag reason</Label>
<SelectNative
value={form.flagCode}
onChange={(e) =>
setForm({ ...form, flagCode: e.target.value as PrescriptionFlagCode })
}
>
{PRESCRIPTION_FLAG_CODES.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</SelectNative>
</div>
<div className={cn('space-y-1.5', createFieldFull)}>
<Label>Flag note</Label>
<Input
required={form.attachFlag}
value={form.flagNote}
onChange={(e) => setForm({ ...form, flagNote: e.target.value })}
placeholder="Why this Rx needs attention"
/>
</div>
</div>
) : null}
</div>
</form> </form>
</CreatePageLayout> </CreatePageLayout>
) )

View File

@ -1,29 +1,111 @@
import { useMemo, useState } from 'react' import { useState, type FormEvent } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { toast } from 'sonner' 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 { 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 { ApprovalStepper, type ApprovalStep } from '@/components/detail/ApprovalStepper'
import { KeyValueGrid } from '@/components/detail/KeyValueGrid' import { KeyValueGrid } from '@/components/detail/KeyValueGrid'
import { RowActions, type RowAction } from '@/components/table/RowActions' import { RowActions, type RowAction } from '@/components/table/RowActions'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' 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 { StatusBadge, statusLabel } from '@/components/ui/status-badge'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' 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 { branches, doctors, prescriptions } from '@/mocks/data'
import { useAuthStore } from '@/zustand/authStore' import { useAuthStore } from '@/zustand/authStore'
import { formatDoseTimes, formatFrequencyLabel } from '@/lib/dosingSchedule' import { formatDoseTimes, formatFrequencyLabel } from '@/lib/dosingSchedule'
import {
PRESCRIPTION_FLAG_CODES,
defaultSeverityForFlag,
flagCodeLabel,
openFlagCount,
severityBadgeVariant,
sortFlags,
} from '@/lib/prescriptionFlags'
import { formatDateTime } from '@/lib/utils' 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'] 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() { export function PrescriptionDetailPage() {
const { id } = useParams() const { id } = useParams()
const user = useAuthStore((s) => s.user) const user = useAuthStore((s) => s.user)
const seed = prescriptions.find((p) => p.id === id) const seed = prescriptions.find((p) => p.id === id)
const [rx, setRx] = useState<Prescription | undefined>(seed ? { ...seed, items: [...seed.items] } : undefined) const [rx, setRx] = useState<Prescription | undefined>(
seed ? { ...seed, items: [...seed.items], flags: seed.flags ? [...seed.flags] : [] } : undefined,
)
const [addOpen, setAddOpen] = useState(false)
const [resolveTarget, setResolveTarget] = useState<PrescriptionFlag | null>(null)
const [addForm, setAddForm] = useState({
code: 'needs_clarification' as PrescriptionFlagCode,
severity: 'warning' as PrescriptionFlagSeverity,
note: '',
})
const [resolutionNote, setResolutionNote] = useState('')
if (!rx) { if (!rx) {
return ( return (
@ -39,6 +121,15 @@ export function PrescriptionDetailPage() {
const doctor = rx.doctorId ? doctors.find((d) => d.id === rx.doctorId) : undefined const doctor = rx.doctorId ? doctors.find((d) => d.id === rx.doctorId) : undefined
const controlled = rx.items.some((i) => i.controlled) const controlled = rx.items.some((i) => i.controlled)
const isOpen = OPEN_STATUSES.includes(rx.status) 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 act = (next: Prescription['status']) => {
const patch = { const patch = {
@ -57,6 +148,61 @@ export function PrescriptionDetailPage() {
toast.message(`${rx.id} copied to clipboard`) 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[] = [ const overflow: RowAction[] = [
...(isOpen ...(isOpen
? [ ? [
@ -71,51 +217,6 @@ export function PrescriptionDetailPage() {
{ label: 'Copy Rx ID', icon: <ClipboardCopy className="h-3.5 w-3.5" />, onClick: copyId }, { label: 'Copy Rx ID', icon: <ClipboardCopy className="h-3.5 w-3.5" />, onClick: copyId },
] ]
const steps = useMemo<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,
},
]
}, [rx])
const decisionCluster = ( const decisionCluster = (
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
{isOpen ? ( {isOpen ? (
@ -146,220 +247,415 @@ export function PrescriptionDetailPage() {
</div> </div>
) )
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 ( return (
<DetailPageLayout <>
backTo="/prescriptions" <DetailPageLayout
backLabel="Back to prescriptions" backTo="/prescriptions"
title={rx.id} backLabel="Back to prescriptions"
subtitle={`${rx.customerName} · ${rx.prescriber ?? 'Unknown prescriber'}`} title={rx.id}
badges={ subtitle={`${rx.customerName} · ${rx.prescriber ?? 'Unknown prescriber'}`}
<> badges={
<StatusBadge domain="prescription" status={rx.status} /> <>
{controlled ? <Badge variant="danger">Controlled</Badge> : null} <StatusBadge domain="prescription" status={rx.status} />
</> {controlled ? <Badge variant="danger">Controlled</Badge> : null}
} {openCount > 0 ? (
actions={ <Badge variant="warning" className="gap-1">
<> <Flag className="h-3 w-3" />
{isOpen ? ( {openCount} open flag{openCount === 1 ? '' : 's'}
<> </Badge>
<Button className="h-10 gap-1.5" onClick={() => act('approved')}> ) : null}
<Check className="h-4 w-4" /> </>
Approve }
actions={
<>
{isOpen ? (
<>
<Button className="h-10 gap-1.5" onClick={() => act('approved')}>
<Check className="h-4 w-4" />
Approve
</Button>
<Button variant="outline" className="h-10 gap-1.5" onClick={() => act('queried')}>
<HelpCircle className="h-4 w-4" />
Query
</Button>
</>
) : null}
<RowActions items={overflow} />
</>
}
sidebar={
<DetailSection title="Review trail">
<ActivityTimeline events={trailEvents} />
</DetailSection>
}
>
<div className="space-y-5">
<DetailSection
title="Review status"
description="Where this prescription sits in the pharmacist workflow"
>
<ApprovalStepper steps={steps} />
<div
className={
rx.status === 'rejected'
? 'mt-5 rounded-xl border border-destructive/25 bg-destructive/5 px-4 py-3'
: rx.status === 'approved'
? 'mt-5 rounded-xl border border-brand-200 bg-brand-50 px-4 py-3'
: 'mt-5 rounded-xl border border-orange/30 bg-orange/5 px-4 py-3'
}
>
<p className="text-sm font-semibold text-forest">
{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'}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{rx.reviewedBy && rx.reviewedAt
? `${statusLabel('prescription', rx.status)} by ${rx.reviewedBy} · ${formatDateTime(rx.reviewedAt)}`
: `Submitted ${formatDateTime(rx.submittedAt)} · no decision recorded yet`}
</p>
{rx.decisionNote ? (
<p className="mt-2 text-xs text-grayScale-600">{rx.decisionNote}</p>
) : null}
</div>
</DetailSection>
<DetailSection
title="Flags"
description="Operational and clinical review markers — open flags stay visible until resolved"
action={
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5" onClick={openAddDialog}>
<Plus className="h-3.5 w-3.5" />
Add flag
</Button> </Button>
<Button variant="outline" className="h-10 gap-1.5" onClick={() => act('queried')}>
<HelpCircle className="h-4 w-4" />
Query
</Button>
</>
) : null}
<RowActions items={overflow} />
</>
}
sidebar={
<DetailSection title="Review trail">
<ActivityTimeline
events={[
{
id: '1',
at: rx.submittedAt,
title: 'Submitted by customer',
detail: branch?.name,
tone: 'default',
},
...(rx.reviewStartedAt
? [
{
id: '2',
at: rx.reviewStartedAt,
title: 'Picked up for pharmacist review',
detail: branch?.name,
tone: 'warning' as const,
},
]
: [
{
id: '2',
at: rx.submittedAt,
title: 'Queued for pharmacist review',
tone: 'warning' as const,
},
]),
...(rx.status === 'approved'
? [
{
id: '3',
at: rx.reviewedAt ?? rx.submittedAt,
title: 'Approved for dispensing',
detail: rx.reviewedBy,
tone: 'success' as const,
},
]
: []),
...(rx.status === 'queried'
? [
{
id: '3',
at: rx.reviewedAt ?? rx.submittedAt,
title: 'Query sent to customer',
detail: rx.reviewedBy,
tone: 'warning' as const,
},
]
: []),
...(rx.status === 'rejected'
? [
{
id: '3',
at: rx.reviewedAt ?? rx.submittedAt,
title: 'Rejected',
detail: rx.reviewedBy,
tone: 'danger' as const,
},
]
: []),
]}
/>
</DetailSection>
}
>
<div className="space-y-5">
<DetailSection
title="Review status"
description="Where this prescription sits in the pharmacist workflow"
>
<ApprovalStepper steps={steps} />
<div
className={
rx.status === 'rejected'
? 'mt-5 rounded-xl border border-destructive/25 bg-destructive/5 px-4 py-3'
: rx.status === 'approved'
? 'mt-5 rounded-xl border border-brand-200 bg-brand-50 px-4 py-3'
: 'mt-5 rounded-xl border border-orange/30 bg-orange/5 px-4 py-3'
} }
> >
<p className="text-sm font-semibold text-forest"> {flags.length === 0 ? (
{rx.status === 'approved' <p className="text-sm text-muted-foreground">No flags on this prescription yet.</p>
? 'Approved and ready to dispense' ) : (
: rx.status === 'rejected' <ul className="space-y-3">
? 'Rejected — nothing will be dispensed' {flags.map((flag) => (
: rx.status === 'queried' <li
? 'Waiting on the customer' key={flag.id}
: rx.status === 'under_review' className={
? 'Open with a pharmacist' flag.status === 'open'
: 'Waiting for a pharmacist'} ? 'rounded-xl border border-orange/25 bg-orange/5 px-4 py-3'
</p> : 'rounded-xl border bg-muted/30 px-4 py-3'
<p className="mt-1 text-xs text-muted-foreground"> }
{rx.reviewedBy && rx.reviewedAt >
? `${statusLabel('prescription', rx.status)} by ${rx.reviewedBy} · ${formatDateTime(rx.reviewedAt)}` <div className="flex flex-wrap items-start justify-between gap-3">
: `Submitted ${formatDateTime(rx.submittedAt)} · no decision recorded yet`} <div className="space-y-1.5">
</p> <div className="flex flex-wrap items-center gap-1.5">
{rx.decisionNote ? ( <Badge variant={severityBadgeVariant(flag.severity)} className="gap-1 capitalize">
<p className="mt-2 text-xs text-grayScale-600">{rx.decisionNote}</p> <Flag className="h-3 w-3" />
) : null} {flag.severity}
</div> </Badge>
</DetailSection> <Badge variant="outline">{flagCodeLabel(flag.code)}</Badge>
<Badge variant={flag.status === 'open' ? 'warning' : 'success'}>
{flag.status === 'open' ? 'Open' : 'Resolved'}
</Badge>
</div>
<p className="text-sm text-forest">{flag.note}</p>
<p className="text-[11px] text-muted-foreground">
Added by {flag.createdBy} · {formatDateTime(flag.createdAt)}
</p>
{flag.status === 'resolved' ? (
<p className="text-xs text-muted-foreground">
Resolved by {flag.resolvedBy}
{flag.resolvedAt ? ` · ${formatDateTime(flag.resolvedAt)}` : ''}
{flag.resolutionNote ? ` — ${flag.resolutionNote}` : ''}
</p>
) : null}
</div>
{flag.status === 'open' ? (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0"
onClick={() => {
setResolveTarget(flag)
setResolutionNote('')
}}
>
Resolve
</Button>
) : null}
</div>
</li>
))}
</ul>
)}
</DetailSection>
<DetailSection title="Details"> <DetailSection title="Details">
<KeyValueGrid <KeyValueGrid
columns={3} columns={3}
items={[ items={[
{ label: 'Branch', value: branch?.name }, { label: 'Branch', value: branch?.name },
{ {
label: 'Prescriber', label: 'Prescriber',
value: doctor ? ( value: doctor ? (
<Link to={`/doctors/${doctor.id}`} className="text-brand-700 hover:underline"> <Link to={`/doctors/${doctor.id}`} className="text-brand-700 hover:underline">
{doctor.name} {doctor.name}
</Link> </Link>
) : ( ) : (
rx.prescriber ?? '—' rx.prescriber ?? '—'
), ),
}, },
{ label: 'Hospital', value: doctor?.orgName ?? '—' }, { label: 'Hospital', value: doctor?.orgName ?? '—' },
{ label: 'License', value: doctor?.licenseNumber ?? '—' }, { label: 'License', value: doctor?.licenseNumber ?? '—' },
{ label: 'Submitted', value: formatDateTime(rx.submittedAt) }, { label: 'Submitted', value: formatDateTime(rx.submittedAt) },
{ label: 'Items', value: String(rx.items.length) }, { label: 'Items', value: String(rx.items.length) },
{ label: 'Controlled items', value: controlled ? 'Yes — stamp required' : 'None' }, { label: 'Controlled items', value: controlled ? 'Yes — stamp required' : 'None' },
{ label: 'Reviewed by', value: rx.reviewedBy ?? 'Not reviewed yet' }, { label: 'Open flags', value: String(openCount) },
]} { label: 'Reviewed by', value: rx.reviewedBy ?? 'Not reviewed yet' },
/> ]}
</DetailSection> />
</DetailSection>
<DetailSection <DetailSection
title="Medications" title="Medications"
description="Verify strength, qty, dosing schedule, and controlled flags" description="Verify strength, qty, dosing schedule, and controlled flags"
> >
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="hover:bg-transparent"> <TableRow className="hover:bg-transparent">
<TableHead>Medication</TableHead> <TableHead>Medication</TableHead>
<TableHead>Qty</TableHead> <TableHead>Qty</TableHead>
<TableHead>Interval</TableHead> <TableHead>Interval</TableHead>
<TableHead>Dose times</TableHead> <TableHead>Dose times</TableHead>
<TableHead>Flags</TableHead> <TableHead>Flags</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rx.items.map((item) => (
<TableRow key={item.name}>
<TableCell className="font-medium">{item.name}</TableCell>
<TableCell>{item.qty}</TableCell>
<TableCell>
{formatFrequencyLabel(item.frequency, item.intervalHours)}
{item.frequency ? (
<span className="mt-0.5 block font-mono text-[11px] text-muted-foreground">
{item.frequency}
{item.frequency === 'QXH' && item.intervalHours
? ` · ${item.intervalHours}h`
: ''}
</span>
) : null}
</TableCell>
<TableCell className="font-mono text-sm">
{formatDoseTimes(item.times)}
</TableCell>
<TableCell>
{item.controlled ? (
<Badge variant="danger">Controlled</Badge>
) : (
<Badge variant="secondary">OTC/Rx</Badge>
)}
</TableCell>
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {rx.items.map((item) => (
<div className="mt-5 flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between"> <TableRow key={item.name}>
<p className="text-xs text-muted-foreground"> <TableCell className="font-medium">{item.name}</TableCell>
{isOpen <TableCell>{item.qty}</TableCell>
? controlled <TableCell>
? 'Approving stamps a dispensing record and logs the controlled item against your licence.' {formatFrequencyLabel(item.frequency, item.intervalHours)}
: 'Approving stamps a dispensing record against this branch’s stock.' {item.frequency ? (
: `Decision recorded by ${rx.reviewedBy ?? 'a pharmacist'}.`} <span className="mt-0.5 block font-mono text-[11px] text-muted-foreground">
</p> {item.frequency}
{decisionCluster} {item.frequency === 'QXH' && item.intervalHours
</div> ? ` · ${item.intervalHours}h`
</DetailSection> : ''}
</div> </span>
</DetailPageLayout> ) : null}
</TableCell>
<TableCell className="font-mono text-sm">{formatDoseTimes(item.times)}</TableCell>
<TableCell>
{item.controlled ? (
<Badge variant="danger">Controlled</Badge>
) : (
<Badge variant="secondary">OTC/Rx</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="mt-5 flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
{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'}.`}
</p>
{decisionCluster}
</div>
</DetailSection>
</div>
</DetailPageLayout>
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add prescription flag</DialogTitle>
<DialogDescription>
Mark a review, risk, or operational issue. Open flags show on the queue until someone resolves them.
</DialogDescription>
</DialogHeader>
<form className="space-y-3" onSubmit={onAddFlag}>
<div className="space-y-1.5">
<Label htmlFor="rx-flag-code">Reason</Label>
<SelectNative
id="rx-flag-code"
value={addForm.code}
onChange={(e) => {
const code = e.target.value as PrescriptionFlagCode
setAddForm((prev) => ({
...prev,
code,
severity: defaultSeverityForFlag(code),
}))
}}
>
{PRESCRIPTION_FLAG_CODES.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</SelectNative>
</div>
<div className="space-y-1.5">
<Label htmlFor="rx-flag-severity">Severity</Label>
<SelectNative
id="rx-flag-severity"
value={addForm.severity}
onChange={(e) =>
setAddForm((prev) => ({
...prev,
severity: e.target.value as PrescriptionFlagSeverity,
}))
}
>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="critical">Critical</option>
</SelectNative>
</div>
<div className="space-y-1.5">
<Label htmlFor="rx-flag-note">Note</Label>
<Textarea
id="rx-flag-note"
required
value={addForm.note}
onChange={(e) => setAddForm((prev) => ({ ...prev, note: e.target.value }))}
placeholder="What should the next pharmacist check?"
rows={3}
/>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setAddOpen(false)}>
Cancel
</Button>
<Button type="submit">Add flag</Button>
</div>
</form>
</DialogContent>
</Dialog>
<Dialog
open={!!resolveTarget}
onOpenChange={(open) => {
if (!open) {
setResolveTarget(null)
setResolutionNote('')
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Resolve flag</DialogTitle>
<DialogDescription>
{resolveTarget
? `${flagCodeLabel(resolveTarget.code)} — document how this was cleared.`
: 'Document how this was cleared.'}
</DialogDescription>
</DialogHeader>
<form className="space-y-3" onSubmit={onResolveFlag}>
<div className="space-y-1.5">
<Label htmlFor="rx-flag-resolution">Resolution note</Label>
<Input
id="rx-flag-resolution"
required
value={resolutionNote}
onChange={(e) => setResolutionNote(e.target.value)}
placeholder="Prescriber confirmed dose · stock transferred…"
autoFocus
/>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
setResolveTarget(null)
setResolutionNote('')
}}
>
Cancel
</Button>
<Button type="submit">Mark resolved</Button>
</div>
</form>
</DialogContent>
</Dialog>
</>
) )
} }

View File

@ -7,6 +7,7 @@ import {
Clock3, Clock3,
Eye, Eye,
FileHeart, FileHeart,
Flag,
HelpCircle, HelpCircle,
Plus, Plus,
ShieldAlert, ShieldAlert,
@ -23,7 +24,15 @@ import { TableToolbar, type TableFilter } from '@/components/table/TableToolbar'
import { RowActions, type RowAction } from '@/components/table/RowActions' import { RowActions, type RowAction } from '@/components/table/RowActions'
import { useAuthStore } from '@/zustand/authStore' import { useAuthStore } from '@/zustand/authStore'
import { branches, prescriptions as seed } from '@/mocks/data' import { branches, prescriptions as seed } from '@/mocks/data'
import type { Prescription } from '@/types' import {
PRESCRIPTION_FLAG_CODES,
flagCodeLabel,
hasOpenFlags,
highestOpenSeverity,
openFlagCount,
severityBadgeVariant,
} from '@/lib/prescriptionFlags'
import type { Prescription, PrescriptionFlagCode } from '@/types'
const OPEN_STATUSES: Prescription['status'][] = ['submitted', 'under_review', 'queried'] const OPEN_STATUSES: Prescription['status'][] = ['submitted', 'under_review', 'queried']
@ -44,6 +53,8 @@ export function PrescriptionsPage() {
const [status, setStatus] = useState('all') const [status, setStatus] = useState('all')
const [branchFilter, setBranchFilter] = useState('all') const [branchFilter, setBranchFilter] = useState('all')
const [controlledOnly, setControlledOnly] = useState('all') const [controlledOnly, setControlledOnly] = useState('all')
const [flaggedFilter, setFlaggedFilter] = useState('all')
const [flagTypeFilter, setFlagTypeFilter] = useState('all')
const canPickBranch = !(user?.role === 'pharmacist' && user.branchId) const canPickBranch = !(user?.role === 'pharmacist' && user.branchId)
@ -61,6 +72,7 @@ export function PrescriptionsPage() {
open: scoped.filter((p) => OPEN_STATUSES.includes(p.status)).length, open: scoped.filter((p) => OPEN_STATUSES.includes(p.status)).length,
approved: scoped.filter((p) => p.status === 'approved').length, approved: scoped.filter((p) => p.status === 'approved').length,
controlled: scoped.filter((p) => p.items.some((i) => i.controlled)).length, controlled: scoped.filter((p) => p.items.some((i) => i.controlled)).length,
flagged: scoped.filter((p) => hasOpenFlags(p)).length,
} }
}, [scoped]) }, [scoped])
@ -69,6 +81,12 @@ export function PrescriptionsPage() {
if (canPickBranch && branchFilter !== 'all') list = list.filter((p) => p.branchId === branchFilter) if (canPickBranch && branchFilter !== 'all') list = list.filter((p) => p.branchId === branchFilter)
if (status !== 'all') list = list.filter((p) => p.status === status) if (status !== 'all') list = list.filter((p) => p.status === status)
if (controlledOnly === 'yes') list = list.filter((p) => p.items.some((i) => i.controlled)) if (controlledOnly === 'yes') list = list.filter((p) => p.items.some((i) => i.controlled))
if (flaggedFilter === 'open') list = list.filter((p) => hasOpenFlags(p))
if (flaggedFilter === 'none') list = list.filter((p) => !hasOpenFlags(p))
if (flagTypeFilter !== 'all') {
const code = flagTypeFilter as PrescriptionFlagCode
list = list.filter((p) => (p.flags ?? []).some((f) => f.status === 'open' && f.code === code))
}
if (q) { if (q) {
const needle = q.toLowerCase() const needle = q.toLowerCase()
list = list.filter( list = list.filter(
@ -79,7 +97,16 @@ export function PrescriptionsPage() {
) )
} }
return list return list
}, [scoped, canPickBranch, q, status, branchFilter, controlledOnly]) }, [
scoped,
canPickBranch,
q,
status,
branchFilter,
controlledOnly,
flaggedFilter,
flagTypeFilter,
])
const act = (id: string, next: Prescription['status']) => { const act = (id: string, next: Prescription['status']) => {
const patch = { const patch = {
@ -165,15 +192,37 @@ export function PrescriptionsPage() {
{ value: 'yes', label: 'Controlled only' }, { value: 'yes', label: 'Controlled only' },
], ],
}, },
{
id: 'flagged',
label: 'Flags',
value: flaggedFilter,
onChange: setFlaggedFilter,
options: [
{ value: 'all', label: 'All flag states' },
{ value: 'open', label: 'Open flags' },
{ value: 'none', label: 'No open flags' },
],
},
{
id: 'flagType',
label: 'Flag type',
value: flagTypeFilter,
onChange: setFlagTypeFilter,
options: [
{ value: 'all', label: 'All flag types' },
...PRESCRIPTION_FLAG_CODES.map((c) => ({ value: c.value, label: c.label })),
],
},
] ]
return ( return (
<PageShell title={t('prescriptions.title')} subtitle={t('prescriptions.subtitle')}> <PageShell title={t('prescriptions.title')} subtitle={t('prescriptions.subtitle')}>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
<StatCard label="Submitted today" value={kpis.today} hint="Across the scoped queue" icon={FileHeart} tone="brand" /> <StatCard label="Submitted today" value={kpis.today} hint="Across the scoped queue" icon={FileHeart} tone="brand" />
<StatCard label="Awaiting decision" value={kpis.open} hint="Submitted, in review or queried" icon={Clock3} tone="orange" /> <StatCard label="Awaiting decision" value={kpis.open} hint="Submitted, in review or queried" icon={Clock3} tone="orange" />
<StatCard label="Approved" value={kpis.approved} hint="Ready to dispense" icon={Check} tone="forest" /> <StatCard label="Approved" value={kpis.approved} hint="Ready to dispense" icon={Check} tone="forest" />
<StatCard label="Controlled in queue" value={kpis.controlled} hint="Needs stamp / schedule check" icon={ShieldAlert} tone="muted" /> <StatCard label="Controlled in queue" value={kpis.controlled} hint="Needs stamp / schedule check" icon={ShieldAlert} tone="muted" />
<StatCard label="Open flags" value={kpis.flagged} hint="Needs review before dispense" icon={Flag} tone="orange" />
</div> </div>
<Card className="overflow-hidden"> <Card className="overflow-hidden">
@ -188,6 +237,8 @@ export function PrescriptionsPage() {
setStatus('all') setStatus('all')
setBranchFilter('all') setBranchFilter('all')
setControlledOnly('all') setControlledOnly('all')
setFlaggedFilter('all')
setFlagTypeFilter('all')
}} }}
actions={ actions={
<Button asChild className="h-10 gap-2 shadow-none"> <Button asChild className="h-10 gap-2 shadow-none">
@ -206,6 +257,7 @@ export function PrescriptionsPage() {
<TableHead>Customer</TableHead> <TableHead>Customer</TableHead>
<TableHead>Prescriber</TableHead> <TableHead>Prescriber</TableHead>
<TableHead>Items</TableHead> <TableHead>Items</TableHead>
<TableHead>Flags</TableHead>
<TableHead>{t('common.branch')}</TableHead> <TableHead>{t('common.branch')}</TableHead>
<TableHead>{t('common.status')}</TableHead> <TableHead>{t('common.status')}</TableHead>
<TableHead className="text-right">{t('common.actions')}</TableHead> <TableHead className="text-right">{t('common.actions')}</TableHead>
@ -214,13 +266,18 @@ export function PrescriptionsPage() {
<TableBody> <TableBody>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground"> <TableCell colSpan={8} className="h-24 text-center text-muted-foreground">
{t('common.noResults')} {t('common.noResults')}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : null} ) : null}
{filtered.map((p) => { {filtered.map((p) => {
const actions = rowActions(p) const actions = rowActions(p)
const count = openFlagCount(p)
const severity = highestOpenSeverity(p)
const openCodes = [
...new Set((p.flags ?? []).filter((f) => f.status === 'open').map((f) => f.code)),
]
return ( return (
<TableRow key={p.id}> <TableRow key={p.id}>
<TableCell className="font-medium"> <TableCell className="font-medium">
@ -239,6 +296,26 @@ export function PrescriptionsPage() {
))} ))}
</div> </div>
</TableCell> </TableCell>
<TableCell>
{count > 0 && severity ? (
<div className="flex flex-wrap items-center gap-1">
<Badge variant={severityBadgeVariant(severity)} className="gap-1">
<Flag className="h-3 w-3" />
{count} open
</Badge>
{openCodes.slice(0, 2).map((code) => (
<Badge key={code} variant="outline" className="font-normal">
{flagCodeLabel(code)}
</Badge>
))}
{openCodes.length > 2 ? (
<span className="text-[11px] text-muted-foreground">+{openCodes.length - 2}</span>
) : null}
</div>
) : (
<span className="text-xs text-muted-foreground">—</span>
)}
</TableCell>
<TableCell>{branches.find((b) => b.id === p.branchId)?.name ?? p.branchId}</TableCell> <TableCell>{branches.find((b) => b.id === p.branchId)?.name ?? p.branchId}</TableCell>
<TableCell> <TableCell>
<StatusBadge domain="prescription" status={p.status} /> <StatusBadge domain="prescription" status={p.status} />

View File

@ -77,6 +77,37 @@ export interface PrescriptionItem {
times?: string[] times?: string[]
} }
/**
* Pharmacist / staff operational review flags on a prescription
* (distinct from line-item `controlled` and Rx workflow `status`).
*/
export type PrescriptionFlagCode =
| 'controlled_substance'
| 'interaction_concern'
| 'incomplete_rx'
| 'fraud_suspicion'
| 'stock_shortage'
| 'needs_clarification'
| 'allergy_concern'
| 'dosing_concern'
| 'other'
export type PrescriptionFlagSeverity = 'info' | 'warning' | 'critical'
export type PrescriptionFlagStatus = 'open' | 'resolved'
export interface PrescriptionFlag {
id: string
code: PrescriptionFlagCode
severity: PrescriptionFlagSeverity
note: string
createdBy: string
createdAt: string
status: PrescriptionFlagStatus
resolvedBy?: string
resolvedAt?: string
resolutionNote?: string
}
export interface Prescription { export interface Prescription {
id: string id: string
customerName: string customerName: string
@ -96,6 +127,8 @@ export interface Prescription {
reviewedBy?: string reviewedBy?: string
reviewedAt?: string reviewedAt?: string
decisionNote?: string decisionNote?: string
/** Operational / clinical review flags (open + resolved history). */
flags?: PrescriptionFlag[]
} }
/** End-customer on an order — platform account vs walk-in / phone / guest. */ /** End-customer on an order — platform account vs walk-in / phone / guest. */