Google, phone OTP, and Telegram join email auth; prescription lines carry frequency, intervalHours, and times, with backend spec updated for this batch. Co-authored-by: Cursor <cursoragent@cursor.com>
366 lines
13 KiB
TypeScript
366 lines
13 KiB
TypeScript
import { useMemo, useState } from 'react'
|
||
import { Link, useParams } from 'react-router-dom'
|
||
import { toast } from 'sonner'
|
||
import { Check, ClipboardCopy, HelpCircle, X } from 'lucide-react'
|
||
import { DetailPageLayout } from '@/layouts/DetailPageLayout'
|
||
import { ActivityTimeline, DetailSection } 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 { StatusBadge, statusLabel } from '@/components/ui/status-badge'
|
||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||
import { branches, doctors, prescriptions } from '@/mocks/data'
|
||
import { useAuthStore } from '@/zustand/authStore'
|
||
import { formatDoseTimes, formatFrequencyLabel } from '@/lib/dosingSchedule'
|
||
import { formatDateTime } from '@/lib/utils'
|
||
import type { Prescription } 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[]>(() => {
|
||
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 = (
|
||
<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>
|
||
)
|
||
|
||
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}
|
||
</>
|
||
}
|
||
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={[
|
||
{
|
||
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">
|
||
{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="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: '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>
|
||
)
|
||
}
|