mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
Merge pull request #4 from temetro/feature/clinical-fixes-and-chat
Feature/clinical fixes and chat
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE "dispenses" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"patient_file_number" text DEFAULT '' NOT NULL,
|
||||
"patient_name" text NOT NULL,
|
||||
"patient_initials" text DEFAULT '' NOT NULL,
|
||||
"medication" text NOT NULL,
|
||||
"dose" text DEFAULT '' NOT NULL,
|
||||
"quantity" integer DEFAULT 0 NOT NULL,
|
||||
"unit" text DEFAULT '' NOT NULL,
|
||||
"prescription_id" uuid,
|
||||
"dispensed_by" text,
|
||||
"dispensed_by_name" text DEFAULT '' NOT NULL,
|
||||
"dispensed_at" timestamp DEFAULT now() NOT NULL,
|
||||
"notes" text,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "prescriptions" ADD COLUMN "start_date" date;--> statement-breakpoint
|
||||
ALTER TABLE "prescriptions" ADD COLUMN "end_date" date;--> statement-breakpoint
|
||||
ALTER TABLE "dispenses" ADD CONSTRAINT "dispenses_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "dispenses" ADD CONSTRAINT "dispenses_dispensed_by_user_id_fk" FOREIGN KEY ("dispensed_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "dispenses_org_idx" ON "dispenses" USING btree ("organization_id");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -141,6 +141,13 @@
|
||||
"when": 1781538797034,
|
||||
"tag": "0019_black_mole_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1781629630102,
|
||||
"tag": "0020_freezing_tomas",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { index, integer, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
|
||||
import { organization, user } from "./auth.js";
|
||||
|
||||
// One row per dispensing event — the record of a medication actually handed to a
|
||||
// patient at the pharmacy, scoped to a clinic (organization). Distinct from
|
||||
// `prescriptions` (the order) and `inventory` (standing stock): this is the
|
||||
// fulfilment ledger ("who took which medication"). Patient + dispenser identity
|
||||
// are denormalized so the list renders without joining; `prescriptionId` links
|
||||
// back to the source order when the dispense came from the queue.
|
||||
export const dispenses = pgTable(
|
||||
"dispenses",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
patientFileNumber: text("patient_file_number").notNull().default(""),
|
||||
patientName: text("patient_name").notNull(),
|
||||
patientInitials: text("patient_initials").notNull().default(""),
|
||||
medication: text("medication").notNull(),
|
||||
dose: text("dose").notNull().default(""),
|
||||
quantity: integer("quantity").notNull().default(0),
|
||||
unit: text("unit").notNull().default(""),
|
||||
prescriptionId: uuid("prescription_id"),
|
||||
dispensedBy: text("dispensed_by").references(() => user.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
dispensedByName: text("dispensed_by_name").notNull().default(""),
|
||||
dispensedAt: timestamp("dispensed_at").defaultNow().notNull(),
|
||||
notes: text("notes"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
},
|
||||
(t) => [index("dispenses_org_idx").on(t.organizationId)],
|
||||
);
|
||||
@@ -5,6 +5,7 @@ export * from "./appointments.js";
|
||||
export * from "./prescriptions.js";
|
||||
export * from "./invoices.js";
|
||||
export * from "./inventory.js";
|
||||
export * from "./dispenses.js";
|
||||
export * from "./tasks.js";
|
||||
export * from "./activity.js";
|
||||
export * from "./messaging.js";
|
||||
|
||||
@@ -28,6 +28,11 @@ export const prescriptions = pgTable(
|
||||
frequency: text("frequency").notNull(),
|
||||
prescriber: text("prescriber").notNull(),
|
||||
prescribedAt: date("prescribed_at").defaultNow().notNull(),
|
||||
// Optional explicit course window. When set, `endDate` drives expiry instead
|
||||
// of parsing the free-text `duration`. Both are opt-in (the dialog reveals
|
||||
// them behind a toggle), so they stay nullable.
|
||||
startDate: date("start_date"),
|
||||
endDate: date("end_date"),
|
||||
status: text("status").$type<PrescriptionStatus>().notNull(),
|
||||
duration: text("duration"),
|
||||
notes: text("notes"),
|
||||
|
||||
@@ -14,6 +14,7 @@ import { analyticsRouter } from "./routes/analytics.js";
|
||||
import { appointmentsRouter } from "./routes/appointments.js";
|
||||
import { chatRouter } from "./routes/chat.js";
|
||||
import { conversationsRouter } from "./routes/conversations.js";
|
||||
import { dispensesRouter } from "./routes/dispenses.js";
|
||||
import { inventoryRouter } from "./routes/inventory.js";
|
||||
import { invoicesRouter } from "./routes/invoices.js";
|
||||
import { notesRouter } from "./routes/notes.js";
|
||||
@@ -66,6 +67,7 @@ app.use("/api/notes", notesRouter);
|
||||
app.use("/api/appointments", appointmentsRouter);
|
||||
app.use("/api/prescriptions", prescriptionsRouter);
|
||||
app.use("/api/inventory", inventoryRouter);
|
||||
app.use("/api/dispenses", dispensesRouter);
|
||||
app.use("/api/invoices", invoicesRouter);
|
||||
app.use("/api/tasks", tasksRouter);
|
||||
app.use("/api/staff", staffRouter);
|
||||
@@ -92,6 +94,7 @@ server.listen(env.PORT, () => {
|
||||
console.log(` • appts: /api/appointments`);
|
||||
console.log(` • rx: /api/prescriptions`);
|
||||
console.log(` • stock: /api/inventory`);
|
||||
console.log(` • dispense: /api/dispenses`);
|
||||
console.log(` • tasks: /api/tasks`);
|
||||
console.log(` • staff: /api/staff`);
|
||||
console.log(` • activity: /api/activity`);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const nonEmpty = z.string().trim().min(1);
|
||||
|
||||
// Payload accepted by POST /api/dispenses. Recorded when the pharmacy dispenses
|
||||
// a medication (typically from the dispensing queue). The route fills the
|
||||
// dispenser identity from the signed-in user and the DB defaults `dispensedAt`.
|
||||
export const dispenseInputSchema = z.object({
|
||||
fileNumber: z.string().trim().default(""),
|
||||
name: nonEmpty.max(200),
|
||||
initials: z.string().trim().max(4).default(""),
|
||||
medication: nonEmpty.max(200),
|
||||
dose: z.string().trim().max(120).default(""),
|
||||
quantity: z.number().int().min(0).max(1_000_000).default(0),
|
||||
unit: z.string().trim().max(60).default(""),
|
||||
prescriptionId: z.string().uuid().nullish(),
|
||||
notes: z.string().max(5000).nullish(),
|
||||
});
|
||||
|
||||
export type DispenseInput = z.infer<typeof dispenseInputSchema>;
|
||||
@@ -18,6 +18,14 @@ export const prescriptionInputSchema = z.object({
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD.")
|
||||
.optional(),
|
||||
startDate: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD.")
|
||||
.nullish(),
|
||||
endDate: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD.")
|
||||
.nullish(),
|
||||
status: z.enum(["active", "completed", "expired"]).default("active"),
|
||||
duration: z.string().trim().max(120).nullish(),
|
||||
notes: z.string().max(5000).nullish(),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Router } from "express";
|
||||
|
||||
import { dispenseInputSchema } from "../lib/dispense-validation.js";
|
||||
import {
|
||||
requireAuth,
|
||||
requireOrg,
|
||||
requirePermission,
|
||||
} from "../middleware/auth.js";
|
||||
import { recordActivity } from "../services/activity.js";
|
||||
import * as service from "../services/dispenses.js";
|
||||
|
||||
// The dispensing ledger. Reuses the `inventory` RBAC statement (pharmacy holds
|
||||
// inventory read/write), so no new access-control role is needed.
|
||||
export const dispensesRouter = Router();
|
||||
|
||||
dispensesRouter.use(requireAuth, requireOrg);
|
||||
|
||||
dispensesRouter.get(
|
||||
"/",
|
||||
requirePermission({ inventory: ["read"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
res.json(await service.listDispenses(req.organizationId!));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
dispensesRouter.post(
|
||||
"/",
|
||||
requirePermission({ inventory: ["write"] }),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const input = dispenseInputSchema.parse(req.body);
|
||||
const created = await service.createDispense(
|
||||
req.organizationId!,
|
||||
{ id: req.user!.id, name: req.user!.name || "Pharmacy" },
|
||||
input,
|
||||
);
|
||||
await recordActivity({
|
||||
orgId: req.organizationId!,
|
||||
actor: { id: req.user!.id, name: req.user!.name },
|
||||
action: `Dispensed ${created.medication} to ${created.name}`,
|
||||
entityType: "dispense",
|
||||
entityId: created.id,
|
||||
patientName: created.name,
|
||||
patientFileNumber: created.fileNumber || null,
|
||||
});
|
||||
res.status(201).json(created);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,61 @@
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
|
||||
import { db } from "../db/index.js";
|
||||
import { dispenses } from "../db/schema/dispenses.js";
|
||||
import type { DispenseInput } from "../lib/dispense-validation.js";
|
||||
import type { Dispense } from "../types/dispense.js";
|
||||
|
||||
type DispenseRow = typeof dispenses.$inferSelect;
|
||||
|
||||
function toDispense(row: DispenseRow): Dispense {
|
||||
return {
|
||||
id: row.id,
|
||||
fileNumber: row.patientFileNumber,
|
||||
name: row.patientName,
|
||||
initials: row.patientInitials,
|
||||
medication: row.medication,
|
||||
dose: row.dose,
|
||||
quantity: row.quantity,
|
||||
unit: row.unit,
|
||||
prescriptionId: row.prescriptionId,
|
||||
dispensedBy: row.dispensedBy,
|
||||
dispensedByName: row.dispensedByName,
|
||||
dispensedAt: row.dispensedAt.toISOString(),
|
||||
notes: row.notes,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listDispenses(orgId: string): Promise<Dispense[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(dispenses)
|
||||
.where(eq(dispenses.organizationId, orgId))
|
||||
.orderBy(desc(dispenses.dispensedAt));
|
||||
return rows.map(toDispense);
|
||||
}
|
||||
|
||||
export async function createDispense(
|
||||
orgId: string,
|
||||
dispenser: { id: string; name: string },
|
||||
input: DispenseInput,
|
||||
): Promise<Dispense> {
|
||||
const [row] = await db
|
||||
.insert(dispenses)
|
||||
.values({
|
||||
organizationId: orgId,
|
||||
patientFileNumber: input.fileNumber,
|
||||
patientName: input.name,
|
||||
patientInitials: input.initials,
|
||||
medication: input.medication,
|
||||
dose: input.dose,
|
||||
quantity: input.quantity,
|
||||
unit: input.unit,
|
||||
prescriptionId: input.prescriptionId ?? null,
|
||||
dispensedBy: dispenser.id,
|
||||
dispensedByName: dispenser.name,
|
||||
notes: input.notes ?? null,
|
||||
})
|
||||
.returning();
|
||||
return toDispense(row!);
|
||||
}
|
||||
@@ -22,6 +22,8 @@ function toPrescription(row: PrescriptionRow): Prescription {
|
||||
frequency: row.frequency,
|
||||
prescriber: row.prescriber,
|
||||
prescribedAt: row.prescribedAt,
|
||||
startDate: row.startDate,
|
||||
endDate: row.endDate,
|
||||
status: row.status,
|
||||
duration: row.duration,
|
||||
notes: row.notes,
|
||||
@@ -45,6 +47,8 @@ function columns(orgId: string, input: PrescriptionInput, createdBy?: string) {
|
||||
duration: input.duration ?? null,
|
||||
notes: input.notes ?? null,
|
||||
source: input.source,
|
||||
startDate: input.startDate ?? null,
|
||||
endDate: input.endDate ?? null,
|
||||
// Only set prescribedAt when supplied; otherwise the column default (today).
|
||||
...(input.prescribedAt ? { prescribedAt: input.prescribedAt } : {}),
|
||||
...(createdBy ? { createdBy } : {}),
|
||||
|
||||
@@ -8,6 +8,7 @@ export type ActivityEntityType =
|
||||
| "prescription"
|
||||
| "invoice"
|
||||
| "inventory"
|
||||
| "dispense"
|
||||
| "task";
|
||||
|
||||
export type ActivityEntry = {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// The canonical Dispense shape returned by the API. Mirrors the frontend
|
||||
// `lib/dispenses.ts` Dispense type. Scoped to the active clinic; patient and
|
||||
// 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;
|
||||
};
|
||||
@@ -13,6 +13,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: PrescriptionStatus;
|
||||
duration: string | null;
|
||||
notes: string | null;
|
||||
|
||||
@@ -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