mirror of
https://github.com/temetro/temetro.git
synced 2026-07-27 12:18:56 +00:00
feat: delete controls across add-pages (lab, dispense, rx, inventory, tasks)
Backend: - DELETE /api/patients/:fileNumber/labs (remove one lab result, lab:write) - DELETE /api/dispenses/:id (void a ledger entry, inventory:write) Frontend (all guarded by ConfirmDialog): - lab "Recent results" rows: delete result - pharmacy "Recently dispensed" rows: delete record - prescriptions/tasks detail sheets + inventory detail dialog gain a delete action (prescription delete stays off the shared Pharmacy sheet) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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"] }),
|
||||
|
||||
@@ -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<boolean> {
|
||||
const deleted = await db
|
||||
.delete(dispenses)
|
||||
.where(and(eq(dispenses.organizationId, orgId), eq(dispenses.id, id)))
|
||||
.returning({ id: dispenses.id });
|
||||
return deleted.length > 0;
|
||||
}
|
||||
|
||||
@@ -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<Patient | null> {
|
||||
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,
|
||||
|
||||
@@ -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<Patient[]>([]);
|
||||
const [recent, setRecent] = useState<RecentResult[]>([]);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
// The result staged for deletion (drives the confirm dialog).
|
||||
const [toDelete, setToDelete] = useState<RecentResult | null>(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() {
|
||||
<Badge className="shrink-0" variant={flagVariant[lab.flag]}>
|
||||
{t(`patientCard.labFlag.${lab.flag}`)}
|
||||
</Badge>
|
||||
<button
|
||||
aria-label={t("lab.recent.delete")}
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-destructive-foreground"
|
||||
onClick={() => setToDelete({ patient, lab })}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
@@ -681,6 +731,25 @@ export function LabView() {
|
||||
open={addOpen}
|
||||
patients={patients}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
cancelLabel={t("lab.recent.deleteCancel")}
|
||||
confirmLabel={t("lab.recent.deleteConfirm")}
|
||||
description={
|
||||
toDelete
|
||||
? t("lab.recent.deleteBody", {
|
||||
test: toDelete.lab.name,
|
||||
name: toDelete.patient.name,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
onConfirm={confirmDelete}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setToDelete(null);
|
||||
}}
|
||||
open={toDelete !== null}
|
||||
title={t("lab.recent.deleteTitle")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
</DialogPanel>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter variant={onDelete ? "bare" : undefined}>
|
||||
{onDelete && (
|
||||
<Button
|
||||
className="me-auto"
|
||||
onClick={onDelete}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
{t("inventory.detail.delete")}
|
||||
</Button>
|
||||
)}
|
||||
<DialogClose render={<Button type="button" variant="outline" />}>
|
||||
{t("inventory.detail.close")}
|
||||
</DialogClose>
|
||||
|
||||
@@ -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<InventoryItem | null>(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() {
|
||||
|
||||
<InventoryDetailDialog
|
||||
item={selected}
|
||||
onDelete={() => setConfirmOpen(true)}
|
||||
onOpenChange={setDetailOpen}
|
||||
open={detailOpen}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
cancelLabel={t("inventory.delete.cancel")}
|
||||
confirmLabel={t("inventory.delete.confirm")}
|
||||
description={
|
||||
selected
|
||||
? t("inventory.delete.body", { name: selected.name })
|
||||
: undefined
|
||||
}
|
||||
onConfirm={removeItem}
|
||||
onOpenChange={setConfirmOpen}
|
||||
open={confirmOpen}
|
||||
title={t("inventory.delete.title")}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{kpis.map((k) => (
|
||||
<Kpi key={k.label} {...k} />
|
||||
|
||||
@@ -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<Prescription | null>(null);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
// Dispense ledger entry staged for deletion (drives the confirm dialog).
|
||||
const [toDelete, setToDelete] = useState<Dispense | null>(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 (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-6 py-10">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
@@ -380,6 +411,14 @@ export function PharmacyView() {
|
||||
{formatDispensedAt(d.dispensedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
aria-label={t("pharmacy.dispensed.delete")}
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-destructive-foreground"
|
||||
onClick={() => setToDelete(d)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{dispenses.length === 0 && (
|
||||
@@ -395,6 +434,25 @@ export function PharmacyView() {
|
||||
open={sheetOpen}
|
||||
rx={selected}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
cancelLabel={t("pharmacy.dispensed.deleteCancel")}
|
||||
confirmLabel={t("pharmacy.dispensed.deleteConfirm")}
|
||||
description={
|
||||
toDelete
|
||||
? t("pharmacy.dispensed.deleteBody", {
|
||||
medication: toDelete.medication,
|
||||
name: toDelete.name,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
onConfirm={confirmDelete}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setToDelete(null);
|
||||
}}
|
||||
open={toDelete !== null}
|
||||
title={t("pharmacy.dispensed.deleteTitle")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
)}
|
||||
</SheetPanel>
|
||||
{rx && onDelete && (
|
||||
<SheetFooter>
|
||||
<Button onClick={onDelete} type="button" variant="destructive">
|
||||
<Trash2 className="size-4" />
|
||||
{t("prescriptions.detail.delete")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
)}
|
||||
</SheetPopup>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
@@ -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<Prescription[]>([]);
|
||||
const [selected, setSelected] = useState<Prescription | null>(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() {
|
||||
/>
|
||||
|
||||
<PrescriptionDetailSheet
|
||||
onDelete={() => setConfirmOpen(true)}
|
||||
onOpenChange={setSheetOpen}
|
||||
open={sheetOpen}
|
||||
rx={selected}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
cancelLabel={t("prescriptions.delete.cancel")}
|
||||
confirmLabel={t("prescriptions.delete.confirm")}
|
||||
description={
|
||||
selected
|
||||
? t("prescriptions.delete.body", {
|
||||
medication: selected.medication,
|
||||
name: selected.name,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
onConfirm={removeRx}
|
||||
onOpenChange={setConfirmOpen}
|
||||
open={confirmOpen}
|
||||
title={t("prescriptions.delete.title")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
)}
|
||||
</SheetPanel>
|
||||
{task && onDelete && (
|
||||
<SheetFooter>
|
||||
<Button onClick={onDelete} type="button" variant="destructive">
|
||||
<Trash2 className="size-4" />
|
||||
{t("tasks.detail.delete")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
)}
|
||||
</SheetPopup>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
@@ -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<Task[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addStatus, setAddStatus] = useState<TaskStatus>("todo");
|
||||
const [dragId, setDragId] = useState<string | null>(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() {
|
||||
/>
|
||||
|
||||
<TaskDetailSheet
|
||||
onDelete={() => setConfirmOpen(true)}
|
||||
onMove={moveTask}
|
||||
onOpenChange={setSheetOpen}
|
||||
open={sheetOpen}
|
||||
task={selected}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
cancelLabel={t("tasks.delete.cancel")}
|
||||
confirmLabel={t("tasks.delete.confirm")}
|
||||
description={
|
||||
selected
|
||||
? t("tasks.delete.body", { title: selected.title })
|
||||
: undefined
|
||||
}
|
||||
onConfirm={removeTask}
|
||||
onOpenChange={setConfirmOpen}
|
||||
open={confirmOpen}
|
||||
title={t("tasks.delete.title")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,3 +44,7 @@ export function createDispense(input: DispenseInput): Promise<Dispense> {
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteDispense(id: string): Promise<void> {
|
||||
return apiFetch<void>(`/api/dispenses/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -140,6 +140,27 @@ export async function deletePatient(fileNumber: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// 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<Lab, "name" | "value" | "takenAt">,
|
||||
): Promise<Patient> {
|
||||
return apiFetch<Patient>(
|
||||
`/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,
|
||||
|
||||
Reference in New Issue
Block a user