"use client"; import { CircleCheck, Clock, PackageCheck, Pill, Search, Trash2, 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 { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { Input } from "@/components/ui/input"; import { type Dispense, createDispense, deleteDispense, listDispenses, } from "@/lib/dispenses"; 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; } const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; // When an active course runs out, or null if it has no determinable end. An // explicit `endDate` wins; otherwise we add the parsed duration to the start // (the optional `startDate`, else `prescribedAt`). function expiresAt(rx: Prescription): Date | null { if (rx.endDate && ISO_DATE.test(rx.endDate)) { return new Date(`${rx.endDate}T00:00:00`); } const days = parseDurationDays(rx.duration); if (days == null) return null; const base = rx.startDate && ISO_DATE.test(rx.startDate) ? rx.startDate : rx.prescribedAt; const end = new Date(`${base}T00:00:00`); end.setDate(end.getDate() + days); return end; } // ISO timestamp -> "Jun 16, 2026, 2:30 PM" for the dispensed feed. function formatDispensedAt(iso: string): string { const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; return d.toLocaleString("en-US", { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit", }); } 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 [dispenses, setDispenses] = useState([]); const [selected, setSelected] = useState(null); const [sheetOpen, setSheetOpen] = useState(false); const [query, setQuery] = useState(""); // Dispense ledger entry staged for deletion (drives the confirm dialog). const [toDelete, setToDelete] = useState(null); useEffect(() => { let active = true; listPrescriptions() .then((data) => { if (active) setList(data); }) .catch(() => { /* api-client redirects on 401; otherwise leave the list empty */ }); listDispenses() .then((data) => { if (active) setDispenses(data); }) .catch(() => { /* leave the dispensed feed empty */ }); return () => { active = false; }; }, []); const now = new Date(); // Start of today, so a course ending today still counts as expiring (not // already elapsed by a few hours). const startOfToday = new Date(now); startOfToday.setHours(0, 0, 0, 0); // Only flag courses genuinely about to run out (≤2 days left), not every // freshly-issued short course — which made the badge fire on nearly every row. const soon = new Date(startOfToday); soon.setDate(soon.getDate() + 2); const isExpiringSoon = (rx: Prescription) => { if (rx.status !== "active") return false; const end = expiresAt(rx); return end != null && end >= startOfToday && 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); }; // Dispensing records who received which medication (the fulfilment ledger) and // completes the course. PUT requires the full record — resend with the new // status. const dispense = async (rx: Prescription) => { try { const record = await createDispense({ fileNumber: rx.fileNumber, name: rx.name, initials: rx.initials, medication: rx.medication, dose: rx.dose, prescriptionId: rx.id, }); 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, startDate: rx.startDate, endDate: rx.endDate, status: "completed", duration: rx.duration, notes: rx.notes, }); setList((prev) => prev.map((p) => (p.id === updated.id ? updated : p))); setDispenses((prev) => [record, ...prev]); notify.success( t("pharmacy.dispensedTitle"), t("pharmacy.dispensedBody", { medication: rx.medication, name: rx.name }), ); } catch { notify.error(t("pharmacy.completeFailedTitle"), t("pharmacy.completeFailedBody")); } }; // Remove a dispense ledger entry (a correction — the medication itself isn't // un-dispensed, just the record). const confirmDelete = async () => { if (!toDelete) return; const id = toDelete.id; try { await deleteDispense(id); setDispenses((prev) => prev.filter((d) => d.id !== id)); notify.success(t("pharmacy.dispensed.deletedTitle"), toDelete.medication); } catch { notify.error( t("pharmacy.dispensed.deleteFailedTitle"), t("pharmacy.dispensed.deleteFailedBody"), ); } finally { setToDelete(null); } }; 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) => ( dispense(rx)} onOpen={() => openRx(rx)} rx={rx} /> ))} {queue.length === 0 && (

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

)}

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

{t("pharmacy.dispensed.description")}

{dispenses.map((d) => (
{d.medication} {d.dose && ( {" "} · {d.dose} )} {d.name} {d.fileNumber ? ` · #${d.fileNumber}` : ""}
{d.dispensedByName || "—"} {formatDispensedAt(d.dispensedAt)}
))} {dispenses.length === 0 && (

{t("pharmacy.dispensed.empty")}

)}
{ if (!o) setToDelete(null); }} open={toDelete !== null} title={t("pharmacy.dispensed.deleteTitle")} />
); }