diff --git a/backend/src/services/appointments.ts b/backend/src/services/appointments.ts index bbad675..59e767d 100644 --- a/backend/src/services/appointments.ts +++ b/backend/src/services/appointments.ts @@ -4,6 +4,7 @@ import { db } from "../db/index.js"; import { appointments } from "../db/schema/appointments.js"; import type { AppointmentInput } from "../lib/appointment-validation.js"; import type { Appointment } from "../types/appointment.js"; +import * as patients from "./patients.js"; type AppointmentRow = typeof appointments.$inferSelect; @@ -58,9 +59,16 @@ export async function createAppointment( userId: string, input: AppointmentInput, ): Promise { + // Link to a patient — creating one when the booking has no file number (e.g. + // an AI-imported appointment), so the person shows up on the Patients page. + const fileNumber = await patients.ensurePatient(orgId, userId, { + fileNumber: input.fileNumber, + name: input.name, + initials: input.initials, + }); const [row] = await db .insert(appointments) - .values(columns(orgId, input, userId)) + .values(columns(orgId, { ...input, fileNumber }, userId)) .returning(); return toAppointment(row!); } diff --git a/backend/src/services/patients.ts b/backend/src/services/patients.ts index 11d195f..640f6d9 100644 --- a/backend/src/services/patients.ts +++ b/backend/src/services/patients.ts @@ -11,7 +11,10 @@ import { problems, } from "../db/schema/patients.js"; import { HttpError } from "../lib/http-error.js"; -import type { PatientInput } from "../lib/patient-validation.js"; +import { + patientInputSchema, + type PatientInput, +} from "../lib/patient-validation.js"; import type { Allergy, Encounter, @@ -314,6 +317,40 @@ export async function generateFileNumber(orgId: string): Promise { return String(Number(r?.max ?? 9999) + 1); } +// Resolve the file number to attach a denormalized record (e.g. an appointment) +// to a patient. With a file number, use it as-is. Without one (AI-imported rows), +// reuse an existing same-name patient when present — deduping repeat rows and +// re-imports — otherwise create a minimal patient (auto file number, source "ai") +// so they appear on the Patients page. Returns the file number to link. +export async function ensurePatient( + orgId: string, + userId: string, + patient: { fileNumber: string; name: string; initials: string }, +): Promise { + if (patient.fileNumber) return patient.fileNumber; + const [existing] = await db + .select({ fileNumber: patients.fileNumber }) + .from(patients) + .where( + and( + eq(patients.organizationId, orgId), + eq(patients.name, patient.name), + ), + ) + .limit(1); + if (existing) return existing.fileNumber; + const created = await createPatient( + orgId, + userId, + patientInputSchema.parse({ + name: patient.name, + initials: patient.initials, + source: "ai", + }), + ); + return created.fileNumber; +} + export async function listPatients( orgId: string, demographicsOnly = false, diff --git a/frontend/components/analysis/live-hospital-chart.tsx b/frontend/components/analysis/live-hospital-chart.tsx index 804d99c..a7b3959 100644 --- a/frontend/components/analysis/live-hospital-chart.tsx +++ b/frontend/components/analysis/live-hospital-chart.tsx @@ -86,7 +86,7 @@ export function LiveHospitalChart() {
diff --git a/frontend/components/chat/action-preview-card.tsx b/frontend/components/chat/action-preview-card.tsx index 18cc114..8a2ae3c 100644 --- a/frontend/components/chat/action-preview-card.tsx +++ b/frontend/components/chat/action-preview-card.tsx @@ -14,14 +14,16 @@ import { notify } from "@/lib/toast"; type Status = "pending" | "committing" | "done" | "rejected"; -const ICONS = { +export const ACTION_ICONS = { appointment: CalendarPlus, task: ClipboardList, prescription: Pill, } as const; +const ICONS = ACTION_ICONS; + // Summarise the proposed record into a couple of readable lines per kind. -function summarize(data: ActionPreviewData): string[] { +export function summarize(data: ActionPreviewData): string[] { const r = data.record as Record; if (data.kind === "appointment") { return [ @@ -44,7 +46,7 @@ function summarize(data: ActionPreviewData): string[] { ].filter(Boolean); } -async function commit(data: ActionPreviewData): Promise { +export async function commitAction(data: ActionPreviewData): Promise { // Stamp provenance so the committed record is flagged "Added by AI" and shows // up for review/editing on the relevant page. if (data.kind === "appointment") { @@ -75,7 +77,7 @@ export function ActionPreviewCard({ data }: { data: ActionPreviewData }) { const approve = async () => { setStatus("committing"); try { - await commit(data); + await commitAction(data); setStatus("done"); notify.success( t("chat.actionCard.addedTitle"), diff --git a/frontend/components/chat/batch-action-preview-card.tsx b/frontend/components/chat/batch-action-preview-card.tsx new file mode 100644 index 0000000..ae4e70c --- /dev/null +++ b/frontend/components/chat/batch-action-preview-card.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { AlertTriangle, Check, Sparkles, X } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { + ACTION_ICONS, + commitAction, + summarize, +} from "@/components/chat/action-preview-card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "@/components/ui/dialog"; +import type { ActionPreviewData } from "@/lib/ai-chat"; +import { notify } from "@/lib/toast"; + +type Status = "pending" | "committing" | "done" | "rejected"; + +// A single approval surface for many agent-proposed records (e.g. an imported +// file of appointments) instead of one card per record. The clinician reviews +// the full list in a dialog, removes any they don't want, and adds them all at +// once. Each commit goes through the same RBAC-gated create endpoint as the +// single-record card; appointments without a file number create a patient. +export function BatchActionPreviewCard({ items }: { items: ActionPreviewData[] }) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [removed, setRemoved] = useState>(new Set()); + const [status, setStatus] = useState("pending"); + const [result, setResult] = useState<{ added: number; failed: number } | null>( + null, + ); + + const kept = useMemo( + () => items.filter((it) => !removed.has(it.token)), + [items, removed], + ); + + const Icon = ACTION_ICONS[items[0]?.kind ?? "appointment"]; + + const addAll = async () => { + setStatus("committing"); + let added = 0; + let failed = 0; + // Sequential so server-side patient de-dup (by name) sees prior creates. + for (const it of kept) { + try { + await commitAction(it); + added += 1; + } catch { + failed += 1; + } + } + setResult({ added, failed }); + setStatus("done"); + setOpen(false); + if (added > 0) { + notify.success( + t("chat.actionCard.addedTitle"), + t("chat.actionCard.batch.done", { added, total: kept.length }), + ); + } else if (failed > 0) { + notify.error( + t("chat.actionCard.failedTitle"), + t("chat.actionCard.failedBody"), + ); + } + }; + + const discardAll = () => { + setStatus("rejected"); + setOpen(false); + }; + + return ( + +
+ + + {t("chat.actionCard.batch.title", { count: items.length })} + + + + AI + +
+ + {status === "done" && result ? ( +

+ + {t("chat.actionCard.batch.done", { + added: result.added, + total: items.length, + })} + {result.failed > 0 + ? ` · ${t("chat.actionCard.batch.failedCount", { count: result.failed })}` + : ""} +

+ ) : status === "rejected" ? ( +

+ {t("chat.actionCard.batch.discarded")} +

+ ) : ( +
+ + +
+ )} + + + + + {t("chat.actionCard.batch.dialogTitle")} + + {t("chat.actionCard.batch.dialogDescription")} + + + + + {kept.length === 0 ? ( +

+ {t("chat.actionCard.batch.discarded")} +

+ ) : ( + kept.map((it) => { + const lines = summarize(it); + const record = it.record as Record; + const newPatient = + it.kind === "appointment" && !record.fileNumber; + return ( +
+
+ {lines.map((line, i) => ( + + {line} + + ))} + {newPatient ? ( + + + {t("chat.actionCard.batch.newPatient")} + + ) : null} + {it.issues && it.issues.length > 0 ? ( + + + {it.issues[0]} + + ) : null} +
+ +
+ ); + }) + )} +
+ + + + + +
+
+
+ ); +} diff --git a/frontend/components/chat/chat-input.tsx b/frontend/components/chat/chat-input.tsx index f0d7367..e3b7153 100644 --- a/frontend/components/chat/chat-input.tsx +++ b/frontend/components/chat/chat-input.tsx @@ -126,7 +126,7 @@ export function ChatInput({ event.preventDefault(); submit(); }} - className="w-full overflow-hidden rounded-[28px] border border-border bg-input shadow-sm" + className="w-full shrink-0 overflow-hidden rounded-[28px] border border-border bg-input shadow-sm" > {/* Textarea + toolbar, filling the rounded card. */}
diff --git a/frontend/components/chat/chat-panel.tsx b/frontend/components/chat/chat-panel.tsx index 8c31d03..2b1631f 100644 --- a/frontend/components/chat/chat-panel.tsx +++ b/frontend/components/chat/chat-panel.tsx @@ -43,6 +43,7 @@ import { ToolOutput, } from "@/components/ai-elements/tool"; import { ActionPreviewCard } from "@/components/chat/action-preview-card"; +import { BatchActionPreviewCard } from "@/components/chat/batch-action-preview-card"; import { ChatInput } from "@/components/chat/chat-input"; import { ImportPreviewCard } from "@/components/chat/import-preview-card"; import { LabChartCard } from "@/components/chat/lab-chart-card"; @@ -65,7 +66,7 @@ import { type Effort, getModel, } from "@/lib/ai-models"; -import type { TemetroUIMessage } from "@/lib/ai-chat"; +import type { ActionPreviewData, TemetroUIMessage } from "@/lib/ai-chat"; import { getAiConfig } from "@/lib/ai-settings"; import { API_BASE_URL } from "@/lib/api-client"; import { getPatient } from "@/lib/patients"; @@ -325,6 +326,14 @@ export function ChatPanel() { const renderMessage = (message: TemetroUIMessage, isLast: boolean) => { const steps = message.parts.filter((p) => p.type === "data-step"); const isWorking = status === "submitted" || status === "streaming"; + // When the agent proposes many records at once (e.g. an imported file), + // collapse them into one batched approval instead of a card per record. + const actionPreviews = message.parts.filter( + (p) => p.type === "data-actionPreview", + ); + const firstActionPreviewIdx = message.parts.findIndex( + (p) => p.type === "data-actionPreview", + ); return ( @@ -405,6 +414,18 @@ export function ChatPanel() { return ; } if (part.type === "data-actionPreview") { + if (actionPreviews.length >= 2) { + // Render the batch once (at the first proposal), skip the rest. + if (i !== firstActionPreviewIdx) return null; + return ( + (p as { data: ActionPreviewData }).data, + )} + key={key} + /> + ); + } return ; } if (part.type === "data-appointmentList") { @@ -452,8 +473,8 @@ export function ChatPanel() { if (messages.length === 0) { return ( -
-
+
+

{t("chat.heading")}

diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index b7ba111..24f4326 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -809,7 +809,21 @@ "discarded": "Discarded — nothing was saved.", "addedTitle": "Added", "failedTitle": "Could not add", - "failedBody": "Something went wrong, or you don't have permission. Please try again." + "failedBody": "Something went wrong, or you don't have permission. Please try again.", + "batch": { + "title": "{{count}} records proposed", + "review": "Review & add", + "dialogTitle": "Review proposed records", + "dialogDescription": "Add them all at once, or remove any you don't want.", + "addAll": "Add all", + "adding": "Adding…", + "discardAll": "Discard all", + "newPatient": "New patient will be created", + "remove": "Remove", + "done": "Added {{added}} of {{total}}.", + "failedCount": "{{count}} failed", + "discarded": "Discarded — nothing was saved." + } }, "lists": { "appointments": "Appointments",