Files
temetro/frontend/components/patients/patient-detail-sheet.tsx
T
Khalid Abdi bb536ba6da feat: clinic→wallet record-update push
A clinician can push an updated record to a wallet-linked patient (permanent
share). The snapshot is signed with the clinic Ed25519 key and sealed to the
wallet's X25519 key — derived from its Ed25519 wallet number via the birational
map, verified byte-for-byte against the wallet's own derivation. Stored pending,
delivered over the /wallet relay live and on the wallet's next authenticated
connect (offline catch-up). The patient approves/denies in-app; the wallet signs
its decision, the backend verifies it, and the record is replaced only on
approval. Wallet pins the clinic key (TOFU) and warns on change.

Backend: walletRecordUpdates table + service, ed25519PubToX25519Hex helper,
POST /api/patients/wallet/push, GET .../link/:fileNumber|updates|updates/:id,
wallet:update-request / wallet:update-response relay events.
Frontend: "Push to wallet" dialog with live status, wallet-link gating on the
patient sheet, "Sent updates" list under Settings → Signing, walletPush /
walletUpdatesList locale namespaces across all five languages. Bumps to v0.5.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:57:38 +03:00

290 lines
9.9 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AiBadge } from "@/components/ai-badge";
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
import { RecordGraph } from "@/components/graph/record-graph";
import { PatientDetail } from "@/components/patients/patient-detail";
import { ScribeDialog } from "@/components/patients/scribe-dialog";
import { TransferPatientDialog } from "@/components/patients/transfer-patient-dialog";
import { WalletPushDialog } from "@/components/patients/wallet-push-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import {
Dialog,
DialogDescription,
DialogHeader,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import {
Sheet,
SheetHeader,
SheetPanel,
SheetPopup,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { type Appointment, listAppointments } from "@/lib/appointments";
import { type Invoice, listInvoices } from "@/lib/invoices";
import { deletePatient, getPatient, type Patient } from "@/lib/patients";
import { listPrescriptions, type Prescription } from "@/lib/prescriptions";
import { useAiAccess } from "@/lib/ai-policy";
import { hasClinicalAccess, useActiveRole } from "@/lib/roles";
import { notify } from "@/lib/toast";
import { getWalletLink } from "@/lib/wallet-updates";
type Status = "loading" | "ready" | "not-found";
function DetailSkeleton() {
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-full" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-52" />
</div>
</div>
{[0, 1, 2, 3].map((section) => (
<div className="rounded-2xl border bg-card/30 p-4" key={section}>
<Skeleton className="mb-3 h-4 w-24" />
<div className="flex flex-col gap-2.5">
{[0, 1, 2].map((row) => (
<Skeleton className="h-3.5 w-full" key={row} />
))}
</div>
</div>
))}
</div>
);
}
// Right-side Sheet showing a patient's full record, laid out to fit the sheet
// width (see PatientDetail). Opened from the Patients table instead of routing
// into the AI chat.
export function PatientDetailSheet({
fileNumber,
open,
onOpenChange,
}: {
fileNumber: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslation();
const role = useActiveRole();
// Clinical roles can reassign a chart; show optimistically while role loads.
const canTransfer = role == null || hasClinicalAccess(role);
// Deleting a chart is destructive — only offer it once we know the role is
// a full clinician (patient:delete), never optimistically.
const canDelete = role != null && hasClinicalAccess(role);
// The ambient scribe writes a clinical note, so it needs full clinical write
// access AND the clinic's AI must be enabled for this member.
const { allowed: aiAllowed } = useAiAccess();
const canScribe = role != null && hasClinicalAccess(role) && aiAllowed;
const [patient, setPatient] = useState<Patient | null>(null);
const [status, setStatus] = useState<Status>("loading");
const [editOpen, setEditOpen] = useState(false);
const [scribeOpen, setScribeOpen] = useState(false);
const [walletPushOpen, setWalletPushOpen] = useState(false);
// Set once we confirm this patient is linked to a wallet (permanent share).
const [walletLinked, setWalletLinked] = useState(false);
const [transferOpen, setTransferOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
// Graph popped out of the sheet into its own dialog (the sheet closes first).
const [graphOpen, setGraphOpen] = useState(false);
// Related records aggregated into the sheet for a 360° view.
const [prescriptions, setPrescriptions] = useState<Prescription[]>([]);
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [invoices, setInvoices] = useState<Invoice[]>([]);
// Bumped on open so the editor remounts with the latest patient data.
const [editKey, setEditKey] = useState(0);
useEffect(() => {
if (!open || !fileNumber) return;
let active = true;
setStatus("loading");
setPatient(null);
setPrescriptions([]);
setAppointments([]);
setInvoices([]);
getPatient(fileNumber)
.then((data) => {
if (!active) return;
setPatient(data);
setStatus(data ? "ready" : "not-found");
})
.catch(() => {
if (active) setStatus("not-found");
});
// Pull related records in parallel; filter to this chart. Best-effort — a
// missing permission (e.g. reception + prescriptions) just leaves it empty.
const forFile = (fn: string) => fn === fileNumber;
listPrescriptions()
.then((rx) => active && setPrescriptions(rx.filter((r) => forFile(r.fileNumber))))
.catch(() => {});
listAppointments()
.then((a) => active && setAppointments(a.filter((r) => forFile(r.fileNumber))))
.catch(() => {});
listInvoices()
.then((i) => active && setInvoices(i.filter((r) => forFile(r.fileNumber))))
.catch(() => {});
return () => {
active = false;
};
}, [open, fileNumber]);
// Whether this patient is wallet-linked (drives the "Push update" button).
// Separate from the main load so it re-checks once the role resolves without
// refetching the record. Only clinicians can push.
useEffect(() => {
setWalletLinked(false);
if (!open || !fileNumber || !hasClinicalAccess(role)) return;
let active = true;
getWalletLink(fileNumber)
.then(() => active && setWalletLinked(true))
.catch(() => {});
return () => {
active = false;
};
}, [open, fileNumber, role]);
const remove = async () => {
if (!patient) return;
try {
await deletePatient(patient.fileNumber);
notify.success(t("patients.delete.doneTitle"), patient.name);
onOpenChange(false);
} catch {
notify.error(
t("patients.delete.failedTitle"),
t("patients.delete.failedBody"),
);
}
};
const title =
status === "ready" && patient
? patient.name
: status === "not-found"
? t("patients.detail.notFound")
: t("patients.detail.loading");
return (
<>
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetPopup className="sm:max-w-xl" side="right">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
{title}
{status === "ready" && <AiBadge source={patient?.source} />}
</SheetTitle>
</SheetHeader>
<SheetPanel className="min-h-0 flex-1">
{status === "loading" && <DetailSkeleton />}
{status === "not-found" && (
<p className="text-muted-foreground text-sm">
{t("patients.detail.noPatientForFile", { number: fileNumber })}
</p>
)}
{status === "ready" && patient && (
<PatientDetail
appointments={appointments}
invoices={invoices}
onDelete={canDelete ? () => setConfirmOpen(true) : undefined}
onEdit={() => {
setEditKey((k) => k + 1);
setEditOpen(true);
}}
onScribe={canScribe ? () => setScribeOpen(true) : undefined}
onWalletPush={
walletLinked ? () => setWalletPushOpen(true) : undefined
}
onOpenGraph={() => {
onOpenChange(false);
setGraphOpen(true);
}}
onTransfer={
canTransfer ? () => setTransferOpen(true) : undefined
}
patient={patient}
prescriptions={prescriptions}
/>
)}
</SheetPanel>
</SheetPopup>
</Sheet>
{patient && (
<PatientFormDialog
key={editKey}
mode="edit"
onOpenChange={setEditOpen}
onSaved={(updated) => setPatient(updated)}
open={editOpen}
patient={patient}
/>
)}
{patient && (
<ScribeDialog
onOpenChange={setScribeOpen}
onSaved={(updated) => setPatient(updated)}
open={scribeOpen}
patient={patient}
/>
)}
{patient && (
<WalletPushDialog
onOpenChange={setWalletPushOpen}
open={walletPushOpen}
patient={patient}
/>
)}
{patient && (
<TransferPatientDialog
onOpenChange={setTransferOpen}
onTransferred={(updated) => setPatient(updated)}
open={transferOpen}
patient={patient}
/>
)}
{patient && (
<ConfirmDialog
cancelLabel={t("patients.delete.cancel")}
confirmLabel={t("patients.delete.confirm")}
description={t("patients.delete.body", { name: patient.name })}
onConfirm={remove}
onOpenChange={setConfirmOpen}
open={confirmOpen}
title={t("patients.delete.title")}
/>
)}
{patient && (
<Dialog onOpenChange={setGraphOpen} open={graphOpen}>
<DialogPopup className="flex h-[80dvh] flex-col sm:max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{t("patientCard.graph.title")} · {patient.name}
</DialogTitle>
<DialogDescription>
{t("patientCard.graph.hint")}
</DialogDescription>
</DialogHeader>
<RecordGraph
className="h-full min-h-0 flex-1 border-0 bg-transparent"
patient={patient}
/>
</DialogPopup>
</Dialog>
)}
</>
);
}