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,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -192,8 +193,14 @@ function Section({
|
||||
|
||||
export function AppointmentsView() {
|
||||
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 [calendarOpen, setCalendarOpen] = useState(false);
|
||||
const [calendarOpen, setCalendarOpen] = useState(
|
||||
() => searchParams.get("calendar") != null,
|
||||
);
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedAppt, setSelectedAppt] = useState<Appointment | null>(null);
|
||||
@@ -381,6 +388,7 @@ export function AppointmentsView() {
|
||||
|
||||
<CalendarDialog
|
||||
appointments={appointments}
|
||||
initialDate={dateParam}
|
||||
onOpenChange={setCalendarOpen}
|
||||
open={calendarOpen}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
@@ -53,18 +53,32 @@ export function CalendarDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
appointments,
|
||||
initialDate,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
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();
|
||||
// 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 t = parseKey(TODAY);
|
||||
return new Date(t.getFullYear(), t.getMonth(), 1);
|
||||
const d = parseKey(startKey);
|
||||
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 map = new Map<string, Appointment[]>();
|
||||
|
||||
@@ -106,10 +106,10 @@ export function ChatInput({
|
||||
event.preventDefault();
|
||||
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. */}
|
||||
<div className="bg-input">
|
||||
<div>
|
||||
<textarea
|
||||
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"
|
||||
@@ -141,12 +141,16 @@ export function ChatInput({
|
||||
</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
|
||||
aria-label={t("chat.input.attachFiles")}
|
||||
className="hidden"
|
||||
className="sr-only"
|
||||
multiple
|
||||
onChange={handleFilesSelected}
|
||||
ref={fileInputRef}
|
||||
tabIndex={-1}
|
||||
type="file"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { CalendarClock, ClipboardList, Pill } from "lucide-react";
|
||||
import { CalendarClock, ChevronRight, ClipboardList, Pill } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { Appointment } from "@/lib/appointments";
|
||||
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 rows: Row[] = appointments.map((a) => ({
|
||||
primary: a.name,
|
||||
secondary: [a.date, a.time, a.type, a.provider].filter(Boolean).join(" · "),
|
||||
badge: a.status,
|
||||
}));
|
||||
|
||||
if (appointments.length === 0) {
|
||||
return (
|
||||
<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 (
|
||||
<Shell
|
||||
emptyKey="chat.lists.noAppointments"
|
||||
icon={CalendarClock}
|
||||
rows={rows}
|
||||
title={t("chat.lists.appointments")}
|
||||
/>
|
||||
<Card className="w-full gap-0 overflow-hidden p-0">
|
||||
<div className="flex items-center gap-2 border-b px-4 py-3">
|
||||
<CalendarClock className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">
|
||||
{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";
|
||||
|
||||
import { Check, FlaskConical, Plus, Search } from "lucide-react";
|
||||
import { Check, ChevronDown, FlaskConical, Plus, Search } from "lucide-react";
|
||||
import {
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
@@ -14,6 +14,11 @@ import { useTranslation } from "react-i18next";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Dialog,
|
||||
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
|
||||
// test name, value, flag and date. Posts to the lab-only append endpoint.
|
||||
function AddResultDialog({
|
||||
@@ -541,39 +556,76 @@ export function LabView() {
|
||||
</div>
|
||||
) : (
|
||||
queue.map((task) => (
|
||||
<div className="flex items-center gap-3 px-4 py-3" key={task.id}>
|
||||
<CheckButton
|
||||
done={task.done}
|
||||
label={
|
||||
task.done ? t("tasks.markNotDone") : t("tasks.markDone")
|
||||
}
|
||||
onClick={() => toggle(task.id)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-sm",
|
||||
task.done
|
||||
? "text-muted-foreground line-through"
|
||||
: "font-medium text-foreground",
|
||||
)}
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
{task.due}
|
||||
{task.createdByName
|
||||
? ` · ${t("tasks.list.byCreator", { name: task.createdByName })}`
|
||||
: ""}
|
||||
</span>
|
||||
<Collapsible key={task.id}>
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<CheckButton
|
||||
done={task.done}
|
||||
label={
|
||||
task.done ? t("tasks.markNotDone") : t("tasks.markDone")
|
||||
}
|
||||
onClick={() => toggle(task.id)}
|
||||
/>
|
||||
<CollapsibleTrigger className="group flex min-w-0 flex-1 items-center gap-3 text-left">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-sm",
|
||||
task.done
|
||||
? "text-muted-foreground line-through"
|
||||
: "font-medium text-foreground",
|
||||
)}
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
{task.due}
|
||||
{task.createdByName
|
||||
? ` · ${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>
|
||||
<Badge
|
||||
className="shrink-0"
|
||||
variant={priorityVariant[task.priority]}
|
||||
>
|
||||
{t(`tasks.priority.${task.priority}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<div className="space-y-3 px-4 pb-4 pl-12 text-sm">
|
||||
<p
|
||||
className={cn(
|
||||
"whitespace-pre-wrap",
|
||||
task.notes
|
||||
? "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>
|
||||
|
||||
@@ -626,12 +626,15 @@ export function MessagesView() {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Visually hidden (not `display:none`) so the programmatic
|
||||
`.click()` reliably opens the picker across browsers. */}
|
||||
<input
|
||||
aria-label={t("messages.attach.file")}
|
||||
className="hidden"
|
||||
className="sr-only"
|
||||
multiple
|
||||
onChange={onPickFiles}
|
||||
ref={fileInputRef}
|
||||
tabIndex={-1}
|
||||
type="file"
|
||||
/>
|
||||
<Menu>
|
||||
@@ -649,11 +652,20 @@ export function MessagesView() {
|
||||
<Plus className="size-4" />
|
||||
</MenuTrigger>
|
||||
<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" />
|
||||
{t("messages.attach.file")}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={openApptPicker}>
|
||||
<MenuItem
|
||||
onClick={() => requestAnimationFrame(openApptPicker)}
|
||||
>
|
||||
<CalendarClock className="size-4 text-muted-foreground" />
|
||||
{t("messages.attach.appointment")}
|
||||
</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 { AddInventoryDialog } from "@/components/pharmacy/add-inventory-dialog";
|
||||
import { InventoryDetailDialog } from "@/components/pharmacy/inventory-detail-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 availability = availabilityOf(item);
|
||||
const descriptor = [item.strength, item.form].filter(Boolean).join(" · ");
|
||||
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">
|
||||
<Pill className="size-4" />
|
||||
</div>
|
||||
@@ -96,6 +114,13 @@ export function InventoryView() {
|
||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
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.
|
||||
const addItem = async (input: InventoryInput) => {
|
||||
@@ -180,6 +205,12 @@ export function InventoryView() {
|
||||
open={addOpen}
|
||||
/>
|
||||
|
||||
<InventoryDetailDialog
|
||||
item={selected}
|
||||
onOpenChange={setDetailOpen}
|
||||
open={detailOpen}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{kpis.map((k) => (
|
||||
<Kpi key={k.label} {...k} />
|
||||
@@ -197,7 +228,7 @@ export function InventoryView() {
|
||||
</div>
|
||||
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
|
||||
{results.map((item) => (
|
||||
<ItemRow item={item} key={item.id} />
|
||||
<ItemRow item={item} key={item.id} onOpen={() => openItem(item)} />
|
||||
))}
|
||||
{results.length === 0 && (
|
||||
<p className="p-6 text-center text-muted-foreground text-sm">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"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 { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -10,6 +10,11 @@ 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 Dispense,
|
||||
createDispense,
|
||||
listDispenses,
|
||||
} from "@/lib/dispenses";
|
||||
import {
|
||||
type Prescription,
|
||||
formatPrescribedAt,
|
||||
@@ -32,15 +37,39 @@ function parseDurationDays(duration: string | null): number | null {
|
||||
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 {
|
||||
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 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);
|
||||
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,
|
||||
@@ -123,7 +152,7 @@ function QueueRow({
|
||||
variant="outline"
|
||||
>
|
||||
<CircleCheck className="size-4" />
|
||||
{t("pharmacy.markCompleted")}
|
||||
{t("pharmacy.dispense")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -136,6 +165,7 @@ function QueueRow({
|
||||
export function PharmacyView() {
|
||||
const { t } = useTranslation();
|
||||
const [list, setList] = useState<Prescription[]>([]);
|
||||
const [dispenses, setDispenses] = useState<Dispense[]>([]);
|
||||
const [selected, setSelected] = useState<Prescription | null>(null);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -149,6 +179,13 @@ export function PharmacyView() {
|
||||
.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;
|
||||
};
|
||||
@@ -159,12 +196,13 @@ export function PharmacyView() {
|
||||
// already elapsed by a few hours).
|
||||
const startOfToday = new Date(now);
|
||||
startOfToday.setHours(0, 0, 0, 0);
|
||||
const soon = new Date(now);
|
||||
soon.setDate(soon.getDate() + 7);
|
||||
// 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);
|
||||
// Only courses ending in the next 7 days — not ones that already elapsed.
|
||||
return end != null && end >= startOfToday && end <= soon;
|
||||
};
|
||||
|
||||
@@ -212,9 +250,19 @@ export function PharmacyView() {
|
||||
setSheetOpen(true);
|
||||
};
|
||||
|
||||
// PUT requires the full record — resend the row with the new status.
|
||||
const markCompleted = async (rx: Prescription) => {
|
||||
// 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,
|
||||
@@ -224,14 +272,17 @@ export function PharmacyView() {
|
||||
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.completedTitle"),
|
||||
t("pharmacy.completedBody", { medication: rx.medication, name: rx.name }),
|
||||
t("pharmacy.dispensedTitle"),
|
||||
t("pharmacy.dispensedBody", { medication: rx.medication, name: rx.name }),
|
||||
);
|
||||
} catch {
|
||||
notify.error(t("pharmacy.completeFailedTitle"), t("pharmacy.completeFailedBody"));
|
||||
@@ -278,7 +329,7 @@ export function PharmacyView() {
|
||||
<QueueRow
|
||||
expiring={isExpiringSoon(rx)}
|
||||
key={rx.id}
|
||||
onComplete={() => markCompleted(rx)}
|
||||
onComplete={() => dispense(rx)}
|
||||
onOpen={() => openRx(rx)}
|
||||
rx={rx}
|
||||
/>
|
||||
@@ -291,6 +342,54 @@ export function PharmacyView() {
|
||||
</div>
|
||||
</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
|
||||
onOpenChange={setSheetOpen}
|
||||
open={sheetOpen}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, Search } from "lucide-react";
|
||||
import { type FormEvent, type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { AlertTriangle, CalendarPlus, Search, X } from "lucide-react";
|
||||
import {
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
@@ -18,8 +25,10 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { type InventoryItem, listInventory } from "@/lib/inventory";
|
||||
import { listPatients, type Patient } from "@/lib/patients";
|
||||
import { notify } from "@/lib/toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type NewPrescription = {
|
||||
fileNumber: string;
|
||||
@@ -29,6 +38,8 @@ export type NewPrescription = {
|
||||
dose: string;
|
||||
frequency: string;
|
||||
duration: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
@@ -125,16 +136,25 @@ export function AddPrescriptionDialog({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [inventory, setInventory] = useState<InventoryItem[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [selected, setSelected] = useState<Patient | null>(null);
|
||||
const [medication, setMedication] = useState("");
|
||||
const [medFocused, setMedFocused] = useState(false);
|
||||
const [medIndex, setMedIndex] = useState(0);
|
||||
const [dose, setDose] = useState("");
|
||||
const [frequency, setFrequency] = useState(FREQUENCIES[0]);
|
||||
const [durationChoice, setDurationChoice] = useState(DURATIONS[0]);
|
||||
const [durationCustom, setDurationCustom] = useState("");
|
||||
const [showDates, setShowDates] = useState(false);
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = 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(() => {
|
||||
if (!open) return;
|
||||
let active = true;
|
||||
@@ -145,6 +165,13 @@ export function AddPrescriptionDialog({
|
||||
.catch(() => {
|
||||
/* search just stays empty */
|
||||
});
|
||||
listInventory()
|
||||
.then((data) => {
|
||||
if (active) setInventory(data);
|
||||
})
|
||||
.catch(() => {
|
||||
/* no inventory → medication stays free text */
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
@@ -160,6 +187,76 @@ export function AddPrescriptionDialog({
|
||||
.slice(0, 6);
|
||||
}, [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(
|
||||
() => (selected ? findConflicts(medication, selected) : []),
|
||||
[medication, selected],
|
||||
@@ -167,12 +264,18 @@ export function AddPrescriptionDialog({
|
||||
|
||||
const reset = () => {
|
||||
setQuery("");
|
||||
setActiveIndex(0);
|
||||
setSelected(null);
|
||||
setMedication("");
|
||||
setMedFocused(false);
|
||||
setMedIndex(0);
|
||||
setDose("");
|
||||
setFrequency(FREQUENCIES[0]);
|
||||
setDurationChoice(DURATIONS[0]);
|
||||
setDurationCustom("");
|
||||
setShowDates(false);
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
setNotes("");
|
||||
};
|
||||
|
||||
@@ -201,6 +304,14 @@ export function AddPrescriptionDialog({
|
||||
);
|
||||
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({
|
||||
fileNumber: selected.fileNumber,
|
||||
name: selected.name,
|
||||
@@ -209,6 +320,8 @@ export function AddPrescriptionDialog({
|
||||
dose: dose.trim(),
|
||||
frequency,
|
||||
duration,
|
||||
startDate: showDates ? startDate : "",
|
||||
endDate: showDates ? endDate : "",
|
||||
notes: notes.trim(),
|
||||
});
|
||||
notify.success(
|
||||
@@ -267,9 +380,11 @@ export function AddPrescriptionDialog({
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
aria-autocomplete="list"
|
||||
autoFocus
|
||||
className="pl-9"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={onPatientKeyDown}
|
||||
placeholder={t("prescriptions.dialog.searchPlaceholder")}
|
||||
value={query}
|
||||
/>
|
||||
@@ -281,14 +396,17 @@ export function AddPrescriptionDialog({
|
||||
{t("prescriptions.dialog.noPatients")}
|
||||
</p>
|
||||
) : (
|
||||
matches.map((p) => (
|
||||
matches.map((p, i) => (
|
||||
<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}
|
||||
onClick={() => {
|
||||
setSelected(p);
|
||||
setQuery("");
|
||||
}}
|
||||
onClick={() => pickPatient(p)}
|
||||
onMouseEnter={() => setActiveIndex(i)}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate text-foreground text-sm">
|
||||
@@ -307,11 +425,61 @@ export function AddPrescriptionDialog({
|
||||
</Field>
|
||||
|
||||
<Field label={t("prescriptions.dialog.medication")}>
|
||||
<Input
|
||||
onChange={(event) => setMedication(event.target.value)}
|
||||
placeholder={t("prescriptions.dialog.medicationPlaceholder")}
|
||||
value={medication}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
aria-autocomplete="list"
|
||||
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>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -359,6 +527,60 @@ export function AddPrescriptionDialog({
|
||||
)}
|
||||
</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")}>
|
||||
<Textarea
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
|
||||
@@ -89,6 +89,21 @@ export function PrescriptionDetailSheet({
|
||||
{t("prescriptions.detail.duration")}
|
||||
</dt>
|
||||
<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">
|
||||
{t("prescriptions.detail.prescriber")}
|
||||
</dt>
|
||||
|
||||
@@ -162,6 +162,8 @@ export function PrescriptionsView() {
|
||||
dose: rx.dose,
|
||||
frequency: rx.frequency,
|
||||
duration: rx.duration || null,
|
||||
startDate: rx.startDate || null,
|
||||
endDate: rx.endDate || null,
|
||||
notes: rx.notes || null,
|
||||
});
|
||||
setList((prev) => [created, ...prev]);
|
||||
|
||||
@@ -50,7 +50,7 @@ export function NavChatHistory() {
|
||||
};
|
||||
|
||||
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">
|
||||
{t("chat.history.title")}
|
||||
</span>
|
||||
|
||||
@@ -389,11 +389,17 @@ export function TasksView() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col gap-6 px-6 py-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="font-semibold text-2xl tracking-tight">
|
||||
{t("tasks.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">{t("tasks.subtitle")}</p>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="font-semibold text-2xl tracking-tight">
|
||||
{t("tasks.title")}
|
||||
</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 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>
|
||||
<div
|
||||
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,
|
||||
)}
|
||||
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",
|
||||
"date": "Date",
|
||||
"status": "Status",
|
||||
"notes": "Notes"
|
||||
"notes": "Notes",
|
||||
"courseDates": "Course"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "New prescription",
|
||||
@@ -413,7 +414,16 @@
|
||||
"needMedBody": "Enter the medication name.",
|
||||
"needDurationTitle": "Add a 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": {
|
||||
@@ -422,7 +432,7 @@
|
||||
"searchPlaceholder": "Search prescriptions",
|
||||
"kpi": {
|
||||
"active": "Active prescriptions",
|
||||
"expiring": "Expiring within 7 days",
|
||||
"expiring": "Expiring soon",
|
||||
"patients": "Patients on medication"
|
||||
},
|
||||
"queue": {
|
||||
@@ -436,7 +446,15 @@
|
||||
"completedTitle": "Prescription completed",
|
||||
"completedBody": "{{medication}} for {{name}} marked as completed.",
|
||||
"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": {
|
||||
"title": "Inventory",
|
||||
@@ -485,6 +503,11 @@
|
||||
"addedTitle": "Item added",
|
||||
"failedTitle": "Could not add item",
|
||||
"failedBody": "Something went wrong. Please try again."
|
||||
},
|
||||
"detail": {
|
||||
"description": "Stock item",
|
||||
"notes": "Notes",
|
||||
"close": "Close"
|
||||
}
|
||||
},
|
||||
"lab": {
|
||||
@@ -493,7 +516,14 @@
|
||||
"queue": {
|
||||
"title": "Work queue",
|
||||
"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": {
|
||||
"button": "Add result",
|
||||
@@ -550,7 +580,8 @@
|
||||
"board": {
|
||||
"addTask": "Add task",
|
||||
"moveTo": "Move to",
|
||||
"emptyColumn": "No tasks."
|
||||
"emptyColumn": "No tasks.",
|
||||
"newTask": "New task"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All",
|
||||
@@ -884,7 +915,9 @@
|
||||
"noTasks": "No tasks.",
|
||||
"noPrescriptions": "No prescriptions.",
|
||||
"inventory": "Inventory",
|
||||
"noInventory": "No inventory items."
|
||||
"noInventory": "No inventory items.",
|
||||
"moreAppointments": "+{{count}} more",
|
||||
"viewInCalendar": "View in calendar"
|
||||
},
|
||||
"clinicCard": {
|
||||
"title": "Clinic",
|
||||
|
||||
@@ -14,6 +14,8 @@ export type Prescription = {
|
||||
frequency: string;
|
||||
prescriber: string;
|
||||
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;
|
||||
duration: string | null;
|
||||
notes: string | null;
|
||||
@@ -35,6 +37,8 @@ export type PrescriptionInput = {
|
||||
notes?: string | null;
|
||||
prescriber?: string;
|
||||
prescribedAt?: string;
|
||||
startDate?: string | null;
|
||||
endDate?: string | null;
|
||||
status?: RxStatus;
|
||||
source?: "manual" | "ai";
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user