taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 725c4f06b5cec6e96eb07fd85c84fd99183095c6
parent 3f7e8ab611d02cc2a4b66d1e8619b74c01544b0d
Author: Sebastian <sebasjm@gmail.com>
Date:   Thu,  3 Jul 2025 16:16:00 -0300

delete class when empty

Diffstat:
Mpackages/bank-ui/src/Routing.tsx | 11++++++++++-
Mpackages/bank-ui/src/app.tsx | 2++
Mpackages/bank-ui/src/hooks/regional.ts | 10++++++++++
Mpackages/bank-ui/src/hooks/session.ts | 2+-
Mpackages/bank-ui/src/pages/ConversionRateClassDetails.tsx | 181++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mpackages/bank-ui/src/pages/regional/CreateCashout.tsx | 7++++---
Mpackages/taler-util/src/types-taler-corebank.ts | 2+-
7 files changed, 181 insertions(+), 34 deletions(-)

diff --git a/packages/bank-ui/src/Routing.tsx b/packages/bank-ui/src/Routing.tsx @@ -684,7 +684,13 @@ function PrivateRouting({ case "conversionRateClassCreate": { return ( <NewConversionRateClass - onCreated={(id) => navigateTo(privatePages.conversionRateClassDetails.url({classId: String(id)}))} + onCreated={(id) => + navigateTo( + privatePages.conversionRateClassDetails.url({ + classId: String(id), + }), + ) + } routeCancel={privatePages.home} /> ); @@ -698,6 +704,9 @@ function PrivateRouting({ <ConversionRateClassDetails classId={id} routeCancel={privatePages.home} + onClassDeleted={() => { + navigateTo(privatePages.home.url({})); + }} /> ); } diff --git a/packages/bank-ui/src/app.tsx b/packages/bank-ui/src/app.tsx @@ -48,6 +48,7 @@ import { revalidateCashouts, revalidateConversionInfo, revalidateConversionRateClassDetails, + revalidateConversionRateClasses, } from "./hooks/regional.js"; const WITH_LOCAL_STORAGE_CACHE = false; @@ -225,6 +226,7 @@ const evictBankSwrCache: CacheEvictor<TalerCoreBankCacheEviction> = { revalidateCashouts(), revalidateTransactions(), revalidateConversionRateClassDetails(), + revalidateConversionRateClasses() ]); } diff --git a/packages/bank-ui/src/hooks/regional.ts b/packages/bank-ui/src/hooks/regional.ts @@ -729,6 +729,16 @@ export function useConversionRateClassDetails(classId: number) { return undefined; } +export function revalidateConversionRateClassUsers() { + return mutate( + (key) => + Array.isArray(key) && + key[key.length - 1] === "useConversionRateClassUsers", + undefined, + { revalidate: true }, + ); +} + export function useConversionRateClassUsers( classId: number | undefined, username?: string, diff --git a/packages/bank-ui/src/hooks/session.ts b/packages/bank-ui/src/hooks/session.ts @@ -215,7 +215,7 @@ export function useRefreshSessionBeforeExpires() { timeLeftBeforeExpiration.d_ms - refreshWindow.d_ms, 0, ); - console.log("CACACA", remain); + const timeoutId = setTimeout(async () => { const result = await bank.createAccessToken( refreshSession.username, diff --git a/packages/bank-ui/src/pages/ConversionRateClassDetails.tsx b/packages/bank-ui/src/pages/ConversionRateClassDetails.tsx @@ -31,6 +31,8 @@ import { useFormState, } from "../hooks/form.js"; import { + revalidateConversionRateClassDetails, + revalidateConversionRateClassUsers, TransferCalculation, useCashinEstimator, useCashinEstimatorForClass, @@ -50,6 +52,7 @@ import { AmountJson } from "@gnu-taler/taler-util"; interface Props { classId: number; routeCancel: RouteDefinition; + onClassDeleted: () => void; } type FormType = { @@ -64,6 +67,7 @@ type FormType = { export function ConversionRateClassDetails({ routeCancel, classId, + onClassDeleted }: Props): VNode { const { i18n } = useTranslationContext(); @@ -106,6 +110,7 @@ export function ConversionRateClassDetails({ detailsResult={detailsResult.body} routeCancel={routeCancel} classId={classId} + onClassDeleted={onClassDeleted} /> ); } @@ -115,11 +120,13 @@ function Form({ detailsResult, routeCancel, classId, + onClassDeleted, }: { conversionInfo: TalerBankConversionApi.TalerConversionInfoConfig; detailsResult: TalerCorebankApi.ConversionRateClass; routeCancel: RouteDefinition; classId: number; + onClassDeleted: () => void; }) { const { i18n } = useTranslationContext(); const { state: credentials } = useSessionState(); @@ -131,8 +138,8 @@ function Form({ const [notification, notify, handleError] = useLocalNotification(); const [section, setSection] = useState< - "detail" | "cashout" | "cashin" | "users" | "test" - >("test"); + "detail" | "cashout" | "cashin" | "users" | "test" | "delete" + >("delete"); const initalState: FormValues<FormType> = { name: detailsResult.name, @@ -158,6 +165,17 @@ function Form({ ), ); + async function doDeleteClass() { + if (!creds) return; + await bank.deleteConversionRateClass(creds.token, classId); + onClassDeleted(); + } + + const doDelete = + !creds || section !== "delete" || detailsResult.num_users > 0 + ? undefined + : doDeleteClass; + async function doUpdateClass() { if (!creds) return; if (status.status !== "ok") { @@ -277,7 +295,6 @@ function Form({ </span> </span> </label> - <label data-enabled={section === "cashout"} class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 -- data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" @@ -324,7 +341,6 @@ function Form({ </span> </span> </label> - <label data-enabled={section === "users"} class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" @@ -370,6 +386,29 @@ function Form({ </span> </span> </span> + </label>{" "} + <label + data-enabled={section === "delete"} + class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" + > + <input + type="radio" + name="project-type" + value="Newsletter" + class="sr-only" + aria-labelledby="project-type-0-label" + aria-describedby="project-type-0-description-0 project-type-0-description-1" + onChange={() => { + setSection("delete"); + }} + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span class="block text-sm font-medium text-gray-900"> + <i18n.Translate>Delete</i18n.Translate> + </span> + </span> + </span> </label> </div> </div> @@ -421,6 +460,16 @@ function Form({ <div class="px-6 pt-6"> <div class="justify-between items-center flex "> <dt class="text-sm text-gray-600"> + <i18n.Translate>Users</i18n.Translate> + </dt> + <dd class="text-sm text-gray-900"> + {detailsResult.num_users} + </dd> + </div> + </div> + <div class="px-6 pt-6"> + <div class="justify-between items-center flex "> + <dt class="text-sm text-gray-600"> <i18n.Translate>Name</i18n.Translate> </dt> <dd class="text-sm text-gray-900"> @@ -448,6 +497,7 @@ function Form({ </dd> </div> </div> + <div class="px-6 pt-6"> <div class="justify-between items-center flex "> <dt class="text-sm text-gray-600"> @@ -565,6 +615,12 @@ function Form({ {section == "users" && ( <AccountsOnConversionClass classId={classId} /> )} + {section == "delete" && ( + <DeleteConversionClass + classId={classId} + userCount={detailsResult.num_users} + /> + )} {section == "test" && <TestConversionClass classId={classId} />} @@ -615,6 +671,19 @@ function Form({ </button> </Fragment> ) : undefined} + {section == "delete" ? ( + <Fragment> + <button + type="submit" + name="update conversion" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" + disabled={!doDelete} + onClick={doDelete} + > + <i18n.Translate>Delete</i18n.Translate> + </button> + </Fragment> + ) : undefined} </div> </form> </div> @@ -748,8 +817,10 @@ function TestConversionClass({ classId }: { classId: number }): VNode { ? result.body : undefined; - const { estimateByDebit: calculateCashoutFromDebit } = useCashoutEstimatorForClass(classId); - const { estimateByDebit: calculateCashinFromDebit } = useCashinEstimatorForClass(classId); + const { estimateByDebit: calculateCashoutFromDebit } = + useCashoutEstimatorForClass(classId); + const { estimateByDebit: calculateCashinFromDebit } = + useCashinEstimatorForClass(classId); const [amount, setAmount] = useState<string>("100"); const [error, setError] = useState<string>(); @@ -787,7 +858,7 @@ function TestConversionClass({ classId }: { classId: number }): VNode { if (!info) { return <Loading />; } - + const cashinCalc = calculationResult?.cashin === "amount-is-too-small" ? undefined @@ -921,27 +992,56 @@ function TestConversionClass({ classId }: { classId: number }): VNode { </div> </dl> </div> - - {/* {cashoutCalc && - error === undefined && - Amounts.cmp(status.result.amount, cashoutCalc.credit) < 0 ? ( - <div class="p-4"> - <Attention title={i18n.str`Bad configuration`} type="warning"> - <i18n.Translate> - This configuration allows users to cash out more of what has - been cashed in. - </i18n.Translate> - </Attention> - </div> - ) : undefined} */} </div> )} </Fragment> ); } +function DeleteConversionClass({ + classId, + userCount, +}: { + classId: number; + userCount: number; +}): VNode { + const { i18n } = useTranslationContext(); + + return ( + <Fragment> + <div class="px-4 mt-4"> + {userCount > 0 ? ( + <Attention + type="danger" + title={i18n.str`Can't remove the conversion rate group`} + > + <i18n.Translate> + There are some user associated to this group. You need to remove + them first. + </i18n.Translate> + </Attention> + ) : ( + <Attention + type="warning" + title={i18n.str`You are going to remove the conversion rate group`} + > + <i18n.Translate>This step can't be undone.</i18n.Translate> + </Attention> + )} + </div> + </Fragment> + ); +} + function AccountsOnConversionClass({ classId }: { classId: number }): VNode { const { i18n } = useTranslationContext(); + const { + lib: { bank }, + config, + } = useBankCoreApiContext(); + const { state } = useSessionState(); + const token = state.status === "loggedIn" ? state.token : undefined; + const [filter, setFilter] = useState<{ showAll?: boolean; classId?: number; @@ -1073,14 +1173,39 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { {"<pending>"} </td> <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> - <a - href="" - class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" - > - <i18n.Translate> - Quit from this group - </i18n.Translate> - </a> + {classId === filter.classId ? ( + <button + class="disabled:opacity-50 disabled:bg-gray-600 disabled:hover:bg-gray-600 disabled:cursor-default cursor-pointer rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" + onClick={async () => { + if (token) { + await bank.updateAccount( + { username: item.username, token }, + { conversion_rate_class_id: null }, + ); + await revalidateConversionRateClassUsers(); + await revalidateConversionRateClassDetails(); + } + }} + > + <i18n.Translate>Remove</i18n.Translate> + </button> + ) : ( + <button + class="disabled:opacity-50 disabled:bg-gray-600 disabled:hover:bg-gray-600 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={async () => { + if (token) { + await bank.updateAccount( + { username: item.username, token }, + { conversion_rate_class_id: classId }, + ); + await revalidateConversionRateClassUsers(); + await revalidateConversionRateClassDetails(); + } + }} + > + <i18n.Translate>Add</i18n.Translate> + </button> + )} </td> </tr> ); diff --git a/packages/bank-ui/src/pages/regional/CreateCashout.tsx b/packages/bank-ui/src/pages/regional/CreateCashout.tsx @@ -45,6 +45,7 @@ import { TransferCalculation, useCashoutEstimator, useConversionInfo, + useConversionInfoForUser, } from "../../hooks/regional.js"; import { useSessionState } from "../../hooks/session.js"; import { TanChannel, undefinedIfEmpty } from "../../utils.js"; @@ -100,7 +101,7 @@ export function CreateCashout({ } = useBankCoreApiContext(); const [form, setForm] = useState<Partial<FormType>>({ isDebit: true }); const [notification, notify, handleError] = useLocalNotification(); - const info = useConversionInfo(); + const info = useConversionInfoForUser(accountName); if (!config.allow_conversion) { return ( @@ -195,9 +196,9 @@ export function CreateCashout({ resultAccount.body.balance.credit_debit_indicator == "debit", debitThreshold: Amounts.parseOrThrow(resultAccount.body.debit_threshold), minCashout: - resultAccount.body.conversion_rate === undefined + conversionInfo.cashin_min_amount === undefined ? regionalZero - : Amounts.parseOrThrow(resultAccount.body.conversion_rate.cashin_min_amount), + : Amounts.parseOrThrow(conversionInfo.cashin_min_amount), }; const limit = account.balanceIsDebit diff --git a/packages/taler-util/src/types-taler-corebank.ts b/packages/taler-util/src/types-taler-corebank.ts @@ -354,7 +354,7 @@ export interface AccountReconfiguration { // If present, set the user conversion rate class // Only admin can set this property. // @since **v9** - conversion_rate_class_id?: Integer; + conversion_rate_class_id?: Integer | null; // If present, enables 2FA and set the TAN channel used for challenges tan_channel?: TanChannel | null;