commit 671f547a151fedf2fb9279381bee92edf9ed5e73
parent 26e4780afc1442f7cc568541ddbbe682620af67c
Author: Sebastian <sebasjm@gmail.com>
Date: Mon, 10 Nov 2025 17:27:20 -0300
fix #10413
Diffstat:
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, """)
+ .replace(/'/g, "'")
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">");
+ 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;