mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
feffce6cbf
When the selected patient has a linked wallet, create/edit dialogs now show a two-step stepper: after saving, step 2 offers to push the change to the patient's wallet (reusing pushWalletUpdate + approval polling). Added a shared useWalletSync hook and DialogStepper/WalletSyncStep components, wired into the appointment, invoice, prescription, patient-edit, and scribe dialogs. Falls back to the old close-on-save when the patient has no wallet. Added walletSync.* keys to all locales. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
466 lines
15 KiB
TypeScript
466 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { Mic, Square, Sparkles, Trash2 } from "lucide-react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogPanel,
|
|
DialogPopup,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Spinner } from "@/components/ui/spinner";
|
|
import {
|
|
Tabs,
|
|
TabsList,
|
|
TabsPanel,
|
|
TabsTab,
|
|
} from "@/components/ui/tabs";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { uploadAttachment } from "@/lib/attachments";
|
|
import type { Encounter, Patient } from "@/lib/patients";
|
|
import { draftNote, saveNote, transcribeRecording } from "@/lib/scribe";
|
|
import { notify } from "@/lib/toast";
|
|
import { ApiError } from "@/lib/api-client";
|
|
import { useWalletSync } from "@/components/wallet/use-wallet-sync";
|
|
import {
|
|
DialogStepper,
|
|
WalletSyncStep,
|
|
} from "@/components/wallet/wallet-sync-step";
|
|
|
|
type Phase = "input" | "processing" | "review";
|
|
type InputTab = "record" | "paste";
|
|
type RecState = "idle" | "recording" | "recorded";
|
|
|
|
// Pick a supported audio mime for MediaRecorder (Opus in WebM/OGG is tiny for
|
|
// speech; Safari falls back to mp4). Returns "" to let the browser choose.
|
|
function pickAudioMime(): string {
|
|
if (typeof MediaRecorder === "undefined") return "";
|
|
const candidates = [
|
|
"audio/webm;codecs=opus",
|
|
"audio/webm",
|
|
"audio/ogg;codecs=opus",
|
|
"audio/mp4",
|
|
];
|
|
return candidates.find((m) => MediaRecorder.isTypeSupported(m)) ?? "";
|
|
}
|
|
|
|
function fmtElapsed(sec: number): string {
|
|
const m = Math.floor(sec / 60);
|
|
const s = sec % 60;
|
|
return `${m}:${String(s).padStart(2, "0")}`;
|
|
}
|
|
|
|
// The ambient AI scribe: record or paste a visit conversation, draft a SOAP
|
|
// encounter note, review it, and append it to the patient record.
|
|
export function ScribeDialog({
|
|
patient,
|
|
open,
|
|
onOpenChange,
|
|
onSaved,
|
|
}: {
|
|
patient: Patient;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onSaved: (updated: Patient) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [phase, setPhase] = useState<Phase>("input");
|
|
const [tab, setTab] = useState<InputTab>("record");
|
|
const [recState, setRecState] = useState<RecState>("idle");
|
|
const [elapsed, setElapsed] = useState(0);
|
|
const [transcript, setTranscript] = useState("");
|
|
const [visitType, setVisitType] = useState("");
|
|
const [draft, setDraft] = useState<Encounter | null>(null);
|
|
const [veilNote, setVeilNote] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [walletStep, setWalletStep] = useState(false);
|
|
|
|
const sync = useWalletSync(patient.fileNumber);
|
|
|
|
const mediaRef = useRef<MediaRecorder | null>(null);
|
|
const chunksRef = useRef<Blob[]>([]);
|
|
const blobRef = useRef<Blob | null>(null);
|
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
// Tear down any live recording + object state when the dialog closes.
|
|
const stopTracks = () => {
|
|
mediaRef.current?.stream.getTracks().forEach((track) => track.stop());
|
|
mediaRef.current = null;
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
timerRef.current = null;
|
|
};
|
|
|
|
// Stop tracks on unmount.
|
|
useEffect(() => () => stopTracks(), []);
|
|
|
|
// Reset all state for the next open. Called on every close (the parent only
|
|
// ever closes this dialog through our onOpenChange), so no reset-in-effect.
|
|
const reset = () => {
|
|
setPhase("input");
|
|
setTab("record");
|
|
setRecState("idle");
|
|
setElapsed(0);
|
|
setTranscript("");
|
|
setVisitType("");
|
|
setDraft(null);
|
|
setVeilNote(null);
|
|
setError(null);
|
|
setWalletStep(false);
|
|
sync.reset();
|
|
chunksRef.current = [];
|
|
blobRef.current = null;
|
|
};
|
|
|
|
const handleOpenChange = (next: boolean) => {
|
|
if (!next) {
|
|
stopTracks();
|
|
reset();
|
|
}
|
|
onOpenChange(next);
|
|
};
|
|
|
|
const startRecording = async () => {
|
|
setError(null);
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
const mime = pickAudioMime();
|
|
const recorder = new MediaRecorder(
|
|
stream,
|
|
mime ? { mimeType: mime } : undefined,
|
|
);
|
|
chunksRef.current = [];
|
|
recorder.ondataavailable = (e) => {
|
|
if (e.data.size > 0) chunksRef.current.push(e.data);
|
|
};
|
|
recorder.onstop = () => {
|
|
blobRef.current = new Blob(chunksRef.current, {
|
|
type: recorder.mimeType || "audio/webm",
|
|
});
|
|
setRecState("recorded");
|
|
};
|
|
recorder.start();
|
|
mediaRef.current = recorder;
|
|
setRecState("recording");
|
|
setElapsed(0);
|
|
timerRef.current = setInterval(() => setElapsed((s) => s + 1), 1000);
|
|
} catch {
|
|
setError(t("scribe.errors.mic"));
|
|
}
|
|
};
|
|
|
|
const stopRecording = () => {
|
|
mediaRef.current?.stop();
|
|
mediaRef.current?.stream.getTracks().forEach((track) => track.stop());
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
timerRef.current = null;
|
|
};
|
|
|
|
const discardRecording = () => {
|
|
blobRef.current = null;
|
|
chunksRef.current = [];
|
|
setRecState("idle");
|
|
setElapsed(0);
|
|
};
|
|
|
|
const filenameFor = (blob: Blob): string => {
|
|
const ext = blob.type.includes("mp4")
|
|
? "m4a"
|
|
: blob.type.includes("ogg")
|
|
? "ogg"
|
|
: "webm";
|
|
return `visit-${patient.fileNumber}-${Date.now()}.${ext}`;
|
|
};
|
|
|
|
const generate = async () => {
|
|
setError(null);
|
|
setPhase("processing");
|
|
try {
|
|
let text = transcript.trim();
|
|
if (tab === "record") {
|
|
const blob = blobRef.current;
|
|
if (!blob) {
|
|
setError(t("scribe.errors.noRecording"));
|
|
setPhase("input");
|
|
return;
|
|
}
|
|
// Store the recording as a patient attachment (auditable), then
|
|
// transcribe it server-side.
|
|
const file = new File([blob], filenameFor(blob), { type: blob.type });
|
|
const attachment = await uploadAttachment({
|
|
file,
|
|
fileNumber: patient.fileNumber,
|
|
labKey: "scribe",
|
|
});
|
|
const res = await transcribeRecording(attachment.id);
|
|
text = res.transcript.trim();
|
|
setTranscript(text);
|
|
}
|
|
if (!text) {
|
|
setError(t("scribe.errors.empty"));
|
|
setPhase("input");
|
|
return;
|
|
}
|
|
const { draft: note, veil } = await draftNote({
|
|
fileNumber: patient.fileNumber,
|
|
transcript: text,
|
|
visitType: visitType.trim() || undefined,
|
|
});
|
|
setDraft(note);
|
|
setVeilNote(
|
|
veil.active ? t("scribe.review.veil", { provider: veil.provider }) : null,
|
|
);
|
|
setPhase("review");
|
|
} catch (err) {
|
|
setError(
|
|
err instanceof ApiError ? err.message : t("scribe.errors.generic"),
|
|
);
|
|
setPhase("input");
|
|
}
|
|
};
|
|
|
|
const save = async () => {
|
|
if (!draft) return;
|
|
setPhase("processing");
|
|
try {
|
|
const updated = await saveNote(patient.fileNumber, draft);
|
|
notify.success(t("scribe.saved.title"), patient.name);
|
|
onSaved(updated);
|
|
if (sync.linked) {
|
|
setPhase("review");
|
|
setWalletStep(true);
|
|
} else {
|
|
handleOpenChange(false);
|
|
}
|
|
} catch (err) {
|
|
setError(
|
|
err instanceof ApiError ? err.message : t("scribe.errors.generic"),
|
|
);
|
|
setPhase("review");
|
|
}
|
|
};
|
|
|
|
const busy = phase === "processing";
|
|
|
|
return (
|
|
<Dialog onOpenChange={handleOpenChange} open={open}>
|
|
<DialogPopup className="flex max-h-[85dvh] flex-col sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Sparkles className="size-4 text-primary" />
|
|
{t("scribe.title")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t("scribe.subtitle", { name: patient.name })}
|
|
</DialogDescription>
|
|
{sync.linked && (
|
|
<DialogStepper step={walletStep ? "wallet" : "form"} />
|
|
)}
|
|
</DialogHeader>
|
|
|
|
{walletStep ? (
|
|
<WalletSyncStep
|
|
onDone={() => handleOpenChange(false)}
|
|
patientName={patient.name}
|
|
summary={t("walletSync.summary.note")}
|
|
sync={sync}
|
|
/>
|
|
) : (
|
|
<>
|
|
<DialogPanel className="min-h-0 flex-1 overflow-y-auto">
|
|
{phase === "review" && draft ? (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="scribe-type">{t("scribe.review.type")}</Label>
|
|
<Input
|
|
id="scribe-type"
|
|
onChange={(e) =>
|
|
setDraft({ ...draft, type: e.target.value })
|
|
}
|
|
value={draft.type}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="scribe-date">{t("scribe.review.date")}</Label>
|
|
<Input
|
|
id="scribe-date"
|
|
onChange={(e) =>
|
|
setDraft({ ...draft, date: e.target.value })
|
|
}
|
|
type="date"
|
|
value={draft.date}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="scribe-summary">
|
|
{t("scribe.review.summary")}
|
|
</Label>
|
|
<Textarea
|
|
className="min-h-56"
|
|
id="scribe-summary"
|
|
onChange={(e) =>
|
|
setDraft({ ...draft, summary: e.target.value })
|
|
}
|
|
value={draft.summary}
|
|
/>
|
|
</div>
|
|
<p className="text-muted-foreground text-xs">
|
|
{t("scribe.review.provider", { provider: draft.provider })}
|
|
</p>
|
|
{veilNote && (
|
|
<p className="rounded-lg bg-muted px-3 py-2 text-muted-foreground text-xs">
|
|
{veilNote}
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<Tabs
|
|
onValueChange={(v) => setTab(v as InputTab)}
|
|
value={tab}
|
|
>
|
|
<TabsList className="w-full">
|
|
<TabsTab value="record">
|
|
<Mic className="size-4" />
|
|
{t("scribe.tabs.record")}
|
|
</TabsTab>
|
|
<TabsTab value="paste">{t("scribe.tabs.paste")}</TabsTab>
|
|
</TabsList>
|
|
|
|
<TabsPanel className="pt-3" value="record">
|
|
<div className="flex flex-col items-center gap-4 py-4">
|
|
{recState === "recording" ? (
|
|
<>
|
|
<div className="flex items-center gap-2 text-destructive">
|
|
<span className="size-2.5 animate-pulse rounded-full bg-destructive" />
|
|
<span className="font-mono text-lg tabular-nums">
|
|
{fmtElapsed(elapsed)}
|
|
</span>
|
|
</div>
|
|
<Button
|
|
onClick={stopRecording}
|
|
type="button"
|
|
variant="destructive"
|
|
>
|
|
<Square className="size-4" />
|
|
{t("scribe.record.stop")}
|
|
</Button>
|
|
</>
|
|
) : recState === "recorded" ? (
|
|
<>
|
|
<p className="text-foreground text-sm">
|
|
{t("scribe.record.ready", {
|
|
duration: fmtElapsed(elapsed),
|
|
})}
|
|
</p>
|
|
<Button
|
|
onClick={discardRecording}
|
|
size="sm"
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
<Trash2 className="size-4" />
|
|
{t("scribe.record.discard")}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button onClick={startRecording} type="button">
|
|
<Mic className="size-4" />
|
|
{t("scribe.record.start")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</TabsPanel>
|
|
|
|
<TabsPanel className="pt-3" value="paste">
|
|
<Textarea
|
|
className="min-h-40"
|
|
onChange={(e) => setTranscript(e.target.value)}
|
|
placeholder={t("scribe.paste.placeholder")}
|
|
value={transcript}
|
|
/>
|
|
</TabsPanel>
|
|
|
|
<div className="mt-4 flex flex-col gap-3">
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="scribe-visit-type">
|
|
{t("scribe.visitType.label")}
|
|
</Label>
|
|
<Input
|
|
id="scribe-visit-type"
|
|
onChange={(e) => setVisitType(e.target.value)}
|
|
placeholder={t("scribe.visitType.placeholder")}
|
|
value={visitType}
|
|
/>
|
|
</div>
|
|
<p className="rounded-lg bg-muted px-3 py-2 text-muted-foreground text-xs">
|
|
{t("scribe.consent")}
|
|
</p>
|
|
</div>
|
|
</Tabs>
|
|
)}
|
|
|
|
{error && (
|
|
<p className="mt-3 text-destructive text-sm" role="alert">
|
|
{error}
|
|
</p>
|
|
)}
|
|
</DialogPanel>
|
|
|
|
<DialogFooter>
|
|
{phase === "review" ? (
|
|
<>
|
|
<Button
|
|
disabled={busy}
|
|
onClick={() => setPhase("input")}
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
{t("scribe.review.back")}
|
|
</Button>
|
|
<Button disabled={busy} onClick={save} type="button">
|
|
{busy && <Spinner className="size-4" />}
|
|
{t("scribe.review.save")}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button
|
|
disabled={busy}
|
|
onClick={() => handleOpenChange(false)}
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
{t("scribe.cancel")}
|
|
</Button>
|
|
<Button
|
|
disabled={
|
|
busy ||
|
|
(tab === "record"
|
|
? recState !== "recorded"
|
|
: transcript.trim().length === 0)
|
|
}
|
|
onClick={generate}
|
|
type="button"
|
|
>
|
|
{busy && <Spinner className="size-4" />}
|
|
{busy ? t("scribe.processing") : t("scribe.generate")}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</DialogFooter>
|
|
</>
|
|
)}
|
|
</DialogPopup>
|
|
</Dialog>
|
|
);
|
|
}
|