[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]
[taler-typescript-core] 02/02: working on transfers view #9548
From: |
Admin |
Subject: |
[taler-typescript-core] 02/02: working on transfers view #9548 |
Date: |
Mon, 17 Feb 2025 21:49:51 +0100 |
This is an automated email from the git hooks/post-receive script.
sebasjm pushed a commit to branch master
in repository taler-typescript-core.
commit 23b999c4e248e2c4e31755fb1b8b349ff873e7fe
Author: Sebastian <sebasjm@gmail.com>
AuthorDate: Mon Feb 17 17:48:59 2025 -0300
working on transfers view #9548
---
.../aml-backoffice-ui/src/ExchangeAmlFrame.tsx | 6 +
packages/aml-backoffice-ui/src/Routing.tsx | 6 +
packages/aml-backoffice-ui/src/hooks/transfers.ts | 205 ++++++++++++++
packages/aml-backoffice-ui/src/pages/Cases.tsx | 17 ++
packages/aml-backoffice-ui/src/pages/Transfers.tsx | 312 +++++++++++++++++++++
5 files changed, 546 insertions(+)
diff --git a/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx
b/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx
index 2c08f7346..fa75980a8 100644
--- a/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx
+++ b/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx
@@ -39,6 +39,7 @@ import {
PeopleIcon,
SearchIcon,
ToInvestigateIcon,
+ TransfersIcon,
} from "./pages/Cases.js";
/**
@@ -251,6 +252,11 @@ function Navigation(): VNode {
Icon: ToInvestigateIcon,
label: i18n.str`Investigation`,
},
+ {
+ route: privatePages.transfers,
+ Icon: TransfersIcon,
+ label: i18n.str`Transfers`,
+ },
{ route: privatePages.active, Icon: HomeIcon, label: i18n.str`Active` },
{
route: privatePages.search,
diff --git a/packages/aml-backoffice-ui/src/Routing.tsx
b/packages/aml-backoffice-ui/src/Routing.tsx
index c27ee9aef..2820b7bbe 100644
--- a/packages/aml-backoffice-ui/src/Routing.tsx
+++ b/packages/aml-backoffice-ui/src/Routing.tsx
@@ -45,6 +45,7 @@ import {
import { useCurrentDecisionRequest } from "./hooks/decision-request.js";
import { Dashboard } from "./pages/Dashboard.js";
import { NewMeasure } from "./pages/NewMeasure.js";
+import { Transfers } from "./pages/Transfers.js";
export function Routing(): VNode {
const session = useOfficer();
@@ -127,6 +128,7 @@ export const privatePages = {
measures: urlPattern(/\/measures/, () => "#/measures"),
search: urlPattern(/\/search/, () => "#/search"),
investigation: urlPattern(/\/investigation/, () => "#/investigation"),
+ transfers: urlPattern(/\/transfers/, () => "#/transfers"),
active: urlPattern(/\/active/, () => "#/active"),
caseUpdate: urlPattern<{ cid: string; type: string }>(
/\/case\/(?<cid>[a-zA-Z0-9]+)\/new\/(?<type>[a-zA-Z0-9_.]+)/,
@@ -293,6 +295,10 @@ function PrivateRouting(): VNode {
case "dashboard": {
return <Dashboard routeToDownloadStats={privatePages.statsDownload} />;
}
+ case "transfers": {
+ return <Transfers />;
+ }
+
default:
assertUnreachable(location);
}
diff --git a/packages/aml-backoffice-ui/src/hooks/transfers.ts
b/packages/aml-backoffice-ui/src/hooks/transfers.ts
new file mode 100644
index 000000000..bbb50f414
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/hooks/transfers.ts
@@ -0,0 +1,205 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 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.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { useState } from "preact/hooks";
+
+// FIX default import https://github.com/microsoft/TypeScript/issues/49189
+import {
+ OfficerAccount,
+ OperationOk,
+ opFixedSuccess,
+ opSuccessFromHttp,
+ TalerExchangeResultByMethod,
+ TalerHttpError,
+} from "@gnu-taler/taler-util";
+import { useExchangeApiContext } from "@gnu-taler/web-util/browser";
+import _useSWR, { SWRHook, mutate } from "swr";
+import { useOfficer } from "./officer.js";
+const useSWR = _useSWR as unknown as SWRHook;
+
+export const PAGINATED_LIST_SIZE = 10;
+// when doing paginated request, ask for one more
+// and use it to know if there are more to request
+export const PAGINATED_LIST_REQUEST = PAGINATED_LIST_SIZE + 1;
+
+export function revalidateAccountDecisions() {
+ return mutate(
+ (key) => Array.isArray(key) && key[key.length - 1] === "getAmlDecisions",
+ undefined,
+ { revalidate: true },
+ );
+}
+
+/**
+ * @param args
+ * @returns
+ */
+export function useTransferDebit() {
+ const officer = useOfficer();
+ const session = officer.state === "ready" ? officer.account : undefined;
+ const {
+ lib: { exchange: api },
+ } = useExchangeApiContext();
+
+ const [offset, setOffset] = useState<string>();
+
+ async function fetcher([officer, offset]: [
+ OfficerAccount,
+ string,
+ string | undefined,
+ ]) {
+ return await api.getTransfersDebit(officer, {
+ order: "dec",
+ offset,
+ limit: PAGINATED_LIST_REQUEST,
+ });
+ }
+
+ const { data, error } = useSWR<
+ TalerExchangeResultByMethod<"getTransfersDebit">,
+ TalerHttpError
+ >(!session ? undefined : [session, offset, "getTransfersDebit"], fetcher);
+
+ if (error) return error;
+ if (data === undefined) return undefined;
+ if (data.type !== "ok") return data;
+
+ return buildPaginatedResult(data.body.transfers, offset, setOffset, (d) =>
+ String(d.rowid),
+ );
+}
+
+/**
+ * @param args
+ * @returns
+ */
+export function useTransferCredit() {
+ const officer = useOfficer();
+ const session = officer.state === "ready" ? officer.account : undefined;
+ const {
+ lib: { exchange: api },
+ } = useExchangeApiContext();
+
+ const [offset, setOffset] = useState<string>();
+
+ async function fetcher([officer, offset]: [
+ OfficerAccount,
+ string,
+ string | undefined,
+ ]) {
+ return await api.getTransfersCredit(officer, {
+ order: "dec",
+ offset,
+ limit: PAGINATED_LIST_REQUEST,
+ });
+ }
+
+ const { data, error } = useSWR<
+ TalerExchangeResultByMethod<"getTransfersCredit">,
+ TalerHttpError
+ >(!session ? undefined : [session, offset, "getTransfersCredit"], fetcher);
+
+ if (error) return error;
+ if (data === undefined) return undefined;
+ if (data.type !== "ok") return data;
+
+ return buildPaginatedResult(data.body.transfers, offset, setOffset, (d) =>
+ String(d.rowid),
+ );
+}
+
+/**
+ * @param account
+ * @param args
+ * @returns
+ */
+export function useAccountActiveDecision(accountStr?: string) {
+ const officer = useOfficer();
+ const session =
+ accountStr !== undefined && officer.state === "ready"
+ ? officer.account
+ : undefined;
+ const {
+ lib: { exchange: api },
+ } = useExchangeApiContext();
+
+ const [offset, setOffset] = useState<string>();
+
+ async function fetcher([officer, account, offset]: [
+ OfficerAccount,
+ string,
+ string | undefined,
+ ]) {
+ return await api.getAmlDecisions(officer, {
+ order: "dec",
+ offset,
+ account,
+ active: true,
+ limit: PAGINATED_LIST_REQUEST,
+ });
+ }
+
+ const { data, error } = useSWR<
+ TalerExchangeResultByMethod<"getAmlDecisions">,
+ TalerHttpError
+ >(
+ !session ? undefined : [session, accountStr, offset, "getAmlDecisions"],
+ fetcher,
+ );
+
+ if (error) return error;
+ if (data === undefined) return undefined;
+ if (data.type !== "ok") return data;
+
+ if (!data.body.records.length) return opFixedSuccess(undefined);
+ return opFixedSuccess(data.body.records[0]);
+}
+
+type PaginatedResult<T> = OperationOk<T> & {
+ isLastPage: boolean;
+ isFirstPage: boolean;
+ loadNext(): void;
+ loadFirst(): void;
+};
+
+//TODO: consider sending this to web-util
+export function buildPaginatedResult<R, OffId>(
+ data: R[],
+ offset: OffId | undefined,
+ setOffset: (o: OffId | undefined) => void,
+ getId: (r: R) => OffId,
+): PaginatedResult<R[]> {
+ const isLastPage = data.length < PAGINATED_LIST_REQUEST;
+ const isFirstPage = offset === undefined;
+
+ const result = structuredClone(data);
+ if (result.length == PAGINATED_LIST_REQUEST) {
+ result.pop();
+ }
+ return {
+ type: "ok",
+ body: result,
+ isLastPage,
+ isFirstPage,
+ loadNext: () => {
+ if (!result.length) return;
+ const id = getId(result[result.length - 1]);
+ setOffset(id);
+ },
+ loadFirst: () => {
+ setOffset(undefined);
+ },
+ };
+}
diff --git a/packages/aml-backoffice-ui/src/pages/Cases.tsx
b/packages/aml-backoffice-ui/src/pages/Cases.tsx
index 69632d1b2..96175dc53 100644
--- a/packages/aml-backoffice-ui/src/pages/Cases.tsx
+++ b/packages/aml-backoffice-ui/src/pages/Cases.tsx
@@ -358,6 +358,23 @@ export const ToInvestigateIcon = () => (
</svg>
);
+export const TransfersIcon = () => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="size-6"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21
7.5H7.5"
+ />
+ </svg>
+);
+
export const PeopleIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
diff --git a/packages/aml-backoffice-ui/src/pages/Transfers.tsx
b/packages/aml-backoffice-ui/src/pages/Transfers.tsx
new file mode 100644
index 000000000..2c35d1e5f
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/pages/Transfers.tsx
@@ -0,0 +1,312 @@
+import {
+ Attention,
+ Loading,
+ Time,
+ useExchangeApiContext,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useTransferCredit } from "../hooks/transfers.js";
+import {
+ AbsoluteTime,
+ AmountJson,
+ Amounts,
+ assertUnreachable,
+ CurrencySpecification,
+ HttpStatusCode,
+ TalerError,
+} from "@gnu-taler/taler-util";
+import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js";
+import { Officer } from "./Officer.js";
+import { format } from "date-fns";
+
+export function Transfers(): VNode {
+ const { i18n, dateLocale } = useTranslationContext();
+ const { config } = useExchangeApiContext();
+ const resp = useTransferCredit();
+ const isDebit = true; //FIXME: shoud be an option debit/credit
+
+ if (!resp) {
+ return <Loading />;
+ }
+ if (resp instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={resp} />;
+ }
+ if (resp.type === "fail") {
+ switch (resp.case) {
+ case HttpStatusCode.Forbidden:
+ return (
+ <Fragment>
+ <Attention type="danger" title={i18n.str`Operation denied`}>
+ <i18n.Translate>
+ This account signature is invalid, contact administrator or
+ create a new one.
+ </i18n.Translate>
+ </Attention>
+ <Officer />
+ </Fragment>
+ );
+ case HttpStatusCode.NotFound:
+ return (
+ <Fragment>
+ <Attention type="danger" title={i18n.str`Operation denied`}>
+ <i18n.Translate>
+ The designated AML account is not known, contact administrator
+ or create a new one.
+ </i18n.Translate>
+ </Attention>
+ <Officer />
+ </Fragment>
+ );
+ case HttpStatusCode.Conflict:
+ return (
+ <Fragment>
+ <Attention type="danger" title={i18n.str`Operation denied`}>
+ <i18n.Translate>
+ The designated AML account is not enabled, contact
administrator
+ or create a new one.
+ </i18n.Translate>
+ </Attention>
+ <Officer />
+ </Fragment>
+ );
+ default:
+ assertUnreachable(resp);
+ }
+ }
+ const transactions = resp.body;
+
+ if (!transactions.length) {
+ return (
+ <div class="px-4 mt-4">
+ <div class="sm:flex sm:items-center">
+ <div class="sm:flex-auto">
+ <h1 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Transfers history</i18n.Translate>
+ </h1>
+ </div>
+ </div>
+
+ <Attention type="low" title={i18n.str`No transfers yet.`}>
+ <i18n.Translate>
+ There are no transfer reported by the exchange with the current
+ threshold.
+ </i18n.Translate>
+ </Attention>
+ </div>
+ );
+ }
+
+ const txByDate = transactions.reduce(
+ (prev, cur) => {
+ const d =
+ cur.execution_time.t_s === "never"
+ ? ""
+ : format(cur.execution_time.t_s * 1000, "dd/MM/yyyy", {
+ locale: dateLocale,
+ });
+ if (!prev[d]) {
+ prev[d] = [];
+ }
+ prev[d].push(cur);
+ return prev;
+ },
+ {} as Record<string, typeof transactions>,
+ );
+
+ const onGoNext = resp.isLastPage ? undefined : resp.loadNext;
+ const onGoStart = resp.isFirstPage ? undefined : resp.loadFirst;
+ return (
+ <div class="px-4 mt-8">
+ <div class="sm:flex sm:items-center">
+ <div class="sm:flex-auto">
+ <h1 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Transfers history</i18n.Translate>
+ </h1>
+ </div>
+ </div>
+ <div class="-mx-4 mt-5 ring-1 ring-gray-300 sm:mx-0 rounded-lg min-w-fit
bg-white">
+ <table class="min-w-full divide-y divide-gray-300">
+ <thead>
+ <tr>
+ <th
+ scope="col"
+ class="pl-2 py-3.5 text-left text-sm font-semibold
text-gray-900 "
+ >{i18n.str`Date`}</th>
+ <th
+ scope="col"
+ class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm
font-semibold text-gray-900 "
+ >{i18n.str`Amount`}</th>
+ <th
+ scope="col"
+ class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm
font-semibold text-gray-900 "
+ >{i18n.str`Counterpart`}</th>
+ {/* <th
+ scope="col"
+ class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm
font-semibold text-gray-900 "
+ >{i18n.str`Subject`}</th> */}
+ </tr>
+ </thead>
+ <tbody>
+ {Object.entries(txByDate).map(([date, txs], idx) => {
+ return (
+ <Fragment key={idx}>
+ <tr class="border-t border-gray-200">
+ <th
+ colSpan={4}
+ scope="colgroup"
+ class="bg-gray-50 py-2 pl-4 pr-3 text-left text-sm
font-semibold text-gray-900 sm:pl-3"
+ >
+ {date}
+ </th>
+ </tr>
+ {txs.map((item) => {
+ return (
+ <tr
+ key={idx}
+ class="border-b border-gray-200 last:border-none"
+ >
+ <td class="relative py-2 pl-2 pr-2 text-sm ">
+ <div class="font-medium text-gray-900">
+ <Time
+ format="HH:mm:ss"
+ timestamp={AbsoluteTime.fromProtocolTimestamp(
+ item.execution_time,
+ )}
+ // relative={Duration.fromSpec({ days: 1 })}
+ />
+ </div>
+ <dl class="font-normal sm:hidden">
+ <dt class="sr-only sm:hidden">
+ <i18n.Translate>Amount</i18n.Translate>
+ </dt>
+ <dd class="mt-1 truncate text-gray-700">
+ {isDebit ? i18n.str`sent` :
i18n.str`received`}{" "}
+ {item.amount ? (
+ <span
+ data-negative={isDebit ? "true" : "false"}
+ class="data-[negative=false]:text-green-600
data-[negative=true]:text-red-600"
+ >
+ <RenderAmount
+ value={Amounts.parseOrThrow(item.amount)}
+ spec={config.config.currency_specification}
+ />
+ </span>
+ ) : (
+ <span style={{ color: "grey" }}>
+ <{i18n.str`Invalid value`}>
+ </span>
+ )}
+ </dd>
+
+ <dt class="sr-only sm:hidden">
+ <i18n.Translate>Counterpart</i18n.Translate>
+ </dt>
+ <dd class="mt-1 truncate text-gray-500 sm:hidden">
+ {isDebit ? i18n.str`to` : i18n.str`from`}{" "}
+ {item.payto_uri}
+ </dd>
+ {/* <dd class="mt-1 text-gray-500 sm:hidden">
+ <pre class="break-words w-56
whitespace-break-spaces p-2 rounded-md mx-auto my-2 bg-gray-100">
+ {item.subject}
+ </pre>
+ </dd> */}
+ </dl>
+ </td>
+ <td
+ data-negative={isDebit ? "true" : "false"}
+ class="hidden sm:table-cell px-3 py-3.5 text-sm
text-gray-500 "
+ >
+ {item.amount ? (
+ <RenderAmount
+ value={Amounts.parseOrThrow(item.amount)}
+ negative={isDebit}
+ withColor
+ spec={config.config.currency_specification}
+ />
+ ) : (
+ <span style={{ color: "grey" }}>
+ <
+ {i18n.str`Invalid value`}>
+ </span>
+ )}
+ </td>
+ <td class="hidden sm:table-cell px-3 py-3.5 text-sm
text-gray-500">
+ {item.payto_uri}
+ </td>
+ {/* <td class="hidden sm:table-cell px-3 py-3.5
text-sm text-gray-500 break-all min-w-md">
+ {item.subject}
+ </td> */}
+ </tr>
+ );
+ })}
+ </Fragment>
+ );
+ })}
+ </tbody>
+ </table>
+
+ <nav
+ class="flex items-center justify-between border-t border-gray-200
bg-white px-4 py-3 sm:px-6 rounded-lg"
+ aria-label="Pagination"
+ >
+ <div class="flex flex-1 justify-between sm:justify-end">
+ <button
+ name="first page"
+ class="relative disabled:bg-gray-100 disabled:text-gray-500
inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold
text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50
focus-visible:outline-offset-0"
+ disabled={!onGoStart}
+ onClick={onGoStart}
+ >
+ <i18n.Translate>First page</i18n.Translate>
+ </button>
+ <button
+ name="next page"
+ class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3
inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold
text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50
focus-visible:outline-offset-0"
+ disabled={!onGoNext}
+ onClick={onGoNext}
+ >
+ <i18n.Translate>Next</i18n.Translate>
+ </button>
+ </div>
+ </nav>
+ </div>
+ </div>
+ );
+}
+
+/**
+ * send to web-utils
+ * @param param0
+ * @returns
+ */
+export function RenderAmount({
+ value,
+ spec,
+ negative,
+ withColor,
+ hideSmall,
+}: {
+ spec: CurrencySpecification;
+ value: AmountJson;
+ hideSmall?: boolean;
+ negative?: boolean;
+ withColor?: boolean;
+}): VNode {
+ const neg = !!negative; // convert to true or false
+
+ const { currency, normal, small } = Amounts.stringifyValueWithSpec(
+ value,
+ spec,
+ );
+
+ return (
+ <span
+ data-negative={withColor ? neg : undefined}
+ class="whitespace-nowrap data-[negative=false]:text-green-600
data-[negative=true]:text-red-600"
+ >
+ {negative ? "- " : undefined}
+ {currency} {normal}{" "}
+ {!hideSmall && small && <sup class="-ml-1">{small}</sup>}
+ </span>
+ );
+}
--
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.