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"),
`${selected.name} · ${time}`,
);
if (sync.linked) {
if (await sync.ensureLinked()) {
setWalletSummary(
t("walletSync.summary.appointment", { date: keyOf(date), time }),
);
@@ -396,7 +396,7 @@ export function PatientFormDialog({
t("patientForm.updatedTitle"),
t("patientForm.updatedBody", { name: saved.name }),
);
if (sync.linked) {
if (await sync.ensureLinked()) {
setStep("wallet");
return;
}
@@ -273,7 +273,7 @@ export function InvoiceFormDialog({
})
: await createInvoice(payload);
onSaved(saved);
if (sync.linked) {
if (await sync.ensureLinked()) {
setWalletSummary(
mode === "edit"
? t("walletSync.summary.invoiceUpdated", { number: saved.number })
@@ -233,7 +233,7 @@ export function ScribeDialog({
const updated = await saveNote(patient.fileNumber, draft);
notify.success(t("scribe.saved.title"), patient.name);
onSaved(updated);
if (sync.linked) {
if (await sync.ensureLinked()) {
setPhase("review");
setWalletStep(true);
} else {
@@ -347,7 +347,7 @@ export function AddPrescriptionDialog({
t("prescriptions.dialog.addedTitle"),
`${medication.trim()} · ${selected.name}`,
);
if (sync.linked) {
if (await sync.ensureLinked()) {
setWalletSummary(
t("walletSync.summary.prescription", { drug: medication.trim() }),
);
+32 -2
View File
@@ -74,13 +74,43 @@ export function useWalletSync(fileNumber: string | null | undefined) {
};
}, [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(
async (changes: string[]) => {
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);
setState("pending");
try {
const created = await pushWalletUpdate({ fileNumber, changes });
const created = await pushWalletUpdate({ fileNumber, changes: clean });
setUpdate(created);
} catch (err) {
setError(err instanceof ApiError ? err.message : "generic");
@@ -96,7 +126,7 @@ export function useWalletSync(fileNumber: string | null | undefined) {
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>;
@@ -60,6 +60,9 @@ export function WalletSyncStep({
const { t } = useTranslation();
const { state, update, error, push } = sync;
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 (
<>
@@ -79,7 +82,7 @@ export function WalletSyncStep({
{t("walletSync.changesLabel")}
</span>
<div className="rounded-lg border bg-muted/50 px-3 py-2 text-foreground text-sm">
{summary}
{changeSummary}
</div>
</div>
{error && (
@@ -119,7 +122,7 @@ export function WalletSyncStep({
<Button onClick={onDone} type="button" variant="outline">
{t("walletSync.skip")}
</Button>
<Button onClick={() => push([summary])} type="button">
<Button onClick={() => push([changeSummary])} type="button">
<Send className="size-4" />
{t("walletSync.send")}
</Button>
@@ -2257,6 +2257,7 @@
"prescription": "وصفة جديدة: {{drug}}",
"demographics": "تم تحديث البيانات الديموغرافية",
"note": "ملاحظة سريرية جديدة"
}
},
"summaryFallback": "تم تحديث السجل"
}
}
@@ -2237,6 +2237,7 @@
"prescription": "Neues Rezept: {{drug}}",
"demographics": "Stammdaten aktualisiert",
"note": "Neue klinische Notiz"
}
},
"summaryFallback": "Datensatz aktualisiert"
}
}
@@ -2237,6 +2237,7 @@
"prescription": "New prescription: {{drug}}",
"demographics": "Demographics updated",
"note": "New clinical note"
}
},
"summaryFallback": "Record updated"
}
}
@@ -2237,6 +2237,7 @@
"prescription": "Nouvelle ordonnance : {{drug}}",
"demographics": "Données démographiques mises à jour",
"note": "Nouvelle note clinique"
}
},
"summaryFallback": "Dossier mis à jour"
}
}
@@ -2237,6 +2237,7 @@
"prescription": "Rijeeto cusub: {{drug}}",
"demographics": "Xogta bukaanka la cusboonaysiiyay",
"note": "Qoraal caafimaad cusub"
}
},
"summaryFallback": "Diiwaanka waa la cusboonaysiiyay"
}
}