gnunet-svn
[Top][All Lists]
Advanced

[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" }}>
+                                  &lt;{i18n.str`Invalid value`}&gt;
+                                </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" }}>
+                              &lt;
+                              {i18n.str`Invalid value`}&gt;
+                            </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.



reply via email to

[Prev in Thread] Current Thread [Next in Thread]