mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
Merge pull request #5 from temetro/feature/clinical-fixes-and-chat
Clinical UX batch: record graph, AI modes, payments, deletes
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+3
-2
@@ -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)
|
||||
|
||||
@@ -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<Analytics | null>(null);
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
|
||||
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() {
|
||||
<section className="flex flex-col gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg tracking-tight">
|
||||
{t("analysis.live.title")}
|
||||
{t("analysis.area.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("analysis.live.subtitle")}
|
||||
{t("analysis.area.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Card className="gap-3 p-4">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("analysis.live.label")}
|
||||
</span>
|
||||
<LiveHospitalChart />
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("analysis.area.label")}
|
||||
</span>
|
||||
<span className="font-semibold text-foreground text-xl tabular-nums">
|
||||
{visitTotal}
|
||||
</span>
|
||||
</div>
|
||||
<AreaChart aspectRatio="3 / 1" data={visitData}>
|
||||
<Grid horizontal />
|
||||
<Area dataKey="visits" fill="var(--chart-line-primary)" />
|
||||
<XAxis numTicks={Math.max(visitData.length, 2)} tickMode="data" />
|
||||
<ChartTooltip showDatePill={false} />
|
||||
</AreaChart>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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<LiveLinePoint[]>([]);
|
||||
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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
onClick={() => setLive((v) => !v)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={live ? "outline" : "default"}
|
||||
>
|
||||
{live ? (
|
||||
<>
|
||||
<Pause className="size-4" />
|
||||
{t("analysis.live.pause")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="size-4" />
|
||||
{t("analysis.live.start")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{live ? (
|
||||
<div className="h-56 w-full">
|
||||
<LiveLineChart
|
||||
data={data}
|
||||
margin={{ left: 52, right: 48 }}
|
||||
value={value}
|
||||
window={WINDOW_SECONDS}
|
||||
>
|
||||
<LiveLine
|
||||
dataKey="value"
|
||||
formatValue={(v) => String(Math.round(v))}
|
||||
momentumColors={{
|
||||
up: "var(--color-emerald-500)",
|
||||
down: "var(--color-red-500)",
|
||||
flat: "var(--chart-line-primary)",
|
||||
}}
|
||||
/>
|
||||
<LiveYAxis
|
||||
formatValue={(v) => String(Math.round(v))}
|
||||
position="left"
|
||||
/>
|
||||
<ChartTooltip showDatePill={false} />
|
||||
</LiveLineChart>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-56 w-full items-center justify-center rounded-2xl border border-dashed bg-card/20 text-center text-muted-foreground text-sm">
|
||||
{t("analysis.live.idle")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLInputElement>) => {
|
||||
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({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visually hidden (not `display:none`) so programmatic `.click()` from
|
||||
the attach button works reliably across browsers — a `display:none`
|
||||
file input can refuse to open the picker. */}
|
||||
<input
|
||||
aria-label={t("chat.input.attachFiles")}
|
||||
className="sr-only"
|
||||
multiple
|
||||
onChange={handleFilesSelected}
|
||||
ref={fileInputRef}
|
||||
tabIndex={-1}
|
||||
type="file"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 px-3 pb-3">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<button
|
||||
{/* The attach control is a real <label> wrapping the file input, so
|
||||
the browser opens the picker natively on click. A programmatic
|
||||
`inputRef.click()` gets dropped when the textarea is focused with
|
||||
text (the click blurs it first, losing the user-activation), which
|
||||
is why attaching failed while typing. A label has no such gate. */}
|
||||
<label
|
||||
aria-label={t("chat.input.attachFile")}
|
||||
className={iconButton}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
type="button"
|
||||
className={cn(iconButton, "cursor-pointer")}
|
||||
>
|
||||
<Plus className="size-[18px]" />
|
||||
</button>
|
||||
<input
|
||||
aria-label={t("chat.input.attachFiles")}
|
||||
className="sr-only"
|
||||
multiple
|
||||
onChange={handleFilesSelected}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className={cn(contextPill, "ml-0.5")}
|
||||
onClick={() => {
|
||||
@@ -178,11 +177,9 @@ export function ChatInput({
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<ModelPicker
|
||||
effort={effort}
|
||||
model={model}
|
||||
onEffortChange={onEffortChange}
|
||||
onModelChange={onModelChange}
|
||||
<ModePicker
|
||||
mode={mode}
|
||||
onModeChange={onModeChange}
|
||||
triggerClassName={cn(pillButton, "mr-1")}
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -61,6 +61,7 @@ import { InventoryListCard } from "@/components/chat/inventory-list-card";
|
||||
import { ImportPreviewCard } from "@/components/chat/import-preview-card";
|
||||
import { LabChartCard } from "@/components/chat/lab-chart-card";
|
||||
import { PatientResult } from "@/components/chat/patient-cards";
|
||||
import { RecordGraph } from "@/components/graph/record-graph";
|
||||
import {
|
||||
AppointmentListCard,
|
||||
PrescriptionListCard,
|
||||
@@ -79,6 +80,7 @@ import {
|
||||
type Effort,
|
||||
getModel,
|
||||
} from "@/lib/ai-models";
|
||||
import { type ChatMode, DEFAULT_MODE } from "@/lib/chat-modes";
|
||||
import type { ActionPreviewData, TemetroUIMessage } from "@/lib/ai-chat";
|
||||
import {
|
||||
getThread,
|
||||
@@ -114,6 +116,9 @@ export function ChatPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [model, setModel] = useState<string>(DEFAULT_MODEL_ID);
|
||||
const [effort, setEffort] = useState<Effort>(DEFAULT_EFFORT);
|
||||
// The clinician-facing "situation" mode (Chat / Analysis / Graph). The model
|
||||
// itself comes from Settings → AI; this shapes what the assistant does.
|
||||
const [mode, setMode] = useState<ChatMode>(DEFAULT_MODE);
|
||||
|
||||
// Veil consent: cloud models de-identify + send data externally. We ask once
|
||||
// per session before the first such send — inline (no modal). `pendingConsent`
|
||||
@@ -185,10 +190,10 @@ export function ChatPanel() {
|
||||
const fileParts = await Promise.all(files.map(fileToPart));
|
||||
sendMessage(
|
||||
{ text, files: fileParts },
|
||||
{ body: { model: modelId, effort, threadId: threadIdRef.current } },
|
||||
{ body: { model: modelId, effort, mode, threadId: threadIdRef.current } },
|
||||
);
|
||||
},
|
||||
[sendMessage, effort],
|
||||
[sendMessage, effort, mode],
|
||||
);
|
||||
|
||||
const send = useCallback(
|
||||
@@ -223,7 +228,11 @@ export function ChatPanel() {
|
||||
id: nanoid(),
|
||||
role: "assistant",
|
||||
parts: patient
|
||||
? [{ type: "data-patientCard", data: patient }]
|
||||
? [
|
||||
mode === "graph"
|
||||
? { type: "data-recordGraph", data: patient }
|
||||
: { type: "data-patientCard", data: patient },
|
||||
]
|
||||
: [
|
||||
{
|
||||
type: "text",
|
||||
@@ -245,6 +254,7 @@ export function ChatPanel() {
|
||||
[
|
||||
consented,
|
||||
isCloudModel,
|
||||
mode,
|
||||
model,
|
||||
pendingConsent,
|
||||
runAgentWith,
|
||||
@@ -353,10 +363,8 @@ export function ChatPanel() {
|
||||
|
||||
const promptInput = (
|
||||
<ChatInput
|
||||
effort={effort}
|
||||
model={model}
|
||||
onEffortChange={setEffort}
|
||||
onModelChange={setModel}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
onStop={stop}
|
||||
onSubmit={send}
|
||||
status={status}
|
||||
@@ -545,6 +553,26 @@ export function ChatPanel() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (part.type === "data-recordGraph") {
|
||||
return (
|
||||
<div
|
||||
className="w-full overflow-hidden rounded-2xl border bg-card/30"
|
||||
key={key}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-4 pt-3">
|
||||
<span className="font-medium text-foreground text-sm">
|
||||
{part.data.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("chat.graphCard.label")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<RecordGraph patient={part.data} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (part.type === "data-labCard") {
|
||||
return <LabChartCard data={part.data} key={key} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
Menu,
|
||||
MenuPopup,
|
||||
MenuRadioGroup,
|
||||
MenuRadioItem,
|
||||
MenuTrigger,
|
||||
} from "@/components/ui/menu";
|
||||
import { type ChatMode, CHAT_MODES, getMode } from "@/lib/chat-modes";
|
||||
|
||||
type ModePickerProps = {
|
||||
mode: ChatMode;
|
||||
onModeChange: (mode: ChatMode) => void;
|
||||
triggerClassName: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The situation-mode control in the chat composer (Chat / Analysis / Graph).
|
||||
* Replaces the old model picker — clinicians pick what they want the assistant
|
||||
* to do, not which LLM runs (that's configured once in Settings → AI).
|
||||
*/
|
||||
export function ModePicker({
|
||||
mode,
|
||||
onModeChange,
|
||||
triggerClassName,
|
||||
}: ModePickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const selected = getMode(mode);
|
||||
const SelectedIcon = selected.icon;
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
<MenuTrigger
|
||||
render={
|
||||
<button
|
||||
aria-label={t("chat.input.mode")}
|
||||
className={triggerClassName}
|
||||
type="button"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SelectedIcon className="size-4 opacity-80" />
|
||||
<span className="truncate font-medium text-foreground">
|
||||
{t(selected.labelKey)}
|
||||
</span>
|
||||
<ChevronDown className="size-4 opacity-70" />
|
||||
</MenuTrigger>
|
||||
<MenuPopup align="end" className="min-w-64">
|
||||
<MenuRadioGroup
|
||||
onValueChange={(value) => onModeChange(value as ChatMode)}
|
||||
value={mode}
|
||||
>
|
||||
{CHAT_MODES.map((m) => {
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
<MenuRadioItem key={m.id} value={m.id}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon className="size-4 opacity-80" />
|
||||
<span className="flex flex-col">
|
||||
<span className="text-foreground">{t(m.labelKey)}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t(m.descriptionKey)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</MenuRadioItem>
|
||||
);
|
||||
})}
|
||||
</MenuRadioGroup>
|
||||
</MenuPopup>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
// An Obsidian-style knowledge graph of one patient's record: the patient sits
|
||||
// at the centre, their problems (illnesses) and encounters (visits) orbit it,
|
||||
// and a visit links to a problem when the visit references it. Layout is a
|
||||
// d3-force simulation computed once; rendering + pan/zoom/drag is React Flow.
|
||||
|
||||
import {
|
||||
Background,
|
||||
Controls,
|
||||
type Edge,
|
||||
Handle,
|
||||
type Node,
|
||||
type NodeProps,
|
||||
Position,
|
||||
ReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import {
|
||||
forceCenter,
|
||||
forceCollide,
|
||||
forceLink,
|
||||
forceManyBody,
|
||||
forceSimulation,
|
||||
type SimulationLinkDatum,
|
||||
type SimulationNodeDatum,
|
||||
} from "d3-force";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { Patient } from "@/lib/patients";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Kind = "patient" | "problem" | "visit";
|
||||
|
||||
type SimNode = SimulationNodeDatum & {
|
||||
id: string;
|
||||
kind: Kind;
|
||||
label: string;
|
||||
sub?: string;
|
||||
};
|
||||
type SimLink = SimulationLinkDatum<SimNode>;
|
||||
|
||||
type RecordNodeData = { label: string; sub?: string; kind: Kind };
|
||||
|
||||
const kindClass: Record<Kind, string> = {
|
||||
patient: "bg-primary text-primary-foreground border-primary px-4 py-3 text-sm",
|
||||
problem: "bg-destructive/12 text-foreground border-destructive/50",
|
||||
visit: "bg-muted text-foreground border-border",
|
||||
};
|
||||
|
||||
// A pill node with hidden connection handles so edges meet it cleanly.
|
||||
function RecordNode({ data }: NodeProps) {
|
||||
const { label, sub, kind } = data as RecordNodeData;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl border px-3 py-2 text-center text-xs shadow-sm",
|
||||
kindClass[kind],
|
||||
kind === "patient" && "font-semibold",
|
||||
)}
|
||||
>
|
||||
<Handle className="!opacity-0" position={Position.Top} type="target" />
|
||||
<div className="max-w-36 truncate font-medium">{label}</div>
|
||||
{sub ? <div className="max-w-36 truncate opacity-70">{sub}</div> : null}
|
||||
<Handle className="!opacity-0" position={Position.Bottom} type="source" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeTypes = { record: RecordNode };
|
||||
|
||||
// Build nodes + edges from the record. A visit links to a problem when its
|
||||
// type/summary mentions the problem label (case-insensitive) — that produces
|
||||
// the clustered "hub" look.
|
||||
function buildGraph(patient: Patient): {
|
||||
nodes: SimNode[];
|
||||
edges: { source: string; target: string }[];
|
||||
} {
|
||||
const nodes: SimNode[] = [
|
||||
{ id: "patient", kind: "patient", label: patient.name },
|
||||
];
|
||||
const edges: { source: string; target: string }[] = [];
|
||||
|
||||
patient.problems.forEach((p, i) => {
|
||||
const id = `prob-${i}`;
|
||||
nodes.push({ id, kind: "problem", label: p.label, sub: p.since });
|
||||
edges.push({ source: "patient", target: id });
|
||||
});
|
||||
|
||||
patient.encounters.forEach((e, i) => {
|
||||
const id = `enc-${i}`;
|
||||
nodes.push({ id, kind: "visit", label: e.type, sub: e.date });
|
||||
edges.push({ source: "patient", target: id });
|
||||
const hay = `${e.type} ${e.summary}`.toLowerCase();
|
||||
patient.problems.forEach((p, pi) => {
|
||||
if (p.label && hay.includes(p.label.toLowerCase())) {
|
||||
edges.push({ source: id, target: `prob-${pi}` });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
// Run the force simulation to completion so positions are stable on first paint.
|
||||
function layout(nodes: SimNode[], edges: { source: string; target: string }[]) {
|
||||
const links: SimLink[] = edges.map((e) => ({ ...e }));
|
||||
forceSimulation(nodes)
|
||||
.force("charge", forceManyBody().strength(-340))
|
||||
.force(
|
||||
"link",
|
||||
forceLink<SimNode, SimLink>(links)
|
||||
.id((d) => d.id)
|
||||
.distance(96)
|
||||
.strength(0.45),
|
||||
)
|
||||
.force("center", forceCenter(240, 170))
|
||||
.force("collide", forceCollide(50))
|
||||
.stop()
|
||||
.tick(320);
|
||||
}
|
||||
|
||||
export function RecordGraph({
|
||||
patient,
|
||||
className,
|
||||
}: {
|
||||
patient: Patient;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { nodes, edges } = useMemo(() => {
|
||||
const g = buildGraph(patient);
|
||||
layout(g.nodes, g.edges);
|
||||
const rfNodes: Node[] = g.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
type: "record",
|
||||
position: { x: n.x ?? 0, y: n.y ?? 0 },
|
||||
data: { label: n.label, sub: n.sub, kind: n.kind },
|
||||
}));
|
||||
const rfEdges: Edge[] = g.edges.map((e, i) => ({
|
||||
id: `e-${i}`,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
style: { stroke: "var(--border)", strokeWidth: 1.5 },
|
||||
}));
|
||||
return { nodes: rfNodes, edges: rfEdges };
|
||||
}, [patient]);
|
||||
|
||||
if (patient.problems.length === 0 && patient.encounters.length === 0) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">{t("patientCard.graph.empty")}</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-80 w-full overflow-hidden rounded-2xl border bg-card/30",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ReactFlow
|
||||
edges={edges}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
nodeTypes={nodeTypes}
|
||||
nodes={nodes}
|
||||
nodesConnectable={false}
|
||||
panOnScroll
|
||||
proOptions={{ hideAttribution: true }}
|
||||
zoomOnScroll={false}
|
||||
>
|
||||
<Background color="var(--border)" gap={20} />
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Download, Pencil, Split, Trash2 } from "lucide-react";
|
||||
import {
|
||||
CircleCheck,
|
||||
Download,
|
||||
Pencil,
|
||||
Split,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -24,6 +30,9 @@ import {
|
||||
type Invoice,
|
||||
type InvoiceStatus,
|
||||
invoiceTotal,
|
||||
isInstallmentOverdue,
|
||||
markInvoicePaid,
|
||||
payInstallment,
|
||||
splitInvoice,
|
||||
} from "@/lib/invoices";
|
||||
import { notify } from "@/lib/toast";
|
||||
@@ -74,6 +83,14 @@ export function InvoiceDetailSheet({
|
||||
}
|
||||
|
||||
const total = invoiceTotal(invoice);
|
||||
const isPaid = invoice.status === "paid";
|
||||
const isSettled = isPaid || invoice.status === "void";
|
||||
// The schedule window: the latest installment due date, for the timeframe hint.
|
||||
const lastDue = invoice.installments.reduce<string | null>(
|
||||
(latest, it) =>
|
||||
it.dueAt && (!latest || it.dueAt > latest) ? it.dueAt : latest,
|
||||
null,
|
||||
);
|
||||
|
||||
const split = async () => {
|
||||
setBusy(true);
|
||||
@@ -94,6 +111,41 @@ export function InvoiceDetailSheet({
|
||||
}
|
||||
};
|
||||
|
||||
const payAll = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const updated = await markInvoicePaid(invoice);
|
||||
onChanged(updated);
|
||||
notify.success(t("invoices.sheet.paidTitle"), invoice.number);
|
||||
} catch {
|
||||
notify.error(
|
||||
t("invoices.sheet.payFailedTitle"),
|
||||
t("invoices.sheet.payFailedBody"),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const payOne = async (index: number) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const updated = await payInstallment(invoice, index);
|
||||
onChanged(updated);
|
||||
notify.success(
|
||||
t("invoices.sheet.installmentPaidTitle"),
|
||||
invoice.installments[index]?.label ?? invoice.number,
|
||||
);
|
||||
} catch {
|
||||
notify.error(
|
||||
t("invoices.sheet.payFailedTitle"),
|
||||
t("invoices.sheet.payFailedBody"),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -185,58 +237,98 @@ export function InvoiceDetailSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("invoices.sheet.installments")}
|
||||
</span>
|
||||
{invoice.installments.length > 0 ? (
|
||||
<div className="divide-y divide-border overflow-hidden rounded-2xl border">
|
||||
{invoice.installments.map((it, i) => (
|
||||
<div
|
||||
className="flex items-center justify-between gap-3 px-3 py-2 text-sm"
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: positional
|
||||
key={i}
|
||||
>
|
||||
<span className="text-foreground">{it.label}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{it.dueAt ? formatInvoiceDate(it.dueAt) : "—"}
|
||||
</span>
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{formatMoney(it.amount)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{/* Installments are a payment plan for an open invoice — once it's
|
||||
settled there's nothing left to schedule, so hide them. */}
|
||||
{isPaid ? null : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("invoices.sheet.installments")}
|
||||
</span>
|
||||
{invoice.installments.length > 0 ? (
|
||||
<div className="divide-y divide-border overflow-hidden rounded-2xl border">
|
||||
{invoice.installments.map((it, i) => {
|
||||
const overdue = isInstallmentOverdue(it);
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between gap-3 px-3 py-2 text-sm"
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: positional
|
||||
key={i}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-foreground">
|
||||
{it.label}
|
||||
</span>
|
||||
{overdue ? (
|
||||
<Badge variant="destructive">
|
||||
{t("invoices.sheet.overdue")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground text-xs">
|
||||
{it.dueAt ? formatInvoiceDate(it.dueAt) : "—"}
|
||||
</span>
|
||||
<span className="shrink-0 font-medium text-foreground tabular-nums">
|
||||
{formatMoney(it.amount)}
|
||||
</span>
|
||||
{it.paid ? (
|
||||
<Badge className="shrink-0" variant="outline">
|
||||
{t("invoices.sheet.installmentPaid")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
className="shrink-0"
|
||||
disabled={busy}
|
||||
onClick={() => payOne(i)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t("invoices.sheet.payInstallment")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("invoices.sheet.noInstallments")}
|
||||
</p>
|
||||
)}
|
||||
{invoice.installments.length > 0 && lastDue ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("invoices.sheet.timeframe", {
|
||||
count: invoice.installments.length,
|
||||
date: formatInvoiceDate(lastDue),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex items-end gap-2">
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("invoices.sheet.splitCount")}
|
||||
</span>
|
||||
<Input
|
||||
className="w-20"
|
||||
max={36}
|
||||
min={1}
|
||||
onChange={(e) => setCount(Number(e.target.value) || 1)}
|
||||
type="number"
|
||||
value={count}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={split}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Split className="size-4" />
|
||||
{t("invoices.sheet.split")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("invoices.sheet.noInstallments")}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-end gap-2">
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("invoices.sheet.splitCount")}
|
||||
</span>
|
||||
<Input
|
||||
className="w-20"
|
||||
max={36}
|
||||
min={1}
|
||||
onChange={(e) => setCount(Number(e.target.value) || 1)}
|
||||
type="number"
|
||||
value={count}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={split}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Split className="size-4" />
|
||||
{t("invoices.sheet.split")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invoice.notes ? (
|
||||
<p className="whitespace-pre-wrap text-foreground text-sm leading-relaxed">
|
||||
@@ -265,7 +357,17 @@ export function InvoiceDetailSheet({
|
||||
<Download className="size-4" />
|
||||
{t("invoices.sheet.download")}
|
||||
</Button>
|
||||
<Button onClick={() => onEdit(invoice)} type="button">
|
||||
{isSettled ? null : (
|
||||
<Button disabled={busy} onClick={payAll} type="button">
|
||||
<CircleCheck className="size-4" />
|
||||
{t("invoices.sheet.markPaid")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => onEdit(invoice)}
|
||||
type="button"
|
||||
variant={isSettled ? "default" : "outline"}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
{t("invoices.sheet.edit")}
|
||||
</Button>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<dt className="text-muted-foreground text-sm">{label}</dt>
|
||||
<dd className="text-right text-foreground text-sm">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogPopup className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<CalendarClock className="size-4 text-muted-foreground" />
|
||||
{t("messages.attach.apptDetailTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{a.name}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogPanel>
|
||||
<dl className="flex flex-col gap-2.5">
|
||||
<Row
|
||||
label={t("messages.attach.apptWhen")}
|
||||
value={[a.date, a.time].filter(Boolean).join(" · ")}
|
||||
/>
|
||||
<Row label={t("messages.attach.apptType")} value={a.type} />
|
||||
<Row label={t("messages.attach.apptProvider")} value={a.provider} />
|
||||
<Row
|
||||
label={t("messages.attach.apptPatient")}
|
||||
value={a.fileNumber ? `#${a.fileNumber}` : null}
|
||||
/>
|
||||
<Row
|
||||
label={t("messages.attach.apptStatus")}
|
||||
value={
|
||||
statusLabel ? <Badge variant="outline">{statusLabel}</Badge> : null
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
</DialogPanel>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<button
|
||||
@@ -109,21 +111,32 @@ function SentAttachment({ att }: { att: MessageAttachment }) {
|
||||
}
|
||||
const a = att.appointment;
|
||||
return (
|
||||
<div className="max-w-[75%] rounded-2xl border bg-card p-3 text-sm">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
|
||||
<CalendarClock className="size-3.5" />
|
||||
{t("messages.attach.apptCardLabel")}
|
||||
</div>
|
||||
<p className="mt-1 font-medium text-foreground">{a.name}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{[a.date, a.time].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
{[a.type, a.provider].filter(Boolean).length > 0 && (
|
||||
<>
|
||||
<button
|
||||
className="max-w-[75%] rounded-2xl border bg-card p-3 text-left text-sm transition-colors hover:bg-accent"
|
||||
onClick={() => setApptOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
|
||||
<CalendarClock className="size-3.5" />
|
||||
{t("messages.attach.apptCardLabel")}
|
||||
</div>
|
||||
<p className="mt-1 font-medium text-foreground">{a.name}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{[a.type, a.provider].filter(Boolean).join(" · ")}
|
||||
{[a.date, a.time].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{[a.type, a.provider].filter(Boolean).length > 0 && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{[a.type, a.provider].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
<AppointmentDetailDialog
|
||||
appointment={a}
|
||||
onOpenChange={setApptOpen}
|
||||
open={apptOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -283,12 +296,15 @@ export function MessagesView() {
|
||||
|
||||
// Open the file picker; on select, upload each and stage it.
|
||||
const onPickFiles = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
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() {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Visually hidden (not `display:none`) so the programmatic
|
||||
`.click()` reliably opens the picker across browsers. */}
|
||||
<input
|
||||
{/* The attach control is a native <label> 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. */}
|
||||
<label
|
||||
aria-label={t("messages.attach.file")}
|
||||
className="sr-only"
|
||||
multiple
|
||||
onChange={onPickFiles}
|
||||
ref={fileInputRef}
|
||||
tabIndex={-1}
|
||||
type="file"
|
||||
/>
|
||||
<Menu>
|
||||
<MenuTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={t("messages.attach.menu")}
|
||||
disabled={uploading}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</MenuTrigger>
|
||||
<MenuPopup align="start" side="top">
|
||||
{/* Defer past the menu's close/unmount so the file picker
|
||||
and the appointment dialog aren't swallowed by the
|
||||
closing overlay's focus handling. */}
|
||||
<MenuItem
|
||||
onClick={() =>
|
||||
requestAnimationFrame(() => fileInputRef.current?.click())
|
||||
}
|
||||
>
|
||||
<FileText className="size-4 text-muted-foreground" />
|
||||
{t("messages.attach.file")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => requestAnimationFrame(openApptPicker)}
|
||||
>
|
||||
<CalendarClock className="size-4 text-muted-foreground" />
|
||||
{t("messages.attach.appointment")}
|
||||
</MenuItem>
|
||||
</MenuPopup>
|
||||
</Menu>
|
||||
className={cn(
|
||||
"inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground",
|
||||
uploading && "pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
<input
|
||||
aria-label={t("messages.attach.file")}
|
||||
className="sr-only"
|
||||
multiple
|
||||
onChange={onPickFiles}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
aria-label={t("messages.attach.appointment")}
|
||||
disabled={uploading}
|
||||
onClick={openApptPicker}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<CalendarClock className="size-4" />
|
||||
</Button>
|
||||
<Input
|
||||
aria-label={t("messages.newMessage")}
|
||||
className="border-0 bg-transparent shadow-none before:hidden"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AiBadge } from "@/components/ai-badge";
|
||||
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
|
||||
import { PatientDetail } from "@/components/patients/patient-detail";
|
||||
import { TransferPatientDialog } from "@/components/patients/transfer-patient-dialog";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import {
|
||||
Sheet,
|
||||
SheetHeader,
|
||||
@@ -15,8 +16,12 @@ import {
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { getPatient, type Patient } from "@/lib/patients";
|
||||
import { type Appointment, listAppointments } from "@/lib/appointments";
|
||||
import { type Invoice, listInvoices } from "@/lib/invoices";
|
||||
import { deletePatient, getPatient, type Patient } from "@/lib/patients";
|
||||
import { listPrescriptions, type Prescription } from "@/lib/prescriptions";
|
||||
import { hasClinicalAccess, useActiveRole } from "@/lib/roles";
|
||||
import { notify } from "@/lib/toast";
|
||||
|
||||
type Status = "loading" | "ready" | "not-found";
|
||||
|
||||
@@ -60,10 +65,18 @@ export function PatientDetailSheet({
|
||||
const role = useActiveRole();
|
||||
// Clinical roles can reassign a chart; show optimistically while role loads.
|
||||
const canTransfer = role == null || hasClinicalAccess(role);
|
||||
// Deleting a chart is destructive — only offer it once we know the role is
|
||||
// a full clinician (patient:delete), never optimistically.
|
||||
const canDelete = role != null && hasClinicalAccess(role);
|
||||
const [patient, setPatient] = useState<Patient | null>(null);
|
||||
const [status, setStatus] = useState<Status>("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<Prescription[]>([]);
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||
// 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 && (
|
||||
<PatientDetail
|
||||
appointments={appointments}
|
||||
invoices={invoices}
|
||||
onDelete={canDelete ? () => 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}
|
||||
/>
|
||||
)}
|
||||
</SheetPanel>
|
||||
@@ -145,6 +191,18 @@ export function PatientDetailSheet({
|
||||
patient={patient}
|
||||
/>
|
||||
)}
|
||||
|
||||
{patient && (
|
||||
<ConfirmDialog
|
||||
cancelLabel={t("patients.delete.cancel")}
|
||||
confirmLabel={t("patients.delete.confirm")}
|
||||
description={t("patients.delete.body", { name: patient.name })}
|
||||
onConfirm={remove}
|
||||
onOpenChange={setConfirmOpen}
|
||||
open={confirmOpen}
|
||||
title={t("patients.delete.title")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
aria-label={t("patients.delete.action")}
|
||||
onClick={onDelete}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -159,6 +186,13 @@ export function PatientDetail({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t("patientCard.graph.title")}>
|
||||
<p className="mb-3 text-muted-foreground text-xs">
|
||||
{t("patientCard.graph.hint")}
|
||||
</p>
|
||||
<RecordGraph patient={patient} />
|
||||
</Section>
|
||||
|
||||
<Section title={t("patientCard.vitals.title")}>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">
|
||||
<Stat label={t("patientCard.vitals.bp")} value={patient.vitals.bp} />
|
||||
@@ -301,6 +335,87 @@ export function PatientDetail({
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{appointments && (
|
||||
<Section title={t("patientCard.appointments.title")}>
|
||||
{appointments.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("patientCard.appointments.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{appointments.map((appt) => (
|
||||
<Row
|
||||
key={appt.id}
|
||||
label={`${appt.date} · ${appt.time}`}
|
||||
value={
|
||||
<span className="flex items-center gap-2">
|
||||
{appt.type}
|
||||
<Badge variant="outline">
|
||||
{t(`appointments.status.${appt.status}`)}
|
||||
</Badge>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{prescriptions && (
|
||||
<Section title={t("patientCard.prescriptions.title")}>
|
||||
{prescriptions.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("patientCard.prescriptions.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{prescriptions.map((rx) => (
|
||||
<Row
|
||||
key={rx.id}
|
||||
label={rx.medication}
|
||||
value={
|
||||
<span className="flex items-center gap-2">
|
||||
{`${rx.dose} · ${rx.frequency}`}
|
||||
<Badge variant="outline">
|
||||
{t(`prescriptions.status.${rx.status}`)}
|
||||
</Badge>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{invoices && (
|
||||
<Section title={t("patientCard.invoices.title")}>
|
||||
{invoices.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("patientCard.invoices.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{invoices.map((inv) => (
|
||||
<Row
|
||||
key={inv.id}
|
||||
label={inv.number}
|
||||
value={
|
||||
<span className="flex items-center gap-2 tabular-nums">
|
||||
{formatMoney(invoiceTotal(inv))}
|
||||
<Badge variant="outline">
|
||||
{t(`invoices.status.${inv.status}`)}
|
||||
</Badge>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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<Invoice> {
|
||||
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<Invoice> {
|
||||
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<Invoice> {
|
||||
return apiFetch<Invoice>(`/api/invoices/${id}/split`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -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<void> {
|
||||
await apiFetch<void>(
|
||||
`/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<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,
|
||||
|
||||
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user