diff --git a/frontend/app/(app)/pharmacy/page.tsx b/frontend/app/(app)/pharmacy/page.tsx new file mode 100644 index 0000000..25ca851 --- /dev/null +++ b/frontend/app/(app)/pharmacy/page.tsx @@ -0,0 +1,10 @@ +import { PharmacyView } from "@/components/pharmacy/pharmacy-view"; +import { SidebarInset } from "@/components/ui/sidebar"; + +export default function PharmacyPage() { + return ( + + + + ); +} diff --git a/frontend/components/pharmacy/pharmacy-view.tsx b/frontend/components/pharmacy/pharmacy-view.tsx new file mode 100644 index 0000000..bf73e05 --- /dev/null +++ b/frontend/components/pharmacy/pharmacy-view.tsx @@ -0,0 +1,296 @@ +"use client"; + +import { CircleCheck, Clock, Pill, Search, Users } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { PrescriptionDetailSheet } from "@/components/prescriptions/prescription-detail-sheet"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + type Prescription, + formatPrescribedAt, + listPrescriptions, + updatePrescription, +} from "@/lib/prescriptions"; +import { notify } from "@/lib/toast"; + +// "3 days" / "14 days" / "1 month" → a course length in days; null for +// open-ended values ("Ongoing", "As needed", free text we can't parse). +function parseDurationDays(duration: string | null): number | null { + if (!duration) return null; + const match = duration + .trim() + .toLowerCase() + .match(/^(\d+)\s*(day|week|month)s?$/); + if (!match) return null; + const n = Number(match[1]); + const unit = match[2] === "day" ? 1 : match[2] === "week" ? 7 : 30; + return n * unit; +} + +// When an active course runs out, or null if it has no parseable end. +function expiresAt(rx: Prescription): Date | null { + const days = parseDurationDays(rx.duration); + if (days == null) return null; + const end = new Date(`${rx.prescribedAt}T00:00:00`); + end.setDate(end.getDate() + days); + return end; +} + +function Kpi({ + label, + value, + icon: Icon, +}: { + label: string; + value: string; + icon: typeof Pill; +}) { + return ( + +
+ +
+
+ {label} + + {value} + +
+
+ ); +} + +function QueueRow({ + rx, + expiring, + onOpen, + onComplete, +}: { + rx: Prescription; + expiring: boolean; + onOpen: () => void; + onComplete: () => void; +}) { + const { t } = useTranslation(); + return ( +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onOpen(); + } + }} + role="button" + tabIndex={0} + > + + {rx.initials} + +
+ + {rx.medication} + {rx.dose && ( + · {rx.dose} + )} + + + {rx.name} · #{rx.fileNumber} · {rx.frequency} + +
+
+ {rx.prescriber} + + {formatPrescribedAt(rx.prescribedAt)} + +
+ {expiring && ( + {t("pharmacy.expiringBadge")} + )} + +
+ ); +} + +// The pharmacy department home: a dispensing work queue over the clinic's +// prescriptions. Pharmacy holds prescription read/write (no delete and no +// create UI — prescribing stays with clinicians), so the only action here is +// completing a course. +export function PharmacyView() { + const { t } = useTranslation(); + const [list, setList] = useState([]); + const [selected, setSelected] = useState(null); + const [sheetOpen, setSheetOpen] = useState(false); + const [query, setQuery] = useState(""); + + useEffect(() => { + let active = true; + listPrescriptions() + .then((data) => { + if (active) setList(data); + }) + .catch(() => { + /* api-client redirects on 401; otherwise leave the list empty */ + }); + return () => { + active = false; + }; + }, []); + + const now = new Date(); + const soon = new Date(now); + soon.setDate(soon.getDate() + 7); + const isExpiringSoon = (rx: Prescription) => { + if (rx.status !== "active") return false; + const end = expiresAt(rx); + return end != null && end <= soon; + }; + + const active = useMemo( + () => list.filter((rx) => rx.status === "active"), + [list], + ); + + // The dispensing queue: active prescriptions, oldest first. + const search = query.trim().toLowerCase(); + const queue = useMemo(() => { + const base = [...active].sort((a, b) => + a.prescribedAt.localeCompare(b.prescribedAt), + ); + if (!search) return base; + return base.filter( + (rx) => + rx.name.toLowerCase().includes(search) || + rx.fileNumber.includes(search) || + rx.medication.toLowerCase().includes(search) || + rx.prescriber.toLowerCase().includes(search), + ); + }, [active, search]); + + const kpis = [ + { + label: t("pharmacy.kpi.active"), + value: String(active.length), + icon: Pill, + }, + { + label: t("pharmacy.kpi.expiring"), + value: String(active.filter(isExpiringSoon).length), + icon: Clock, + }, + { + label: t("pharmacy.kpi.patients"), + value: String(new Set(active.map((rx) => rx.fileNumber)).size), + icon: Users, + }, + ]; + + const openRx = (rx: Prescription) => { + setSelected(rx); + setSheetOpen(true); + }; + + // PUT requires the full record — resend the row with the new status. + const markCompleted = async (rx: Prescription) => { + try { + const updated = await updatePrescription(rx.id, { + fileNumber: rx.fileNumber, + name: rx.name, + initials: rx.initials, + medication: rx.medication, + dose: rx.dose, + frequency: rx.frequency, + prescriber: rx.prescriber, + prescribedAt: rx.prescribedAt, + status: "completed", + duration: rx.duration, + notes: rx.notes, + }); + setList((prev) => prev.map((p) => (p.id === updated.id ? updated : p))); + notify.success( + t("pharmacy.completedTitle"), + t("pharmacy.completedBody", { medication: rx.medication, name: rx.name }), + ); + } catch { + notify.error(t("pharmacy.completeFailedTitle"), t("pharmacy.completeFailedBody")); + } + }; + + return ( +
+
+
+

+ {t("pharmacy.title")} +

+

{t("pharmacy.subtitle")}

+
+
+ + setQuery(event.target.value)} + placeholder={t("pharmacy.searchPlaceholder")} + value={query} + /> +
+
+ +
+ {kpis.map((k) => ( + + ))} +
+ +
+
+

+ {t("pharmacy.queue.title")} +

+

+ {t("pharmacy.queue.description")} +

+
+
+ {queue.map((rx) => ( + markCompleted(rx)} + onOpen={() => openRx(rx)} + rx={rx} + /> + ))} + {queue.length === 0 && ( +

+ {search ? t("pharmacy.queue.noMatches") : t("pharmacy.queue.empty")} +

+ )} +
+
+ + +
+ ); +}