summaryrefslogtreecommitdiff
path: root/packages/aml-backoffice-ui/src/pages/Cases.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/aml-backoffice-ui/src/pages/Cases.tsx')
-rw-r--r--packages/aml-backoffice-ui/src/pages/Cases.tsx343
1 files changed, 343 insertions, 0 deletions
diff --git a/packages/aml-backoffice-ui/src/pages/Cases.tsx b/packages/aml-backoffice-ui/src/pages/Cases.tsx
new file mode 100644
index 000000000..2e92c111e
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/pages/Cases.tsx
@@ -0,0 +1,343 @@
+/*
+ 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 {
+ HttpStatusCode,
+ TalerError,
+ TalerExchangeApi,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ ErrorLoading,
+ InputChoiceHorizontal,
+ Loading,
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { useCases } from "../hooks/useCases.js";
+
+import { privatePages } from "../Routing.js";
+import { FormErrors, RecursivePartial, useFormState } from "../hooks/form.js";
+import { undefinedIfEmpty } from "./CreateAccount.js";
+import { Officer } from "./Officer.js";
+
+type FormType = {
+ state: TalerExchangeApi.AmlState;
+};
+
+export function CasesUI({
+ records,
+ filter,
+ onChangeFilter,
+ onFirstPage,
+ onNext,
+}: {
+ onFirstPage?: () => void;
+ onNext?: () => void;
+ filter: TalerExchangeApi.AmlState;
+ onChangeFilter: (f: TalerExchangeApi.AmlState) => void;
+ records: TalerExchangeApi.AmlRecord[];
+}): VNode {
+ const { i18n } = useTranslationContext();
+
+ const [form, status] = useFormState<FormType>(
+ {
+ state: filter,
+ },
+ (state) => {
+ const errors = undefinedIfEmpty<FormErrors<FormType>>({
+ state: state.state === undefined ? i18n.str`required` : undefined,
+ });
+ if (errors === undefined) {
+ const result: FormType = {
+ state: state.state!,
+ };
+ return {
+ status: "ok",
+ result,
+ errors,
+ };
+ }
+ const result: RecursivePartial<FormType> = {
+ state: state.state,
+ };
+ return {
+ status: "fail",
+ result,
+ errors,
+ };
+ },
+ );
+ useEffect(() => {
+ if (status.status === "ok" && filter !== status.result.state) {
+ onChangeFilter(status.result.state);
+ }
+ }, [form?.state?.value]);
+
+ return (
+ <div>
+ <div class="sm:flex sm:items-center">
+ <div class="px-2 sm:flex-auto">
+ <h1 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Cases</i18n.Translate>
+ </h1>
+ <p class="mt-2 text-sm text-gray-700 w-80">
+ <i18n.Translate>
+ A list of all the account with the status
+ </i18n.Translate>
+ </p>
+ </div>
+ <div class="px-2">
+ <InputChoiceHorizontal<FormType, "state">
+ name="state"
+ label={i18n.str`Filter`}
+ handler={form.state}
+ choices={[
+ {
+ label: i18n.str`Pending`,
+ value: TalerExchangeApi.AmlState.pending,
+ },
+ {
+ label: i18n.str`Frozen`,
+ value: TalerExchangeApi.AmlState.frozen,
+ },
+ {
+ label: i18n.str`Normal`,
+ value: TalerExchangeApi.AmlState.normal,
+ },
+ ]}
+ />
+ </div>
+ </div>
+ <div class="mt-8 flow-root">
+ <div class="overflow-x-auto">
+ {!records.length ? (
+ <div>empty result </div>
+ ) : (
+ <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
+ <table class="min-w-full divide-y divide-gray-300">
+ <thead>
+ <tr>
+ <th
+ scope="col"
+ class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 w-80"
+ >
+ <i18n.Translate>Account Id</i18n.Translate>
+ </th>
+ <th
+ scope="col"
+ class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 w-40"
+ >
+ <i18n.Translate>Status</i18n.Translate>
+ </th>
+ <th
+ scope="col"
+ class="sm:hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 w-40"
+ >
+ <i18n.Translate>Threshold</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody class="divide-y divide-gray-200 bg-white">
+ {records.map((r) => {
+ return (
+ <tr key={r.h_payto} class="hover:bg-gray-100 ">
+ <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-500 ">
+ <div class="text-gray-900">
+ <a
+ href={privatePages.caseDetails.url({
+ cid: r.h_payto,
+ })}
+ class="text-indigo-600 hover:text-indigo-900"
+ >
+ {r.h_payto.substring(0, 16)}...
+ </a>
+ </div>
+ </td>
+ <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-500">
+ {((state: TalerExchangeApi.AmlState): VNode => {
+ switch (state) {
+ case TalerExchangeApi.AmlState.normal: {
+ return (
+ <span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">
+ Normal
+ </span>
+ );
+ }
+ case TalerExchangeApi.AmlState.pending: {
+ return (
+ <span class="inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-green-600/20">
+ Pending
+ </span>
+ );
+ }
+ case TalerExchangeApi.AmlState.frozen: {
+ return (
+ <span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-green-600/20">
+ Frozen
+ </span>
+ );
+ }
+ }
+ })(r.current_state)}
+ </td>
+ <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-900">
+ {r.threshold}
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ <Pagination onFirstPage={onFirstPage} onNext={onNext} />
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
+
+export function Cases() {
+ const [stateFilter, setStateFilter] = useState(
+ TalerExchangeApi.AmlState.pending,
+ );
+
+ const list = useCases(stateFilter);
+ const { i18n } = useTranslationContext();
+
+ if (!list) {
+ return <Loading />;
+ }
+ if (list instanceof TalerError) {
+ return <ErrorLoading error={list} />;
+ }
+
+ if (list.type === "fail") {
+ switch (list.case) {
+ case HttpStatusCode.Forbidden: {
+ return (
+ <Fragment>
+ <Attention type="danger" title={i18n.str`Operation denied`}>
+ <i18n.Translate>
+ This account doesnt have access. Request account activation
+ sending your public key.
+ </i18n.Translate>
+ </Attention>
+ <Officer />
+ </Fragment>
+ );
+ }
+ case HttpStatusCode.Unauthorized: {
+ return (
+ <Fragment>
+ <Attention type="danger" title={i18n.str`Operation denied`}>
+ <i18n.Translate>
+ This account is not allowed to perform list the cases.
+ </i18n.Translate>
+ </Attention>
+ <Officer />
+ </Fragment>
+ );
+ }
+ case HttpStatusCode.NotFound:
+ case HttpStatusCode.Conflict:
+ return <Officer />;
+ default:
+ assertUnreachable(list);
+ }
+ }
+
+ return (
+ <CasesUI
+ records={list.body}
+ onFirstPage={list.isFirstPage ? undefined : list.loadFirst}
+ onNext={list.isLastPage ? undefined : list.loadNext}
+ filter={stateFilter}
+ onChangeFilter={(d) => {
+ setStateFilter(d)
+ }}
+ />
+ );
+}
+
+export const PeopleIcon = () => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-6 h-6"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
+ />
+ </svg>
+);
+
+export const HomeIcon = () => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-6 h-6"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
+ />
+ </svg>
+);
+
+function Pagination({
+ onFirstPage,
+ onNext,
+}: {
+ onFirstPage?: () => void;
+ onNext?: () => void;
+}) {
+ const { i18n } = useTranslationContext();
+ return (
+ <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
+ 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={!onFirstPage}
+ onClick={onFirstPage}
+ >
+ <i18n.Translate>First page</i18n.Translate>
+ </button>
+ <button
+ 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={!onNext}
+ onClick={onNext}
+ >
+ <i18n.Translate>Next</i18n.Translate>
+ </button>
+ </div>
+ </nav>
+ );
+}