diff --git a/backend/src/routes/dispenses.ts b/backend/src/routes/dispenses.ts index b4593cc..31f035d 100644 --- a/backend/src/routes/dispenses.ts +++ b/backend/src/routes/dispenses.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import { dispenseInputSchema } from "../lib/dispense-validation.js"; +import { HttpError } from "../lib/http-error.js"; import { requireAuth, requireOrg, @@ -53,3 +54,27 @@ dispensesRouter.post( } }, ); + +dispensesRouter.delete( + "/:id", + requirePermission({ inventory: ["write"] }), + async (req, res, next) => { + try { + const ok = await service.deleteDispense( + req.organizationId!, + req.params.id as string, + ); + if (!ok) throw new HttpError(404, "Dispense not found."); + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: "Voided a dispense record", + entityType: "dispense", + entityId: req.params.id as string, + }); + res.status(204).end(); + } catch (err) { + next(err); + } + }, +); diff --git a/backend/src/routes/patients.ts b/backend/src/routes/patients.ts index af0e1cb..de4452c 100644 --- a/backend/src/routes/patients.ts +++ b/backend/src/routes/patients.ts @@ -28,6 +28,12 @@ const labsAppendSchema = z.object({ labs: z.array(labSchema).min(1).max(50), }); +const labDeleteSchema = z.object({ + name: z.string().trim().min(1), + value: z.string(), + takenAt: z.string(), +}); + // Notify the rest of the clinic about a patient record change (best-effort, // pushed live over the socket). async function notifyClinic( @@ -255,6 +261,36 @@ patientsRouter.post( }, ); +// Remove one lab result. Gated by `lab:write` like appending, so lab staff can +// correct their own submissions without patient-edit rights. +patientsRouter.delete( + "/:fileNumber/labs", + requirePermission({ lab: ["write"] }), + async (req, res, next) => { + try { + const match = labDeleteSchema.parse(req.body); + const updated = await service.deleteLab( + req.organizationId!, + req.params.fileNumber as string, + match, + ); + if (!updated) throw new HttpError(404, "Patient not found."); + await recordActivity({ + orgId: req.organizationId!, + actor: { id: req.user!.id, name: req.user!.name }, + action: `Removed lab result ${match.name} for ${updated.name}`, + entityType: "patient", + entityId: updated.fileNumber, + patientName: updated.name, + patientFileNumber: updated.fileNumber, + }); + res.json(updated); + } catch (err) { + next(err); + } + }, +); + patientsRouter.delete( "/:fileNumber", requirePermission({ patient: ["delete"] }), diff --git a/backend/src/services/dispenses.ts b/backend/src/services/dispenses.ts index d0528b1..f314d50 100644 --- a/backend/src/services/dispenses.ts +++ b/backend/src/services/dispenses.ts @@ -1,4 +1,4 @@ -import { desc, eq } from "drizzle-orm"; +import { and, desc, eq } from "drizzle-orm"; import { db } from "../db/index.js"; import { dispenses } from "../db/schema/dispenses.js"; @@ -59,3 +59,14 @@ export async function createDispense( .returning(); return toDispense(row!); } + +export async function deleteDispense( + orgId: string, + id: string, +): Promise { + const deleted = await db + .delete(dispenses) + .where(and(eq(dispenses.organizationId, orgId), eq(dispenses.id, id))) + .returning({ id: dispenses.id }); + return deleted.length > 0; +} diff --git a/backend/src/services/patients.ts b/backend/src/services/patients.ts index 640f6d9..fc6f04c 100644 --- a/backend/src/services/patients.ts +++ b/backend/src/services/patients.ts @@ -566,6 +566,41 @@ export async function appendLabs( return getPatient(orgId, fileNumber); } +// Remove a single lab result from a patient, identified by its +// name/value/takenAt (the frontend has no row id). Scoped to the org via the +// owning patient. Returns the reloaded patient, or null when the chart is gone. +export async function deleteLab( + orgId: string, + fileNumber: string, + match: { name: string; value: string; takenAt: string }, +): Promise { + const [existing] = await db + .select({ id: patients.id }) + .from(patients) + .where( + and( + eq(patients.organizationId, orgId), + eq(patients.fileNumber, fileNumber), + ), + ); + if (!existing) return null; + await db + .delete(labs) + .where( + and( + eq(labs.patientId, existing.id), + eq(labs.name, match.name), + eq(labs.value, match.value), + eq(labs.takenAt, match.takenAt), + ), + ); + await db + .update(patients) + .set({ updatedAt: new Date() }) + .where(eq(patients.id, existing.id)); + return getPatient(orgId, fileNumber); +} + export async function deletePatient( orgId: string, fileNumber: string, diff --git a/frontend/components/lab/lab-view.tsx b/frontend/components/lab/lab-view.tsx index b27dfd7..9307230 100644 --- a/frontend/components/lab/lab-view.tsx +++ b/frontend/components/lab/lab-view.tsx @@ -1,6 +1,13 @@ "use client"; -import { Check, ChevronDown, FlaskConical, Plus, Search } from "lucide-react"; +import { + Check, + ChevronDown, + FlaskConical, + Plus, + Search, + Trash2, +} from "lucide-react"; import { type FormEvent, type KeyboardEvent, @@ -19,6 +26,7 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { Dialog, DialogClose, @@ -37,6 +45,7 @@ import { type LabFlag, type Patient, appendLabs, + deleteLab, listPatients, } from "@/lib/patients"; import { type Priority, type Task, listTasks, updateTask } from "@/lib/tasks"; @@ -454,6 +463,8 @@ export function LabView() { const [patients, setPatients] = useState([]); const [recent, setRecent] = useState([]); const [addOpen, setAddOpen] = useState(false); + // The result staged for deletion (drives the confirm dialog). + const [toDelete, setToDelete] = useState(null); useEffect(() => { let active = true; @@ -497,6 +508,37 @@ export function LabView() { }); }; + // Remove a result from the feed + the patient's record. + const confirmDelete = async () => { + if (!toDelete) return; + const { patient, lab } = toDelete; + try { + await deleteLab(patient.fileNumber, lab); + setRecent((prev) => + prev.filter( + (r) => + !( + r.patient.fileNumber === patient.fileNumber && + r.lab.name === lab.name && + r.lab.value === lab.value && + r.lab.takenAt === lab.takenAt + ), + ), + ); + notify.success( + t("lab.recent.deletedTitle"), + t("lab.recent.deletedBody", { test: lab.name, name: patient.name }), + ); + } catch { + notify.error( + t("lab.recent.deleteFailedTitle"), + t("lab.recent.deleteFailedBody"), + ); + } finally { + setToDelete(null); + } + }; + // Optimistically flip done, then persist; roll back on failure. const toggle = async (id: string) => { const current = tasks.find((task) => task.id === id); @@ -669,6 +711,14 @@ export function LabView() { {t(`patientCard.labFlag.${lab.flag}`)} + )) )} @@ -681,6 +731,25 @@ export function LabView() { open={addOpen} patients={patients} /> + + { + if (!o) setToDelete(null); + }} + open={toDelete !== null} + title={t("lab.recent.deleteTitle")} + /> ); } diff --git a/frontend/components/pharmacy/inventory-detail-dialog.tsx b/frontend/components/pharmacy/inventory-detail-dialog.tsx index bf7163f..d7e008c 100644 --- a/frontend/components/pharmacy/inventory-detail-dialog.tsx +++ b/frontend/components/pharmacy/inventory-detail-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { Pill } from "lucide-react"; +import { Pill, Trash2 } from "lucide-react"; import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; @@ -47,10 +47,12 @@ export function InventoryDetailDialog({ item, open, onOpenChange, + onDelete, }: { item: InventoryItem | null; open: boolean; onOpenChange: (open: boolean) => void; + onDelete?: () => void; }) { const { t } = useTranslation(); if (!item) return null; @@ -120,7 +122,18 @@ export function InventoryDetailDialog({ - + + {onDelete && ( + + )} }> {t("inventory.detail.close")} diff --git a/frontend/components/pharmacy/inventory-view.tsx b/frontend/components/pharmacy/inventory-view.tsx index a678025..8a987ee 100644 --- a/frontend/components/pharmacy/inventory-view.tsx +++ b/frontend/components/pharmacy/inventory-view.tsx @@ -10,12 +10,14 @@ 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 { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { type Availability, type InventoryInput, type InventoryItem, availabilityOf, createInventory, + deleteInventory, listInventory, } from "@/lib/inventory"; import { notify } from "@/lib/toast"; @@ -116,12 +118,31 @@ export function InventoryView() { const [addOpen, setAddOpen] = useState(false); const [selected, setSelected] = useState(null); const [detailOpen, setDetailOpen] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); const openItem = (item: InventoryItem) => { setSelected(item); setDetailOpen(true); }; + const removeItem = async () => { + if (!selected) return; + const id = selected.id; + try { + await deleteInventory(id); + setItems((prev) => prev.filter((it) => it.id !== id)); + setDetailOpen(false); + notify.success(t("inventory.delete.doneTitle"), selected.name); + } catch { + notify.error( + t("inventory.delete.failedTitle"), + t("inventory.delete.failedBody"), + ); + } finally { + setConfirmOpen(false); + } + }; + // Persist a new item, then prepend the saved record so it appears immediately. const addItem = async (input: InventoryInput) => { try { @@ -207,10 +228,25 @@ export function InventoryView() { setConfirmOpen(true)} onOpenChange={setDetailOpen} open={detailOpen} /> + +
{kpis.map((k) => ( diff --git a/frontend/components/pharmacy/pharmacy-view.tsx b/frontend/components/pharmacy/pharmacy-view.tsx index b7e763d..8bb0aa1 100644 --- a/frontend/components/pharmacy/pharmacy-view.tsx +++ b/frontend/components/pharmacy/pharmacy-view.tsx @@ -1,6 +1,14 @@ "use client"; -import { CircleCheck, Clock, PackageCheck, Pill, Search, Users } from "lucide-react"; +import { + CircleCheck, + Clock, + PackageCheck, + Pill, + Search, + Trash2, + Users, +} from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -9,10 +17,12 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { Input } from "@/components/ui/input"; import { type Dispense, createDispense, + deleteDispense, listDispenses, } from "@/lib/dispenses"; import { @@ -169,6 +179,8 @@ export function PharmacyView() { const [selected, setSelected] = useState(null); const [sheetOpen, setSheetOpen] = useState(false); const [query, setQuery] = useState(""); + // Dispense ledger entry staged for deletion (drives the confirm dialog). + const [toDelete, setToDelete] = useState(null); useEffect(() => { let active = true; @@ -289,6 +301,25 @@ export function PharmacyView() { } }; + // Remove a dispense ledger entry (a correction — the medication itself isn't + // un-dispensed, just the record). + const confirmDelete = async () => { + if (!toDelete) return; + const id = toDelete.id; + try { + await deleteDispense(id); + setDispenses((prev) => prev.filter((d) => d.id !== id)); + notify.success(t("pharmacy.dispensed.deletedTitle"), toDelete.medication); + } catch { + notify.error( + t("pharmacy.dispensed.deleteFailedTitle"), + t("pharmacy.dispensed.deleteFailedBody"), + ); + } finally { + setToDelete(null); + } + }; + return (
@@ -380,6 +411,14 @@ export function PharmacyView() { {formatDispensedAt(d.dispensedAt)}
+
))} {dispenses.length === 0 && ( @@ -395,6 +434,25 @@ export function PharmacyView() { open={sheetOpen} rx={selected} /> + + { + if (!o) setToDelete(null); + }} + open={toDelete !== null} + title={t("pharmacy.dispensed.deleteTitle")} + />
); } diff --git a/frontend/components/prescriptions/prescription-detail-sheet.tsx b/frontend/components/prescriptions/prescription-detail-sheet.tsx index 83a6097..cca4401 100644 --- a/frontend/components/prescriptions/prescription-detail-sheet.tsx +++ b/frontend/components/prescriptions/prescription-detail-sheet.tsx @@ -1,11 +1,14 @@ "use client"; +import { Trash2 } from "lucide-react"; 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 { Sheet, + SheetFooter, SheetHeader, SheetPanel, SheetPopup, @@ -30,10 +33,14 @@ export function PrescriptionDetailSheet({ rx, open, onOpenChange, + onDelete, }: { rx: Prescription | null; open: boolean; onOpenChange: (open: boolean) => void; + // Optional: only the Prescriptions page (full clinician) passes this, so the + // shared sheet stays read-only when opened from Pharmacy. + onDelete?: () => void; }) { const { t } = useTranslation(); return ( @@ -135,6 +142,14 @@ export function PrescriptionDetailSheet({ )} + {rx && onDelete && ( + + + + )} ); diff --git a/frontend/components/prescriptions/prescriptions-view.tsx b/frontend/components/prescriptions/prescriptions-view.tsx index e668549..4fe16d6 100644 --- a/frontend/components/prescriptions/prescriptions-view.tsx +++ b/frontend/components/prescriptions/prescriptions-view.tsx @@ -14,11 +14,13 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { Input } from "@/components/ui/input"; import { type Prescription, type RxStatus, createPrescription, + deletePrescription, formatPrescribedAt, listPrescriptions, } from "@/lib/prescriptions"; @@ -130,6 +132,7 @@ export function PrescriptionsView() { const [list, setList] = useState([]); const [selected, setSelected] = useState(null); const [sheetOpen, setSheetOpen] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); const [query, setQuery] = useState(""); useEffect(() => { @@ -151,6 +154,24 @@ export function PrescriptionsView() { setSheetOpen(true); }; + const removeRx = async () => { + if (!selected) return; + const id = selected.id; + try { + await deletePrescription(id); + setList((prev) => prev.filter((r) => r.id !== id)); + setSheetOpen(false); + notify.success(t("prescriptions.delete.doneTitle"), selected.medication); + } catch { + notify.error( + t("prescriptions.delete.failedTitle"), + t("prescriptions.delete.failedBody"), + ); + } finally { + setConfirmOpen(false); + } + }; + // Persist a new prescription, then add the saved record to the top of the list. const addPrescription = async (rx: NewPrescription) => { try { @@ -273,10 +294,28 @@ export function PrescriptionsView() { /> setConfirmOpen(true)} onOpenChange={setSheetOpen} open={sheetOpen} rx={selected} /> + + ); } diff --git a/frontend/components/tasks/task-detail-sheet.tsx b/frontend/components/tasks/task-detail-sheet.tsx index 9d8a984..f075cd8 100644 --- a/frontend/components/tasks/task-detail-sheet.tsx +++ b/frontend/components/tasks/task-detail-sheet.tsx @@ -1,11 +1,13 @@ "use client"; +import { Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Sheet, + SheetFooter, SheetHeader, SheetPanel, SheetPopup, @@ -37,11 +39,13 @@ export function TaskDetailSheet({ open, onOpenChange, onMove, + onDelete, }: { task: Task | null; open: boolean; onOpenChange: (open: boolean) => void; onMove: (id: string, status: TaskStatus) => void; + onDelete?: () => void; }) { const { t } = useTranslation(); return ( @@ -132,6 +136,14 @@ export function TaskDetailSheet({ )} + {task && onDelete && ( + + + + )} ); diff --git a/frontend/components/tasks/tasks-view.tsx b/frontend/components/tasks/tasks-view.tsx index 57beb84..f099fc0 100644 --- a/frontend/components/tasks/tasks-view.tsx +++ b/frontend/components/tasks/tasks-view.tsx @@ -24,6 +24,7 @@ import { DialogPopup, DialogTitle, } from "@/components/ui/dialog"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { Input } from "@/components/ui/input"; import { Tabs, TabsList, TabsPanel, TabsTab } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; @@ -35,6 +36,7 @@ import { type TaskInput, type TaskStatus, createTask, + deleteTask, listTasks, updateTask, } from "@/lib/tasks"; @@ -306,6 +308,7 @@ export function TasksView() { const [tasks, setTasks] = useState([]); const [selectedId, setSelectedId] = useState(null); const [sheetOpen, setSheetOpen] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); const [addOpen, setAddOpen] = useState(false); const [addStatus, setAddStatus] = useState("todo"); const [dragId, setDragId] = useState(null); @@ -362,6 +365,24 @@ export function TasksView() { setSheetOpen(true); }; + const removeTask = async () => { + if (!selected) return; + const id = selected.id; + try { + await deleteTask(id); + setTasks((prev) => prev.filter((task) => task.id !== id)); + setSheetOpen(false); + notify.success(t("tasks.delete.doneTitle"), selected.title); + } catch { + notify.error( + t("tasks.delete.failedTitle"), + t("tasks.delete.failedBody"), + ); + } finally { + setConfirmOpen(false); + } + }; + const addTask = async (task: TaskInput) => { try { const created = await createTask(task); @@ -481,11 +502,26 @@ export function TasksView() { /> setConfirmOpen(true)} onMove={moveTask} onOpenChange={setSheetOpen} open={sheetOpen} task={selected} /> + + ); } diff --git a/frontend/lib/dispenses.ts b/frontend/lib/dispenses.ts index 4351d79..84799f6 100644 --- a/frontend/lib/dispenses.ts +++ b/frontend/lib/dispenses.ts @@ -44,3 +44,7 @@ export function createDispense(input: DispenseInput): Promise { body: JSON.stringify(input), }); } + +export function deleteDispense(id: string): Promise { + return apiFetch(`/api/dispenses/${id}`, { method: "DELETE" }); +} diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index f8b54e1..7c14715 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -405,7 +405,17 @@ "date": "Date", "status": "Status", "notes": "Notes", - "courseDates": "Course" + "courseDates": "Course", + "delete": "Delete prescription" + }, + "delete": { + "title": "Delete prescription?", + "body": "Permanently delete the {{medication}} prescription for {{name}}. This cannot be undone.", + "confirm": "Delete", + "cancel": "Cancel", + "doneTitle": "Prescription deleted", + "failedTitle": "Couldn't delete prescription", + "failedBody": "Please try again." }, "dialog": { "title": "New prescription", @@ -472,7 +482,15 @@ "dispensed": { "title": "Recently dispensed", "description": "Medications handed to patients at the pharmacy.", - "empty": "Nothing dispensed yet." + "empty": "Nothing dispensed yet.", + "delete": "Delete record", + "deleteTitle": "Delete dispense record?", + "deleteBody": "Remove the {{medication}} dispense record for {{name}}. This only corrects the ledger.", + "deleteConfirm": "Delete", + "deleteCancel": "Cancel", + "deletedTitle": "Dispense record deleted", + "deleteFailedTitle": "Couldn't delete record", + "deleteFailedBody": "Please try again." } }, "inventory": { @@ -526,7 +544,17 @@ "detail": { "description": "Stock item", "notes": "Notes", - "close": "Close" + "close": "Close", + "delete": "Delete item" + }, + "delete": { + "title": "Delete inventory item?", + "body": "Permanently delete {{name}} from inventory. This cannot be undone.", + "confirm": "Delete", + "cancel": "Cancel", + "doneTitle": "Item deleted", + "failedTitle": "Couldn't delete item", + "failedBody": "Please try again." } }, "lab": { @@ -577,7 +605,16 @@ "recent": { "title": "Recent results", "description": "Analyses recorded on patient records, newest first.", - "empty": "No results recorded yet." + "empty": "No results recorded yet.", + "delete": "Delete result", + "deleteTitle": "Delete lab result?", + "deleteBody": "Remove the {{test}} result from {{name}}'s record. This cannot be undone.", + "deleteConfirm": "Delete", + "deleteCancel": "Cancel", + "deletedTitle": "Result deleted", + "deletedBody": "Removed {{test}} from {{name}}'s record.", + "deleteFailedTitle": "Couldn't delete result", + "deleteFailedBody": "Please try again." }, "toast": { "updateFailedTitle": "Couldn't update the task", @@ -650,7 +687,17 @@ "details": "Details", "reopen": "Reopen task", "complete": "Mark complete", - "moveTo": "Move to" + "moveTo": "Move to", + "delete": "Delete task" + }, + "delete": { + "title": "Delete task?", + "body": "Permanently delete \"{{title}}\". This cannot be undone.", + "confirm": "Delete", + "cancel": "Cancel", + "doneTitle": "Task deleted", + "failedTitle": "Couldn't delete task", + "failedBody": "Please try again." }, "toast": { "needSubjectTitle": "Add a subject", diff --git a/frontend/lib/patients.ts b/frontend/lib/patients.ts index 5cdc704..abb644b 100644 --- a/frontend/lib/patients.ts +++ b/frontend/lib/patients.ts @@ -140,6 +140,27 @@ export async function deletePatient(fileNumber: string): Promise { ); } +// Remove a single lab result from a patient's record. The lab has no id on the +// client, so it's identified by name + value + takenAt (DELETE +// /api/patients/:fileNumber/labs, gated by `lab:write`). Returns the updated +// patient. +export async function deleteLab( + fileNumber: string, + lab: Pick, +): Promise { + return apiFetch( + `/api/patients/${encodeURIComponent(fileNumber.trim())}/labs`, + { + method: "DELETE", + body: JSON.stringify({ + name: lab.name, + value: lab.value, + takenAt: lab.takenAt, + }), + }, + ); +} + // Reassign a patient to another clinician (sets their primary provider + PCP). export async function transferPatient( fileNumber: string,