Compare commits
2 Commits
065423efbf
...
12a4aa8a9f
| Author | SHA1 | Date | |
|---|---|---|---|
| 12a4aa8a9f | |||
| d55c88c999 |
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -339,6 +339,34 @@ interface PrescriptionItem {
|
|||
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 {
|
||||
id: string
|
||||
customerName: string
|
||||
|
|
@ -359,6 +387,7 @@ interface Prescription {
|
|||
rejection_reason?: string
|
||||
days_supply?: number
|
||||
refill_due_at?: DateOnly
|
||||
flags?: PrescriptionFlag[]
|
||||
}
|
||||
|
||||
type PrescriptionVerifyBody = {
|
||||
|
|
|
|||
76
src/lib/prescriptionFlags.ts
Normal file
76
src/lib/prescriptionFlags.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</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>
|
||||
</CreatePageLayout>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,77 +1,49 @@
|
|||
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']
|
||||
|
||||
export function PrescriptionDetailPage() {
|
||||
const { id } = useParams()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const seed = prescriptions.find((p) => p.id === id)
|
||||
const [rx, setRx] = useState<Prescription | undefined>(seed ? { ...seed, items: [...seed.items] } : undefined)
|
||||
|
||||
if (!rx) {
|
||||
return (
|
||||
<DetailPageLayout backTo="/prescriptions" title="Prescription not found">
|
||||
<Button asChild>
|
||||
<Link to="/prescriptions">Back to prescriptions</Link>
|
||||
</Button>
|
||||
</DetailPageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const branch = branches.find((b) => b.id === rx.branchId)
|
||||
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 act = (next: Prescription['status']) => {
|
||||
const patch = {
|
||||
status: next,
|
||||
reviewedBy: user?.name ?? 'Pharmacist',
|
||||
reviewedAt: new Date().toISOString(),
|
||||
}
|
||||
setRx((prev) => (prev ? { ...prev, ...patch } : prev))
|
||||
const target = prescriptions.find((p) => p.id === rx.id)
|
||||
if (target) Object.assign(target, patch)
|
||||
toast.success(`Prescription ${next}`)
|
||||
}
|
||||
|
||||
const copyId = () => {
|
||||
void navigator.clipboard?.writeText(rx.id)
|
||||
toast.message(`${rx.id} copied to clipboard`)
|
||||
}
|
||||
|
||||
const overflow: RowAction[] = [
|
||||
...(isOpen
|
||||
? [
|
||||
{
|
||||
label: 'Reject prescription',
|
||||
icon: <X className="h-3.5 w-3.5" />,
|
||||
variant: 'destructive' as const,
|
||||
onClick: () => act('rejected'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ label: 'Copy Rx ID', icon: <ClipboardCopy className="h-3.5 w-3.5" />, onClick: copyId },
|
||||
]
|
||||
|
||||
const steps = useMemo<ApprovalStep[]>(() => {
|
||||
function buildSteps(rx: Prescription): ApprovalStep[] {
|
||||
const decided = rx.status === 'approved' || rx.status === 'rejected'
|
||||
const reviewSeen = decided || rx.status === 'queried'
|
||||
|
||||
|
|
@ -109,12 +81,141 @@ export function PrescriptionDetailPage() {
|
|||
: rx.status === 'queried'
|
||||
? 'current'
|
||||
: 'upcoming',
|
||||
actor: rx.status === 'queried' || rx.status === 'approved' || rx.status === 'rejected' ? rx.reviewedBy : undefined,
|
||||
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])
|
||||
}
|
||||
|
||||
export function PrescriptionDetailPage() {
|
||||
const { id } = useParams()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const seed = prescriptions.find((p) => p.id === id)
|
||||
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) {
|
||||
return (
|
||||
<DetailPageLayout backTo="/prescriptions" title="Prescription not found">
|
||||
<Button asChild>
|
||||
<Link to="/prescriptions">Back to prescriptions</Link>
|
||||
</Button>
|
||||
</DetailPageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const branch = branches.find((b) => b.id === rx.branchId)
|
||||
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 = {
|
||||
status: next,
|
||||
reviewedBy: user?.name ?? 'Pharmacist',
|
||||
reviewedAt: new Date().toISOString(),
|
||||
}
|
||||
setRx((prev) => (prev ? { ...prev, ...patch } : prev))
|
||||
const target = prescriptions.find((p) => p.id === rx.id)
|
||||
if (target) Object.assign(target, patch)
|
||||
toast.success(`Prescription ${next}`)
|
||||
}
|
||||
|
||||
const copyId = () => {
|
||||
void navigator.clipboard?.writeText(rx.id)
|
||||
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
|
||||
? [
|
||||
{
|
||||
label: 'Reject prescription',
|
||||
icon: <X className="h-3.5 w-3.5" />,
|
||||
variant: 'destructive' as const,
|
||||
onClick: () => act('rejected'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ label: 'Copy Rx ID', icon: <ClipboardCopy className="h-3.5 w-3.5" />, onClick: copyId },
|
||||
]
|
||||
|
||||
const decisionCluster = (
|
||||
<div className="flex justify-end gap-2">
|
||||
|
|
@ -146,7 +247,74 @@ export function PrescriptionDetailPage() {
|
|||
</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 (
|
||||
<>
|
||||
<DetailPageLayout
|
||||
backTo="/prescriptions"
|
||||
backLabel="Back to prescriptions"
|
||||
|
|
@ -156,6 +324,12 @@ export function PrescriptionDetailPage() {
|
|||
<>
|
||||
<StatusBadge domain="prescription" status={rx.status} />
|
||||
{controlled ? <Badge variant="danger">Controlled</Badge> : null}
|
||||
{openCount > 0 ? (
|
||||
<Badge variant="warning" className="gap-1">
|
||||
<Flag className="h-3 w-3" />
|
||||
{openCount} open flag{openCount === 1 ? '' : 's'}
|
||||
</Badge>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
|
|
@ -177,68 +351,7 @@ export function PrescriptionDetailPage() {
|
|||
}
|
||||
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,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
<ActivityTimeline events={trailEvents} />
|
||||
</DetailSection>
|
||||
}
|
||||
>
|
||||
|
|
@ -279,6 +392,74 @@ export function PrescriptionDetailPage() {
|
|||
</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>
|
||||
}
|
||||
>
|
||||
{flags.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No flags on this prescription yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{flags.map((flag) => (
|
||||
<li
|
||||
key={flag.id}
|
||||
className={
|
||||
flag.status === 'open'
|
||||
? 'rounded-xl border border-orange/25 bg-orange/5 px-4 py-3'
|
||||
: 'rounded-xl border bg-muted/30 px-4 py-3'
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant={severityBadgeVariant(flag.severity)} className="gap-1 capitalize">
|
||||
<Flag className="h-3 w-3" />
|
||||
{flag.severity}
|
||||
</Badge>
|
||||
<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">
|
||||
<KeyValueGrid
|
||||
columns={3}
|
||||
|
|
@ -299,6 +480,7 @@ export function PrescriptionDetailPage() {
|
|||
{ 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' },
|
||||
]}
|
||||
/>
|
||||
|
|
@ -334,9 +516,7 @@ export function PrescriptionDetailPage() {
|
|||
</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{formatDoseTimes(item.times)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{formatDoseTimes(item.times)}</TableCell>
|
||||
<TableCell>
|
||||
{item.controlled ? (
|
||||
<Badge variant="danger">Controlled</Badge>
|
||||
|
|
@ -361,5 +541,121 @@ export function PrescriptionDetailPage() {
|
|||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
Clock3,
|
||||
Eye,
|
||||
FileHeart,
|
||||
Flag,
|
||||
HelpCircle,
|
||||
Plus,
|
||||
ShieldAlert,
|
||||
|
|
@ -23,7 +24,15 @@ import { TableToolbar, type TableFilter } from '@/components/table/TableToolbar'
|
|||
import { RowActions, type RowAction } from '@/components/table/RowActions'
|
||||
import { useAuthStore } from '@/zustand/authStore'
|
||||
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']
|
||||
|
||||
|
|
@ -44,6 +53,8 @@ export function PrescriptionsPage() {
|
|||
const [status, setStatus] = useState('all')
|
||||
const [branchFilter, setBranchFilter] = useState('all')
|
||||
const [controlledOnly, setControlledOnly] = useState('all')
|
||||
const [flaggedFilter, setFlaggedFilter] = useState('all')
|
||||
const [flagTypeFilter, setFlagTypeFilter] = useState('all')
|
||||
|
||||
const canPickBranch = !(user?.role === 'pharmacist' && user.branchId)
|
||||
|
||||
|
|
@ -61,6 +72,7 @@ export function PrescriptionsPage() {
|
|||
open: scoped.filter((p) => OPEN_STATUSES.includes(p.status)).length,
|
||||
approved: scoped.filter((p) => p.status === 'approved').length,
|
||||
controlled: scoped.filter((p) => p.items.some((i) => i.controlled)).length,
|
||||
flagged: scoped.filter((p) => hasOpenFlags(p)).length,
|
||||
}
|
||||
}, [scoped])
|
||||
|
||||
|
|
@ -69,6 +81,12 @@ export function PrescriptionsPage() {
|
|||
if (canPickBranch && branchFilter !== 'all') list = list.filter((p) => p.branchId === branchFilter)
|
||||
if (status !== 'all') list = list.filter((p) => p.status === status)
|
||||
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) {
|
||||
const needle = q.toLowerCase()
|
||||
list = list.filter(
|
||||
|
|
@ -79,7 +97,16 @@ export function PrescriptionsPage() {
|
|||
)
|
||||
}
|
||||
return list
|
||||
}, [scoped, canPickBranch, q, status, branchFilter, controlledOnly])
|
||||
}, [
|
||||
scoped,
|
||||
canPickBranch,
|
||||
q,
|
||||
status,
|
||||
branchFilter,
|
||||
controlledOnly,
|
||||
flaggedFilter,
|
||||
flagTypeFilter,
|
||||
])
|
||||
|
||||
const act = (id: string, next: Prescription['status']) => {
|
||||
const patch = {
|
||||
|
|
@ -165,15 +192,37 @@ export function PrescriptionsPage() {
|
|||
{ 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 (
|
||||
<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="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="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>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
|
|
@ -188,6 +237,8 @@ export function PrescriptionsPage() {
|
|||
setStatus('all')
|
||||
setBranchFilter('all')
|
||||
setControlledOnly('all')
|
||||
setFlaggedFilter('all')
|
||||
setFlagTypeFilter('all')
|
||||
}}
|
||||
actions={
|
||||
<Button asChild className="h-10 gap-2 shadow-none">
|
||||
|
|
@ -206,6 +257,7 @@ export function PrescriptionsPage() {
|
|||
<TableHead>Customer</TableHead>
|
||||
<TableHead>Prescriber</TableHead>
|
||||
<TableHead>Items</TableHead>
|
||||
<TableHead>Flags</TableHead>
|
||||
<TableHead>{t('common.branch')}</TableHead>
|
||||
<TableHead>{t('common.status')}</TableHead>
|
||||
<TableHead className="text-right">{t('common.actions')}</TableHead>
|
||||
|
|
@ -214,13 +266,18 @@ export function PrescriptionsPage() {
|
|||
<TableBody>
|
||||
{filtered.length === 0 ? (
|
||||
<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')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
{filtered.map((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 (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-medium">
|
||||
|
|
@ -239,6 +296,26 @@ export function PrescriptionsPage() {
|
|||
))}
|
||||
</div>
|
||||
</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>
|
||||
<StatusBadge domain="prescription" status={p.status} />
|
||||
|
|
|
|||
|
|
@ -77,6 +77,37 @@ export interface PrescriptionItem {
|
|||
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 {
|
||||
id: string
|
||||
customerName: string
|
||||
|
|
@ -96,6 +127,8 @@ export interface Prescription {
|
|||
reviewedBy?: string
|
||||
reviewedAt?: string
|
||||
decisionNote?: string
|
||||
/** Operational / clinical review flags (open + resolved history). */
|
||||
flags?: PrescriptionFlag[]
|
||||
}
|
||||
|
||||
/** End-customer on an order — platform account vs walk-in / phone / guest. */
|
||||
|
|
|
|||
Reference in New Issue
Block a user