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")}
+
+ )}
+
+
+
+
+
+ );
+}