diff --git a/CLAUDE.md b/CLAUDE.md index 82493dd..353b837 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,8 +31,12 @@ repository (published as `temetro`). ## Layout `frontend/` and `backend/` were previously separate per-folder git repos; they have been **merged -into this single monorepo with full history of both preserved**. The marketing **landing page lives -in its own separate repo** (`temetro-landing`), not here. +into this single monorepo with full history of both preserved**. + +The marketing **landing page is not in this monorepo** — it lives in the sibling Desktop folder +`../temetro/landing-page` (`/Users/khalidabdi/Desktop/temetro/landing-page`), right next to the +`../temetro/docs` site. It is **its own git repository** (a Next.js app with the marketing +`components/landing/`); edit and commit it there, separately from this repo. - **`frontend/`** — the Next.js product app (the AI chat UI). This is where almost all current work happens. **It has its own `CLAUDE.md`** — read `frontend/CLAUDE.md` for the stack, commands, diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index cb47fe7..c172db5 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -78,7 +78,23 @@ function inlineTextFiles(messages: UIMessage[]): UIMessage[] { }); } -function systemPrompt(veilActive: boolean, providerLabel: string): string { +// A short directive for the clinician's chosen "situation" mode, appended to the +// base prompt. Chat mode adds nothing. +function modeDirective(mode: string | undefined): string { + if (mode === "analysis") { + return "Mode — Analysis: the clinician wants interpretation, not just retrieval. After fetching a patient's data, surface patterns and correlations across their problems, labs and visits (e.g. recurring complaints, trends, likely links) and call out anything notable. Stay grounded in the tool results."; + } + if (mode === "graph") { + return "Mode — Graph: the clinician wants to see how a patient's problems and visits connect. When they reference a patient, call getPatient (its record graph renders automatically) and briefly describe the key relationships between illnesses and encounters."; + } + return ""; +} + +function systemPrompt( + veilActive: boolean, + providerLabel: string, + mode?: string, +): string { return [ "You are temetro, a clinical assistant that helps clinicians retrieve,", "organize, and add patient information. You operate over a real patient", @@ -130,6 +146,7 @@ function systemPrompt(veilActive: boolean, providerLabel: string): string { veilActive ? `Privacy: this conversation runs on an external provider (${providerLabel}). Patient identifiers are de-identified as tokens like [PATIENT_1] / [MRN_1]; refer to patients generically ("this patient") rather than repeating tokens.` : "", + modeDirective(mode), ] .filter(Boolean) .join("\n"); @@ -137,10 +154,11 @@ function systemPrompt(veilActive: boolean, providerLabel: string): string { chatRouter.post("/", async (req, res, next) => { try { - const { messages, model: requestedModel } = req.body as { + const { messages, model: requestedModel, mode } = req.body as { messages: UIMessage[]; model?: string; effort?: string; + mode?: string; }; if (!Array.isArray(messages)) { res.status(400).json({ error: "messages must be an array." }); @@ -171,7 +189,7 @@ chatRouter.post("/", async (req, res, next) => { }; const modelMessages = await convertToModelMessages(inlineTextFiles(messages)); - const system = systemPrompt(veil.active, resolved.providerLabel); + const system = systemPrompt(veil.active, resolved.providerLabel, mode); const stream = createUIMessageStream({ execute: async ({ writer }) => { 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/CLAUDE.md b/frontend/CLAUDE.md index a53c6a1..1a56433 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -129,8 +129,9 @@ notifications, notes). Keys are grouped by feature (`appointments.*`, `patientCa `next build`. There's a `Dockerfile` for the standalone build. - **`lucide-react@1.17` dropped brand glyphs** (e.g. `Github`, `Discord`) — import them and you get a build error. Use inline SVGs instead. -- A sibling **`../landing-page/`** directory is a copy of this app with a marketing landing page - (`components/landing/`); it is a separate project, not part of this git repo. +- The marketing **landing page is not in this monorepo** — it lives in the sibling Desktop folder + `~/Desktop/temetro/landing-page` (next to `~/Desktop/temetro/docs`), a separate Next.js app / + git repo seeded from this app (with `components/landing/`). Edit and commit it there. - Multiple lockfiles in the tree produce a harmless Turbopack "inferred workspace root" warning. ### COSS composition gotchas (hit during the shadcn→COSS migration) diff --git a/frontend/components/analysis/analysis-view.tsx b/frontend/components/analysis/analysis-view.tsx index 79fb982..aa42698 100644 --- a/frontend/components/analysis/analysis-view.tsx +++ b/frontend/components/analysis/analysis-view.tsx @@ -4,7 +4,6 @@ import { type ReactNode, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { EarningsChart } from "@/components/analysis/earnings-chart"; -import { LiveHospitalChart } from "@/components/analysis/live-hospital-chart"; import { Area, AreaChart } from "@/components/charts/area-chart"; import { Bar } from "@/components/charts/bar"; import { BarChart } from "@/components/charts/bar-chart"; @@ -14,6 +13,7 @@ import { ChartTooltip } from "@/components/charts/tooltip"; import { XAxis } from "@/components/charts/x-axis"; import { Card } from "@/components/ui/card"; import { type Analytics, getAnalytics } from "@/lib/analytics"; +import { type Appointment, listAppointments } from "@/lib/appointments"; import { formatMoney } from "@/lib/invoices"; // Clinic analytics computed on the server from real data (patients, @@ -90,6 +90,7 @@ function ChartCard({ export function AnalysisView() { const { t } = useTranslation(); const [data, setData] = useState(null); + const [appointments, setAppointments] = useState([]); useEffect(() => { let active = true; @@ -100,6 +101,13 @@ export function AnalysisView() { .catch(() => { /* api-client redirects on 401; otherwise leave it loading */ }); + listAppointments() + .then((a) => { + if (active) setAppointments(a); + }) + .catch(() => { + /* leave the visits chart empty on failure */ + }); return () => { active = false; }; @@ -107,6 +115,28 @@ export function AnalysisView() { const n = (v: number | undefined) => String(v ?? 0); + // Patient visits per month over the last six months, aggregated from real + // appointments (no server-side monthly series exists yet). + const visitData = useMemo(() => { + const now = new Date(); + const months = Array.from({ length: 6 }, (_, i) => ({ + date: new Date(now.getFullYear(), now.getMonth() - (5 - i), 1), + visits: 0, + })); + for (const a of appointments) { + const d = new Date(`${a.date}T00:00:00`); + if (Number.isNaN(d.getTime())) continue; + const m = months.find( + (mo) => + mo.date.getFullYear() === d.getFullYear() && + mo.date.getMonth() === d.getMonth(), + ); + if (m) m.visits += 1; + } + return months; + }, [appointments]); + const visitTotal = visitData.reduce((sum, p) => sum + p.visits, 0); + // The area chart needs real Date x-values: synthesise one month per point, // ending with the current month. const monthData = useMemo(() => { @@ -147,17 +177,27 @@ export function AnalysisView() {

- {t("analysis.live.title")} + {t("analysis.area.title")}

- {t("analysis.live.subtitle")} + {t("analysis.area.subtitle")}

- - {t("analysis.live.label")} - - +
+ + {t("analysis.area.label")} + + + {visitTotal} + +
+ + + + + +
diff --git a/frontend/components/analysis/live-hospital-chart.tsx b/frontend/components/analysis/live-hospital-chart.tsx deleted file mode 100644 index 0a63b29..0000000 --- a/frontend/components/analysis/live-hospital-chart.tsx +++ /dev/null @@ -1,128 +0,0 @@ -"use client"; - -import { Pause, Play } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; - -import { - LiveLineChart, - type LiveLinePoint, -} from "@/components/charts/live-line-chart"; -import { LiveLine } from "@/components/charts/live-line"; -import { LiveYAxis } from "@/components/charts/live-y-axis"; -import { ChartTooltip } from "@/components/charts/tooltip"; -import { Button } from "@/components/ui/button"; -import { getLiveMetric } from "@/lib/analytics"; - -// The "Live" panel plots a REAL clinic signal — patients checked in today — by -// polling GET /api/analytics/live. It only runs while the clinician toggles it -// on, so the Analysis page stays idle by default. The metric changes slowly -// (only as people check in), so the line scrolls smoothly using the last value -// and refreshes from the server every few seconds. -const WINDOW_SECONDS = 30; -const REFETCH_EVERY_TICKS = 5; // poll the server every 5s (1s render ticks) - -export function LiveHospitalChart() { - const { t } = useTranslation(); - const [live, setLive] = useState(false); - const [data, setData] = useState([]); - const [value, setValue] = useState(0); - const valueRef = useRef(0); - - useEffect(() => { - if (!live) return; - let active = true; - - const refetch = () => - getLiveMetric() - .then((r) => { - if (!active) return; - valueRef.current = r.value; - setValue(r.value); - }) - .catch(() => { - /* keep the last value on a transient error */ - }); - - // Seed a short flat history from the first reading so the line draws at once. - refetch().finally(() => { - if (!active) return; - const now = Date.now() / 1000; - setData( - Array.from({ length: WINDOW_SECONDS }, (_, i) => ({ - time: now - (WINDOW_SECONDS - i), - value: valueRef.current, - })), - ); - }); - - let tick = 0; - const id = setInterval(() => { - tick += 1; - if (tick % REFETCH_EVERY_TICKS === 0) void refetch(); - setData((prev) => [ - ...prev.slice(-500), - { time: Date.now() / 1000, value: valueRef.current }, - ]); - }, 1000); - return () => { - active = false; - clearInterval(id); - }; - }, [live]); - - return ( -
-
- -
- - {live ? ( -
- - String(Math.round(v))} - momentumColors={{ - up: "var(--color-emerald-500)", - down: "var(--color-red-500)", - flat: "var(--chart-line-primary)", - }} - /> - String(Math.round(v))} - position="left" - /> - - -
- ) : ( -
- {t("analysis.live.idle")} -
- )} -
- ); -} diff --git a/frontend/components/chat/chat-input.tsx b/frontend/components/chat/chat-input.tsx index 834c374..fa5071a 100644 --- a/frontend/components/chat/chat-input.tsx +++ b/frontend/components/chat/chat-input.tsx @@ -11,19 +11,17 @@ import { } from "react"; import { useTranslation } from "react-i18next"; -import { ModelPicker } from "@/components/chat/model-picker"; +import { ModePicker } from "@/components/chat/mode-picker"; import { PatientFormDialog } from "@/components/chat/patient-form-dialog"; -import type { Effort } from "@/lib/ai-models"; +import type { ChatMode } from "@/lib/chat-modes"; import { cn } from "@/lib/utils"; type ChatInputProps = { onSubmit: (text: string, files: File[]) => void; status: ChatStatus; onStop?: () => void; - model: string; - effort: Effort; - onModelChange: (model: string) => void; - onEffortChange: (effort: Effort) => void; + mode: ChatMode; + onModeChange: (mode: ChatMode) => void; }; const iconButton = @@ -37,10 +35,8 @@ export function ChatInput({ onSubmit, status, onStop, - model, - effort, - onModelChange, - onEffortChange, + mode, + onModeChange, }: ChatInputProps) { const { t } = useTranslation(); @@ -85,12 +81,17 @@ export function ChatInput({ const handleFilesSelected = useCallback( (event: ChangeEvent) => { - const selected = event.target.files; - if (selected && selected.length > 0) { - setFiles((prev) => [...prev, ...Array.from(selected)]); - } + // Copy the files out NOW: `event.target.files` is a live FileList, and the + // `event.target.value = ""` reset below empties it. React runs the + // functional setState updater during a later render, so reading the + // FileList inside the updater would see it already cleared (no file added, + // no chip). Snapshot to a plain array first. + const picked = Array.from(event.target.files ?? []); // Reset so picking the same file again still fires onChange. event.target.value = ""; + if (picked.length > 0) { + setFiles((prev) => [...prev, ...picked]); + } }, [] ); @@ -141,29 +142,27 @@ export function ChatInput({ )} - {/* 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. */} - -
- + +
-
-
- - {t("invoices.sheet.installments")} - - {invoice.installments.length > 0 ? ( -
- {invoice.installments.map((it, i) => ( -
- {it.label} - - {it.dueAt ? formatInvoiceDate(it.dueAt) : "—"} - - - {formatMoney(it.amount)} - -
- ))} + {/* Installments are a payment plan for an open invoice — once it's + settled there's nothing left to schedule, so hide them. */} + {isPaid ? null : ( +
+ + {t("invoices.sheet.installments")} + + {invoice.installments.length > 0 ? ( +
+ {invoice.installments.map((it, i) => { + const overdue = isInstallmentOverdue(it); + return ( +
+ + + {it.label} + + {overdue ? ( + + {t("invoices.sheet.overdue")} + + ) : null} + + + {it.dueAt ? formatInvoiceDate(it.dueAt) : "—"} + + + {formatMoney(it.amount)} + + {it.paid ? ( + + {t("invoices.sheet.installmentPaid")} + + ) : ( + + )} +
+ ); + })} +
+ ) : ( +

+ {t("invoices.sheet.noInstallments")} +

+ )} + {invoice.installments.length > 0 && lastDue ? ( +

+ {t("invoices.sheet.timeframe", { + count: invoice.installments.length, + date: formatInvoiceDate(lastDue), + })} +

+ ) : null} +
+ +
- ) : ( -

- {t("invoices.sheet.noInstallments")} -

- )} -
- -
-
+ )} {invoice.notes ? (

@@ -265,7 +357,17 @@ export function InvoiceDetailSheet({ {t("invoices.sheet.download")} - + )} + 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/messages/appointment-detail-dialog.tsx b/frontend/components/messages/appointment-detail-dialog.tsx new file mode 100644 index 0000000..682746d --- /dev/null +++ b/frontend/components/messages/appointment-detail-dialog.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { CalendarClock } from "lucide-react"; +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; + +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogDescription, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "@/components/ui/dialog"; +import type { MessageAttachment } from "@/lib/messages"; + +// The appointment data carried inside a shared-appointment message. It's a +// point-in-time snapshot, so it survives the appointment later being edited or +// deleted. +type AppointmentSnapshot = Extract< + MessageAttachment, + { kind: "appointment" } +>["appointment"]; + +function Row({ label, value }: { label: string; value: ReactNode }) { + if (!value) return null; + return ( +
+
{label}
+
{value}
+
+ ); +} + +// A read-only view of a shared appointment, opened by clicking the card in the +// thread. Works the same for the sender and the recipient. +export function AppointmentDetailDialog({ + appointment, + open, + onOpenChange, +}: { + appointment: AppointmentSnapshot; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useTranslation(); + const a = appointment; + const statusLabel = a.status + ? t(`appointments.status.${a.status}`, { defaultValue: a.status }) + : null; + + return ( + + + + + + {t("messages.attach.apptDetailTitle")} + + {a.name} + + +
+ + + + + {statusLabel} : null + } + /> +
+
+
+
+ ); +} diff --git a/frontend/components/messages/messages-view.tsx b/frontend/components/messages/messages-view.tsx index 6658ffe..7c3cb7a 100644 --- a/frontend/components/messages/messages-view.tsx +++ b/frontend/components/messages/messages-view.tsx @@ -5,6 +5,7 @@ import { Download, FileText, Mail, + Paperclip, Plus, Search, SendHorizonal, @@ -21,6 +22,7 @@ import { } from "react"; import { useTranslation } from "react-i18next"; +import { AppointmentDetailDialog } from "@/components/messages/appointment-detail-dialog"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { @@ -40,7 +42,6 @@ import { EmptyTitle, } from "@/components/ui/empty"; import { Input } from "@/components/ui/input"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "@/components/ui/menu"; import { type Appointment, listAppointments } from "@/lib/appointments"; import { authClient } from "@/lib/auth-client"; import { @@ -90,6 +91,7 @@ const GROUP_WINDOW_MS = 5 * 60 * 1000; // shared-appointment card. Alignment (left/right) comes from the parent column. function SentAttachment({ att }: { att: MessageAttachment }) { const { t } = useTranslation(); + const [apptOpen, setApptOpen] = useState(false); if (att.kind === "file") { return ( + + ); } @@ -283,12 +296,15 @@ export function MessagesView() { // Open the file picker; on select, upload each and stage it. const onPickFiles = async (event: ChangeEvent) => { - const files = event.target.files; + // Snapshot before resetting: `event.target.files` is a live FileList that + // `event.target.value = ""` empties, so reading it afterwards (or in a later + // tick) yields nothing. + const picked = Array.from(event.target.files ?? []); event.target.value = ""; - if (!files?.length) return; + if (picked.length === 0) return; setUploading(true); try { - for (const file of Array.from(files)) { + for (const file of picked) { if (file.size > MAX_ATTACHMENT_BYTES) { notify.error( t("messages.attach.tooLargeTitle"), @@ -626,51 +642,38 @@ export function MessagesView() { )}
- {/* Visually hidden (not `display:none`) so the programmatic - `.click()` reliably opens the picker across browsers. */} - wrapping the file + input, so the browser opens the picker on a trusted click. + A programmatic `inputRef.click()` (the old menu item) gets + dropped by user-activation gating once the menu closes — + which is why attaching silently failed and no chip appeared. */} + + (null); const [status, setStatus] = useState("loading"); const [editOpen, setEditOpen] = useState(false); const [transferOpen, setTransferOpen] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + // Related records aggregated into the sheet for a 360° view. + const [prescriptions, setPrescriptions] = useState([]); + const [appointments, setAppointments] = useState([]); + const [invoices, setInvoices] = useState([]); // Bumped on open so the editor remounts with the latest patient data. const [editKey, setEditKey] = useState(0); @@ -72,6 +85,9 @@ export function PatientDetailSheet({ let active = true; setStatus("loading"); setPatient(null); + setPrescriptions([]); + setAppointments([]); + setInvoices([]); getPatient(fileNumber) .then((data) => { if (!active) return; @@ -81,11 +97,37 @@ export function PatientDetailSheet({ .catch(() => { if (active) setStatus("not-found"); }); + // Pull related records in parallel; filter to this chart. Best-effort — a + // missing permission (e.g. reception + prescriptions) just leaves it empty. + const forFile = (fn: string) => fn === fileNumber; + listPrescriptions() + .then((rx) => active && setPrescriptions(rx.filter((r) => forFile(r.fileNumber)))) + .catch(() => {}); + listAppointments() + .then((a) => active && setAppointments(a.filter((r) => forFile(r.fileNumber)))) + .catch(() => {}); + listInvoices() + .then((i) => active && setInvoices(i.filter((r) => forFile(r.fileNumber)))) + .catch(() => {}); return () => { active = false; }; }, [open, fileNumber]); + const remove = async () => { + if (!patient) return; + try { + await deletePatient(patient.fileNumber); + notify.success(t("patients.delete.doneTitle"), patient.name); + onOpenChange(false); + } catch { + notify.error( + t("patients.delete.failedTitle"), + t("patients.delete.failedBody"), + ); + } + }; + const title = status === "ready" && patient ? patient.name @@ -112,6 +154,9 @@ export function PatientDetailSheet({ )} {status === "ready" && patient && ( setConfirmOpen(true) : undefined} onEdit={() => { setEditKey((k) => k + 1); setEditOpen(true); @@ -120,6 +165,7 @@ export function PatientDetailSheet({ canTransfer ? () => setTransferOpen(true) : undefined } patient={patient} + prescriptions={prescriptions} /> )} @@ -145,6 +191,18 @@ export function PatientDetailSheet({ patient={patient} /> )} + + {patient && ( + + )} ); } diff --git a/frontend/components/patients/patient-detail.tsx b/frontend/components/patients/patient-detail.tsx index 6e10cc8..b0bbe1b 100644 --- a/frontend/components/patients/patient-detail.tsx +++ b/frontend/components/patients/patient-detail.tsx @@ -1,14 +1,22 @@ "use client"; -import { ArrowLeftRight, Pencil } from "lucide-react"; +import { ArrowLeftRight, Pencil, Trash2 } from "lucide-react"; import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Sparkline } from "@/components/chat/sparkline"; +import { RecordGraph } from "@/components/graph/record-graph"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import type { Appointment } from "@/lib/appointments"; +import { + formatMoney, + type Invoice, + invoiceTotal, +} from "@/lib/invoices"; import type { AllergySeverity, LabFlag, Patient, Trend } from "@/lib/patients"; +import type { Prescription } from "@/lib/prescriptions"; type BadgeVariant = "default" | "secondary" | "destructive" | "outline"; @@ -82,10 +90,18 @@ export function PatientDetail({ patient, onEdit, onTransfer, + onDelete, + prescriptions, + appointments, + invoices, }: { patient: Patient; onEdit?: () => void; onTransfer?: () => void; + onDelete?: () => void; + prescriptions?: Prescription[]; + appointments?: Appointment[]; + invoices?: Invoice[]; }) { const { t } = useTranslation(); const sex = t(`patientCard.sex.${patient.sex}`); @@ -135,6 +151,17 @@ export function PatientDetail({ {t("patientCard.edit")} )} + {onDelete && ( + + )}
@@ -159,6 +186,13 @@ export function PatientDetail({ +
+

+ {t("patientCard.graph.hint")} +

+ +
+
@@ -301,6 +335,87 @@ export function PatientDetail({
)}
+ + {appointments && ( +
+ {appointments.length === 0 ? ( +

+ {t("patientCard.appointments.empty")} +

+ ) : ( +
+ {appointments.map((appt) => ( + + {appt.type} + + {t(`appointments.status.${appt.status}`)} + + + } + /> + ))} +
+ )} +
+ )} + + {prescriptions && ( +
+ {prescriptions.length === 0 ? ( +

+ {t("patientCard.prescriptions.empty")} +

+ ) : ( +
+ {prescriptions.map((rx) => ( + + {`${rx.dose} · ${rx.frequency}`} + + {t(`prescriptions.status.${rx.status}`)} + + + } + /> + ))} +
+ )} +
+ )} + + {invoices && ( +
+ {invoices.length === 0 ? ( +

+ {t("patientCard.invoices.empty")} +

+ ) : ( +
+ {invoices.map((inv) => ( + + {formatMoney(invoiceTotal(inv))} + + {t(`invoices.status.${inv.status}`)} + + + } + /> + ))} +
+ )} +
+ )} ); } 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/ai-chat.ts b/frontend/lib/ai-chat.ts index b7ba2b8..060c974 100644 --- a/frontend/lib/ai-chat.ts +++ b/frontend/lib/ai-chat.ts @@ -69,6 +69,9 @@ export type ActionPreviewData = { // prefixed with `data-` (e.g. `data-patientCard`), per the AI SDK convention. export type TemetroDataParts = { patientCard: Patient; + // The same patient rendered as an Obsidian-style problems↔visits graph (Graph + // mode). Carries the full record so the graph renders client-side. + recordGraph: Patient; labCard: LabCardData; importPreview: ImportPreviewData; veilNotice: VeilNoticeData; diff --git a/frontend/lib/chat-modes.ts b/frontend/lib/chat-modes.ts new file mode 100644 index 0000000..8de1c30 --- /dev/null +++ b/frontend/lib/chat-modes.ts @@ -0,0 +1,44 @@ +// The chat "situation" modes shown in the composer — what the clinician wants +// the assistant to do, rather than which LLM runs (the model/provider is set +// once in Settings → AI). The mode travels with each send so the backend can +// shape its system prompt, and Graph mode also renders the record graph for the +// `/patient` fast-path. + +import { MessageSquare, Network, Sparkles } from "lucide-react"; + +export type ChatMode = "chat" | "analysis" | "graph"; + +export const DEFAULT_MODE: ChatMode = "chat"; + +export type ChatModeOption = { + id: ChatMode; + icon: typeof MessageSquare; + // i18n keys under `chat.input.modes.*`. + labelKey: string; + descriptionKey: string; +}; + +export const CHAT_MODES: ChatModeOption[] = [ + { + id: "chat", + icon: MessageSquare, + labelKey: "chat.input.modes.chat.label", + descriptionKey: "chat.input.modes.chat.description", + }, + { + id: "analysis", + icon: Sparkles, + labelKey: "chat.input.modes.analysis.label", + descriptionKey: "chat.input.modes.analysis.description", + }, + { + id: "graph", + icon: Network, + labelKey: "chat.input.modes.graph.label", + descriptionKey: "chat.input.modes.graph.description", + }, +]; + +export function getMode(id: string): ChatModeOption { + return CHAT_MODES.find((m) => m.id === id) ?? CHAT_MODES[0]; +} 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 75d2d10..2238875 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -206,6 +206,16 @@ "successBody": "{{name}} is now with {{provider}}.", "errorTitle": "Couldn't transfer patient", "error": "Please try again." + }, + "delete": { + "action": "Delete patient", + "title": "Delete patient?", + "body": "Permanently delete {{name}}'s chart and all of its records. This cannot be undone.", + "confirm": "Delete patient", + "cancel": "Cancel", + "doneTitle": "Patient deleted", + "failedTitle": "Couldn't delete patient", + "failedBody": "Please try again." } }, "appointments": { @@ -351,7 +361,16 @@ "deleteFailedTitle": "Couldn't delete invoice", "deleteFailedBody": "Please try again.", "paid": "Paid", - "unpaid": "Unpaid" + "unpaid": "Unpaid", + "markPaid": "Mark paid", + "paidTitle": "Invoice marked paid", + "payFailedTitle": "Couldn't record payment", + "payFailedBody": "Please try again.", + "payInstallment": "Pay", + "installmentPaid": "Paid", + "installmentPaidTitle": "Installment paid", + "overdue": "Overdue", + "timeframe": "{{count}} payments · due through {{date}}" } }, "prescriptions": { @@ -386,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", @@ -453,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": { @@ -507,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": { @@ -558,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", @@ -631,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", @@ -686,19 +752,22 @@ "apptSearchPlaceholder": "Patient name…", "apptNoMatches": "No appointments match.", "apptEmpty": "No appointments yet.", - "apptCardLabel": "Appointment" + "apptCardLabel": "Appointment", + "apptDetailTitle": "Appointment details", + "apptWhen": "When", + "apptType": "Type", + "apptProvider": "Provider", + "apptPatient": "Patient", + "apptStatus": "Status" } }, "analysis": { "title": "Analysis", "subtitle": "Clinic performance at a glance, computed from your clinic's data.", - "live": { - "title": "Live", - "subtitle": "Patients currently in the building, updating in real time.", - "label": "In the building now", - "start": "Go live", - "pause": "Pause", - "idle": "Live stream paused — press Go live to start." + "area": { + "title": "Patient visits", + "subtitle": "Visit volume over the last six months.", + "label": "Total visits" }, "patientVolume": { "title": "Patient volume", @@ -819,6 +888,21 @@ "send": "Send", "stop": "Stop", "model": "Model", + "mode": "Mode", + "modes": { + "chat": { + "label": "Chat", + "description": "Ask and retrieve patient information." + }, + "analysis": { + "label": "Analysis", + "description": "Interpret patterns across a patient's history." + }, + "graph": { + "label": "Graph", + "description": "See how problems and visits connect." + } + }, "moreModels": "More models", "effort": "Effort", "effortOptions": { @@ -842,6 +926,9 @@ "thinking": "Thinking…", "steps": "Steps", "reasoning": "Reasoning", + "graphCard": { + "label": "Record graph" + }, "history": { "title": "Chats", "untitled": "New chat", @@ -1022,6 +1109,23 @@ "recent": "{{count}} recent", "empty": "No visits yet." }, + "graph": { + "title": "Record graph", + "hint": "How this patient's problems and visits connect. Drag nodes; scroll to pan.", + "empty": "Not enough record data to graph yet." + }, + "appointments": { + "title": "Appointments", + "empty": "No appointments on file." + }, + "prescriptions": { + "title": "Prescriptions", + "empty": "No prescriptions on file." + }, + "invoices": { + "title": "Invoices", + "empty": "No invoices on file." + }, "trend": { "empty": "No trend data yet.", "last": "{{label}} · last {{count}}", diff --git a/frontend/lib/invoices.ts b/frontend/lib/invoices.ts index f45ede8..c0437c9 100644 --- a/frontend/lib/invoices.ts +++ b/frontend/lib/invoices.ts @@ -98,6 +98,55 @@ export function updateInvoice( }); } +// Rebuild the editable input payload from a full invoice, so a partial change +// (paying it, paying an installment) round-trips through PUT without dropping +// any fields. +function invoiceToInput(inv: Invoice): InvoiceInput { + return { + fileNumber: inv.fileNumber, + name: inv.name, + initials: inv.initials, + number: inv.number, + issuedAt: inv.issuedAt, + dueAt: inv.dueAt, + status: inv.status, + lineItems: inv.lineItems, + installments: inv.installments, + notes: inv.notes, + source: inv.source, + }; +} + +// Mark the whole invoice paid: status → paid and every installment settled. +export function markInvoicePaid(inv: Invoice): Promise { + return updateInvoice(inv.id, { + ...invoiceToInput(inv), + status: "paid", + installments: inv.installments.map((it) => ({ ...it, paid: true })), + }); +} + +// Settle a single installment. When that clears the last one, the invoice flips +// to paid automatically. +export function payInstallment(inv: Invoice, index: number): Promise { + const installments = inv.installments.map((it, i) => + i === index ? { ...it, paid: true } : it, + ); + const allPaid = installments.length > 0 && installments.every((it) => it.paid); + return updateInvoice(inv.id, { + ...invoiceToInput(inv), + installments, + status: allPaid ? "paid" : inv.status, + }); +} + +// True when an unpaid installment's due date has passed. +export function isInstallmentOverdue(it: InvoiceInstallment): boolean { + if (it.paid || !it.dueAt) return false; + const due = new Date(`${it.dueAt}T23:59:59`); + return !Number.isNaN(due.getTime()) && due.getTime() < Date.now(); +} + export function splitInvoice(id: string, count: number): Promise { return apiFetch(`/api/invoices/${id}/split`, { method: "POST", diff --git a/frontend/lib/patients.ts b/frontend/lib/patients.ts index 55160cc..abb644b 100644 --- a/frontend/lib/patients.ts +++ b/frontend/lib/patients.ts @@ -130,6 +130,37 @@ export async function appendLabs( ); } +// Permanently delete a patient's chart. Backed by DELETE +// /api/patients/:fileNumber (gated by `patient:delete` — the full-clinician +// marker). Resolves on 204; throws ApiError on failure. +export async function deletePatient(fileNumber: string): Promise { + await apiFetch( + `/api/patients/${encodeURIComponent(fileNumber.trim())}`, + { method: "DELETE" }, + ); +} + +// 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, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8ec9e07..1f5e145 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,6 +22,7 @@ "@tiptap/pm": "^3.25.0", "@tiptap/react": "^3.25.0", "@tiptap/starter-kit": "^3.25.0", + "@types/d3-force": "^3.0.10", "@visx/curve": "^4.0.1-alpha.0", "@visx/event": "^4.0.1-alpha.0", "@visx/gradient": "^4.0.1-alpha.0", @@ -38,6 +39,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "d3-array": "^3.2.4", + "d3-force": "^3.0.0", "embla-carousel-react": "^8.6.0", "framer-motion": "^12.40.0", "i18next": "^26.3.1", diff --git a/frontend/package.json b/frontend/package.json index f799755..c71e290 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "@tiptap/pm": "^3.25.0", "@tiptap/react": "^3.25.0", "@tiptap/starter-kit": "^3.25.0", + "@types/d3-force": "^3.0.10", "@visx/curve": "^4.0.1-alpha.0", "@visx/event": "^4.0.1-alpha.0", "@visx/gradient": "^4.0.1-alpha.0", @@ -39,6 +40,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "d3-array": "^3.2.4", + "d3-force": "^3.0.0", "embla-carousel-react": "^8.6.0", "framer-motion": "^12.40.0", "i18next": "^26.3.1",