taler-typescript-core

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

commit 671f547a151fedf2fb9279381bee92edf9ed5e73
parent 26e4780afc1442f7cc568541ddbbe682620af67c
Author: Sebastian <sebasjm@gmail.com>
Date:   Mon, 10 Nov 2025 17:27:20 -0300

fix #10413

Diffstat:
Apackages/aml-backoffice-ui/src/assets/csv-icon.png | 0
Apackages/aml-backoffice-ui/src/assets/excel-icon.png | 0
Mpackages/aml-backoffice-ui/src/hooks/decisions.ts | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/aml-backoffice-ui/src/pages/AccountList.tsx | 320+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mpackages/taler-util/src/time.ts | 8++++++++
Mpackages/web-util/src/forms/fields/InputToggle.tsx | 2+-
6 files changed, 344 insertions(+), 48 deletions(-)

diff --git a/packages/aml-backoffice-ui/src/assets/csv-icon.png b/packages/aml-backoffice-ui/src/assets/csv-icon.png Binary files differ. diff --git a/packages/aml-backoffice-ui/src/assets/excel-icon.png b/packages/aml-backoffice-ui/src/assets/excel-icon.png Binary files differ. diff --git a/packages/aml-backoffice-ui/src/hooks/decisions.ts b/packages/aml-backoffice-ui/src/hooks/decisions.ts @@ -40,6 +40,68 @@ export const PAGINATED_LIST_REQUEST = PAGINATED_LIST_SIZE + 1; * @param args * @returns */ +export function useAmlAccounts({ + investigated, + highRisk, + open, +}: { + investigated?: boolean; + highRisk?: boolean; + open?: boolean; +} = {}) { + 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, investigation, highRisk, open]: [ + OfficerAccount, + string | undefined, + boolean | undefined, + boolean | undefined, + boolean | undefined, + ]) { + return await api.getAmlAccounts(officer, { + order: "dec", + offset, + limit: PAGINATED_LIST_REQUEST, + investigation, + highRisk, + open, + }); + } + + const { data, error } = useSWR< + TalerExchangeResultByMethod2<"getAmlAccounts">, + TalerHttpError + >( + !session + ? undefined + : [session, offset, investigated, highRisk, open, "getAmlAccounts"], + fetcher, + ); + + if (error) return error; + if (data === undefined) return undefined; + if (data.type !== "ok") return data; + + return buildPaginatedResult( + data.body.accounts, + offset, + setOffset, + (d) => String(d.rowid), + PAGINATED_LIST_REQUEST, + ); +} + +/** + * @param account + * @param args + * @returns + */ export function useCurrentDecisions({ investigated, }: { diff --git a/packages/aml-backoffice-ui/src/pages/AccountList.tsx b/packages/aml-backoffice-ui/src/pages/AccountList.tsx @@ -14,26 +14,35 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ import { + AbsoluteTime, + CustomerAccountSummary, HttpStatusCode, TalerError, + TalerProtocolTimestamp, assertUnreachable, + opFixedSuccess, } from "@gnu-taler/taler-util"; import { Attention, + ButtonBetter, ErrorLoading, InputToggle, Loading, Pagination, RouteDefinition, useExchangeApiContext, + useLocalNotificationBetter, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; -import { useCurrentDecisions } from "../hooks/decisions.js"; +import { useAmlAccounts } from "../hooks/decisions.js"; import { useEffect, useState } from "preact/hooks"; import { useOfficer } from "../hooks/officer.js"; import { Profile } from "./Profile.js"; +import csvIcon from "../assets/csv-icon.png"; +import xlsIcon from "../assets/excel-icon.png"; +import { format } from "date-fns"; export function AccountList({ routeToAccountById: caseByIdRoute, @@ -41,8 +50,11 @@ export function AccountList({ routeToAccountById: RouteDefinition<{ cid: string }>; }): VNode { const { i18n } = useTranslationContext(); - const [filtered, setFiltered] = useState<boolean>(); - const list = useCurrentDecisions({ investigated: filtered }); + const [investigated, setInvestigated] = useState<boolean>(); + const [opened, setOpened] = useState<boolean>(); + const [highRisk, setHighRisk] = useState<boolean>(); + const list = useAmlAccounts({ investigated, open: opened, highRisk }); + const [notification, safeFunctionHandler] = useLocalNotificationBetter(); if (!list) { return <Loading />; @@ -96,49 +108,138 @@ export function AccountList({ const records = list.body; + const description = + investigated === undefined + ? opened + ? highRisk + ? i18n.str`High risk accounts. Only with custom rules.` + : i18n.str`Account with custom rules.` + : highRisk + ? i18n.str`High risk accounts` + : i18n.str`All account known.` + : investigated + ? opened + ? highRisk + ? i18n.str`High risk accounts under investigation. Only with custom rules.` + : i18n.str`Accounts under investigation. Only with custom rules.` + : highRisk + ? i18n.str`High risk accounts under investigation.` + : i18n.str`Accounts under investigation.` + : opened + ? highRisk + ? i18n.str`High risk accounts without investigation. Only with custom rules.` + : i18n.str`Accounts without investigation. Only with custom rules.` + : highRisk + ? i18n.str`High risk accounts witout investigation.` + : i18n.str`Accounts without investigation.`; + + const [exported, setExported] = useState<{ content: string; file: string }>(); + + const fileDescription = + investigated === undefined + ? opened + ? highRisk + ? `risky_opened` + : `opened` + : highRisk + ? `risky` + : `` + : investigated + ? opened + ? highRisk + ? `investigated_risky_opened` + : `investigated_opened` + : highRisk + ? `investigated_risky` + : `investigated` + : opened + ? highRisk + ? `not-investigated_risky_opened` + : `not-investigated_opened` + : highRisk + ? `not-investigated_risky` + : `not-investigated`; + + const time = format(new Date(), "yyyyMMdd_HHmmss"); + const downloadCsv = safeFunctionHandler(convertToCsv, [fields, records]); + const downloadXls = safeFunctionHandler(convertToXls, [fields, records]); + + downloadCsv.onSuccess = (result) => { + setExported({ + content: result, + file: `accounts_${time}_${fileDescription}.csv`, + }); + }; + downloadXls.onSuccess = (result) => { + setExported({ + content: result, + file: `accounts_${time}_${fileDescription}.xls`, + }); + }; return ( <div> <div class="sm:flex sm:items-center"> - {filtered === true ? ( - <div class="px-2 sm:flex-auto"> - <h1 class="text-base font-semibold leading-6 text-gray-900"> - <i18n.Translate>Accounts under investigation</i18n.Translate> - </h1> - <p class="mt-2 text-sm text-gray-700 w-80"> - <i18n.Translate> - A list of all the accounts which are waiting for a deicison to - be made. - </i18n.Translate> - </p> - </div> - ) : filtered === false ? ( - <div class="px-2 sm:flex-auto"> - <h1 class="text-base font-semibold leading-6 text-gray-900"> - <i18n.Translate>Accounts without investigation</i18n.Translate> - </h1> - <p class="mt-2 text-sm text-gray-700 w-80"> - <i18n.Translate> - A list of all the accounts which are active. - </i18n.Translate> - </p> + <div class="px-2 sm:flex-auto"> + <h1 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate>Accounts</i18n.Translate> + </h1> + <p class="mt-2 text-sm text-gray-700 w-80"> + <i18n.Translate>{description}</i18n.Translate> + </p> + <div class="flex space-x-2 mt-4"> + <i18n.Translate>Export as file</i18n.Translate> + <ButtonBetter onClick={downloadCsv}> + <img class="size-6 w-6" src={csvIcon} /> + </ButtonBetter> + <ButtonBetter onClick={downloadXls}> + <img class="size-6 w-6" src={xlsIcon} /> + </ButtonBetter> </div> - ) : ( - <div class="px-2 sm:flex-auto"> - <h1 class="text-base font-semibold leading-6 text-gray-900"> - <i18n.Translate>Accounts</i18n.Translate> - </h1> - <p class="mt-2 text-sm text-gray-700 w-80"> - <i18n.Translate> - A list of all the accounts known to the exchange. - </i18n.Translate> - </p> - </div> - )} + {!exported ? ( + <div class="h-5 mb-5" /> + ) : ( + <a + href={ + "data:text/plain;charset=utf-8," + + encodeURIComponent(exported.content) + } + name="save file" + download={exported.file} + > + <Attention + title={i18n.str`Export completed`} + onClose={() => setExported(undefined)} + > + <i18n.Translate> + Click here to save the file in your computer. + </i18n.Translate> + </Attention> + </a> + )} + </div> <JumpByIdForm caseByIdRoute={caseByIdRoute} - filtered={filtered} - onTog={setFiltered} + filters={{ investigated, highRisk, opened }} + onTog={(f, v) => { + switch (f) { + case "investigated": { + setInvestigated(v); + break; + } + case "highRisk": { + setHighRisk(v); + break; + } + case "open": { + setOpened(v); + break; + } + default: { + assertUnreachable(f); + } + } + }} /> </div> <div class="mt-8 flow-root"> @@ -303,15 +404,19 @@ export const SearchIcon = () => ( ); let latestTimeout: undefined | ReturnType<typeof setTimeout> = undefined; - +type FilterName = "investigated" | "highRisk" | "open"; function JumpByIdForm({ caseByIdRoute, - filtered, + filters = {}, onTog, }: { caseByIdRoute: RouteDefinition<{ cid: string }>; - filtered?: boolean; - onTog: (d: boolean | undefined) => void; + filters: { + investigated?: boolean; + highRisk?: boolean; + opened?: boolean; + }; + onTog: (name: FilterName, d: boolean | undefined) => void; }): VNode { const { i18n } = useTranslationContext(); const [account, setAccount] = useState<string>(""); @@ -383,18 +488,139 @@ function JumpByIdForm({ </div> {!error ? undefined : <p class="mt-2 text-sm text-red-600">{error}</p>} </div> - <div class="mt-2 cursor-default"> + <div class="mt-2 cursor-default space-y-2"> <InputToggle threeState name="inv" - label={i18n.str`Only investigated`} + label={i18n.str`Under investigation: `} handler={{ name: "inv", - onChange: (x) => onTog(x), - value: filtered, + onChange: (x) => onTog("investigated", x), + value: filters.investigated, + }} + /> + <InputToggle + name="risk" + label={i18n.str`High risk:`} + handler={{ + name: "risk", + onChange: (x) => onTog("highRisk", x), + value: filters.highRisk, + }} + /> + <InputToggle + name="open" + label={i18n.str`Open:`} + handler={{ + name: "open", + onChange: (x) => onTog("open", x), + value: filters.opened, }} /> </div> </form> ); } + +// "File number,Customer,Comments,Risky,Acquisition date,Exit date\r\n" +const fields: FieldSet<CustomerAccountSummary> = [ + { + name: "File number", + type: "Number", + convert: (v) => String(v.rowid), + }, + { + name: "Customer", + type: "String", + convert: (v) => v.full_payto, + }, + { + name: "Comments", + type: "String", + convert: (v) => v.comments ?? "", + }, + { + name: "Increased risk business relationship", + type: "Boolean", + convert: (v) => (v.high_risk ? "1" : "0"), + }, + { + name: "Acquisition date", + type: "DateTime", + convert: (v) => { + const t = AbsoluteTime.fromProtocolTimestamp(v.open_time); + if (AbsoluteTime.isNever(t)) return "-"; + return AbsoluteTime.stringify(t); + }, + }, + { + name: "Exit date", + type: "DateTime", + convert: (v) => { + const t = AbsoluteTime.fromProtocolTimestamp(v.close_time); + if (AbsoluteTime.isNever(t)) return "-"; + return AbsoluteTime.stringify(t); + }, + }, +]; +type FieldSet<T> = Field<T>[]; +type Field<T> = { name: string; type: string; convert: (o: T) => string }; + +const CSV_HEADER = ""; +const CSV_FOOTER = "\r\n"; + +const XML_HEADER = + '<?xml version="1.0"?>' + + '<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"' + + 'xmlns:o="urn:schemas-microsoft-com:office:office"' + + 'xmlns:x="urn:schemas-microsoft-com:office:excel"' + + 'xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">' + + '<Worksheet ss:Name="Sheet1">' + + "<Table>"; +const XML_FOOTER = "</Table></Worksheet></Workbook>"; + +async function convertToCsv( + fields: FieldSet<CustomerAccountSummary>, + values: CustomerAccountSummary[], +) { + const columns = fields.map((f) => f.name).join(","); + const HEADER = `${columns}\r\n`; + + const rows = values.reduce((prev, v) => { + const cells = fields + .map((f) => { + const str = f.convert(v); + return str.replace(/"/g, '\\"'); + }) + .join(","); + return `${cells}\r\n`; + }, ""); + const result = CSV_HEADER + HEADER + rows + CSV_FOOTER; + return opFixedSuccess(result); +} + +async function convertToXls( + fields: FieldSet<CustomerAccountSummary>, + values: CustomerAccountSummary[], +) { + const columns = fields.reduce((prev, f) => { + return `${prev}<Cell ss:StyleID="Header"><Data ss:Type=\"String\">${f.name}</Data></Cell>`; + }, ""); + const HEADER = `<Row>${columns}</Row>`; + + const rows = values.reduce((prev, v) => { + const cells = fields.reduce((prev, f) => { + const str = f.convert(v); + const safe = str + .replace(/"/g, "&quot;") + .replace(/'/g, "&apos;") + .replace(/&/g, "&amp;") + .replace(/</g, "&lt;") + .replace(/>/g, "&gt;"); + return `${prev}<Cell><Data ss:Type=\"${f.type}\">${safe}</Data></Cell>`; + }, ""); + return `${prev}<Row>${cells}</Row>`; + }, ""); + const result = XML_HEADER + HEADER + rows + XML_FOOTER; + return opFixedSuccess(result); +} diff --git a/packages/taler-util/src/time.ts b/packages/taler-util/src/time.ts @@ -110,6 +110,14 @@ export namespace TalerProtocolDuration { } export namespace TalerProtocolTimestamp { + export function isTimestamp(x: unknown): x is TalerProtocolTimestamp { + return ( + typeof x === "object" && + x !== null && + "t_s" in x && + (typeof x.t_s === "number" || x.t_s === "never") + ); + } export function now(): TalerProtocolTimestamp { return AbsoluteTime.toProtocolTimestamp(AbsoluteTime.now()); } diff --git a/packages/web-util/src/forms/fields/InputToggle.tsx b/packages/web-util/src/forms/fields/InputToggle.tsx @@ -11,7 +11,7 @@ import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; */ export function InputToggle( props: { - threeState: boolean; + threeState?: boolean; defaultValue?: boolean; trueValue?: any; falseValue?: any;