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>
This commit is contained in:
Khalid Abdi
2026-07-03 18:57:38 +03:00
parent b29fdff1cb
commit bb536ba6da
23 changed files with 5593 additions and 5 deletions
@@ -9,6 +9,7 @@ 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,
@@ -32,6 +33,7 @@ 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";
@@ -86,6 +88,9 @@ export function PatientDetailSheet({
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).
@@ -131,6 +136,21 @@ export function PatientDetailSheet({
};
}, [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 {
@@ -179,6 +199,9 @@ export function PatientDetailSheet({
setEditOpen(true);
}}
onScribe={canScribe ? () => setScribeOpen(true) : undefined}
onWalletPush={
walletLinked ? () => setWalletPushOpen(true) : undefined
}
onOpenGraph={() => {
onOpenChange(false);
setGraphOpen(true);
@@ -214,6 +237,14 @@ export function PatientDetailSheet({
/>
)}
{patient && (
<WalletPushDialog
onOpenChange={setWalletPushOpen}
open={walletPushOpen}
patient={patient}
/>
)}
{patient && (
<TransferPatientDialog
onOpenChange={setTransferOpen}
@@ -6,6 +6,7 @@ import {
Mic,
Network,
Pencil,
Send,
Trash2,
} from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
@@ -181,6 +182,7 @@ export function PatientDetail({
patient,
onEdit,
onScribe,
onWalletPush,
onTransfer,
onDelete,
onOpenGraph,
@@ -192,6 +194,8 @@ export function PatientDetail({
onEdit?: () => void;
// Opens the ambient AI visit scribe (record/transcribe → draft note).
onScribe?: () => void;
// Pushes the record to the patient's wallet (only when wallet-linked).
onWalletPush?: () => void;
onTransfer?: () => void;
onDelete?: () => void;
// Pops the record graph out into its own dialog (closing this sheet).
@@ -309,6 +313,17 @@ export function PatientDetail({
{t("patientCard.edit")}
</Button>
)}
{onWalletPush && (
<Button
onClick={onWalletPush}
size="sm"
type="button"
variant="outline"
>
<Send className="size-4" />
{t("walletPush.action")}
</Button>
)}
{onDelete && (
<Button
aria-label={t("patients.delete.action")}
@@ -0,0 +1,242 @@
"use client";
import { Check, Loader2, Send, X } from "lucide-react";
import { useEffect, 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 { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { ApiError } from "@/lib/api-client";
import type { Patient } from "@/lib/patients";
import { cn } from "@/lib/utils";
import {
getWalletUpdate,
pushWalletUpdate,
type WalletUpdate,
} from "@/lib/wallet-updates";
// The record sections a clinician can flag as changed. The labels double as the
// human-readable change summary the patient sees when approving.
const SECTION_KEYS = [
"demographics",
"problems",
"medications",
"allergies",
"labs",
"vitals",
"visits",
] as const;
type Phase = "compose" | "sent";
// Push the current record to a wallet-linked patient's app. The patient must
// approve it on their phone before their on-device record is replaced.
export function WalletPushDialog({
patient,
open,
onOpenChange,
}: {
patient: Patient;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslation();
const [phase, setPhase] = useState<Phase>("compose");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [note, setNote] = useState("");
const [update, setUpdate] = useState<WalletUpdate | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const reset = () => {
setPhase("compose");
setSelected(new Set());
setNote("");
setUpdate(null);
setBusy(false);
setError(null);
};
const handleOpenChange = (next: boolean) => {
if (!next) reset();
onOpenChange(next);
};
// Poll the update's status until the patient approves/denies (or the dialog
// closes). Live pushes usually resolve within seconds.
useEffect(() => {
if (phase !== "sent" || !update || update.resolvedAt) return;
let active = true;
const timer = setInterval(async () => {
try {
const fresh = await getWalletUpdate(update.id);
if (!active) return;
setUpdate(fresh);
if (fresh.resolvedAt) clearInterval(timer);
} catch {
/* keep polling */
}
}, 3000);
return () => {
active = false;
clearInterval(timer);
};
}, [phase, update]);
const toggle = (key: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const push = async () => {
setBusy(true);
setError(null);
try {
const changes = [
...[...selected].map((k) => t(`walletPush.sections.${k}`)),
...(note.trim() ? [note.trim()] : []),
];
const created = await pushWalletUpdate({
fileNumber: patient.fileNumber,
changes,
});
setUpdate(created);
setPhase("sent");
} catch (err) {
setError(
err instanceof ApiError ? err.message : t("walletPush.errors.generic"),
);
} finally {
setBusy(false);
}
};
const canPush = selected.size > 0 || note.trim().length > 0;
const status = update?.status ?? "pending";
return (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogPopup className="flex max-h-[85dvh] flex-col sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Send className="size-4 text-primary" />
{t("walletPush.title")}
</DialogTitle>
<DialogDescription>
{t("walletPush.subtitle", { name: patient.name })}
</DialogDescription>
</DialogHeader>
<DialogPanel className="min-h-0 flex-1 overflow-y-auto">
{phase === "compose" ? (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label>{t("walletPush.sectionsLabel")}</Label>
<div className="flex flex-wrap gap-2">
{SECTION_KEYS.map((key) => {
const on = selected.has(key);
return (
<button
className={cn(
"rounded-full border px-3 py-1 text-sm transition-colors",
on
? "border-primary bg-primary/10 text-foreground"
: "border-border text-muted-foreground hover:bg-accent",
)}
key={key}
onClick={() => toggle(key)}
type="button"
>
{t(`walletPush.sections.${key}`)}
</button>
);
})}
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="wallet-push-note">
{t("walletPush.noteLabel")}
</Label>
<Textarea
className="min-h-20"
id="wallet-push-note"
onChange={(e) => setNote(e.target.value)}
placeholder={t("walletPush.notePlaceholder")}
value={note}
/>
</div>
<p className="rounded-lg bg-muted px-3 py-2 text-muted-foreground text-xs">
{t("walletPush.notice")}
</p>
</div>
) : (
<div className="flex flex-col items-center gap-4 py-6 text-center">
{status === "approved" ? (
<div className="flex size-12 items-center justify-center rounded-full bg-success/15 text-success">
<Check className="size-6" />
</div>
) : status === "denied" ? (
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/15 text-destructive">
<X className="size-6" />
</div>
) : (
<Loader2 className="size-8 animate-spin text-muted-foreground" />
)}
<div className="flex flex-col gap-1">
<p className="font-medium text-foreground text-sm">
{t(`walletPush.status.${status}.title`)}
</p>
<p className="text-muted-foreground text-sm">
{t(`walletPush.status.${status}.body`)}
</p>
</div>
</div>
)}
{error && (
<p className="mt-3 text-destructive text-sm" role="alert">
{error}
</p>
)}
</DialogPanel>
<DialogFooter>
{phase === "compose" ? (
<>
<Button
onClick={() => handleOpenChange(false)}
type="button"
variant="outline"
>
{t("walletPush.cancel")}
</Button>
<Button disabled={busy || !canPush} onClick={push} type="button">
{busy && <Spinner className="size-4" />}
{t("walletPush.send")}
</Button>
</>
) : (
<Button onClick={() => handleOpenChange(false)} type="button">
{t("walletPush.done")}
</Button>
)}
</DialogFooter>
</DialogPopup>
</Dialog>
);
}
@@ -19,6 +19,7 @@ import {
type SharedRecord,
type SigningKey,
} from "@/lib/signing";
import { listWalletUpdates, type WalletUpdate } from "@/lib/wallet-updates";
import { notify } from "@/lib/toast";
function formatDate(iso: string): string {
@@ -33,17 +34,23 @@ export function SigningPanel() {
const { t } = useTranslation();
const [key, setKey] = useState<SigningKey | null>(null);
const [records, setRecords] = useState<SharedRecord[]>([]);
const [updates, setUpdates] = useState<WalletUpdate[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [rotating, setRotating] = useState(false);
useEffect(() => {
let active = true;
Promise.all([getSigningKey(), listSignedRecords().catch(() => [])])
.then(([k, r]) => {
Promise.all([
getSigningKey(),
listSignedRecords().catch(() => []),
listWalletUpdates().catch(() => []),
])
.then(([k, r, u]) => {
if (!active) return;
setKey(k);
setRecords(r);
setUpdates(u);
setError(null);
})
.catch(() => {
@@ -223,6 +230,40 @@ export function SigningPanel() {
</SettingsCard>
)}
</SettingsSection>
<SettingsSection
description={t("walletUpdatesList.description")}
title={t("walletUpdatesList.title")}
>
{updates.length === 0 ? (
<SettingsCard className="flex items-center justify-center p-12">
<p className="text-sm text-muted-foreground">
{t("walletUpdatesList.none")}
</p>
</SettingsCard>
) : (
<SettingsCard className="divide-y divide-border">
{updates.map((update) => (
<div
className="flex items-center justify-between gap-3 px-4 py-3.5"
key={update.id}
>
<div className="min-w-0 space-y-0.5">
<p className="truncate text-sm">
{update.changes.join(" · ") || `#${update.fileNumber}`}
</p>
<p className="truncate font-mono text-xs text-muted-foreground">
#{update.fileNumber} · {formatDate(update.createdAt)}
</p>
</div>
<Badge variant="secondary">
{t(`walletPush.status.${update.status}.title`)}
</Badge>
</div>
))}
</SettingsCard>
)}
</SettingsSection>
</>
);
}
@@ -43,6 +43,53 @@
"generic": "حدث خطأ ما. يُرجى المحاولة مرة أخرى."
}
},
"walletUpdatesList": {
"title": "التحديثات المُرسَلة إلى المحافظ",
"description": "تحديثات السجلات التي أرسلتها إلى محافظ المرضى، وما إذا كان المريض قد وافق عليها.",
"none": "لم ترسل أي تحديثات إلى المحافظ بعد."
},
"walletPush": {
"action": "إرسال إلى المحفظة",
"title": "إرسال التحديث إلى المحفظة",
"subtitle": "أرسل سجل {{name}} المُحدَّث إلى تطبيق المحفظة الخاص به. يجب أن يوافق عليه على هاتفه قبل أن يحل محل النسخة الموجودة على جهازه.",
"sectionsLabel": "ما الذي تغيّر",
"sections": {
"demographics": "البيانات الديموغرافية",
"problems": "المشكلات",
"medications": "الأدوية",
"allergies": "الحساسية",
"labs": "التحاليل",
"vitals": "العلامات الحيوية",
"visits": "الزيارات"
},
"noteLabel": "ملاحظة (اختياري)",
"notePlaceholder": "ملاحظة قصيرة عن هذا التحديث…",
"notice": "يُرسِل هذا السجل الحالي كاملاً، موقَّعًا بمفتاح عيادتك. ولا يحل محل النسخة الموجودة على جهاز المريض إلا بعد موافقته.",
"cancel": "إلغاء",
"send": "إرسال التحديث",
"done": "تم",
"errors": {
"generic": "تعذّر إرسال التحديث. يُرجى المحاولة مرة أخرى."
},
"status": {
"pending": {
"title": "بانتظار المريض",
"body": "تم إرسال التحديث. سيصل إلى هاتف المريض الآن، أو في المرة التالية التي يفتح فيها التطبيق."
},
"delivered": {
"title": "تم التسليم إلى الهاتف",
"body": "استلم المريض التحديث ويلزمه الموافقة عليه على جهازه."
},
"approved": {
"title": "وافق المريض",
"body": "وافق المريض على التحديث، وسجلّه على الجهاز محدَّث الآن."
},
"denied": {
"title": "رفض المريض",
"body": "رفض المريض هذا التحديث، لذا بقي سجلّه على الجهاز دون تغيير."
}
}
},
"common": {
"appName": "temetro",
"email": "البريد الإلكتروني",
@@ -43,6 +43,53 @@
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut."
}
},
"walletUpdatesList": {
"title": "An Wallets gesendete Updates",
"description": "Aktenupdates, die Sie an Patienten-Wallets gesendet haben, und ob der Patient sie bestätigt hat.",
"none": "Sie haben noch keine Wallet-Updates gesendet."
},
"walletPush": {
"action": "An Wallet senden",
"title": "Update an Wallet senden",
"subtitle": "Senden Sie {{name}}s aktualisierte Akte an die Wallet-App. Der Patient muss sie auf dem Telefon bestätigen, bevor sie die Kopie auf dem Gerät ersetzt.",
"sectionsLabel": "Was sich geändert hat",
"sections": {
"demographics": "Stammdaten",
"problems": "Probleme",
"medications": "Medikamente",
"allergies": "Allergien",
"labs": "Labor",
"vitals": "Vitalwerte",
"visits": "Besuche"
},
"noteLabel": "Notiz (optional)",
"notePlaceholder": "Eine kurze Notiz zu diesem Update…",
"notice": "Dies sendet die vollständige aktuelle Akte, signiert mit Ihrem Klinikschlüssel. Sie ersetzt die Kopie auf dem Gerät des Patienten erst nach dessen Zustimmung.",
"cancel": "Abbrechen",
"send": "Update senden",
"done": "Fertig",
"errors": {
"generic": "Update konnte nicht gesendet werden. Bitte versuchen Sie es erneut."
},
"status": {
"pending": {
"title": "Warten auf den Patienten",
"body": "Das Update wurde gesendet. Es erreicht das Telefon des Patienten jetzt oder beim nächsten Öffnen der App."
},
"delivered": {
"title": "An das Telefon zugestellt",
"body": "Der Patient hat das Update erhalten und muss es auf seinem Gerät bestätigen."
},
"approved": {
"title": "Vom Patienten bestätigt",
"body": "Der Patient hat das Update bestätigt; seine Akte auf dem Gerät ist nun aktuell."
},
"denied": {
"title": "Vom Patienten abgelehnt",
"body": "Der Patient hat dieses Update abgelehnt; seine Akte auf dem Gerät bleibt unverändert."
}
}
},
"common": {
"appName": "temetro",
"email": "E-Mail",
@@ -43,6 +43,53 @@
"generic": "Something went wrong. Please try again."
}
},
"walletUpdatesList": {
"title": "Updates sent to wallets",
"description": "Record updates you've pushed to patient wallets, and whether the patient has approved them.",
"none": "You haven't pushed any wallet updates yet."
},
"walletPush": {
"action": "Push to wallet",
"title": "Push update to wallet",
"subtitle": "Send {{name}}'s updated record to their wallet app. They must approve it on their phone before it replaces the copy on their device.",
"sectionsLabel": "What changed",
"sections": {
"demographics": "Demographics",
"problems": "Problems",
"medications": "Medications",
"allergies": "Allergies",
"labs": "Labs",
"vitals": "Vitals",
"visits": "Visits"
},
"noteLabel": "Note (optional)",
"notePlaceholder": "A short note about this update…",
"notice": "This sends the full current record, signed with your clinic key. It replaces the patient's on-device copy only after they approve it.",
"cancel": "Cancel",
"send": "Send update",
"done": "Done",
"errors": {
"generic": "Couldn't send the update. Please try again."
},
"status": {
"pending": {
"title": "Waiting for the patient",
"body": "The update was sent. It will arrive on the patient's phone now, or the next time they open the app."
},
"delivered": {
"title": "Delivered to the phone",
"body": "The patient has received the update and needs to approve it on their device."
},
"approved": {
"title": "Approved by the patient",
"body": "The patient approved the update and their on-device record is now up to date."
},
"denied": {
"title": "Declined by the patient",
"body": "The patient declined this update, so their on-device record is unchanged."
}
}
},
"common": {
"appName": "temetro",
"email": "Email",
@@ -43,6 +43,53 @@
"generic": "Une erreur s'est produite. Veuillez réessayer."
}
},
"walletUpdatesList": {
"title": "Mises à jour envoyées aux portefeuilles",
"description": "Les mises à jour de dossiers que vous avez envoyées aux portefeuilles des patients, et si le patient les a approuvées.",
"none": "Vous n'avez encore envoyé aucune mise à jour de portefeuille."
},
"walletPush": {
"action": "Envoyer au portefeuille",
"title": "Envoyer la mise à jour au portefeuille",
"subtitle": "Envoyez le dossier mis à jour de {{name}} vers son application portefeuille. Le patient doit l'approuver sur son téléphone avant qu'elle ne remplace la copie sur son appareil.",
"sectionsLabel": "Ce qui a changé",
"sections": {
"demographics": "Données démographiques",
"problems": "Problèmes",
"medications": "Médicaments",
"allergies": "Allergies",
"labs": "Analyses",
"vitals": "Signes vitaux",
"visits": "Visites"
},
"noteLabel": "Note (facultatif)",
"notePlaceholder": "Une courte note sur cette mise à jour…",
"notice": "Ceci envoie le dossier actuel complet, signé avec la clé de votre clinique. Il ne remplace la copie sur l'appareil du patient qu'après son approbation.",
"cancel": "Annuler",
"send": "Envoyer la mise à jour",
"done": "Terminé",
"errors": {
"generic": "Impossible d'envoyer la mise à jour. Veuillez réessayer."
},
"status": {
"pending": {
"title": "En attente du patient",
"body": "La mise à jour a été envoyée. Elle arrivera sur le téléphone du patient maintenant, ou à la prochaine ouverture de l'application."
},
"delivered": {
"title": "Reçue sur le téléphone",
"body": "Le patient a reçu la mise à jour et doit l'approuver sur son appareil."
},
"approved": {
"title": "Approuvée par le patient",
"body": "Le patient a approuvé la mise à jour ; son dossier sur l'appareil est maintenant à jour."
},
"denied": {
"title": "Refusée par le patient",
"body": "Le patient a refusé cette mise à jour ; son dossier sur l'appareil est inchangé."
}
}
},
"common": {
"appName": "temetro",
"email": "E-mail",
@@ -43,6 +43,53 @@
"generic": "Wax baa qaldamay. Fadlan isku day mar kale."
}
},
"walletUpdatesList": {
"title": "Cusboonaysiimaha loo diray walletyada",
"description": "Cusboonaysiimaha diiwaanka ee aad u dirtay walletyada bukaannada, iyo haddii bukaanku ansixiyay.",
"none": "Weli ma aadan dirin wax cusboonaysiin walletka ah."
},
"walletPush": {
"action": "U dir walletka",
"title": "U dir cusboonaysiinta walletka",
"subtitle": "U dir diiwaanka la cusboonaysiiyay ee {{name}} abka walletkooda. Waa inay taleefankooda ku ansixiyaan ka hor inta uusan bedelin nuqulka qalabkooda ku jira.",
"sectionsLabel": "Waxa isbedelay",
"sections": {
"demographics": "Xogta shakhsi",
"problems": "Dhibaatooyinka",
"medications": "Daawooyinka",
"allergies": "Xasaasiyadda",
"labs": "Baaritaannada",
"vitals": "Calaamadaha muhiimka ah",
"visits": "Booqashooyinka"
},
"noteLabel": "Qoraal (ikhtiyaari)",
"notePlaceholder": "Qoraal gaaban oo ku saabsan cusboonaysiintan…",
"notice": "Tan waxay dirtaa diiwaanka hadda oo dhan, oo lagu saxeexay furaha rugtaada. Waxay bedeshaa nuqulka qalabka bukaanka oo keliya ka dib marka ay ansixiyaan.",
"cancel": "Jooji",
"send": "Dir cusboonaysiinta",
"done": "Dhammaystiran",
"errors": {
"generic": "Lama diri karin cusboonaysiinta. Fadlan isku day mar kale."
},
"status": {
"pending": {
"title": "Sugaya bukaanka",
"body": "Cusboonaysiintii waa la diray. Waxay ku iman doontaa taleefanka bukaanka hadda, ama marka xigta ee ay furaan abka."
},
"delivered": {
"title": "La gaarsiiyay taleefanka",
"body": "Bukaanku wuu helay cusboonaysiinta wuxuuna u baahan yahay inuu ku ansixiyo qalabkiisa."
},
"approved": {
"title": "Bukaanku wuu ansixiyay",
"body": "Bukaanku wuu ansixiyay cusboonaysiinta, diiwaanka qalabkooduna hadda waa cusub yahay."
},
"denied": {
"title": "Bukaanku wuu diiday",
"body": "Bukaanku wuu diiday cusboonaysiintan, sidaas darteed diiwaanka qalabkoodu isma bedelin."
}
}
},
"common": {
"appName": "temetro",
"email": "Iimayl",
+47
View File
@@ -0,0 +1,47 @@
// Client for the clinic→wallet record-update push. When a clinician edits a
// wallet-linked patient, they can push the updated record to the patient's app;
// it stays pending until the patient approves it on their phone.
import { apiFetch } from "@/lib/api-client";
export type WalletUpdateStatus = "pending" | "delivered" | "approved" | "denied";
export type WalletUpdate = {
id: string;
fileNumber: string;
walletNumber: string;
status: WalletUpdateStatus;
changes: string[];
createdAt: string;
deliveredAt: string | null;
resolvedAt: string | null;
};
// Resolve the wallet a patient is linked to. Rejects (404) when not wallet-backed.
export function getWalletLink(
fileNumber: string,
): Promise<{ walletNumber: string }> {
return apiFetch<{ walletNumber: string }>(
`/api/patients/wallet/link/${encodeURIComponent(fileNumber)}`,
);
}
export function pushWalletUpdate(input: {
fileNumber: string;
changes: string[];
}): Promise<WalletUpdate> {
return apiFetch<WalletUpdate>("/api/patients/wallet/push", {
method: "POST",
body: JSON.stringify(input),
});
}
export function listWalletUpdates(): Promise<WalletUpdate[]> {
return apiFetch<WalletUpdate[]>("/api/patients/wallet/updates");
}
export function getWalletUpdate(id: string): Promise<WalletUpdate> {
return apiFetch<WalletUpdate>(
`/api/patients/wallet/updates/${encodeURIComponent(id)}`,
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "0.4.0",
"version": "0.5.0",
"private": true,
"scripts": {
"dev": "next dev",