fix: chat input clipping, AI appointments create patients, batched approval

1. Chat input toolbar was clipped in the empty state: the form's overflow-hidden
   made its flex min-height resolve to 0, so the vertically-centered layout
   squeezed it and hid the bottom toolbar (attach/add-patient/model/mic/send).
   Add `shrink-0` to the form; let the empty-state column scroll.
2. AI-imported appointments now create/link a patient: services.ensurePatient
   reuses a same-name patient or creates one (auto file number, source "ai");
   appointments.createAppointment calls it when the booking has no file number,
   so imported people appear on the Patients page.
3. Many proposals collapse into one BatchActionPreviewCard → a review dialog
   with per-row remove and "Add all" (commits sequentially), instead of one
   Add/Discard card per record.

Plus: widen the Live chart right margin so the value pill stays inside the card.
Verified with `next build`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-14 21:23:25 +03:00
parent 8c1f520047
commit b946dc9226
8 changed files with 304 additions and 12 deletions
+9 -1
View File
@@ -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<Appointment> {
// 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!);
}
+38 -1
View File
@@ -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<string> {
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<string> {
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,
@@ -86,7 +86,7 @@ export function LiveHospitalChart() {
<div className="h-56 w-full">
<LiveLineChart
data={data}
margin={{ left: 44 }}
margin={{ left: 44, right: 28 }}
value={value}
window={WINDOW_SECONDS}
>
@@ -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<string, unknown>;
if (data.kind === "appointment") {
return [
@@ -44,7 +46,7 @@ function summarize(data: ActionPreviewData): string[] {
].filter(Boolean);
}
async function commit(data: ActionPreviewData): Promise<void> {
export async function commitAction(data: ActionPreviewData): Promise<void> {
// 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"),
@@ -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<Set<string>>(new Set());
const [status, setStatus] = useState<Status>("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 (
<Card className="w-full gap-3 p-4">
<div className="flex items-center gap-2">
<Icon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">
{t("chat.actionCard.batch.title", { count: items.length })}
</span>
<Badge className="ml-auto gap-1" variant="secondary">
<Sparkles className="size-3" />
AI
</Badge>
</div>
{status === "done" && result ? (
<p className="flex items-center gap-1.5 text-foreground text-sm">
<Check className="size-4" />
{t("chat.actionCard.batch.done", {
added: result.added,
total: items.length,
})}
{result.failed > 0
? ` · ${t("chat.actionCard.batch.failedCount", { count: result.failed })}`
: ""}
</p>
) : status === "rejected" ? (
<p className="text-muted-foreground text-sm">
{t("chat.actionCard.batch.discarded")}
</p>
) : (
<div className="flex items-center gap-2">
<Button onClick={() => setOpen(true)} size="sm">
{t("chat.actionCard.batch.review")}
</Button>
<Button onClick={discardAll} size="sm" variant="outline">
<X className="size-4" />
{t("chat.actionCard.discard")}
</Button>
</div>
)}
<Dialog onOpenChange={setOpen} open={open}>
<DialogPopup className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("chat.actionCard.batch.dialogTitle")}</DialogTitle>
<DialogDescription>
{t("chat.actionCard.batch.dialogDescription")}
</DialogDescription>
</DialogHeader>
<DialogPanel className="flex max-h-[60vh] flex-col gap-2 overflow-y-auto">
{kept.length === 0 ? (
<p className="py-6 text-center text-muted-foreground text-sm">
{t("chat.actionCard.batch.discarded")}
</p>
) : (
kept.map((it) => {
const lines = summarize(it);
const record = it.record as Record<string, unknown>;
const newPatient =
it.kind === "appointment" && !record.fileNumber;
return (
<div
className="flex items-start gap-2 rounded-xl border bg-card/30 p-3"
key={it.token}
>
<div className="flex min-w-0 flex-1 flex-col">
{lines.map((line, i) => (
<span
className={
i === 0
? "truncate font-medium text-foreground text-sm"
: "truncate text-muted-foreground text-xs"
}
key={line + i}
>
{line}
</span>
))}
{newPatient ? (
<span className="mt-1 inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Sparkles className="size-3" />
{t("chat.actionCard.batch.newPatient")}
</span>
) : null}
{it.issues && it.issues.length > 0 ? (
<span className="mt-1 flex items-center gap-1 text-warning-foreground text-xs">
<AlertTriangle className="size-3" />
{it.issues[0]}
</span>
) : null}
</div>
<Button
aria-label={t("chat.actionCard.batch.remove")}
className="shrink-0"
onClick={() =>
setRemoved((prev) => new Set(prev).add(it.token))
}
size="icon"
variant="ghost"
>
<X className="size-4" />
</Button>
</div>
);
})
)}
</DialogPanel>
<DialogFooter>
<Button onClick={discardAll} type="button" variant="outline">
{t("chat.actionCard.batch.discardAll")}
</Button>
<Button
disabled={status === "committing" || kept.length === 0}
onClick={addAll}
type="button"
>
{status === "committing"
? t("chat.actionCard.batch.adding")
: `${t("chat.actionCard.batch.addAll")} (${kept.length})`}
</Button>
</DialogFooter>
</DialogPopup>
</Dialog>
</Card>
);
}
+1 -1
View File
@@ -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. */}
<div className="bg-input">
+24 -3
View File
@@ -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 (
<Message from={message.role} key={message.id}>
<MessageContent className="w-full">
@@ -405,6 +414,18 @@ export function ChatPanel() {
return <ImportPreviewCard data={part.data} key={key} />;
}
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 (
<BatchActionPreviewCard
items={actionPreviews.map(
(p) => (p as { data: ActionPreviewData }).data,
)}
key={key}
/>
);
}
return <ActionPreviewCard data={part.data} key={key} />;
}
if (part.type === "data-appointmentList") {
@@ -452,8 +473,8 @@ export function ChatPanel() {
if (messages.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center px-4">
<div className="flex w-full max-w-3xl flex-col items-center gap-10">
<div className="flex flex-1 flex-col items-center justify-center overflow-y-auto px-4 py-8">
<div className="flex w-full max-w-3xl shrink-0 flex-col items-center gap-10">
<h1 className="text-center font-semibold text-3xl text-balance tracking-tight sm:text-4xl">
{t("chat.heading")}
</h1>
+15 -1
View File
@@ -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",