frontend: fix wallet push silently skipping in form dialogs

The "send to wallet" step in the appointment/invoice/prescription/
patient-edit/scribe dialogs was gated on sync.linked, which resolves
asynchronously via getWalletLink in an effect. A fast save (or a
transient failure that collapsed it to false) left a wallet-linked
patient looking unlinked, so the dialog took the else branch and pushed
nothing.

- Add ensureLinked() to useWalletSync: awaits getWalletLink at submit and
  returns the resolved status. Each of the 5 dialogs now awaits it before
  deciding whether to show the wallet step.
- Harden push() to drop empty/whitespace changes (the backend 400s on an
  empty change set) and add a translated summaryFallback so the step
  never sends an empty summary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-07-14 21:10:04 +03:00
parent c326e9f794
commit 76da310766
12 changed files with 52 additions and 14 deletions
@@ -195,7 +195,7 @@ export function AddAppointmentDialog({
t("appointments.dialog.addedTitle"), t("appointments.dialog.addedTitle"),
`${selected.name} · ${time}`, `${selected.name} · ${time}`,
); );
if (sync.linked) { if (await sync.ensureLinked()) {
setWalletSummary( setWalletSummary(
t("walletSync.summary.appointment", { date: keyOf(date), time }), t("walletSync.summary.appointment", { date: keyOf(date), time }),
); );
@@ -396,7 +396,7 @@ export function PatientFormDialog({
t("patientForm.updatedTitle"), t("patientForm.updatedTitle"),
t("patientForm.updatedBody", { name: saved.name }), t("patientForm.updatedBody", { name: saved.name }),
); );
if (sync.linked) { if (await sync.ensureLinked()) {
setStep("wallet"); setStep("wallet");
return; return;
} }
@@ -273,7 +273,7 @@ export function InvoiceFormDialog({
}) })
: await createInvoice(payload); : await createInvoice(payload);
onSaved(saved); onSaved(saved);
if (sync.linked) { if (await sync.ensureLinked()) {
setWalletSummary( setWalletSummary(
mode === "edit" mode === "edit"
? t("walletSync.summary.invoiceUpdated", { number: saved.number }) ? t("walletSync.summary.invoiceUpdated", { number: saved.number })
@@ -233,7 +233,7 @@ export function ScribeDialog({
const updated = await saveNote(patient.fileNumber, draft); const updated = await saveNote(patient.fileNumber, draft);
notify.success(t("scribe.saved.title"), patient.name); notify.success(t("scribe.saved.title"), patient.name);
onSaved(updated); onSaved(updated);
if (sync.linked) { if (await sync.ensureLinked()) {
setPhase("review"); setPhase("review");
setWalletStep(true); setWalletStep(true);
} else { } else {
@@ -347,7 +347,7 @@ export function AddPrescriptionDialog({
t("prescriptions.dialog.addedTitle"), t("prescriptions.dialog.addedTitle"),
`${medication.trim()} · ${selected.name}`, `${medication.trim()} · ${selected.name}`,
); );
if (sync.linked) { if (await sync.ensureLinked()) {
setWalletSummary( setWalletSummary(
t("walletSync.summary.prescription", { drug: medication.trim() }), t("walletSync.summary.prescription", { drug: medication.trim() }),
); );
+32 -2
View File
@@ -74,13 +74,43 @@ export function useWalletSync(fileNumber: string | null | undefined) {
}; };
}, [state, update]); }, [state, update]);
// Authoritatively resolve link status at submit time. The `linked` state above
// is populated asynchronously by the effect, so a fast save (or a transient
// failure that collapsed it to false) can leave a wallet-backed patient looking
// unlinked. Callers await this before deciding whether to show the wallet step,
// so the decision is never made on an unresolved check.
const ensureLinked = useCallback(async (): Promise<boolean> => {
if (!fileNumber) {
setLinked(false);
return false;
}
setChecking(true);
try {
await getWalletLink(fileNumber);
setLinked(true);
return true;
} catch {
setLinked(false);
return false;
} finally {
setChecking(false);
}
}, [fileNumber]);
const push = useCallback( const push = useCallback(
async (changes: string[]) => { async (changes: string[]) => {
if (!fileNumber) return; if (!fileNumber) return;
// Never push an empty/whitespace change set — the backend rejects it (400).
const clean = changes.map((c) => c.trim()).filter(Boolean);
if (clean.length === 0) {
setError("generic");
setState("error");
return;
}
setError(null); setError(null);
setState("pending"); setState("pending");
try { try {
const created = await pushWalletUpdate({ fileNumber, changes }); const created = await pushWalletUpdate({ fileNumber, changes: clean });
setUpdate(created); setUpdate(created);
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : "generic"); setError(err instanceof ApiError ? err.message : "generic");
@@ -96,7 +126,7 @@ export function useWalletSync(fileNumber: string | null | undefined) {
setError(null); setError(null);
}, []); }, []);
return { linked, checking, state, update, error, push, reset }; return { linked, checking, state, update, error, ensureLinked, push, reset };
} }
export type UseWalletSync = ReturnType<typeof useWalletSync>; export type UseWalletSync = ReturnType<typeof useWalletSync>;
@@ -60,6 +60,9 @@ export function WalletSyncStep({
const { t } = useTranslation(); const { t } = useTranslation();
const { state, update, error, push } = sync; const { state, update, error, push } = sync;
const status = update?.status ?? "pending"; const status = update?.status ?? "pending";
// The summary is the human-readable change the patient approves. Guard against
// an empty one (the backend 400s on empty changes) with a translated fallback.
const changeSummary = summary.trim() || t("walletSync.summaryFallback");
return ( return (
<> <>
@@ -79,7 +82,7 @@ export function WalletSyncStep({
{t("walletSync.changesLabel")} {t("walletSync.changesLabel")}
</span> </span>
<div className="rounded-lg border bg-muted/50 px-3 py-2 text-foreground text-sm"> <div className="rounded-lg border bg-muted/50 px-3 py-2 text-foreground text-sm">
{summary} {changeSummary}
</div> </div>
</div> </div>
{error && ( {error && (
@@ -119,7 +122,7 @@ export function WalletSyncStep({
<Button onClick={onDone} type="button" variant="outline"> <Button onClick={onDone} type="button" variant="outline">
{t("walletSync.skip")} {t("walletSync.skip")}
</Button> </Button>
<Button onClick={() => push([summary])} type="button"> <Button onClick={() => push([changeSummary])} type="button">
<Send className="size-4" /> <Send className="size-4" />
{t("walletSync.send")} {t("walletSync.send")}
</Button> </Button>
@@ -2257,6 +2257,7 @@
"prescription": "وصفة جديدة: {{drug}}", "prescription": "وصفة جديدة: {{drug}}",
"demographics": "تم تحديث البيانات الديموغرافية", "demographics": "تم تحديث البيانات الديموغرافية",
"note": "ملاحظة سريرية جديدة" "note": "ملاحظة سريرية جديدة"
} },
"summaryFallback": "تم تحديث السجل"
} }
} }
@@ -2237,6 +2237,7 @@
"prescription": "Neues Rezept: {{drug}}", "prescription": "Neues Rezept: {{drug}}",
"demographics": "Stammdaten aktualisiert", "demographics": "Stammdaten aktualisiert",
"note": "Neue klinische Notiz" "note": "Neue klinische Notiz"
} },
"summaryFallback": "Datensatz aktualisiert"
} }
} }
@@ -2237,6 +2237,7 @@
"prescription": "New prescription: {{drug}}", "prescription": "New prescription: {{drug}}",
"demographics": "Demographics updated", "demographics": "Demographics updated",
"note": "New clinical note" "note": "New clinical note"
} },
"summaryFallback": "Record updated"
} }
} }
@@ -2237,6 +2237,7 @@
"prescription": "Nouvelle ordonnance : {{drug}}", "prescription": "Nouvelle ordonnance : {{drug}}",
"demographics": "Données démographiques mises à jour", "demographics": "Données démographiques mises à jour",
"note": "Nouvelle note clinique" "note": "Nouvelle note clinique"
} },
"summaryFallback": "Dossier mis à jour"
} }
} }
@@ -2237,6 +2237,7 @@
"prescription": "Rijeeto cusub: {{drug}}", "prescription": "Rijeeto cusub: {{drug}}",
"demographics": "Xogta bukaanka la cusboonaysiiyay", "demographics": "Xogta bukaanka la cusboonaysiiyay",
"note": "Qoraal caafimaad cusub" "note": "Qoraal caafimaad cusub"
} },
"summaryFallback": "Diiwaanka waa la cusboonaysiiyay"
} }
} }