mirror of
https://github.com/temetro/temetro.git
synced 2026-08-11 19:18:03 +00:00
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:
@@ -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!);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user