mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
frontend: clinical UI fixes + chat/composer polish
- chat composer: attach works with text (sr-only file input); lighter bordered input box - messages: defer "+" menu actions past close so file/appointment pickers fire - tasks: top-level New task button - chat: condensed appointment card with "View in calendar" deep-link; appointments page auto-opens the month calendar from ?calendar=&date= - lab: work-queue rows expand to show task detail + complete - inventory: clickable item detail dialog - pharmacy: Dispense action records a dispense + "Recently dispensed" feed; expiring badge uses endDate and only flags <=2 days left - prescriptions: keyboard nav in patient search, inventory-backed medication combobox (free-text fallback), optional start/end dates; thread dates through - sidebar: whole nav scrolls as one - i18n keys for all new strings Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
|||||||
Stethoscope,
|
Stethoscope,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -192,8 +193,14 @@ function Section({
|
|||||||
|
|
||||||
export function AppointmentsView() {
|
export function AppointmentsView() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
// Deep-link from the AI chat appointment card: `?calendar=1&date=YYYY-MM-DD`
|
||||||
|
// opens the month calendar on that date.
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const dateParam = searchParams.get("date");
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
const [calendarOpen, setCalendarOpen] = useState(false);
|
const [calendarOpen, setCalendarOpen] = useState(
|
||||||
|
() => searchParams.get("calendar") != null,
|
||||||
|
);
|
||||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [selectedAppt, setSelectedAppt] = useState<Appointment | null>(null);
|
const [selectedAppt, setSelectedAppt] = useState<Appointment | null>(null);
|
||||||
@@ -381,6 +388,7 @@ export function AppointmentsView() {
|
|||||||
|
|
||||||
<CalendarDialog
|
<CalendarDialog
|
||||||
appointments={appointments}
|
appointments={appointments}
|
||||||
|
initialDate={dateParam}
|
||||||
onOpenChange={setCalendarOpen}
|
onOpenChange={setCalendarOpen}
|
||||||
open={calendarOpen}
|
open={calendarOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -53,18 +53,32 @@ export function CalendarDialog({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
appointments,
|
appointments,
|
||||||
|
initialDate,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
appointments: Appointment[];
|
appointments: Appointment[];
|
||||||
|
// Optional deep-link target (YYYY-MM-DD): when set, the calendar opens on this
|
||||||
|
// date's month with the day selected (e.g. from the AI chat appointment card).
|
||||||
|
initialDate?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// First-of-month for the displayed month; defaults to TODAY's month.
|
const startKey =
|
||||||
|
initialDate && /^\d{4}-\d{2}-\d{2}$/.test(initialDate) ? initialDate : TODAY;
|
||||||
|
// First-of-month for the displayed month; defaults to the deep-link/TODAY month.
|
||||||
const [viewMonth, setViewMonth] = useState<Date>(() => {
|
const [viewMonth, setViewMonth] = useState<Date>(() => {
|
||||||
const t = parseKey(TODAY);
|
const d = parseKey(startKey);
|
||||||
return new Date(t.getFullYear(), t.getMonth(), 1);
|
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||||||
});
|
});
|
||||||
const [selectedKey, setSelectedKey] = useState<string>(TODAY);
|
const [selectedKey, setSelectedKey] = useState<string>(startKey);
|
||||||
|
|
||||||
|
// When opened via a deep-link date, jump the view to that month/day.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !initialDate || !/^\d{4}-\d{2}-\d{2}$/.test(initialDate)) return;
|
||||||
|
const d = parseKey(initialDate);
|
||||||
|
setViewMonth(new Date(d.getFullYear(), d.getMonth(), 1));
|
||||||
|
setSelectedKey(initialDate);
|
||||||
|
}, [open, initialDate]);
|
||||||
|
|
||||||
const byDate = useMemo(() => {
|
const byDate = useMemo(() => {
|
||||||
const map = new Map<string, Appointment[]>();
|
const map = new Map<string, Appointment[]>();
|
||||||
|
|||||||
@@ -106,10 +106,10 @@ export function ChatInput({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
submit();
|
submit();
|
||||||
}}
|
}}
|
||||||
className="w-full shrink-0 overflow-hidden rounded-[28px] border border-border bg-input shadow-sm"
|
className="w-full shrink-0 overflow-hidden rounded-[28px] border border-input bg-background shadow-sm transition-shadow focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/24 dark:bg-input/30"
|
||||||
>
|
>
|
||||||
{/* Textarea + toolbar, filling the rounded card. */}
|
{/* Textarea + toolbar, filling the rounded card. */}
|
||||||
<div className="bg-input">
|
<div>
|
||||||
<textarea
|
<textarea
|
||||||
aria-label={t("chat.input.message")}
|
aria-label={t("chat.input.message")}
|
||||||
className="field-sizing-content block max-h-48 min-h-16 w-full resize-none bg-transparent px-5 pt-5 pb-2 text-base text-foreground outline-none placeholder:text-muted-foreground"
|
className="field-sizing-content block max-h-48 min-h-16 w-full resize-none bg-transparent px-5 pt-5 pb-2 text-base text-foreground outline-none placeholder:text-muted-foreground"
|
||||||
@@ -141,12 +141,16 @@ export function ChatInput({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Visually hidden (not `display:none`) so programmatic `.click()` from
|
||||||
|
the attach button works reliably across browsers — a `display:none`
|
||||||
|
file input can refuse to open the picker. */}
|
||||||
<input
|
<input
|
||||||
aria-label={t("chat.input.attachFiles")}
|
aria-label={t("chat.input.attachFiles")}
|
||||||
className="hidden"
|
className="sr-only"
|
||||||
multiple
|
multiple
|
||||||
onChange={handleFilesSelected}
|
onChange={handleFilesSelected}
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
|
tabIndex={-1}
|
||||||
type="file"
|
type="file"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CalendarClock, ClipboardList, Pill } from "lucide-react";
|
import { CalendarClock, ChevronRight, ClipboardList, Pill } from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import type { Appointment } from "@/lib/appointments";
|
import type { Appointment } from "@/lib/appointments";
|
||||||
import { formatPrescribedAt, type Prescription } from "@/lib/prescriptions";
|
import { formatPrescribedAt, type Prescription } from "@/lib/prescriptions";
|
||||||
@@ -64,20 +66,101 @@ function Shell({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppointmentListCard({ appointments }: { appointments: Appointment[] }) {
|
// "2026-06-16" -> "Jun 16" (compact, for the date-range summary).
|
||||||
|
function shortDate(iso: string): string {
|
||||||
|
return new Date(`${iso}T00:00:00`).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appointments are rendered condensed: rather than one dense card per row (which
|
||||||
|
// floods the chat for a week's schedule), show a small summary + a few previews
|
||||||
|
// and send the clinician to the Appointments calendar for the full picture.
|
||||||
|
const APPT_PREVIEW = 3;
|
||||||
|
|
||||||
|
export function AppointmentListCard({
|
||||||
|
appointments,
|
||||||
|
}: {
|
||||||
|
appointments: Appointment[];
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const rows: Row[] = appointments.map((a) => ({
|
|
||||||
primary: a.name,
|
if (appointments.length === 0) {
|
||||||
secondary: [a.date, a.time, a.type, a.provider].filter(Boolean).join(" · "),
|
return (
|
||||||
badge: a.status,
|
<Shell
|
||||||
}));
|
emptyKey="chat.lists.noAppointments"
|
||||||
|
icon={CalendarClock}
|
||||||
|
rows={[]}
|
||||||
|
title={t("chat.lists.appointments")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dates = appointments.map((a) => a.date).filter(Boolean).sort();
|
||||||
|
const first = dates[0];
|
||||||
|
const last = dates[dates.length - 1];
|
||||||
|
const range = first
|
||||||
|
? first === last
|
||||||
|
? shortDate(first)
|
||||||
|
: `${shortDate(first)} – ${shortDate(last as string)}`
|
||||||
|
: "";
|
||||||
|
// Deep-link to the Appointments calendar, opened on the first appointment's
|
||||||
|
// month (the page reads `?calendar=1&date=`).
|
||||||
|
const href = first
|
||||||
|
? `/appointments?calendar=1&date=${first}`
|
||||||
|
: "/appointments?calendar=1";
|
||||||
|
const preview = appointments.slice(0, APPT_PREVIEW);
|
||||||
|
const remaining = appointments.length - preview.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell
|
<Card className="w-full gap-0 overflow-hidden p-0">
|
||||||
emptyKey="chat.lists.noAppointments"
|
<div className="flex items-center gap-2 border-b px-4 py-3">
|
||||||
icon={CalendarClock}
|
<CalendarClock className="size-4 text-muted-foreground" />
|
||||||
rows={rows}
|
<span className="font-medium text-sm">
|
||||||
title={t("chat.lists.appointments")}
|
{t("chat.lists.appointments")}
|
||||||
/>
|
</span>
|
||||||
|
{range ? (
|
||||||
|
<span className="text-muted-foreground text-xs">{range}</span>
|
||||||
|
) : null}
|
||||||
|
<Badge className="ml-auto" variant="secondary">
|
||||||
|
{appointments.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{preview.map((a, i) => (
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2.5" key={a.id ?? i}>
|
||||||
|
<span className="w-12 shrink-0 text-muted-foreground text-xs tabular-nums">
|
||||||
|
{a.time}
|
||||||
|
</span>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate font-medium text-foreground text-sm">
|
||||||
|
{a.name}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-muted-foreground text-xs">
|
||||||
|
{[shortDate(a.date), a.type, a.provider]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Badge className="shrink-0" variant="outline">
|
||||||
|
{a.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2 border-t px-3 py-2">
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{remaining > 0
|
||||||
|
? t("chat.lists.moreAppointments", { count: remaining })
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
<Button render={<Link href={href} />} size="sm" variant="ghost">
|
||||||
|
{t("chat.lists.viewInCalendar")}
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Check, FlaskConical, Plus, Search } from "lucide-react";
|
import { Check, ChevronDown, FlaskConical, Plus, Search } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
type FormEvent,
|
type FormEvent,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
@@ -14,6 +14,11 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
@@ -122,6 +127,16 @@ function CheckButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One labelled field in a work-queue item's expanded detail.
|
||||||
|
function QueueMeta({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<dt className="text-muted-foreground text-xs">{label}</dt>
|
||||||
|
<dd className="truncate text-foreground text-sm">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Dialog for submitting an analysis result: pick a patient, then enter the
|
// Dialog for submitting an analysis result: pick a patient, then enter the
|
||||||
// test name, value, flag and date. Posts to the lab-only append endpoint.
|
// test name, value, flag and date. Posts to the lab-only append endpoint.
|
||||||
function AddResultDialog({
|
function AddResultDialog({
|
||||||
@@ -541,39 +556,76 @@ export function LabView() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
queue.map((task) => (
|
queue.map((task) => (
|
||||||
<div className="flex items-center gap-3 px-4 py-3" key={task.id}>
|
<Collapsible key={task.id}>
|
||||||
<CheckButton
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
done={task.done}
|
<CheckButton
|
||||||
label={
|
done={task.done}
|
||||||
task.done ? t("tasks.markNotDone") : t("tasks.markDone")
|
label={
|
||||||
}
|
task.done ? t("tasks.markNotDone") : t("tasks.markDone")
|
||||||
onClick={() => toggle(task.id)}
|
}
|
||||||
/>
|
onClick={() => toggle(task.id)}
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
/>
|
||||||
<span
|
<CollapsibleTrigger className="group flex min-w-0 flex-1 items-center gap-3 text-left">
|
||||||
className={cn(
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
"truncate text-sm",
|
<span
|
||||||
task.done
|
className={cn(
|
||||||
? "text-muted-foreground line-through"
|
"truncate text-sm",
|
||||||
: "font-medium text-foreground",
|
task.done
|
||||||
)}
|
? "text-muted-foreground line-through"
|
||||||
>
|
: "font-medium text-foreground",
|
||||||
{task.title}
|
)}
|
||||||
</span>
|
>
|
||||||
<span className="truncate text-muted-foreground text-xs">
|
{task.title}
|
||||||
{task.due}
|
</span>
|
||||||
{task.createdByName
|
<span className="truncate text-muted-foreground text-xs">
|
||||||
? ` · ${t("tasks.list.byCreator", { name: task.createdByName })}`
|
{task.due}
|
||||||
: ""}
|
{task.createdByName
|
||||||
</span>
|
? ` · ${t("tasks.list.byCreator", { name: task.createdByName })}`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
className="shrink-0"
|
||||||
|
variant={priorityVariant[task.priority]}
|
||||||
|
>
|
||||||
|
{t(`tasks.priority.${task.priority}`)}
|
||||||
|
</Badge>
|
||||||
|
<ChevronDown className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[panel-open]:rotate-180" />
|
||||||
|
</CollapsibleTrigger>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<CollapsibleContent>
|
||||||
className="shrink-0"
|
<div className="space-y-3 px-4 pb-4 pl-12 text-sm">
|
||||||
variant={priorityVariant[task.priority]}
|
<p
|
||||||
>
|
className={cn(
|
||||||
{t(`tasks.priority.${task.priority}`)}
|
"whitespace-pre-wrap",
|
||||||
</Badge>
|
task.notes
|
||||||
</div>
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground italic",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{task.notes || t("lab.queue.noNotes")}
|
||||||
|
</p>
|
||||||
|
<dl className="grid grid-cols-1 gap-x-6 gap-y-1.5 sm:grid-cols-2">
|
||||||
|
<QueueMeta
|
||||||
|
label={t("lab.queue.meta.status")}
|
||||||
|
value={t(`tasks.status.${task.status}`)}
|
||||||
|
/>
|
||||||
|
<QueueMeta
|
||||||
|
label={t("lab.queue.meta.patient")}
|
||||||
|
value={task.patient || "—"}
|
||||||
|
/>
|
||||||
|
<QueueMeta
|
||||||
|
label={t("lab.queue.meta.assignee")}
|
||||||
|
value={task.assignee || task.assigneeRole || "—"}
|
||||||
|
/>
|
||||||
|
<QueueMeta
|
||||||
|
label={t("lab.queue.meta.createdBy")}
|
||||||
|
value={task.createdByName || "—"}
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -626,12 +626,15 @@ export function MessagesView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Visually hidden (not `display:none`) so the programmatic
|
||||||
|
`.click()` reliably opens the picker across browsers. */}
|
||||||
<input
|
<input
|
||||||
aria-label={t("messages.attach.file")}
|
aria-label={t("messages.attach.file")}
|
||||||
className="hidden"
|
className="sr-only"
|
||||||
multiple
|
multiple
|
||||||
onChange={onPickFiles}
|
onChange={onPickFiles}
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
|
tabIndex={-1}
|
||||||
type="file"
|
type="file"
|
||||||
/>
|
/>
|
||||||
<Menu>
|
<Menu>
|
||||||
@@ -649,11 +652,20 @@ export function MessagesView() {
|
|||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
</MenuTrigger>
|
</MenuTrigger>
|
||||||
<MenuPopup align="start" side="top">
|
<MenuPopup align="start" side="top">
|
||||||
<MenuItem onClick={() => fileInputRef.current?.click()}>
|
{/* Defer past the menu's close/unmount so the file picker
|
||||||
|
and the appointment dialog aren't swallowed by the
|
||||||
|
closing overlay's focus handling. */}
|
||||||
|
<MenuItem
|
||||||
|
onClick={() =>
|
||||||
|
requestAnimationFrame(() => fileInputRef.current?.click())
|
||||||
|
}
|
||||||
|
>
|
||||||
<FileText className="size-4 text-muted-foreground" />
|
<FileText className="size-4 text-muted-foreground" />
|
||||||
{t("messages.attach.file")}
|
{t("messages.attach.file")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem onClick={openApptPicker}>
|
<MenuItem
|
||||||
|
onClick={() => requestAnimationFrame(openApptPicker)}
|
||||||
|
>
|
||||||
<CalendarClock className="size-4 text-muted-foreground" />
|
<CalendarClock className="size-4 text-muted-foreground" />
|
||||||
{t("messages.attach.appointment")}
|
{t("messages.attach.appointment")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Pill } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogPanel,
|
||||||
|
DialogPopup,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
type Availability,
|
||||||
|
type InventoryItem,
|
||||||
|
availabilityOf,
|
||||||
|
} from "@/lib/inventory";
|
||||||
|
|
||||||
|
const availabilityVariant: Record<
|
||||||
|
Availability,
|
||||||
|
"success" | "warning" | "destructive"
|
||||||
|
> = {
|
||||||
|
"in-stock": "success",
|
||||||
|
low: "warning",
|
||||||
|
out: "destructive",
|
||||||
|
};
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4 py-2">
|
||||||
|
<span className="text-muted-foreground text-sm">{label}</span>
|
||||||
|
<span className="text-right text-foreground text-sm">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-only detail for a single inventory item, opened by clicking a row on the
|
||||||
|
// inventory list. Editing stock is a later step; this surfaces all the fields a
|
||||||
|
// quick row can't show (reorder threshold, location, expiry, notes).
|
||||||
|
export function InventoryDetailDialog({
|
||||||
|
item,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
item: InventoryItem | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (!item) return null;
|
||||||
|
const availability = availabilityOf(item);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||||
|
<DialogPopup className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg border bg-background text-muted-foreground">
|
||||||
|
<Pill className="size-4" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 truncate">{item.name}</span>
|
||||||
|
<Badge
|
||||||
|
className="ml-auto shrink-0"
|
||||||
|
variant={availabilityVariant[availability]}
|
||||||
|
>
|
||||||
|
{t(`inventory.availability.${availability}`)}
|
||||||
|
</Badge>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{[item.strength, item.form].filter(Boolean).join(" · ") ||
|
||||||
|
t("inventory.detail.description")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogPanel>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
<Row
|
||||||
|
label={t("inventory.dialog.stock")}
|
||||||
|
value={t("inventory.stock", {
|
||||||
|
count: item.stockQuantity,
|
||||||
|
unit: item.unit || "units",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label={t("inventory.dialog.reorder")}
|
||||||
|
value={item.reorderThreshold || "—"}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label={t("inventory.dialog.form")}
|
||||||
|
value={item.form || "—"}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label={t("inventory.dialog.strength")}
|
||||||
|
value={item.strength || "—"}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label={t("inventory.dialog.location")}
|
||||||
|
value={item.location || "—"}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label={t("inventory.dialog.expires")}
|
||||||
|
value={item.expiresAt || "—"}
|
||||||
|
/>
|
||||||
|
{item.notes ? (
|
||||||
|
<div className="flex flex-col gap-1 py-2">
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{t("inventory.detail.notes")}
|
||||||
|
</span>
|
||||||
|
<span className="whitespace-pre-wrap text-foreground text-sm">
|
||||||
|
{item.notes}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose render={<Button type="button" variant="outline" />}>
|
||||||
|
{t("inventory.detail.close")}
|
||||||
|
</DialogClose>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogPopup>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { AddInventoryDialog } from "@/components/pharmacy/add-inventory-dialog";
|
import { AddInventoryDialog } from "@/components/pharmacy/add-inventory-dialog";
|
||||||
|
import { InventoryDetailDialog } from "@/components/pharmacy/inventory-detail-dialog";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
@@ -52,12 +53,29 @@ function Kpi({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ItemRow({ item }: { item: InventoryItem }) {
|
function ItemRow({
|
||||||
|
item,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
item: InventoryItem;
|
||||||
|
onOpen: () => void;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const availability = availabilityOf(item);
|
const availability = availabilityOf(item);
|
||||||
const descriptor = [item.strength, item.form].filter(Boolean).join(" · ");
|
const descriptor = [item.strength, item.form].filter(Boolean).join(" · ");
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 px-4 py-3">
|
<div
|
||||||
|
className="flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/50"
|
||||||
|
onClick={onOpen}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
onOpen();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg border bg-background text-muted-foreground">
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg border bg-background text-muted-foreground">
|
||||||
<Pill className="size-4" />
|
<Pill className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
@@ -96,6 +114,13 @@ export function InventoryView() {
|
|||||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<InventoryItem | null>(null);
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
|
||||||
|
const openItem = (item: InventoryItem) => {
|
||||||
|
setSelected(item);
|
||||||
|
setDetailOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
// Persist a new item, then prepend the saved record so it appears immediately.
|
// Persist a new item, then prepend the saved record so it appears immediately.
|
||||||
const addItem = async (input: InventoryInput) => {
|
const addItem = async (input: InventoryInput) => {
|
||||||
@@ -180,6 +205,12 @@ export function InventoryView() {
|
|||||||
open={addOpen}
|
open={addOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<InventoryDetailDialog
|
||||||
|
item={selected}
|
||||||
|
onOpenChange={setDetailOpen}
|
||||||
|
open={detailOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
{kpis.map((k) => (
|
{kpis.map((k) => (
|
||||||
<Kpi key={k.label} {...k} />
|
<Kpi key={k.label} {...k} />
|
||||||
@@ -197,7 +228,7 @@ export function InventoryView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
|
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
|
||||||
{results.map((item) => (
|
{results.map((item) => (
|
||||||
<ItemRow item={item} key={item.id} />
|
<ItemRow item={item} key={item.id} onOpen={() => openItem(item)} />
|
||||||
))}
|
))}
|
||||||
{results.length === 0 && (
|
{results.length === 0 && (
|
||||||
<p className="p-6 text-center text-muted-foreground text-sm">
|
<p className="p-6 text-center text-muted-foreground text-sm">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CircleCheck, Clock, Pill, Search, Users } from "lucide-react";
|
import { CircleCheck, Clock, PackageCheck, Pill, Search, Users } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -10,6 +10,11 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
type Dispense,
|
||||||
|
createDispense,
|
||||||
|
listDispenses,
|
||||||
|
} from "@/lib/dispenses";
|
||||||
import {
|
import {
|
||||||
type Prescription,
|
type Prescription,
|
||||||
formatPrescribedAt,
|
formatPrescribedAt,
|
||||||
@@ -32,15 +37,39 @@ function parseDurationDays(duration: string | null): number | null {
|
|||||||
return n * unit;
|
return n * unit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// When an active course runs out, or null if it has no parseable end.
|
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 {
|
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);
|
const days = parseDurationDays(rx.duration);
|
||||||
if (days == null) return null;
|
if (days == null) return null;
|
||||||
const end = new Date(`${rx.prescribedAt}T00:00:00`);
|
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);
|
end.setDate(end.getDate() + days);
|
||||||
return end;
|
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({
|
function Kpi({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -123,7 +152,7 @@ function QueueRow({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
<CircleCheck className="size-4" />
|
<CircleCheck className="size-4" />
|
||||||
{t("pharmacy.markCompleted")}
|
{t("pharmacy.dispense")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -136,6 +165,7 @@ function QueueRow({
|
|||||||
export function PharmacyView() {
|
export function PharmacyView() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [list, setList] = useState<Prescription[]>([]);
|
const [list, setList] = useState<Prescription[]>([]);
|
||||||
|
const [dispenses, setDispenses] = useState<Dispense[]>([]);
|
||||||
const [selected, setSelected] = useState<Prescription | null>(null);
|
const [selected, setSelected] = useState<Prescription | null>(null);
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
@@ -149,6 +179,13 @@ export function PharmacyView() {
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* api-client redirects on 401; otherwise leave the list empty */
|
/* api-client redirects on 401; otherwise leave the list empty */
|
||||||
});
|
});
|
||||||
|
listDispenses()
|
||||||
|
.then((data) => {
|
||||||
|
if (active) setDispenses(data);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* leave the dispensed feed empty */
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
@@ -159,12 +196,13 @@ export function PharmacyView() {
|
|||||||
// already elapsed by a few hours).
|
// already elapsed by a few hours).
|
||||||
const startOfToday = new Date(now);
|
const startOfToday = new Date(now);
|
||||||
startOfToday.setHours(0, 0, 0, 0);
|
startOfToday.setHours(0, 0, 0, 0);
|
||||||
const soon = new Date(now);
|
// Only flag courses genuinely about to run out (≤2 days left), not every
|
||||||
soon.setDate(soon.getDate() + 7);
|
// 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) => {
|
const isExpiringSoon = (rx: Prescription) => {
|
||||||
if (rx.status !== "active") return false;
|
if (rx.status !== "active") return false;
|
||||||
const end = expiresAt(rx);
|
const end = expiresAt(rx);
|
||||||
// Only courses ending in the next 7 days — not ones that already elapsed.
|
|
||||||
return end != null && end >= startOfToday && end <= soon;
|
return end != null && end >= startOfToday && end <= soon;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -212,9 +250,19 @@ export function PharmacyView() {
|
|||||||
setSheetOpen(true);
|
setSheetOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// PUT requires the full record — resend the row with the new status.
|
// Dispensing records who received which medication (the fulfilment ledger) and
|
||||||
const markCompleted = async (rx: Prescription) => {
|
// completes the course. PUT requires the full record — resend with the new
|
||||||
|
// status.
|
||||||
|
const dispense = async (rx: Prescription) => {
|
||||||
try {
|
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, {
|
const updated = await updatePrescription(rx.id, {
|
||||||
fileNumber: rx.fileNumber,
|
fileNumber: rx.fileNumber,
|
||||||
name: rx.name,
|
name: rx.name,
|
||||||
@@ -224,14 +272,17 @@ export function PharmacyView() {
|
|||||||
frequency: rx.frequency,
|
frequency: rx.frequency,
|
||||||
prescriber: rx.prescriber,
|
prescriber: rx.prescriber,
|
||||||
prescribedAt: rx.prescribedAt,
|
prescribedAt: rx.prescribedAt,
|
||||||
|
startDate: rx.startDate,
|
||||||
|
endDate: rx.endDate,
|
||||||
status: "completed",
|
status: "completed",
|
||||||
duration: rx.duration,
|
duration: rx.duration,
|
||||||
notes: rx.notes,
|
notes: rx.notes,
|
||||||
});
|
});
|
||||||
setList((prev) => prev.map((p) => (p.id === updated.id ? updated : p)));
|
setList((prev) => prev.map((p) => (p.id === updated.id ? updated : p)));
|
||||||
|
setDispenses((prev) => [record, ...prev]);
|
||||||
notify.success(
|
notify.success(
|
||||||
t("pharmacy.completedTitle"),
|
t("pharmacy.dispensedTitle"),
|
||||||
t("pharmacy.completedBody", { medication: rx.medication, name: rx.name }),
|
t("pharmacy.dispensedBody", { medication: rx.medication, name: rx.name }),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
notify.error(t("pharmacy.completeFailedTitle"), t("pharmacy.completeFailedBody"));
|
notify.error(t("pharmacy.completeFailedTitle"), t("pharmacy.completeFailedBody"));
|
||||||
@@ -278,7 +329,7 @@ export function PharmacyView() {
|
|||||||
<QueueRow
|
<QueueRow
|
||||||
expiring={isExpiringSoon(rx)}
|
expiring={isExpiringSoon(rx)}
|
||||||
key={rx.id}
|
key={rx.id}
|
||||||
onComplete={() => markCompleted(rx)}
|
onComplete={() => dispense(rx)}
|
||||||
onOpen={() => openRx(rx)}
|
onOpen={() => openRx(rx)}
|
||||||
rx={rx}
|
rx={rx}
|
||||||
/>
|
/>
|
||||||
@@ -291,6 +342,54 @@ export function PharmacyView() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-lg tracking-tight">
|
||||||
|
{t("pharmacy.dispensed.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t("pharmacy.dispensed.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
|
||||||
|
{dispenses.map((d) => (
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3" key={d.id}>
|
||||||
|
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg border bg-background text-muted-foreground">
|
||||||
|
<PackageCheck className="size-4" />
|
||||||
|
</span>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate font-medium text-foreground text-sm">
|
||||||
|
{d.medication}
|
||||||
|
{d.dose && (
|
||||||
|
<span className="font-normal text-muted-foreground">
|
||||||
|
{" "}
|
||||||
|
· {d.dose}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-muted-foreground text-xs">
|
||||||
|
{d.name}
|
||||||
|
{d.fileNumber ? ` · #${d.fileNumber}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="hidden min-w-0 flex-col items-end sm:flex">
|
||||||
|
<span className="truncate text-foreground text-xs">
|
||||||
|
{d.dispensedByName || "—"}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{formatDispensedAt(d.dispensedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{dispenses.length === 0 && (
|
||||||
|
<p className="p-6 text-center text-muted-foreground text-sm">
|
||||||
|
{t("pharmacy.dispensed.empty")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<PrescriptionDetailSheet
|
<PrescriptionDetailSheet
|
||||||
onOpenChange={setSheetOpen}
|
onOpenChange={setSheetOpen}
|
||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AlertTriangle, Search } from "lucide-react";
|
import { AlertTriangle, CalendarPlus, Search, X } from "lucide-react";
|
||||||
import { type FormEvent, type ReactNode, useEffect, useMemo, useState } from "react";
|
import {
|
||||||
|
type FormEvent,
|
||||||
|
type KeyboardEvent,
|
||||||
|
type ReactNode,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
@@ -18,8 +25,10 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { type InventoryItem, listInventory } from "@/lib/inventory";
|
||||||
import { listPatients, type Patient } from "@/lib/patients";
|
import { listPatients, type Patient } from "@/lib/patients";
|
||||||
import { notify } from "@/lib/toast";
|
import { notify } from "@/lib/toast";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type NewPrescription = {
|
export type NewPrescription = {
|
||||||
fileNumber: string;
|
fileNumber: string;
|
||||||
@@ -29,6 +38,8 @@ export type NewPrescription = {
|
|||||||
dose: string;
|
dose: string;
|
||||||
frequency: string;
|
frequency: string;
|
||||||
duration: string;
|
duration: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,16 +136,25 @@ export function AddPrescriptionDialog({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [patients, setPatients] = useState<Patient[]>([]);
|
const [patients, setPatients] = useState<Patient[]>([]);
|
||||||
|
const [inventory, setInventory] = useState<InventoryItem[]>([]);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const [selected, setSelected] = useState<Patient | null>(null);
|
const [selected, setSelected] = useState<Patient | null>(null);
|
||||||
const [medication, setMedication] = useState("");
|
const [medication, setMedication] = useState("");
|
||||||
|
const [medFocused, setMedFocused] = useState(false);
|
||||||
|
const [medIndex, setMedIndex] = useState(0);
|
||||||
const [dose, setDose] = useState("");
|
const [dose, setDose] = useState("");
|
||||||
const [frequency, setFrequency] = useState(FREQUENCIES[0]);
|
const [frequency, setFrequency] = useState(FREQUENCIES[0]);
|
||||||
const [durationChoice, setDurationChoice] = useState(DURATIONS[0]);
|
const [durationChoice, setDurationChoice] = useState(DURATIONS[0]);
|
||||||
const [durationCustom, setDurationCustom] = useState("");
|
const [durationCustom, setDurationCustom] = useState("");
|
||||||
|
const [showDates, setShowDates] = useState(false);
|
||||||
|
const [startDate, setStartDate] = useState("");
|
||||||
|
const [endDate, setEndDate] = useState("");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
// Load patients lazily when the dialog opens (for the quick search).
|
// Load patients + inventory lazily when the dialog opens (for the quick
|
||||||
|
// searches). Inventory backs the medication combobox; it still accepts free
|
||||||
|
// text when there's no match (or no stock recorded yet).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -145,6 +165,13 @@ export function AddPrescriptionDialog({
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* search just stays empty */
|
/* search just stays empty */
|
||||||
});
|
});
|
||||||
|
listInventory()
|
||||||
|
.then((data) => {
|
||||||
|
if (active) setInventory(data);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* no inventory → medication stays free text */
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
@@ -160,6 +187,76 @@ export function AddPrescriptionDialog({
|
|||||||
.slice(0, 6);
|
.slice(0, 6);
|
||||||
}, [patients, query]);
|
}, [patients, query]);
|
||||||
|
|
||||||
|
// Inventory suggestions for the medication field: match by name/strength while
|
||||||
|
// typing, but never block free text (no exact match required).
|
||||||
|
const medMatches = useMemo(() => {
|
||||||
|
const q = medication.trim().toLowerCase();
|
||||||
|
if (!q) return [];
|
||||||
|
return inventory
|
||||||
|
.filter(
|
||||||
|
(i) =>
|
||||||
|
i.name.toLowerCase().includes(q) ||
|
||||||
|
`${i.name} ${i.strength}`.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
.filter((i) => `${i.name} ${i.strength}`.trim().toLowerCase() !== q)
|
||||||
|
.slice(0, 6);
|
||||||
|
}, [inventory, medication]);
|
||||||
|
|
||||||
|
// Keep the highlighted option in range as the result lists change.
|
||||||
|
useEffect(() => setActiveIndex(0), [query]);
|
||||||
|
useEffect(() => setMedIndex(0), [medication]);
|
||||||
|
|
||||||
|
const pickPatient = (p: Patient) => {
|
||||||
|
setSelected(p);
|
||||||
|
setQuery("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPatientKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (matches.length === 0) return;
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveIndex((i) => Math.min(i + 1, matches.length - 1));
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||||
|
} else if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
const match = matches[activeIndex];
|
||||||
|
if (match) pickPatient(match);
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fill the medication (and dose, when the item carries a strength) from an
|
||||||
|
// inventory suggestion.
|
||||||
|
const pickMedication = (item: InventoryItem) => {
|
||||||
|
setMedication([item.name, item.strength].filter(Boolean).join(" ").trim());
|
||||||
|
if (item.strength && !dose.trim()) setDose(item.strength);
|
||||||
|
setMedFocused(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMedKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (medMatches.length === 0) return;
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
setMedIndex((i) => Math.min(i + 1, medMatches.length - 1));
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
setMedIndex((i) => Math.max(i - 1, 0));
|
||||||
|
} else if (event.key === "Enter") {
|
||||||
|
// Only intercept Enter when a suggestion is highlighted; otherwise let the
|
||||||
|
// free-text value stand (and don't submit the form).
|
||||||
|
const match = medMatches[medIndex];
|
||||||
|
if (match) {
|
||||||
|
event.preventDefault();
|
||||||
|
pickMedication(match);
|
||||||
|
}
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
setMedFocused(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const conflicts = useMemo(
|
const conflicts = useMemo(
|
||||||
() => (selected ? findConflicts(medication, selected) : []),
|
() => (selected ? findConflicts(medication, selected) : []),
|
||||||
[medication, selected],
|
[medication, selected],
|
||||||
@@ -167,12 +264,18 @@ export function AddPrescriptionDialog({
|
|||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
|
setActiveIndex(0);
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setMedication("");
|
setMedication("");
|
||||||
|
setMedFocused(false);
|
||||||
|
setMedIndex(0);
|
||||||
setDose("");
|
setDose("");
|
||||||
setFrequency(FREQUENCIES[0]);
|
setFrequency(FREQUENCIES[0]);
|
||||||
setDurationChoice(DURATIONS[0]);
|
setDurationChoice(DURATIONS[0]);
|
||||||
setDurationCustom("");
|
setDurationCustom("");
|
||||||
|
setShowDates(false);
|
||||||
|
setStartDate("");
|
||||||
|
setEndDate("");
|
||||||
setNotes("");
|
setNotes("");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -201,6 +304,14 @@ export function AddPrescriptionDialog({
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// When both optional dates are set, the end must not precede the start.
|
||||||
|
if (showDates && startDate && endDate && endDate < startDate) {
|
||||||
|
notify.error(
|
||||||
|
t("prescriptions.dialog.badDatesTitle"),
|
||||||
|
t("prescriptions.dialog.badDatesBody"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
onAdd({
|
onAdd({
|
||||||
fileNumber: selected.fileNumber,
|
fileNumber: selected.fileNumber,
|
||||||
name: selected.name,
|
name: selected.name,
|
||||||
@@ -209,6 +320,8 @@ export function AddPrescriptionDialog({
|
|||||||
dose: dose.trim(),
|
dose: dose.trim(),
|
||||||
frequency,
|
frequency,
|
||||||
duration,
|
duration,
|
||||||
|
startDate: showDates ? startDate : "",
|
||||||
|
endDate: showDates ? endDate : "",
|
||||||
notes: notes.trim(),
|
notes: notes.trim(),
|
||||||
});
|
});
|
||||||
notify.success(
|
notify.success(
|
||||||
@@ -267,9 +380,11 @@ export function AddPrescriptionDialog({
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
aria-autocomplete="list"
|
||||||
autoFocus
|
autoFocus
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
onKeyDown={onPatientKeyDown}
|
||||||
placeholder={t("prescriptions.dialog.searchPlaceholder")}
|
placeholder={t("prescriptions.dialog.searchPlaceholder")}
|
||||||
value={query}
|
value={query}
|
||||||
/>
|
/>
|
||||||
@@ -281,14 +396,17 @@ export function AddPrescriptionDialog({
|
|||||||
{t("prescriptions.dialog.noPatients")}
|
{t("prescriptions.dialog.noPatients")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
matches.map((p) => (
|
matches.map((p, i) => (
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-accent"
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left transition-colors",
|
||||||
|
i === activeIndex
|
||||||
|
? "bg-accent"
|
||||||
|
: "hover:bg-accent",
|
||||||
|
)}
|
||||||
key={p.fileNumber}
|
key={p.fileNumber}
|
||||||
onClick={() => {
|
onClick={() => pickPatient(p)}
|
||||||
setSelected(p);
|
onMouseEnter={() => setActiveIndex(i)}
|
||||||
setQuery("");
|
|
||||||
}}
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span className="truncate text-foreground text-sm">
|
<span className="truncate text-foreground text-sm">
|
||||||
@@ -307,11 +425,61 @@ export function AddPrescriptionDialog({
|
|||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label={t("prescriptions.dialog.medication")}>
|
<Field label={t("prescriptions.dialog.medication")}>
|
||||||
<Input
|
<div className="relative">
|
||||||
onChange={(event) => setMedication(event.target.value)}
|
<Input
|
||||||
placeholder={t("prescriptions.dialog.medicationPlaceholder")}
|
aria-autocomplete="list"
|
||||||
value={medication}
|
onBlur={() => setTimeout(() => setMedFocused(false), 120)}
|
||||||
/>
|
onChange={(event) => setMedication(event.target.value)}
|
||||||
|
onFocus={() => setMedFocused(true)}
|
||||||
|
onKeyDown={onMedKeyDown}
|
||||||
|
placeholder={t("prescriptions.dialog.medicationPlaceholder")}
|
||||||
|
value={medication}
|
||||||
|
/>
|
||||||
|
{medFocused && medMatches.length > 0 && (
|
||||||
|
<div className="absolute z-10 mt-1 max-h-56 w-full overflow-y-auto rounded-2xl border bg-popover p-1 shadow-md">
|
||||||
|
{medMatches.map((item, i) => {
|
||||||
|
const out = item.stockQuantity <= 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left transition-colors",
|
||||||
|
i === medIndex ? "bg-accent" : "hover:bg-accent",
|
||||||
|
)}
|
||||||
|
// Use onMouseDown so the pick fires before the input's
|
||||||
|
// blur closes the list.
|
||||||
|
key={item.id}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
pickMedication(item);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setMedIndex(i)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="truncate text-foreground text-sm">
|
||||||
|
{[item.name, item.strength]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 text-xs",
|
||||||
|
out
|
||||||
|
? "text-destructive"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{out
|
||||||
|
? t("prescriptions.dialog.outOfStock")
|
||||||
|
: t("prescriptions.dialog.inStock", {
|
||||||
|
count: item.stockQuantity,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
@@ -359,6 +527,60 @@ export function AddPrescriptionDialog({
|
|||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
{/* Optional explicit course window. When set, the end date drives
|
||||||
|
expiry on the pharmacy queue instead of parsing the duration. */}
|
||||||
|
{showDates ? (
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border bg-input/20 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{t("prescriptions.dialog.courseDates")}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 text-muted-foreground text-xs transition-colors hover:text-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
setShowDates(false);
|
||||||
|
setStartDate("");
|
||||||
|
setEndDate("");
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
{t("prescriptions.dialog.removeDates")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label={t("prescriptions.dialog.startDate")}>
|
||||||
|
<input
|
||||||
|
className={controlClass}
|
||||||
|
onChange={(event) => setStartDate(event.target.value)}
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("prescriptions.dialog.endDate")}>
|
||||||
|
<input
|
||||||
|
className={controlClass}
|
||||||
|
min={startDate || undefined}
|
||||||
|
onChange={(event) => setEndDate(event.target.value)}
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
className="self-start"
|
||||||
|
onClick={() => setShowDates(true)}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<CalendarPlus className="size-4" />
|
||||||
|
{t("prescriptions.dialog.addDates")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Field label={t("prescriptions.dialog.notes")}>
|
<Field label={t("prescriptions.dialog.notes")}>
|
||||||
<Textarea
|
<Textarea
|
||||||
onChange={(event) => setNotes(event.target.value)}
|
onChange={(event) => setNotes(event.target.value)}
|
||||||
|
|||||||
@@ -89,6 +89,21 @@ export function PrescriptionDetailSheet({
|
|||||||
{t("prescriptions.detail.duration")}
|
{t("prescriptions.detail.duration")}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-foreground">{rx.duration || "—"}</dd>
|
<dd className="text-foreground">{rx.duration || "—"}</dd>
|
||||||
|
{(rx.startDate || rx.endDate) && (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted-foreground">
|
||||||
|
{t("prescriptions.detail.courseDates")}
|
||||||
|
</dt>
|
||||||
|
<dd className="text-foreground">
|
||||||
|
{[
|
||||||
|
rx.startDate ? formatPrescribedAt(rx.startDate) : null,
|
||||||
|
rx.endDate ? formatPrescribedAt(rx.endDate) : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" → ") || "—"}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<dt className="text-muted-foreground">
|
<dt className="text-muted-foreground">
|
||||||
{t("prescriptions.detail.prescriber")}
|
{t("prescriptions.detail.prescriber")}
|
||||||
</dt>
|
</dt>
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ export function PrescriptionsView() {
|
|||||||
dose: rx.dose,
|
dose: rx.dose,
|
||||||
frequency: rx.frequency,
|
frequency: rx.frequency,
|
||||||
duration: rx.duration || null,
|
duration: rx.duration || null,
|
||||||
|
startDate: rx.startDate || null,
|
||||||
|
endDate: rx.endDate || null,
|
||||||
notes: rx.notes || null,
|
notes: rx.notes || null,
|
||||||
});
|
});
|
||||||
setList((prev) => [created, ...prev]);
|
setList((prev) => [created, ...prev]);
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function NavChatHistory() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-col gap-0.5 overflow-y-auto px-2">
|
<div className="flex flex-col gap-0.5 px-2">
|
||||||
<span className="px-2 py-1 font-medium text-muted-foreground text-xs">
|
<span className="px-2 py-1 font-medium text-muted-foreground text-xs">
|
||||||
{t("chat.history.title")}
|
{t("chat.history.title")}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -389,11 +389,17 @@ export function TasksView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col gap-6 px-6 py-8">
|
<div className="flex h-full w-full flex-col gap-6 px-6 py-8">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<h1 className="font-semibold text-2xl tracking-tight">
|
<div className="flex flex-col gap-1">
|
||||||
{t("tasks.title")}
|
<h1 className="font-semibold text-2xl tracking-tight">
|
||||||
</h1>
|
{t("tasks.title")}
|
||||||
<p className="text-muted-foreground text-sm">{t("tasks.subtitle")}</p>
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">{t("tasks.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => openAdd("todo")} type="button">
|
||||||
|
<Plus className="size-4" />
|
||||||
|
{t("tasks.board.newTask")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 md:grid-cols-3">
|
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
|||||||
@@ -415,7 +415,10 @@ export function SidebarContent({
|
|||||||
<ScrollArea className="min-h-0 flex-1" fill scrollFade>
|
<ScrollArea className="min-h-0 flex-1" fill scrollFade>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full flex-col gap-2 group-data-[collapsible=icon]:overflow-hidden",
|
// `min-h-full` (not `h-full`) so the content can grow taller than the
|
||||||
|
// viewport and the surrounding ScrollArea actually scrolls, rather than
|
||||||
|
// pinning to the viewport height and clipping a tall nav.
|
||||||
|
"flex min-h-full flex-col gap-2 group-data-[collapsible=icon]:overflow-hidden",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
data-sidebar="content"
|
data-sidebar="content"
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { apiFetch } from "@/lib/api-client";
|
||||||
|
|
||||||
|
// A dispensing event — the record of a medication handed to a patient at the
|
||||||
|
// pharmacy. Mirrors the backend `src/types/dispense.ts`. Scoped to the active
|
||||||
|
// clinic; patient + dispenser fields are denormalized for display.
|
||||||
|
export type Dispense = {
|
||||||
|
id: string;
|
||||||
|
fileNumber: string;
|
||||||
|
name: string;
|
||||||
|
initials: string;
|
||||||
|
medication: string;
|
||||||
|
dose: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
prescriptionId: string | null;
|
||||||
|
dispensedBy: string | null;
|
||||||
|
dispensedByName: string;
|
||||||
|
dispensedAt: string; // ISO timestamp
|
||||||
|
notes: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The fields the dispense action collects; the backend fills the dispenser
|
||||||
|
// identity (from the signed-in user) and the timestamp.
|
||||||
|
export type DispenseInput = {
|
||||||
|
fileNumber: string;
|
||||||
|
name: string;
|
||||||
|
initials?: string;
|
||||||
|
medication: string;
|
||||||
|
dose?: string;
|
||||||
|
quantity?: number;
|
||||||
|
unit?: string;
|
||||||
|
prescriptionId?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function listDispenses(): Promise<Dispense[]> {
|
||||||
|
return apiFetch<Dispense[]>("/api/dispenses");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDispense(input: DispenseInput): Promise<Dispense> {
|
||||||
|
return apiFetch<Dispense>("/api/dispenses", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -385,7 +385,8 @@
|
|||||||
"prescriber": "Prescriber",
|
"prescriber": "Prescriber",
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"notes": "Notes"
|
"notes": "Notes",
|
||||||
|
"courseDates": "Course"
|
||||||
},
|
},
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"title": "New prescription",
|
"title": "New prescription",
|
||||||
@@ -413,7 +414,16 @@
|
|||||||
"needMedBody": "Enter the medication name.",
|
"needMedBody": "Enter the medication name.",
|
||||||
"needDurationTitle": "Add a duration",
|
"needDurationTitle": "Add a duration",
|
||||||
"needDurationBody": "Enter the custom duration.",
|
"needDurationBody": "Enter the custom duration.",
|
||||||
"addedTitle": "Prescription added"
|
"addedTitle": "Prescription added",
|
||||||
|
"badDatesTitle": "Check the dates",
|
||||||
|
"badDatesBody": "The end date can't be before the start date.",
|
||||||
|
"outOfStock": "Out of stock",
|
||||||
|
"inStock": "{{count}} in stock",
|
||||||
|
"courseDates": "Course dates",
|
||||||
|
"removeDates": "Remove",
|
||||||
|
"startDate": "Start date",
|
||||||
|
"endDate": "End date",
|
||||||
|
"addDates": "Add start/end dates"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pharmacy": {
|
"pharmacy": {
|
||||||
@@ -422,7 +432,7 @@
|
|||||||
"searchPlaceholder": "Search prescriptions",
|
"searchPlaceholder": "Search prescriptions",
|
||||||
"kpi": {
|
"kpi": {
|
||||||
"active": "Active prescriptions",
|
"active": "Active prescriptions",
|
||||||
"expiring": "Expiring within 7 days",
|
"expiring": "Expiring soon",
|
||||||
"patients": "Patients on medication"
|
"patients": "Patients on medication"
|
||||||
},
|
},
|
||||||
"queue": {
|
"queue": {
|
||||||
@@ -436,7 +446,15 @@
|
|||||||
"completedTitle": "Prescription completed",
|
"completedTitle": "Prescription completed",
|
||||||
"completedBody": "{{medication}} for {{name}} marked as completed.",
|
"completedBody": "{{medication}} for {{name}} marked as completed.",
|
||||||
"completeFailedTitle": "Couldn't update prescription",
|
"completeFailedTitle": "Couldn't update prescription",
|
||||||
"completeFailedBody": "Please try again."
|
"completeFailedBody": "Please try again.",
|
||||||
|
"dispense": "Dispense",
|
||||||
|
"dispensedTitle": "Dispensed",
|
||||||
|
"dispensedBody": "{{medication}} dispensed to {{name}}.",
|
||||||
|
"dispensed": {
|
||||||
|
"title": "Recently dispensed",
|
||||||
|
"description": "Medications handed to patients at the pharmacy.",
|
||||||
|
"empty": "Nothing dispensed yet."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"inventory": {
|
"inventory": {
|
||||||
"title": "Inventory",
|
"title": "Inventory",
|
||||||
@@ -485,6 +503,11 @@
|
|||||||
"addedTitle": "Item added",
|
"addedTitle": "Item added",
|
||||||
"failedTitle": "Could not add item",
|
"failedTitle": "Could not add item",
|
||||||
"failedBody": "Something went wrong. Please try again."
|
"failedBody": "Something went wrong. Please try again."
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"description": "Stock item",
|
||||||
|
"notes": "Notes",
|
||||||
|
"close": "Close"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lab": {
|
"lab": {
|
||||||
@@ -493,7 +516,14 @@
|
|||||||
"queue": {
|
"queue": {
|
||||||
"title": "Work queue",
|
"title": "Work queue",
|
||||||
"description": "Tasks assigned to the Lab department.",
|
"description": "Tasks assigned to the Lab department.",
|
||||||
"empty": "No lab tasks right now."
|
"empty": "No lab tasks right now.",
|
||||||
|
"noNotes": "No details provided.",
|
||||||
|
"meta": {
|
||||||
|
"status": "Status",
|
||||||
|
"patient": "Patient",
|
||||||
|
"assignee": "Assignee",
|
||||||
|
"createdBy": "Created by"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"addResult": {
|
"addResult": {
|
||||||
"button": "Add result",
|
"button": "Add result",
|
||||||
@@ -550,7 +580,8 @@
|
|||||||
"board": {
|
"board": {
|
||||||
"addTask": "Add task",
|
"addTask": "Add task",
|
||||||
"moveTo": "Move to",
|
"moveTo": "Move to",
|
||||||
"emptyColumn": "No tasks."
|
"emptyColumn": "No tasks.",
|
||||||
|
"newTask": "New task"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"all": "All",
|
"all": "All",
|
||||||
@@ -884,7 +915,9 @@
|
|||||||
"noTasks": "No tasks.",
|
"noTasks": "No tasks.",
|
||||||
"noPrescriptions": "No prescriptions.",
|
"noPrescriptions": "No prescriptions.",
|
||||||
"inventory": "Inventory",
|
"inventory": "Inventory",
|
||||||
"noInventory": "No inventory items."
|
"noInventory": "No inventory items.",
|
||||||
|
"moreAppointments": "+{{count}} more",
|
||||||
|
"viewInCalendar": "View in calendar"
|
||||||
},
|
},
|
||||||
"clinicCard": {
|
"clinicCard": {
|
||||||
"title": "Clinic",
|
"title": "Clinic",
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export type Prescription = {
|
|||||||
frequency: string;
|
frequency: string;
|
||||||
prescriber: string;
|
prescriber: string;
|
||||||
prescribedAt: string; // YYYY-MM-DD
|
prescribedAt: string; // YYYY-MM-DD
|
||||||
|
startDate: string | null; // YYYY-MM-DD, optional course start
|
||||||
|
endDate: string | null; // YYYY-MM-DD, optional course end (drives expiry)
|
||||||
status: RxStatus;
|
status: RxStatus;
|
||||||
duration: string | null;
|
duration: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
@@ -35,6 +37,8 @@ export type PrescriptionInput = {
|
|||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
prescriber?: string;
|
prescriber?: string;
|
||||||
prescribedAt?: string;
|
prescribedAt?: string;
|
||||||
|
startDate?: string | null;
|
||||||
|
endDate?: string | null;
|
||||||
status?: RxStatus;
|
status?: RxStatus;
|
||||||
source?: "manual" | "ai";
|
source?: "manual" | "ai";
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user