commit 725c4f06b5cec6e96eb07fd85c84fd99183095c6
parent 3f7e8ab611d02cc2a4b66d1e8619b74c01544b0d
Author: Sebastian <sebasjm@gmail.com>
Date: Thu, 3 Jul 2025 16:16:00 -0300
delete class when empty
Diffstat:
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;