commit 5d0e09b46049be4fab92a993acb4011462859046
parent cb6924c018c0b1a6d2ca8ebbb6f75ddf4969dc45
Author: Florian Dold <dold@taler.net>
Date: Tue, 3 Nov 2026 20:42:35 +0100
merchant web UI: guard pending report dialog mutations
Diffstat:
2 files changed, 553 insertions(+), 222 deletions(-)
diff --git a/packages/taler-merchant-webui/src/screens/ReportsScreen.state.test.tsx b/packages/taler-merchant-webui/src/screens/ReportsScreen.state.test.tsx
@@ -0,0 +1,254 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+*/
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { render } from "preact";
+import { act } from "preact/test-utils";
+import { ReportsScreen } from "./ReportsScreen.js";
+
+function deferred<T>() {
+ let resolve!: (value: T) => void;
+ let reject!: (error: unknown) => void;
+ const promise = new Promise<T>((yes, no) => {
+ resolve = yes;
+ reject = no;
+ });
+ return { promise, resolve, reject };
+}
+function button(text: string, root: ParentNode = document): HTMLButtonElement {
+ const found = [...root.querySelectorAll("button")].find((b) =>
+ b.textContent?.includes(text),
+ );
+ assert.ok(found, `Missing button: ${text}`);
+ return found;
+}
+function field(id: string): HTMLInputElement {
+ const found = document.querySelector<HTMLInputElement>(`#${id}`);
+ assert.ok(found, `Missing field: ${id}`);
+ return found;
+}
+const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
+const click = (element: HTMLElement) =>
+ act(async () => {
+ element.click();
+ await settle();
+ });
+const input = (element: HTMLInputElement, value: string) =>
+ act(() => {
+ element.value = value;
+ element.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+function submit(element: HTMLInputElement) {
+ element
+ .closest("form")!
+ .dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+}
+function mount() {
+ const root = document.createElement("div");
+ document.body.append(root);
+ return root;
+}
+async function cleanup(root: HTMLElement) {
+ await act(() => render(null, root));
+ root.remove();
+}
+
+for (const { kind, editing } of [
+ { kind: "group", editing: false },
+ { kind: "group", editing: true },
+ { kind: "pot", editing: false },
+ { kind: "pot", editing: true },
+] as const) {
+ test(`${kind} ${editing ? "edit" : "create"} save blocks duplicates and dismissal, preserves failed drafts and allows retry`, async () => {
+ const root = mount();
+ const requests: ReturnType<typeof deferred<void>>[] = [];
+ const save = () => {
+ const request = deferred<void>();
+ requests.push(request);
+ return request.promise;
+ };
+ const label = kind === "group" ? "Add product group" : "Add Money Pot";
+ const id = kind === "group" ? "grp_name_input" : "pot_name_input";
+ try {
+ await act(() =>
+ render(
+ <ReportsScreen
+ initialTab="groupings"
+ onAddGroup={save}
+ onAddPot={save}
+ onUpdateGroup={save}
+ onUpdatePot={save}
+ sampleGroups={[
+ {
+ group_serial: 1,
+ group_name: "existing_group",
+ description: "Original",
+ },
+ ]}
+ samplePots={[
+ { pot_serial: 2, pot_name: "existing_pot", pot_totals: [] },
+ ]}
+ />,
+ root,
+ ),
+ );
+ if (editing) {
+ await click(
+ root.querySelector<HTMLButtonElement>(
+ `button[aria-label="Actions for existing_${kind}"]`,
+ )!,
+ );
+ await click(button("Edit", document.querySelector('[role="menu"]')!));
+ } else await click(button(label));
+ await input(field(id), "first");
+ await act(() => {
+ submit(field(id));
+ submit(field(id));
+ });
+ assert.equal(requests.length, 1);
+ const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!;
+ assert.equal(dialog.querySelector("fieldset")!.disabled, true);
+ await click(
+ dialog.querySelector<HTMLButtonElement>('button[aria-label="Close"]')!,
+ );
+ await click(button("Cancel", dialog));
+ await click(dialog.querySelector<HTMLElement>('[aria-hidden="true"]')!);
+ await act(() => {
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
+ });
+ assert.equal(document.querySelector('[role="dialog"]'), dialog);
+ await act(async () => {
+ requests[0]!.reject(new Error("Request failed"));
+ await settle();
+ });
+ assert.equal(field(id).value, "first");
+ assert.match(dialog.textContent!, /Request failed/);
+ await act(() => submit(field(id)));
+ await act(async () => {
+ requests[1]!.resolve();
+ await settle();
+ });
+ assert.equal(document.querySelector('[role="dialog"]'), null);
+ await click(button(label));
+ assert.equal(field(id).value, "");
+ assert.doesNotMatch(
+ document.querySelector('[role="dialog"]')!.textContent!,
+ /Request failed/,
+ );
+ } finally {
+ await cleanup(root);
+ }
+ });
+}
+
+for (const kind of ["group", "pot", "report"] as const) {
+ test(`${kind} deletion stays open on failure and submits once while pending`, async () => {
+ const root = mount();
+ const requests: ReturnType<typeof deferred<void>>[] = [];
+ const serials: string[] = [];
+ const remove = (serial: string) => {
+ serials.push(serial);
+ const request = deferred<void>();
+ requests.push(request);
+ return request.promise;
+ };
+ try {
+ await act(() =>
+ render(
+ <ReportsScreen
+ initialTab={kind === "report" ? "scheduled" : "groupings"}
+ sampleGroups={[
+ {
+ group_serial: 1,
+ group_name: "existing_group",
+ description: "Original",
+ },
+ ]}
+ samplePots={[
+ { pot_serial: 2, pot_name: "existing_pot", pot_totals: [] },
+ ]}
+ sampleReports={[
+ {
+ report_serial: 3,
+ description: "existing_report",
+ report_frequency: { d_us: 86400000000 },
+ },
+ ]}
+ onDeleteGroup={remove}
+ onDeletePot={remove}
+ onDeleteReport={remove}
+ />,
+ root,
+ ),
+ );
+ const open = async () => {
+ await click(
+ root.querySelector<HTMLButtonElement>(
+ `button[aria-label="Actions for existing_${kind}"]`,
+ )!,
+ );
+ await click(
+ button(
+ kind === "report" ? "Cancel Schedule" : "Delete",
+ document.querySelector('[role="menu"]')!,
+ ),
+ );
+ };
+ await open();
+ const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!;
+ const confirm = button(
+ kind === "group"
+ ? "Delete Group"
+ : kind === "pot"
+ ? "Delete Money Pot"
+ : "Cancel Report",
+ dialog,
+ );
+ await act(() => {
+ confirm.click();
+ confirm.click();
+ });
+ assert.deepEqual(serials, [
+ kind === "group" ? "1" : kind === "pot" ? "2" : "3",
+ ]);
+ await click(
+ dialog.querySelector<HTMLButtonElement>('button[aria-label="Close"]')!,
+ );
+ assert.equal(document.querySelector('[role="dialog"]'), dialog);
+ await act(async () => {
+ requests[0]!.reject(new Error("Delete failed"));
+ await settle();
+ });
+ assert.match(dialog.textContent!, /Delete failed/);
+ await click(button("Cancel", dialog));
+ await open();
+ assert.doesNotMatch(
+ document.querySelector('[role="dialog"]')!.textContent!,
+ /Delete failed/,
+ );
+ await click(
+ button(
+ kind === "group"
+ ? "Delete Group"
+ : kind === "pot"
+ ? "Delete Money Pot"
+ : "Cancel Report",
+ document.querySelector('[role="dialog"]')!,
+ ),
+ );
+ await act(async () => {
+ requests[1]!.resolve();
+ await settle();
+ });
+ assert.equal(document.querySelector('[role="dialog"]'), null);
+ } finally {
+ await cleanup(root);
+ }
+ });
+}
diff --git a/packages/taler-merchant-webui/src/screens/ReportsScreen.tsx b/packages/taler-merchant-webui/src/screens/ReportsScreen.tsx
@@ -15,8 +15,9 @@
*/
import type { VNode } from "preact";
-import { useState } from "preact/hooks";
+import { useRef, useState } from "preact/hooks";
import { Header } from "../ui/Header.js";
+import { ApiErrorBanner } from "../ui/ApiErrorBanner.js";
import { Modal } from "../ui/Modal.js";
import { Button } from "../ui/Button.js";
import { useLocation } from "wouter-preact";
@@ -208,9 +209,39 @@ export function ReportsScreen({
text: string;
} | null>(null);
+ const mutationLock = useRef(false);
+ const [mutationPending, setMutationPending] = useState(false);
+ const [mutationError, setMutationError] = useState<unknown>();
+
+ const runMutation = async (action: () => Promise<void>) => {
+ if (mutationLock.current) return;
+ mutationLock.current = true;
+ setMutationPending(true);
+ setMutationError(undefined);
+ try {
+ await action();
+ } catch (error) {
+ setMutationError(error);
+ } finally {
+ mutationLock.current = false;
+ setMutationPending(false);
+ }
+ };
+
+ const openDialog = (open: () => void) => {
+ if (mutationLock.current) return;
+ setMutationError(undefined);
+ open();
+ };
+ const closeDialog = (close: () => void) => {
+ if (!mutationLock.current) close();
+ };
+
const toSlug = normalizeMerchantSlug;
const handleOpenAddGroup = () => {
+ if (mutationLock.current) return;
+ setMutationError(undefined);
setEditingGroup(null);
setGroupName("");
setGroupDesc("");
@@ -218,6 +249,8 @@ export function ReportsScreen({
};
const handleOpenEditGroup = (grp: TalerMerchantApi.GroupEntry) => {
+ if (mutationLock.current) return;
+ setMutationError(undefined);
setEditingGroup(grp);
setGroupName(grp.group_name);
setGroupDesc(grp.description || "");
@@ -226,17 +259,15 @@ export function ReportsScreen({
const handleSaveGroup = async (e: Event) => {
e.preventDefault();
+ if (mutationLock.current) return;
if (!groupName.trim()) return;
const cleanName = toSlug(groupName);
if (!isValidMerchantSlug(cleanName)) {
- setFeedbackMsg({
- type: "error",
- text: t`Enter a valid product group identifier.`,
- });
+ setMutationError(t`Enter a valid product group identifier.`);
return;
}
- try {
+ await runMutation(async () => {
if (editingGroup) {
await onUpdateGroup?.(String(editingGroup.group_serial), {
group_name: cleanName,
@@ -257,12 +288,12 @@ export function ReportsScreen({
});
}
setShowGroupModal(false);
- } catch {
- setFeedbackMsg({ type: "error", text: t`Failed to save product group.` });
- }
+ });
};
const handleOpenAddPot = () => {
+ if (mutationLock.current) return;
+ setMutationError(undefined);
setEditingPot(null);
setPotName("");
setPotDesc("");
@@ -272,6 +303,8 @@ export function ReportsScreen({
const handleOpenEditPot = (
pot: TalerMerchantApi.PotEntry & { description?: string },
) => {
+ if (mutationLock.current) return;
+ setMutationError(undefined);
setEditingPot(pot);
setPotName(pot.pot_name);
setPotDesc(pot.description ?? "");
@@ -280,17 +313,15 @@ export function ReportsScreen({
const handleSavePot = async (e: Event) => {
e.preventDefault();
+ if (mutationLock.current) return;
if (!potName.trim()) return;
const cleanName = toSlug(potName);
if (!isValidMerchantSlug(cleanName)) {
- setFeedbackMsg({
- type: "error",
- text: t`Enter a valid money pot identifier.`,
- });
+ setMutationError(t`Enter a valid money pot identifier.`);
return;
}
- try {
+ await runMutation(async () => {
if (editingPot) {
await onUpdatePot?.(String(editingPot.pot_serial), {
pot_name: cleanName,
@@ -311,9 +342,7 @@ export function ReportsScreen({
});
}
setShowPotModal(false);
- } catch {
- setFeedbackMsg({ type: "error", text: t`Failed to save money pot.` });
- }
+ });
};
/**
@@ -505,7 +534,9 @@ export function ReportsScreen({
<button
type="button"
onClick={() =>
- setDeletingReportSerial(rep.report_serial)
+ openDialog(() =>
+ setDeletingReportSerial(rep.report_serial),
+ )
}
class={destructiveRowActionClass}
>{t`Cancel Schedule`}</button>
@@ -577,7 +608,9 @@ export function ReportsScreen({
<button
type="button"
onClick={() =>
- setDeletingReportSerial(rep.report_serial)
+ openDialog(() =>
+ setDeletingReportSerial(rep.report_serial),
+ )
}
class={destructiveRowActionClass}
>{t`Cancel Schedule`}</button>
@@ -646,7 +679,9 @@ export function ReportsScreen({
>{t`Edit`}</button>
<button
type="button"
- onClick={() => setDeletingGroup(grp)}
+ onClick={() =>
+ openDialog(() => setDeletingGroup(grp))
+ }
class={destructiveRowActionClass}
>{t`Delete`}</button>
</RowActions>
@@ -690,7 +725,9 @@ export function ReportsScreen({
>{t`Edit`}</button>
<button
type="button"
- onClick={() => setDeletingGroup(grp)}
+ onClick={() =>
+ openDialog(() => setDeletingGroup(grp))
+ }
class={destructiveRowActionClass}
>{t`Delete`}</button>
</RowActions>
@@ -752,7 +789,9 @@ export function ReportsScreen({
>{t`Edit`}</button>
<button
type="button"
- onClick={() => setDeletingPot(pot)}
+ onClick={() =>
+ openDialog(() => setDeletingPot(pot))
+ }
class={destructiveRowActionClass}
>{t`Delete`}</button>
</RowActions>
@@ -792,7 +831,9 @@ export function ReportsScreen({
>{t`Edit`}</button>
<button
type="button"
- onClick={() => setDeletingPot(pot)}
+ onClick={() =>
+ openDialog(() => setDeletingPot(pot))
+ }
class={destructiveRowActionClass}
>{t`Delete`}</button>
</RowActions>
@@ -810,259 +851,295 @@ export function ReportsScreen({
<Modal
isOpen={showGroupModal}
- onClose={() => setShowGroupModal(false)}
+ onClose={() => closeDialog(() => setShowGroupModal(false))}
title={editingGroup ? t`Edit Product Group` : t`Add Product Group`}
>
- <form onSubmit={handleSaveGroup} class="space-y-4">
- <div>
- <label
- htmlFor="grp_name_input"
- class="block text-xs font-bold text-gray-700 mb-1 flex items-center justify-between"
- >
- <span>
- {t`Group Identifier`} <span class="text-red-500">*</span>
- </span>
- <button
- type="button"
- onClick={() =>
- setGroupName(toSlug(groupName || "beverages_group"))
- }
- class="text-[10px] text-blue-600 hover:underline normal-case font-normal"
+ <fieldset disabled={mutationPending} class="min-w-0 space-y-4">
+ <ApiErrorBanner
+ error={mutationError}
+ title={t`Could not save changes`}
+ />
+ <form onSubmit={handleSaveGroup} class="space-y-4">
+ <div>
+ <label
+ htmlFor="grp_name_input"
+ class="block text-xs font-bold text-gray-700 mb-1 flex items-center justify-between"
>
- {t`Reset to suggested`}
- </button>
- </label>
- <input
- id="grp_name_input"
- type="text"
- required
- value={groupName}
- onInput={(e) =>
- setGroupName((e.target as HTMLInputElement).value)
- }
- placeholder={groupIdentifierExample}
- class="w-full px-3.5 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
- />
- </div>
+ <span>
+ {t`Group Identifier`} <span class="text-red-500">*</span>
+ </span>
+ <button
+ type="button"
+ onClick={() =>
+ setGroupName(toSlug(groupName || "beverages_group"))
+ }
+ class="text-[10px] text-blue-600 hover:underline normal-case font-normal"
+ >
+ {t`Reset to suggested`}
+ </button>
+ </label>
+ <input
+ id="grp_name_input"
+ type="text"
+ required
+ value={groupName}
+ onInput={(e) =>
+ setGroupName((e.target as HTMLInputElement).value)
+ }
+ placeholder={groupIdentifierExample}
+ class="w-full px-3.5 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
+ />
+ </div>
- <div>
- <label
- htmlFor="grp_desc_input"
- class="block text-xs font-bold text-gray-700 mb-1"
- >
- {t`Description`}
- </label>
- <textarea
- id="grp_desc_input"
- rows={3}
- value={groupDesc}
- onInput={(e) =>
- setGroupDesc((e.target as HTMLTextAreaElement).value)
- }
- placeholder={t`Describe what products belong to this reporting group...`}
- class="w-full px-3.5 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
- />
- </div>
+ <div>
+ <label
+ htmlFor="grp_desc_input"
+ class="block text-xs font-bold text-gray-700 mb-1"
+ >
+ {t`Description`}
+ </label>
+ <textarea
+ id="grp_desc_input"
+ rows={3}
+ value={groupDesc}
+ onInput={(e) =>
+ setGroupDesc((e.target as HTMLTextAreaElement).value)
+ }
+ placeholder={t`Describe what products belong to this reporting group...`}
+ class="w-full px-3.5 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
+ />
+ </div>
- <div class="flex items-center justify-end space-x-3 pt-2">
- <Button
- variant="secondary"
- onClick={() => setShowGroupModal(false)}
- >
- {t`Cancel`}
- </Button>
- <Button type="submit" variant="primary">
- {editingGroup ? t`Save Group` : t`Create Product Group`}
- </Button>
- </div>
- </form>
+ <div class="flex items-center justify-end space-x-3 pt-2">
+ <Button
+ variant="secondary"
+ onClick={() => closeDialog(() => setShowGroupModal(false))}
+ >
+ {t`Cancel`}
+ </Button>
+ <Button
+ type="submit"
+ variant="primary"
+ isLoading={mutationPending}
+ >
+ {editingGroup ? t`Save Group` : t`Create Product Group`}
+ </Button>
+ </div>
+ </form>
+ </fieldset>
</Modal>
<Modal
isOpen={showPotModal}
- onClose={() => setShowPotModal(false)}
+ onClose={() => closeDialog(() => setShowPotModal(false))}
title={editingPot ? t`Edit Money Pot` : t`Add Money Pot`}
>
- <form onSubmit={handleSavePot} class="space-y-4">
- <div>
- <label
- htmlFor="pot_name_input"
- class="block text-xs font-bold text-gray-700 mb-1 flex items-center justify-between"
- >
- <span>
- {t`Money Pot Identifier`} <span class="text-red-500">*</span>
- </span>
- <button
- type="button"
- onClick={() => setPotName(toSlug(potName || "breakfast_pot"))}
- class="text-[10px] text-blue-600 hover:underline normal-case font-normal"
+ <fieldset disabled={mutationPending} class="min-w-0 space-y-4">
+ <ApiErrorBanner
+ error={mutationError}
+ title={t`Could not save changes`}
+ />
+ <form onSubmit={handleSavePot} class="space-y-4">
+ <div>
+ <label
+ htmlFor="pot_name_input"
+ class="block text-xs font-bold text-gray-700 mb-1 flex items-center justify-between"
>
- {t`Reset to suggested`}
- </button>
- </label>
- <input
- id="pot_name_input"
- type="text"
- required
- value={potName}
- onInput={(e) => setPotName((e.target as HTMLInputElement).value)}
- placeholder={potIdentifierExample}
- class="w-full px-3.5 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
- />
- </div>
+ <span>
+ {t`Money Pot Identifier`} <span class="text-red-500">*</span>
+ </span>
+ <button
+ type="button"
+ onClick={() => setPotName(toSlug(potName || "breakfast_pot"))}
+ class="text-[10px] text-blue-600 hover:underline normal-case font-normal"
+ >
+ {t`Reset to suggested`}
+ </button>
+ </label>
+ <input
+ id="pot_name_input"
+ type="text"
+ required
+ value={potName}
+ onInput={(e) =>
+ setPotName((e.target as HTMLInputElement).value)
+ }
+ placeholder={potIdentifierExample}
+ class="w-full px-3.5 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
+ />
+ </div>
- <div>
- <label
- htmlFor="pot_desc_input"
- class="block text-xs font-bold text-gray-700 mb-1"
- >
- {t`Description / Target Info`}
- </label>
- <input
- id="pot_desc_input"
- type="text"
- value={potDesc}
- onInput={(e) =>
- setPotDesc((e.target as HTMLTextAreaElement).value)
- }
- placeholder={t`Describe revenue target or assigned products...`}
- class="w-full px-3.5 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
- />
- </div>
+ <div>
+ <label
+ htmlFor="pot_desc_input"
+ class="block text-xs font-bold text-gray-700 mb-1"
+ >
+ {t`Description / Target Info`}
+ </label>
+ <input
+ id="pot_desc_input"
+ type="text"
+ value={potDesc}
+ onInput={(e) =>
+ setPotDesc((e.target as HTMLTextAreaElement).value)
+ }
+ placeholder={t`Describe revenue target or assigned products...`}
+ class="w-full px-3.5 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
+ />
+ </div>
- <div class="flex items-center justify-end space-x-3 pt-2">
- <Button variant="secondary" onClick={() => setShowPotModal(false)}>
- {t`Cancel`}
- </Button>
- <Button type="submit" variant="primary">
- {editingPot ? t`Save Money Pot` : t`Create Money Pot`}
- </Button>
- </div>
- </form>
+ <div class="flex items-center justify-end space-x-3 pt-2">
+ <Button
+ variant="secondary"
+ onClick={() => closeDialog(() => setShowPotModal(false))}
+ >
+ {t`Cancel`}
+ </Button>
+ <Button
+ type="submit"
+ variant="primary"
+ isLoading={mutationPending}
+ >
+ {editingPot ? t`Save Money Pot` : t`Create Money Pot`}
+ </Button>
+ </div>
+ </form>
+ </fieldset>
</Modal>
<Modal
isOpen={Boolean(deletingGroup)}
- onClose={() => setDeletingGroup(null)}
+ onClose={() => closeDialog(() => setDeletingGroup(null))}
title={
deletingGroup
? t`Delete group "${deletingGroup.group_name}"?`
: undefined
}
>
- <p class="text-xs text-gray-700 leading-relaxed">
- {t`Are you sure you want to delete this reporting group? Products assigned to it will remain in inventory.`}
- </p>
- <div class="flex items-center justify-end space-x-3 pt-2">
- <Button variant="secondary" onClick={() => setDeletingGroup(null)}>
- {t`Cancel`}
- </Button>
- <Button
- variant="danger"
- onClick={async () => {
- try {
- if (deletingGroup)
+ <fieldset disabled={mutationPending} class="min-w-0 space-y-4">
+ <ApiErrorBanner
+ error={mutationError}
+ title={t`Could not save changes`}
+ />
+ <p class="text-xs text-gray-700 leading-relaxed">
+ {t`Are you sure you want to delete this reporting group? Products assigned to it will remain in inventory.`}
+ </p>
+ <div class="flex items-center justify-end space-x-3 pt-2">
+ <Button
+ variant="secondary"
+ onClick={() => closeDialog(() => setDeletingGroup(null))}
+ >
+ {t`Cancel`}
+ </Button>
+ <Button
+ variant="danger"
+ isLoading={mutationPending}
+ onClick={() =>
+ void runMutation(async () => {
+ if (deletingGroup === null) return;
await onDeleteGroup?.(String(deletingGroup.group_serial));
- setFeedbackMsg({
- type: "success",
- text: t`Product group "${deletingGroup?.group_name}" deleted.`,
- });
- } catch {
- setFeedbackMsg({
- type: "error",
- text: t`Failed to delete group.`,
- });
+ setFeedbackMsg({
+ type: "success",
+ text: t`Product group "${deletingGroup.group_name}" deleted.`,
+ });
+ setDeletingGroup(null);
+ })
}
- setDeletingGroup(null);
- }}
- >
- {t`Delete Group`}
- </Button>
- </div>
+ >
+ {t`Delete Group`}
+ </Button>
+ </div>
+ </fieldset>
</Modal>
<Modal
isOpen={Boolean(deletingPot)}
- onClose={() => setDeletingPot(null)}
+ onClose={() => closeDialog(() => setDeletingPot(null))}
title={
deletingPot
? t`Delete money pot "${deletingPot.pot_name}"?`
: undefined
}
>
- <p class="text-xs text-gray-700 leading-relaxed">
- {t`Are you sure you want to delete this money pot?`}
- </p>
- <div class="flex items-center justify-end space-x-3 pt-2">
- <Button variant="secondary" onClick={() => setDeletingPot(null)}>
- {t`Cancel`}
- </Button>
- <Button
- variant="danger"
- onClick={async () => {
- try {
- if (deletingPot)
+ <fieldset disabled={mutationPending} class="min-w-0 space-y-4">
+ <ApiErrorBanner
+ error={mutationError}
+ title={t`Could not save changes`}
+ />
+ <p class="text-xs text-gray-700 leading-relaxed">
+ {t`Are you sure you want to delete this money pot?`}
+ </p>
+ <div class="flex items-center justify-end space-x-3 pt-2">
+ <Button
+ variant="secondary"
+ onClick={() => closeDialog(() => setDeletingPot(null))}
+ >
+ {t`Cancel`}
+ </Button>
+ <Button
+ variant="danger"
+ isLoading={mutationPending}
+ onClick={() =>
+ void runMutation(async () => {
+ if (deletingPot === null) return;
await onDeletePot?.(String(deletingPot.pot_serial));
- setFeedbackMsg({
- type: "success",
- text: t`Money pot "${deletingPot?.pot_name}" deleted.`,
- });
- } catch {
- setFeedbackMsg({
- type: "error",
- text: t`Failed to delete money pot.`,
- });
+ setFeedbackMsg({
+ type: "success",
+ text: t`Money pot "${deletingPot.pot_name}" deleted.`,
+ });
+ setDeletingPot(null);
+ })
}
- setDeletingPot(null);
- }}
- >
- {t`Delete Money Pot`}
- </Button>
- </div>
+ >
+ {t`Delete Money Pot`}
+ </Button>
+ </div>
+ </fieldset>
</Modal>
<Modal
isOpen={deletingReportSerial !== null}
- onClose={() => setDeletingReportSerial(null)}
+ onClose={() => closeDialog(() => setDeletingReportSerial(null))}
title={
deletingReportSerial !== null
? t`Cancel scheduled report ${deletingReportSerial}?`
: undefined
}
>
- <p class="text-xs text-gray-700 leading-relaxed">
- {t`Are you sure you want to cancel this scheduled report transmission?`}
- </p>
- <div class="flex items-center justify-end space-x-3 pt-2">
- <Button
- variant="secondary"
- onClick={() => setDeletingReportSerial(null)}
- >
- {t`Cancel`}
- </Button>
- <Button
- variant="danger"
- onClick={async () => {
- if (deletingReportSerial !== null) {
- try {
+ <fieldset disabled={mutationPending} class="min-w-0 space-y-4">
+ <ApiErrorBanner
+ error={mutationError}
+ title={t`Could not save changes`}
+ />
+ <p class="text-xs text-gray-700 leading-relaxed">
+ {t`Are you sure you want to cancel this scheduled report transmission?`}
+ </p>
+ <div class="flex items-center justify-end space-x-3 pt-2">
+ <Button
+ variant="secondary"
+ onClick={() => closeDialog(() => setDeletingReportSerial(null))}
+ >
+ {t`Cancel`}
+ </Button>
+ <Button
+ variant="danger"
+ isLoading={mutationPending}
+ onClick={() =>
+ void runMutation(async () => {
+ if (deletingReportSerial === null) return;
await onDeleteReport?.(String(deletingReportSerial));
setFeedbackMsg({
type: "success",
text: t`Scheduled report cancelled.`,
});
- } catch {
- setFeedbackMsg({
- type: "error",
- text: t`Failed to cancel scheduled report.`,
- });
- }
+ setDeletingReportSerial(null);
+ })
}
- setDeletingReportSerial(null);
- }}
- >
- {t`Cancel Report`}
- </Button>
- </div>
+ >
+ {t`Cancel Report`}
+ </Button>
+ </div>
+ </fieldset>
</Modal>
</div>
);