diff options
Diffstat (limited to 'packages/aml-backoffice-ui/src/Dashboard.tsx')
-rw-r--r-- | packages/aml-backoffice-ui/src/Dashboard.tsx | 599 |
1 files changed, 599 insertions, 0 deletions
diff --git a/packages/aml-backoffice-ui/src/Dashboard.tsx b/packages/aml-backoffice-ui/src/Dashboard.tsx new file mode 100644 index 000000000..6794ca1f8 --- /dev/null +++ b/packages/aml-backoffice-ui/src/Dashboard.tsx @@ -0,0 +1,599 @@ +import { useNotifications } from "@gnu-taler/web-util/browser"; +import { Dialog, Transition } from "@headlessui/react"; +import { UserIcon, XCircleIcon } from "@heroicons/react/20/solid"; +import { CheckCircleIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { InformationCircleIcon } from "@heroicons/react/24/solid"; +import { ComponentChildren, Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import logo from "./assets/logo-2021.svg"; +import { Pages } from "./pages.js"; +import { Router, useCurrentLocation } from "./route.js"; + +function classNames(...classes: string[]) { + return classes.filter(Boolean).join(" "); +} + +/** + * mapping route to view + * not found (error page) + * nested, index element, relative routes + * link interception + * form POST interception, call action + * fromData => Object.fromEntries + * segments in the URL + * navigationState: idle, submitting, loading + * form GET interception: does a navigateTo + * form GET Sync: + * 1.- back after submit: useEffect to sync URL to form + * 2.- refresh after submit: input default value + * useSubmit for form submission onChange, history replace + * + * post form without redirect + * + * + * @param param0 + * @returns + */ + +const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; +const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; + +const versionText = VERSION + ? GIT_HASH + ? `v${VERSION} (${GIT_HASH.substring(0, 8)})` + : VERSION + : ""; + +/** + * TO BE FIXED: + * + * 1.- when the form change to other form and both form share the same structure + * the same input component may be rendered in the same place, + * since input are uncontrolled the are not re-rendered and since they are + * uncontrolled it will keep the value of the previous form. + * One solutions could be to remove the form when unloading and when the new + * form load it will start without previous vdom, preventing the cache + * to create this behavior. + * Other solutions could be using IDs in the fields that are constructed + * with the ID of the form, so two fields of different form will need to re-render + * cleaning up the state of the previous form. + * + * 2.- currently the design prop and the behavior prop of the flexible form + * are two side of the same coin. From the design point of view, it is important + * to design the form in a list-of-field manner and there may be additional + * content that is not directly mapped to the form structure (object) + * So maybe we want to change the current shape so the computation of the state + * of the form is in a field level, but this computation required the field value and + * the whole form values and state (since one field may be disabled/hidden) because + * of the value of other field. + * + * 3.- given the previous requirement, maybe the name of the field of the form could be + * a function (P: F -> V) where F is the form (or parent object) and V is the type of the + * property. That will help with the typing of the forms props + * + * 4.- tooltip are not placed correctly: the arrow should point the question mark + * and the text area should be bigger + * + * 5.- date field should have the calendar icon clickable so the user can select date without + * writing text with the correct format + */ + +function LeftMenu() { + const currentLocation = useCurrentLocation(pageList); + + return ( + <nav class="flex flex-1 flex-col"> + <ul role="list" class="flex flex-1 flex-col gap-y-7"> + <li> + <ul role="list" class="-mx-2 space-y-1"> + <li> + <a + href={Pages.info.url} + class={classNames( + Pages.info.url === currentLocation?.path + ? "bg-indigo-700 text-white" + : "text-indigo-200 hover:text-white hover:bg-indigo-700", + "group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold", + )} + > + <InformationCircleIcon + class={classNames( + Pages.info.url === currentLocation?.path + ? "text-white" + : "text-indigo-200 group-hover:text-white", + "h-6 w-6 shrink-0", + )} + aria-hidden="true" + /> + Cases + </a> + </li> + <li> + <a + href={Pages.officer.url} + class={classNames( + Pages.officer.url === currentLocation?.path + ? "bg-indigo-700 text-white" + : "text-indigo-200 hover:text-white hover:bg-indigo-700", + "group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold", + )} + > + <UserIcon + class={classNames( + Pages.officer.url === currentLocation?.path + ? "text-white" + : "text-indigo-200 group-hover:text-white", + "h-6 w-6 shrink-0", + )} + aria-hidden="true" + /> + Account + </a> + </li> + </ul> + </li> + {/* <li> + <div class="text-xs font-semibold leading-6 text-indigo-200"> + Info + </div> + <ul role="list" class="-mx-2 mt-2 space-y-1"> + <li> + <a + href={Pages.info.url} + class={classNames( + Pages.info.url === currentLocation?.path + ? "bg-indigo-700 text-white" + : "text-indigo-200 hover:text-white hover:bg-indigo-700", + "group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold", + )} + > + <span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border border-indigo-400 bg-indigo-500 text-[0.625rem] font-medium text-white"> + asd + </span> + <span class="truncate">qwe</span> + </a> + </li> + </ul> + </li> */} + {/* <li class="mt-auto"> + <a + href={Pages.settings.url} + class={classNames( + Pages.settings.url === currentLocation?.path + ? "bg-indigo-700 text-white" + : "text-indigo-200 hover:text-white hover:bg-indigo-700", + "group -mx-2 flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold", + )} + > + <Cog6ToothIcon + class={classNames( + Pages.officer.url === currentLocation?.path + ? "text-white" + : "text-indigo-200 group-hover:text-white", + "h-6 w-6 shrink-0", + )} + aria-hidden="true" + /> + Settings + </a> + </li> */} + </ul> + </nav> + ); +} + +export function Dashboard({ + children, +}: { + children?: ComponentChildren; +}): VNode { + const [sidebarOpen, setSidebarOpen] = useState(false); + + return ( + <Fragment> + <NavigationBar isOpen={sidebarOpen} setOpen={setSidebarOpen}> + <div class="flex grow flex-col gap-y-5 overflow-y-auto bg-indigo-600 px-6 pb-4"> + <div class="flex h-16 shrink-0 items-center"> + <header class="flex items-center justify-between border-b border-white/5 "> + <h1 class="text-base font-semibold leading-7 text-white"> + Exchange AML Backoffice + </h1> + </header> + </div> + <LeftMenu /> + <Footer /> + </div> + </NavigationBar> + <div class="lg:pl-72"> + <TopBar + onOpenSidebar={() => { + setSidebarOpen(true); + }} + /> + <Notifications /> + <main class="py-10 px-4 sm:px-6 lg:px-8"> + <div class="mx-auto max-w-3xl"> + <Router + pageList={pageList} + onNotFound={() => { + return <div>not found</div>; + }} + /> + </div> + </main> + </div> + </Fragment> + ); +} + +const pageList = Object.values(Pages); + +function NavigationBar({ + isOpen, + setOpen, + children, +}: { + isOpen: boolean; + setOpen: (v: boolean) => void; + children: ComponentChildren; +}) { + return ( + <Fragment> + <Transition.Root show={isOpen} as={Fragment}> + <Dialog + as="div" + /* @ts-ignore */ + class="relative z-50 lg:hidden" + onClose={setOpen} + > + <Transition.Child + as={Fragment} + enter="transition-opacity ease-linear duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="transition-opacity ease-linear duration-300" + leaveFrom="opacity-100" + leaveTo="opacity-0" + > + <div class="fixed inset-0 bg-gray-900/80" /> + </Transition.Child> + + <div class="fixed inset-0 flex"> + <Transition.Child + as={Fragment} + enter="transition ease-in-out duration-300 transform" + enterFrom="-translate-x-full" + enterTo="translate-x-0" + leave="transition ease-in-out duration-300 transform" + leaveFrom="translate-x-0" + leaveTo="-translate-x-full" + > + <Dialog.Panel class="relative mr-16 flex w-full max-w-xs flex-1"> + <Transition.Child + as={Fragment} + enter="ease-in-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in-out duration-300" + leaveFrom="opacity-100" + leaveTo="opacity-0" + > + <div class="absolute left-full top-0 flex w-16 justify-center pt-5"> + <button + type="button" + class="-m-2.5 p-2.5" + onClick={() => setOpen(false)} + > + <span class="sr-only">Close sidebar</span> + <XMarkIcon + class="h-6 w-6 text-white" + aria-hidden="true" + /> + </button> + </div> + </Transition.Child> + {children} + </Dialog.Panel> + </Transition.Child> + </div> + </Dialog> + </Transition.Root> + + <div class="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col"> + {children} + </div> + </Fragment> + ); +} + +function TopBar({ onOpenSidebar }: { onOpenSidebar: () => void }) { + return ( + <div class="relative flex h-16 justify-between"> + <div class="relative z-10 flex p-2 lg:hidden"> + <button + type="button" + onClick={() => { + onOpenSidebar(); + }} + class="inline-flex items-center justify-center rounded-md p-2 text-gray-400 hover:bg-gray-700 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-gray-900" + aria-controls="mobile-menu" + aria-expanded="false" + > + <span class="sr-only">Open menu</span> + <svg + class="block h-6 w-6" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" + /> + </svg> + <svg + class="hidden h-6 w-6" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M6 18L18 6M6 6l12 12" + /> + </svg> + </button> + </div> + <div class="relative z-0 flex flex-1 items-center justify-center px-2 sm:absolute sm:inset-0"> + <div class="w-full sm:max-w-xs flex flex-1 items-center justify-center"> + <img + class="h-8 w-auto" + src={logo} + alt="Taler" + style={{ height: 35, margin: 10 }} + /> + </div> + </div> + {/* <div class="relative z-10 flex items-center lg:hidden">dd</div> */} + </div> + ); +} + +// return ( +// <div class="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8"> +// <button +// type="button" +// class="-m-2.5 p-2.5 text-gray-700 lg:hidden" +// onClick={onOpenSidebar} +// > +// <span class="sr-only">Open sidebar</span> +// <Bars3Icon class="h-6 w-6" aria-hidden="true" /> +// </button> + +// {/* Separator */} +// <div class="h-6 w-px bg-gray-900/10 lg:hidden" aria-hidden="true" /> + +// <div class="flex flex-1 gap-x-4 self-stretch lg:gap-x-6"> +// <div class="relative flex flex-1" /> +// {/* <form class="relative flex flex-1" action="#" method="GET"> +// <label htmlFor="search-field" class="sr-only"> +// Search +// </label> +// <MagnifyingGlassIcon +// class="pointer-events-none absolute inset-y-0 left-0 h-full w-5 text-gray-400" +// aria-hidden="true" +// /> +// <input +// id="search-field" +// class="block h-full w-full border-0 py-0 pl-8 pr-0 text-gray-900 placeholder:text-gray-400 focus:ring-0 sm:text-sm" +// placeholder="Search..." +// type="search" +// name="search" +// /> +// </form> */} +// <div class="flex items-center gap-x-4 lg:gap-x-6"> +// {/* <button +// type="button" +// class="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500" +// > +// <span class="sr-only">View notifications</span> +// <BellIcon class="h-6 w-6" aria-hidden="true" /> +// </button> */} + +// {/* Separator */} +// <div +// class="hidden lg:block lg:h-6 lg:w-px lg:bg-gray-900/10" +// aria-hidden="true" +// /> + +// {/* {officerName === undefined ? ( +// <div /> +// ) : ( +// <Menu +// as="div" +// class="relative" +// > +// <Menu.Button class="-m-1.5 flex items-center p-1.5"> +// <span class="sr-only">Open user menu</span> +// <img +// class="h-8 w-8 rounded-full bg-gray-50" +// src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" +// alt="" +// /> +// <span class="hidden lg:flex lg:items-center"> +// <span +// class="ml-4 text-sm font-semibold leading-6 text-gray-900" +// aria-hidden="true" +// > +// {officerName} +// </span> +// <ChevronDownIcon +// class="ml-2 h-5 w-5 text-gray-400" +// aria-hidden="true" +// /> +// </span> +// </Menu.Button> +// <Transition +// as={Fragment} +// enter="transition ease-out duration-100" +// enterFrom="transform opacity-0 scale-95" +// enterTo="transform opacity-100 scale-100" +// leave="transition ease-in duration-75" +// leaveFrom="transform opacity-100 scale-100" +// leaveTo="transform opacity-0 scale-95" +// > +// <Menu.Items class="absolute right-0 z-10 mt-2.5 w-48 origin-top-right rounded-md bg-white py-2 shadow-lg ring-1 ring-gray-900/5 focus:outline-none"> +// <Menu.Item> +// {({ active }: { active: boolean }) => ( +// <a +// onClick={() => { +// officer.reset(); +// password.reset(); +// }} +// class={classNames( +// active ? "bg-gray-50" : "", +// "block px-3 py-1 text-sm leading-6 text-gray-900", +// )} +// > +// Forget account +// </a> +// )} +// </Menu.Item> +// </Menu.Items> +// </Transition> +// </Menu> +// )} */} +// </div> +// </div> +// </div> +// ); +// } + +function Footer() { + return ( + <footer class="absolute bottom-4"> + <div class="mt-8 md:order-1 md:mt-0"> + <p class="text-xs leading-5 text-gray-300"> + Taler Systems SA. {versionText} + </p> + </div> + </footer> + ); +} + +function Notifications() { + const ns = useNotifications(); + + // useEffect(() => { + // if (ns.length) { + // // remove notifications after some timeout + // } + // }, []); + { + /* <!-- Global notification live region, render this permanently at the end of the document --> */ + } + return ( + <div + aria-live="assertive" + class="pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-start sm:p-6 z-50" + > + <div class="flex w-full flex-col items-center space-y-4 sm:items-end "> + {/* <!-- + Notification panel, dynamically insert this into the live region when it needs to be displayed + + Entering: "transform ease-out duration-300 transition" + From: "translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2" + To: "translate-y-0 opacity-100 sm:translate-x-0" + Leaving: "transition ease-in duration-100" + From: "opacity-100" + To: "opacity-0" +--> */} + {ns.map(({ message, remove }) => { + switch (message.type) { + case "error": { + return ( + <div class="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 "> + <div class="p-4 "> + <div class="flex items-start "> + <div class="flex-shrink-0"> + <XCircleIcon class="h-6 w-6 text-red-400" /> + </div> + <div class="ml-3 w-0 flex-1 pt-0.5"> + <p class="text-sm font-medium text-gray-900"> + {message.title} + </p> + {message.description && ( + <p class="mt-1 text-sm text-gray-500"> + {message.description} + </p> + )} + </div> + <div class="ml-4 flex flex-shrink-0"> + <button + type="button" + onClick={remove} + class="inline-flex rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" + > + <span class="sr-only">Close</span> + <svg + class="h-5 w-5" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" /> + </svg> + </button> + </div> + </div> + </div> + </div> + ); + } + case "info": { + return ( + <div class="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 "> + <div class="p-4 "> + <div class="flex items-start "> + <div class="flex-shrink-0"> + <CheckCircleIcon class="h-6 w-6 text-green-400" /> + </div> + <div class="ml-3 w-0 flex-1 pt-0.5"> + <p class="text-sm font-medium text-gray-900"> + {message.title} + </p> + </div> + <div class="ml-4 flex flex-shrink-0"> + <button + type="button" + onClick={remove} + class="inline-flex rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" + > + <span class="sr-only">Close</span> + <svg + class="h-5 w-5" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" /> + </svg> + </button> + </div> + </div> + </div> + </div> + ); + } + } + })} + </div> + </div> + ); +} |