This repository has been archived on 2026-08-11. You can view files and clone it, but cannot push or open issues or pull requests.
Gishen-Admin/src/pages/prescriptions/PrescriptionDetailPage.tsx
kirukib 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

662 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, type FormEvent } from 'react'
import { Link, useParams } from 'react-router-dom'
import { toast } from 'sonner'
import { Check, ClipboardCopy, Flag, HelpCircle, Plus, X } from 'lucide-react'
import { DetailPageLayout } from '@/layouts/DetailPageLayout'
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,
PrescriptionFlag,
PrescriptionFlagCode,
PrescriptionFlagSeverity,
} from '@/types'
const OPEN_STATUSES: Prescription['status'][] = ['submitted', 'under_review', 'queried']
function buildSteps(rx: Prescription): ApprovalStep[] {
const decided = rx.status === 'approved' || rx.status === 'rejected'
const reviewSeen = decided || rx.status === 'queried'
return [
{
key: 'submitted',
label: 'Submitted',
state: 'done',
actor: rx.customerName,
at: rx.submittedAt,
},
{
key: 'review',
label: 'Pharmacist review',
state: reviewSeen ? 'done' : 'current',
actor: reviewSeen ? rx.reviewedBy : rx.reviewStartedAt ? 'Review in progress' : undefined,
at: rx.reviewStartedAt,
hint: rx.status === 'submitted' ? 'Waiting to be picked up from the queue' : undefined,
},
{
key: 'decision',
label:
rx.status === 'approved'
? 'Approved for dispensing'
: rx.status === 'rejected'
? 'Rejected'
: rx.status === 'queried'
? 'Queried with customer'
: 'Decision',
state:
rx.status === 'approved'
? 'done'
: rx.status === 'rejected'
? 'failed'
: rx.status === 'queried'
? 'current'
: 'upcoming',
actor:
rx.status === 'queried' || rx.status === 'approved' || rx.status === 'rejected'
? rx.reviewedBy
: undefined,
at: rx.status === 'submitted' || rx.status === 'under_review' ? undefined : rx.reviewedAt,
hint: rx.status === 'queried' ? 'Awaiting a clearer scan from the customer' : undefined,
},
]
}
export function PrescriptionDetailPage() {
const { id } = useParams()
const user = useAuthStore((s) => s.user)
const seed = prescriptions.find((p) => p.id === id)
const [rx, setRx] = useState<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">
{isOpen ? (
<>
<Button
type="button"
variant="ghost"
className="h-10 gap-1.5 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => act('rejected')}
>
<X className="h-4 w-4" />
Reject
</Button>
<Button type="button" variant="outline" className="h-10 gap-1.5" onClick={() => act('queried')}>
<HelpCircle className="h-4 w-4" />
Query
</Button>
<Button type="button" className="h-10 gap-1.5" onClick={() => act('approved')}>
<Check className="h-4 w-4" />
Approve
</Button>
</>
) : (
<Button asChild variant="outline" className="h-10">
<Link to="/prescriptions">Back to the review queue</Link>
</Button>
)}
</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"
title={rx.id}
subtitle={`${rx.customerName} · ${rx.prescriber ?? 'Unknown prescriber'}`}
badges={
<>
<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={
<>
{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>
}
>
{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}
items={[
{ label: 'Branch', value: branch?.name },
{
label: 'Prescriber',
value: doctor ? (
<Link to={`/doctors/${doctor.id}`} className="text-brand-700 hover:underline">
{doctor.name}
</Link>
) : (
rx.prescriber ?? '—'
),
},
{ label: 'Hospital', value: doctor?.orgName ?? '—' },
{ label: 'License', value: doctor?.licenseNumber ?? '—' },
{ label: 'Submitted', value: formatDateTime(rx.submittedAt) },
{ label: 'Items', value: String(rx.items.length) },
{ label: 'Controlled items', value: controlled ? 'Yes — stamp required' : 'None' },
{ label: 'Open flags', value: String(openCount) },
{ label: 'Reviewed by', value: rx.reviewedBy ?? 'Not reviewed yet' },
]}
/>
</DetailSection>
<DetailSection
title="Medications"
description="Verify strength, qty, dosing schedule, and controlled flags"
>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Medication</TableHead>
<TableHead>Qty</TableHead>
<TableHead>Interval</TableHead>
<TableHead>Dose times</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>
))}
</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>
</>
)
}