mirror of
https://github.com/temetro/temetro.git
synced 2026-08-09 10:09:39 +00:00
ab2f10bffc
Finish the i18n pass: settings panels (profile/care-team/signing), the chat heading + input, the patient cards / detail / create-edit form, and the notes page + rich-text editor are all keyed in en/translation.json. All 526 static t() keys resolve. Document the new backend resources + Socket.io realtime (backend README/CLAUDE) and the i18n coverage (frontend CLAUDE). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
195 lines
6.0 KiB
TypeScript
195 lines
6.0 KiB
TypeScript
"use client";
|
|
|
|
import { nanoid } from "nanoid";
|
|
import type { ChatStatus } from "ai";
|
|
import { useSearchParams } from "next/navigation";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import {
|
|
Conversation,
|
|
ConversationContent,
|
|
ConversationScrollButton,
|
|
} from "@/components/ai-elements/conversation";
|
|
import {
|
|
Message,
|
|
MessageContent,
|
|
MessageResponse,
|
|
} from "@/components/ai-elements/message";
|
|
import { ChatInput } from "@/components/chat/chat-input";
|
|
import { PatientResult } from "@/components/chat/patient-cards";
|
|
import { getPatient, type Patient } from "@/lib/patients";
|
|
|
|
type ChatMessage =
|
|
| { id: string; role: "user"; text: string }
|
|
| { id: string; role: "assistant"; kind: "text"; text: string }
|
|
| {
|
|
id: string;
|
|
role: "assistant";
|
|
kind: "patient";
|
|
fileNumber: string;
|
|
status: "loading" | "ready" | "not-found";
|
|
patient?: Patient;
|
|
};
|
|
|
|
// Trigger: `/patient 10293` or just `/10293` pulls up records.
|
|
const PATIENT_COMMAND = /^\/(?:patient\s+)?(\d+)$/i;
|
|
|
|
export function ChatPanel() {
|
|
const { t } = useTranslation();
|
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
const [status, setStatus] = useState<ChatStatus>("ready");
|
|
|
|
const send = useCallback(async (text: string) => {
|
|
const trimmed = text.trim();
|
|
if (!trimmed) {
|
|
return;
|
|
}
|
|
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{ id: nanoid(), role: "user", text: trimmed },
|
|
]);
|
|
|
|
const match = trimmed.match(PATIENT_COMMAND);
|
|
|
|
// Patient lookup: append a loading card set, then fill it in once the
|
|
// (mock) record comes back.
|
|
if (match) {
|
|
const fileNumber = match[1];
|
|
const resultId = nanoid();
|
|
setStatus("submitted");
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
id: resultId,
|
|
role: "assistant",
|
|
kind: "patient",
|
|
fileNumber,
|
|
status: "loading",
|
|
},
|
|
]);
|
|
|
|
let patient: Patient | null = null;
|
|
try {
|
|
patient = await getPatient(fileNumber);
|
|
} catch {
|
|
// Network / auth errors fall through as "not found"; a 401 will have
|
|
// already redirected to /login via the API client.
|
|
patient = null;
|
|
}
|
|
setMessages((prev) =>
|
|
prev.map((message) =>
|
|
message.id === resultId &&
|
|
message.role === "assistant" &&
|
|
message.kind === "patient"
|
|
? {
|
|
...message,
|
|
status: patient ? "ready" : "not-found",
|
|
patient: patient ?? undefined,
|
|
}
|
|
: message
|
|
)
|
|
);
|
|
setStatus("ready");
|
|
return;
|
|
}
|
|
|
|
// UI-only: mock assistant reply for anything that isn't a command.
|
|
setStatus("submitted");
|
|
window.setTimeout(() => {
|
|
setStatus("streaming");
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
id: nanoid(),
|
|
role: "assistant",
|
|
kind: "text",
|
|
text: `This is a **UI-only preview** of temetro. Try \`/patient 10293\` to pull up a patient's records.\n\nYou asked:\n\n> ${trimmed}`,
|
|
},
|
|
]);
|
|
window.setTimeout(() => setStatus("ready"), 250);
|
|
}, 500);
|
|
}, []);
|
|
|
|
const handleStop = useCallback(() => setStatus("ready"), []);
|
|
|
|
// Opening a patient from the Patients page lands here as `/?patient=<file#>`;
|
|
// run the lookup once on arrival.
|
|
const searchParams = useSearchParams();
|
|
const requestedPatient = searchParams.get("patient");
|
|
const handledPatientRef = useRef<string | null>(null);
|
|
useEffect(() => {
|
|
if (requestedPatient && handledPatientRef.current !== requestedPatient) {
|
|
handledPatientRef.current = requestedPatient;
|
|
send(`/patient ${requestedPatient}`);
|
|
}
|
|
}, [requestedPatient, send]);
|
|
|
|
const promptInput = (
|
|
<ChatInput onStop={handleStop} onSubmit={send} status={status} />
|
|
);
|
|
|
|
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">
|
|
<h1 className="text-center text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
|
|
{t("chat.heading")}
|
|
</h1>
|
|
{promptInput}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
<Conversation>
|
|
<ConversationContent className="mx-auto w-full max-w-3xl">
|
|
{messages.map((message) => {
|
|
if (message.role === "assistant" && message.kind === "patient") {
|
|
return (
|
|
<Message from="assistant" key={message.id}>
|
|
<MessageContent className="w-full">
|
|
<PatientResult
|
|
fileNumber={message.fileNumber}
|
|
onPatientUpdated={(updated) =>
|
|
setMessages((prev) =>
|
|
prev.map((m) =>
|
|
m.id === message.id &&
|
|
m.role === "assistant" &&
|
|
m.kind === "patient"
|
|
? { ...m, patient: updated, status: "ready" }
|
|
: m
|
|
)
|
|
)
|
|
}
|
|
patient={message.patient}
|
|
status={message.status}
|
|
/>
|
|
</MessageContent>
|
|
</Message>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Message from={message.role} key={message.id}>
|
|
<MessageContent>
|
|
{message.role === "user" ? (
|
|
<span className="whitespace-pre-wrap">{message.text}</span>
|
|
) : (
|
|
<MessageResponse>{message.text}</MessageResponse>
|
|
)}
|
|
</MessageContent>
|
|
</Message>
|
|
);
|
|
})}
|
|
</ConversationContent>
|
|
<ConversationScrollButton />
|
|
</Conversation>
|
|
<div className="mx-auto w-full max-w-3xl px-4 pb-4">{promptInput}</div>
|
|
</div>
|
|
);
|
|
}
|