From 6863684d9e774fd97b6a0d7e1765c0377cc00c4e Mon Sep 17 00:00:00 2001 From: Bryce Date: Thu, 19 Oct 2023 22:11:19 -0700 Subject: [PATCH] Makes the entire form work but it just looks janky --- resources/input.css | 39 ++ resources/public/js/htmx-disable.js | 104 ++- resources/public/output.css | 104 ++- src/clj/auto_ap/graphql.clj | 6 - src/clj/auto_ap/ssr/account.clj | 104 +++ .../auto_ap/ssr/admin/transaction_rules.clj | 627 ++++++++++++++++++ src/clj/auto_ap/ssr/company.clj | 52 +- src/clj/auto_ap/ssr/components.clj | 2 + src/clj/auto_ap/ssr/components/data_grid.clj | 4 +- src/clj/auto_ap/ssr/components/dialog.clj | 40 +- src/clj/auto_ap/ssr/components/inputs.clj | 51 +- src/clj/auto_ap/ssr/components/page.clj | 2 +- src/clj/auto_ap/ssr/components/radio.clj | 23 +- src/clj/auto_ap/ssr/core.clj | 13 +- src/clj/auto_ap/ssr/hx.clj | 20 + src/clj/auto_ap/ssr/ui.clj | 30 +- src/clj/auto_ap/ssr/users.clj | 4 +- src/clj/auto_ap/ssr/utils.clj | 241 +++++-- src/clj/auto_ap/ssr/vendor.clj | 29 + .../auto_ap/shared_views/admin/side_bar.cljc | 4 +- src/cljc/auto_ap/ssr_routes.cljc | 16 +- 21 files changed, 1334 insertions(+), 181 deletions(-) create mode 100644 src/clj/auto_ap/ssr/account.clj create mode 100644 src/clj/auto_ap/ssr/admin/transaction_rules.clj create mode 100644 src/clj/auto_ap/ssr/hx.clj create mode 100644 src/clj/auto_ap/ssr/vendor.clj diff --git a/resources/input.css b/resources/input.css index 66146585..b3a7437d 100644 --- a/resources/input.css +++ b/resources/input.css @@ -13,6 +13,31 @@ opacity: 1.0; } +.htmx-settling .fade-in-settle { + opacity: 0.0 !important; +} + +.htmx-settling.fade-in-settle { + opacity: 0.0 !important; +} + +.fade-in-settle { + opacity: 1.0; +} + + +.htmx-settling .slide-up-settle { + @apply translate-y-5 !important; +} + +.htmx-settling.slide-up-settle { + @apply translate-y-5 !important; +} + +.slide-up-settle { + @apply translate-y-0; +} + .htmx-added .slide-up { @apply translate-y-5 !important; } @@ -87,6 +112,20 @@ @apply bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500 dark:bg-gray-700 p-1 !important; } +.choices:focus-within .choices__inner { + @apply ring-blue-500 border-blue-500 dark:ring-blue-500 dark:border-blue-500 !important; + outline: 2px solid transparent !important; + outline-offset: 2px; + --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: #007dbb; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + border-color: #007dbb; +} + .choices__inner .choices__input { @apply m-0 bg-gray-50 dark:bg-gray-700 dark:text-white !important; } diff --git a/resources/public/js/htmx-disable.js b/resources/public/js/htmx-disable.js index 97af300b..d3784e32 100644 --- a/resources/public/js/htmx-disable.js +++ b/resources/public/js/htmx-disable.js @@ -17,6 +17,108 @@ htmx.defineExtension('disable-submit', { } }) +htmx.defineExtension('rename-params', { + onEvent: function(name , evt) { + if (name === "htmx:configRequest") { + var normal = evt.detail.elt.getAttribute("hx-rename-params"); + if (normal) { + normal = JSON.parse(normal); + for (const [key, value] of Object.entries(normal)) { + console.log(evt.detail.parameters) + evt.detail.parameters[value] = evt.detail.parameters[key]; + delete evt.detail.parameters[key]; + } + } + var exclusive = evt.detail.elt.getAttribute("hx-rename-params-ex"); + if (exclusive) { + exclusive = JSON.parse(exclusive); + var params = {}; + for (const [key, value] of Object.entries(exclusive)) { + var v = evt.detail.parameters[key] + if (v) { + params[value] = v; + } + } + evt.detail.parameters = params; + } + } + } +}); + + +htmx.defineExtension('reactive-trigger', { + + onEvent: function(name , evt) { + if (name === "htmx:beforeProcessNode") { + var element = evt.detail.elt; + var reactiveTrigger = element.getAttribute("hx-reactive-trigger"); + if (reactiveTrigger) { + reactiveTrigger = JSON.parse(reactiveTrigger); + var reactiveSource = element.getAttribute("hx-reactive-source"); + reactiveSource = reactiveSource ? document.querySelector(reactiveSource) : element.closest("form"); + + // var triggerString = Object.keys(reactiveTrigger).map(k => "change from:#" + reactiveSource.getAttribute("id") + " [name=\"" + k + "\"]").join(", "); + var triggerString = reactiveTrigger.map(k => "change from:" + "[name=\"" + k + "\"]").join(", "); + element.setAttribute("hx-trigger", triggerString); + } + } else if (name=="htmx:configRequest") { + var element = evt.detail.elt; + var reactiveTrigger = element.getAttribute("hx-reactive-trigger"); + if (reactiveTrigger) { + reactiveTrigger = JSON.parse(reactiveTrigger); + var reactiveSource = element.getAttribute("hx-reactive-source"); + reactiveSource = reactiveSource ? document.querySelector(reactiveSource) : element.closest("form"); + for (key of reactiveTrigger) { + console.log( reactiveSource.querySelector(`[name="${key}"]`).value) + evt.detail.parameters[key] = reactiveSource.querySelector(`[name="${key}"]`).value || ""; + } + } + } + } +}); + +function deepEqual(obj1, obj2) { + if (obj1 === obj2) { + return true; // Check for equality + } + + if (typeof obj1 !== 'object' || typeof obj2 !== 'object' || obj1 === null || obj2 === null) { + return false; // Check if both are objects + } + + const keys1 = Object.keys(obj1); + const keys2 = Object.keys(obj2); + + if (keys1.length !== keys2.length) { + return false; // Check if they have the same number of properties + } + + for (const key of keys1) { + if (!keys2.includes(key) || !deepEqual(obj1[key], obj2[key])) { + return false; // Recursively compare properties + } + } + + return true; // Objects are deeply equal +} + +htmx.defineExtension('trigger-filter', { + + onEvent: function(name , evt) { + if (name=="htmx:beforeRequest") { + var element = evt.detail.elt; + console.log("HEREEE", element.lastParams, evt.detail.requestConfig.parameters) + if (!deepEqual(element.lastParams, evt.detail.requestConfig.parameters)) { + element.lastParams = evt.detail.requestConfig.parameters; + } else { + console.log("unchanged") + evt.preventDefault(); + } + } + } +}); + + initDatepicker = function(elem) { elem.dp = new Datepicker(elem, {format: "mm/dd/yyyy", autohide: true}); -} \ No newline at end of file +} diff --git a/resources/public/output.css b/resources/public/output.css index 19070cfe..305c2382 100644 --- a/resources/public/output.css +++ b/resources/public/output.css @@ -1208,6 +1208,10 @@ input:checked + .toggle-bg { margin-left: auto; } +.mr-1 { + margin-right: 0.25rem; +} + .mr-10 { margin-right: 2.5rem; } @@ -1248,10 +1252,6 @@ input:checked + .toggle-bg { margin-top: 1.25rem; } -.mr-1 { - margin-right: 0.25rem; -} - .block { display: block; } @@ -1352,6 +1352,10 @@ input:checked + .toggle-bg { width: 4rem; } +.w-24 { + width: 6rem; +} + .w-3 { width: 0.75rem; } @@ -1392,18 +1396,22 @@ input:checked + .toggle-bg { width: 2rem; } -.w-full { - width: 100%; -} - .w-96 { width: 24rem; } +.w-full { + width: 100%; +} + .max-w-2xl { max-width: 42rem; } +.max-w-4xl { + max-width: 56rem; +} + .max-w-lg { max-width: 32rem; } @@ -1420,10 +1428,6 @@ input:checked + .toggle-bg { max-width: 1024px; } -.max-w-4xl { - max-width: 56rem; -} - .flex-1 { flex: 1 1 0%; } @@ -1440,14 +1444,6 @@ input:checked + .toggle-bg { flex-shrink: 1; } -.shrink-0 { - flex-shrink: 0; -} - -.grow-0 { - flex-grow: 0; -} - .basis-1\/4 { flex-basis: 25%; } @@ -1546,10 +1542,6 @@ input:checked + .toggle-bg { appearance: none; } -.grid-cols-2 { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - .grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } @@ -1638,10 +1630,6 @@ input:checked + .toggle-bg { gap: 2rem; } -.gap-3 { - gap: 0.75rem; -} - .gap-x-4 { -moz-column-gap: 1rem; column-gap: 1rem; @@ -1707,12 +1695,6 @@ input:checked + .toggle-bg { margin-bottom: calc(1.5rem * var(--tw-space-y-reverse)); } -.space-x-3 > :not([hidden]) ~ :not([hidden]) { - --tw-space-x-reverse: 0; - margin-right: calc(0.75rem * var(--tw-space-x-reverse)); - margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse))); -} - .divide-y > :not([hidden]) ~ :not([hidden]) { --tw-divide-y-reverse: 0; border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); @@ -2441,6 +2423,33 @@ input:checked + .toggle-bg { opacity: 1.0; } +.htmx-settling .fade-in-settle { + opacity: 0.0 !important; +} + +.htmx-settling.fade-in-settle { + opacity: 0.0 !important; +} + +.fade-in-settle { + opacity: 1.0; +} + +.htmx-settling .slide-up-settle { + --tw-translate-y: 1.25rem !important; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)) !important; +} + +.htmx-settling.slide-up-settle { + --tw-translate-y: 1.25rem !important; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)) !important; +} + +.slide-up-settle { + --tw-translate-y: 0px; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + .htmx-added .slide-up { --tw-translate-y: 1.25rem !important; transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)) !important; @@ -2588,6 +2597,33 @@ input:checked + .toggle-bg { --tw-ring-color: rgb(0 156 234 / var(--tw-ring-opacity)) !important; } +.choices:focus-within .choices__inner { + --tw-border-opacity: 1 !important; + border-color: rgb(0 156 234 / var(--tw-border-opacity)) !important; + --tw-ring-opacity: 1 !important; + --tw-ring-color: rgb(0 156 234 / var(--tw-ring-opacity)) !important; +} + +:is(.dark .choices:focus-within .choices__inner) { + --tw-border-opacity: 1 !important; + border-color: rgb(0 156 234 / var(--tw-border-opacity)) !important; + --tw-ring-opacity: 1 !important; + --tw-ring-color: rgb(0 156 234 / var(--tw-ring-opacity)) !important; +} + +.choices:focus-within .choices__inner { + outline: 2px solid transparent !important; + outline-offset: 2px; + --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: #007dbb; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + border-color: #007dbb; +} + .choices__inner .choices__input { margin: 0px !important; --tw-bg-opacity: 1 !important; diff --git a/src/clj/auto_ap/graphql.clj b/src/clj/auto_ap/graphql.clj index 0a5856ae..b05eabcd 100644 --- a/src/clj/auto_ap/graphql.clj +++ b/src/clj/auto_ap/graphql.clj @@ -194,12 +194,6 @@ :scheduled {:type 'String} :sent {:type 'String} :vendor {:type :vendor}}} - - - - - - :yodlee_merchant {:fields {:id {:type :id} :yodlee_id {:type 'String} diff --git a/src/clj/auto_ap/ssr/account.clj b/src/clj/auto_ap/ssr/account.clj new file mode 100644 index 00000000..6ca3007d --- /dev/null +++ b/src/clj/auto_ap/ssr/account.clj @@ -0,0 +1,104 @@ +(ns auto-ap.ssr.account + (:require + [auto-ap.datomic :refer [conn]] + [auto-ap.graphql.utils + :refer [assert-can-see-client can-see-client? cleanse-query is-admin?]] + [auto-ap.solr :as solr] + [auto-ap.ssr.utils + :refer [entity-id ref->enum-schema wrap-schema-decode]] + [com.brunobonacci.mulog :as mu] + [datomic.api :as dc] + [ring.middleware.json :refer [wrap-json-response]])) + +;; TODO this is basically duplicative of graphql version, make sure to keep in sync +;; TODO use valid clients from request rather than stuff like assert-can-see-client + +(def search-pattern [:db/id + :account/numeric-code + :account/location + {:account/vendor-allowance [:db/ident] + :account/default-allowance [:db/ident] + :account/invoice-allowance [:db/ident]}]) + +(defn search- [id query client] + (let [client-part (if (some->> client (can-see-client? id)) + (format "((applicability:(global OR optional) AND -client_id:*) OR (account_client_override_id:* AND client_id:%s))" client) + "(applicability:(global OR optional) AND -client_id:*)") + query (format "_text_:(%s) AND %s" (cleanse-query query) client-part)] + (mu/log ::searching :search-query query) + (for [{:keys [account_id name] :as g} (solr/query solr/impl "accounts" + {"query" query + "fields" "id, name, client_id, numeric_code, applicability, account_id"})] + + {:account_id (first account_id) + :name (first name)}))) + + +(defn account-search [{{:keys [q client-id allowance vendor-id] :as qp} :query-params id :identity}] + + (when client-id + (assert-can-see-client id client-id)) + (let [num (some-> (re-find #"([0-9]+)" q) + second + (not-empty ) + Integer/parseInt) + + valid-allowances (cond-> #{:allowance/allowed + :allowance/warn} + (is-admin? id) (conj :allowance/admin-only)) + allowance (cond (= allowance :vendor) + :account/vendor-allowance + (= allowance :invoice) + :account/invoice-allowance + :else + :account/default-allowance) + + vendor-account (when vendor-id + (-> (dc/q '[:find ?da + :in $ ?v + :where [?v :vendor/default-account ?da]] + (dc/db conn) + vendor-id) + ffirst)) + xform (comp + (filter (fn [[_ a]] + (or + (valid-allowances (-> a allowance :db/ident)) + (= (:db/id a) vendor-account)))) + (map (fn [[n a]] + {:label (str (:account/numeric-code a) " - " n) + :value (:db/id a) + :location (:account/location a) + :warning (when (= :allowance/warn (-> a allowance :db/ident)) + "This account is not typically used for this purpose.")})))] + {:body (take 10 (if q + (if num + (->> (dc/q '[:find ?n (pull ?i pattern) + :in $ ?numeric-code ?allowance pattern + :where [?i :account/numeric-code ?numeric-code] + [?i :account/name ?n] + (or [?i :account/applicability :account-applicability/global] + [?i :account/applicability :account-applicability/optional] + [?i :account/applicability :account-applicability/customized])] + (dc/db conn) + num + allowance + search-pattern) + (sequence xform)) + (->> (search- id q client-id) + (sequence + (comp (map (fn [i] [(:name i) (dc/pull (dc/db conn) search-pattern (:account_id i))])) + xform)))) + []))})) + +(def account-search (wrap-json-response (wrap-schema-decode account-search + :query-schema [:map + [:q :string] + [:client-id {:optional true + :default nil} + [:maybe entity-id]] + [:vendor-id {:optional true} + [:maybe entity-id]] + [:allowance {:optional true} + [:maybe (ref->enum-schema "allowance")]]]))) + diff --git a/src/clj/auto_ap/ssr/admin/transaction_rules.clj b/src/clj/auto_ap/ssr/admin/transaction_rules.clj new file mode 100644 index 00000000..3661b97b --- /dev/null +++ b/src/clj/auto_ap/ssr/admin/transaction_rules.clj @@ -0,0 +1,627 @@ +(ns auto-ap.ssr.admin.transaction-rules + (:require + [auto-ap.datomic + :refer [add-sorter-fields + apply-pagination + apply-sort-3 + audit-transact + conn + merge-query + pull-attr + pull-many + query2]] + [auto-ap.datomic.accounts :as d-accounts] + [auto-ap.graphql.utils :refer [extract-client-ids]] + [auto-ap.query-params :as query-params] + [auto-ap.routes.utils + :refer [wrap-admin wrap-client-redirect-unauthenticated]] + [auto-ap.ssr-routes :as ssr-routes] + [auto-ap.ssr.company :refer [bank-account-typeahead*]] + [auto-ap.ssr.components :as com] + [auto-ap.ssr.grid-page-helper :as helper] + [auto-ap.ssr.hx :as hx] + [auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]] + [auto-ap.ssr.svg :as svg] + [auto-ap.ssr.utils + :refer [apply-middleware-to-all-handlers + entity-id + field-validation-error + form-validation-error + html-response + many-entity + money + path->name2 + percentage + ref->enum-schema + ref->radio-options + regex + temp-id + wrap-form-4xx-2 + wrap-schema-decode]] + [auto-ap.utils :refer [dollars=]] + [bidi.bidi :as bidi] + [cheshire.core :as cheshire] + [clojure.string :as str] + [datomic.api :as dc] + [hiccup2.core :as hiccup] + [iol-ion.query :refer [ident]] + [malli.core :as mc])) + +;; TODO with dependencies, I really don't like that you have to be ultra specific in what +;; you want to include, and generating the routes and interconnection is weird too. +;; I'm tempted to say to include a full snapshot of the form, and the indicator +;; as to which one to generate. + + +;; TODO lots of escaping concerns (urls in javascript), all these weird name filters + +;; TODO better generation of names? + + +(defn filters [request] + [:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms" + "hx-get" (bidi/path-for ssr-routes/only-routes + :admin-transaction-rule-table) + "hx-target" "#transaction-rule-table" + "hx-indicator" "#transaction-rule-table"} + + [:fieldset.space-y-6 + (com/field {:label "Vendor"} + (com/typeahead {:name "vendor" + :placeholder "Search..." + :url (bidi/path-for ssr-routes/only-routes + :vendor-search) + :id (str "vendor-search") + :value [(:db/id (:vendor (:parsed-query-params request))) + (:vendor/name (:vendor (:parsed-query-params request)))]})) + (com/field {:label "Note"} + (com/text-input {:name "note" + :id "note" + :class "hot-filter" + :value (:note (:parsed-query-params request)) + :placeholder "HOME DEPOT lte 250.0" + :size :small})) + + (com/field {:label "Description"} + (com/text-input {:name "description" + :id "description" + :class "hot-filter" + :value (:description (:parsed-query-params request)) + :placeholder "LOWES" + :size :small}))]]) + +(def default-read '[:db/id + :transaction-rule/description + :transaction-rule/note + :transaction-rule/amount-lte + :transaction-rule/amount-gte + :transaction-rule/dom-lte + :transaction-rule/dom-gte + {:transaction-rule/client [:client/name :db/id :client/code :client/locations]} + {:transaction-rule/bank-account [:db/id :bank-account/name]} + {:transaction-rule/yodlee-merchant [:db/id :yodlee-merchant/name :yodlee-merchant/yodlee-id]} + {[:transaction-rule/transaction-approval-status :xform iol-ion.query/ident] [:db/id :db/ident]} + {:transaction-rule/vendor [:vendor/name :db/id :vendor/default-account]} + {:transaction-rule/accounts [:transaction-rule-account/percentage + :transaction-rule-account/location + {:transaction-rule-account/account [:account/name :db/id :account/numeric-code :account/location + {:account/client-overrides [:db/id + :account-client-override/name + {:account-client-override/client [:db/id :client/name]}]}]} + :db/id]}]) + +(defn fetch-ids [db request] + (let [query-params (:parsed-query-params request) + valid-clients (extract-client-ids (:clients request) + (:client request) + (:client-id query-params) + (when (:client-code query-params) + [:client/code (:client-code query-params)])) + query (cond-> {:query {:find [] + :in ['$] + :where []} + :args [db]} + (:sort query-params) (add-sorter-fields {"client" ['[?e :transaction-rule/client ?c] + '[?c :client/name ?sort-client]] + + "yodlee-merchant" ['[?e :transaction-rule/yodlee-merchant ?ym] + '[?ym :yodlee-merchant/name ?sort-yodlee-merchant]] + "bank-account" ['[?e :transaction-rule/bank-account ?ba] + '[?ba :bank-account/name ?sort-bank-account]] + "description" ['[?e :transaction-rule/description ?sort-description]] + "note" ['[?e :transaction-rule/note ?sort-note]] + "amount-lte" ['[?e :transaction-rule/amount-lte ?sort-amount-lte]] + "amount-gte" ['[?e :transaction-rule/amount-gte ?sort-amount-gte]]} + query-params) + + (seq valid-clients) + (merge-query {:query {:in ['[?xx ...]] + :where ['(or-join [?e] + (and [?e :transaction-rule/client ?xx]) + (and (not [?e :transaction-rule/client]) + [?e :transaction-rule/note]))]} + :args [valid-clients]}) + + (-> query-params :vendor :db/id) + (merge-query {:query {:in ['?vendor-id] + :where ['[?e :transaction-rule/vendor ?vendor-id]]} + :args [(-> query-params :vendor :db/id)]}) + + (not (str/blank? (:note query-params))) + (merge-query {:query {:in ['?note-pattern] + :where ['[?e :transaction-rule/note ?n] + '[(re-find ?note-pattern ?n)]]} + :args [(re-pattern (str "(?i)" (:note query-params)))]}) + + (not (str/blank? (:description query-params))) + (merge-query {:query {:in ['?description] + :where ['[?e :transaction-rule/description ?d] + '[(clojure.string/lower-case ?d) ?d2] + '[(clojure.string/includes? ?d2 ?description)]]} + :args [(clojure.string/lower-case (:description query-params))]}) + + true + (merge-query {:query {:find ['?e] + :where ['[?e :transaction-rule/transaction-approval-status]]}}))] + + (cond->> (query2 query) + true (apply-sort-3 query-params) + true (apply-pagination query-params)))) + +(defn hydrate-results [ids db _] + (let [results (->> (pull-many db default-read ids) + (group-by :db/id)) + refunds (->> ids + (map results) + (map first))] + refunds)) + +(defn fetch-page [request] + (let [db (dc/db conn) + {ids-to-retrieve :ids matching-count :count} (fetch-ids db request)] + + [(->> (hydrate-results ids-to-retrieve db request)) + matching-count])) + +(def grid-page + (helper/build {:id "transaction-rule-table" + :nav (com/admin-aside-nav) + :page-specific-nav filters + :fetch-page fetch-page + :parse-query-params (comp + (query-params/parse-key :vendor #(dc/pull (dc/db conn) '[:vendor/name :db/id] (Long/parseLong %))) + (helper/default-parse-query-params grid-page)) + :action-buttons (fn [request] + [(com/button {:hx-get (str (bidi/path-for ssr-routes/only-routes + :admin-transaction-rule-new-dialog)) + :hx-target "#modal-holder" + :hx-swap "innerHTML" + :color :primary} + "New Transaction Rule")]) + :row-buttons (fn [request entity] + [(com/icon-button {:hx-get (str (bidi/path-for ssr-routes/only-routes + :admin-transaction-rule-edit-dialog + :db/id (:db/id entity))) + :hx-target "#modal-holder" + :hx-swap "innerHTML"} + svg/pencil)]) + :breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes + :admin)} + "Admin"] + + [:a {:href (bidi/path-for ssr-routes/only-routes + :admin-transaction-rules)} + "Transaction Rules"]] + :title "Rules" + :entity-name "Rule" + :route :admin-transaction-rule-table + :headers [{:key "client" + :name "Client" + :sort-key "client" + :render #(-> % :transaction-rule/client :client/name)} + {:key "bank-account" + :name "Bank account" + :sort-key "bank-account" + :render #(-> % :transaction-rule/bank-account :bank-account/name)} + {:key "description" + :name "Description" + :sort-key "description" + :render :transaction-rule/description} + {:key "amount" + :name "Amount" + :sort-key "amount" + :render (fn [{:transaction-rule/keys [amount-gte amount-lte]}] + [:div.flex.gap-2 (when amount-gte + (com/pill {:color :red} (format "more than $%.2f" amount-gte))) + + (when amount-lte + (com/pill {:color :primary} (format "less than $%.2f" amount-lte)))])} + {:key "note" + :name "Note" + :sort-key "note" + :render :transaction-rule/note} + ]})) + +(def row* (partial helper/row* grid-page)) +(def table* (partial helper/table* grid-page)) + +(defn entity->note [{:transaction-rule/keys [amount-lte amount-gte description client dom-lte dom-gte]}] + (str/join " - " (filter (complement str/blank?) + [(when client (pull-attr (dc/db conn) :client/code client)) + description + (when (or amount-lte amount-gte) + (str (when amount-gte + (str amount-gte "<")) + "amt" + (when amount-lte + (str "<" amount-lte)))) + + (when (or dom-lte dom-gte) + (str (when dom-gte + (str dom-gte "<")) + "dom" + (when dom-lte + (str "<" dom-lte))))]))) + +(defn bank-account-belongs-to-client? [bank-account-id client-id] + (get (->> (dc/pull (dc/db conn) [{:client/bank-accounts [:db/id]}] client-id) + :client/bank-accounts + (map :db/id) + (set)) + bank-account-id)) + +(defn transaction-rule-save [{:keys [form-params request-method identity] :as request}] + (let [entity (cond-> form-params + (= :post request-method) (assoc :db/id "new") + true (assoc :transaction-rule/note (entity->note form-params))) + _ (doseq [[{:transaction-rule-account/keys [account location]} i] (map vector (:transaction-rule/accounts entity) (range)) + :let [account-location (pull-attr (dc/db conn) :account/location account)] + :when (and account-location (not= account-location location))] + (field-validation-error (str "must be " account-location) + [:transaction-rule/accounts i :transaction-rule-account/location] + :form entity)) + + total (reduce + + 0.0 + (map :transaction-rule-account/percentage + (:transaction-rule/accounts entity))) + _ (when-not (dollars= 1.0 total) + (form-validation-error (format "Expense accounts total (%d%%) must add to 100%%" (int (* 100.0 total))) + :form entity)) + + _ (when (and (:transaction-rule/bank-account entity) + (not (bank-account-belongs-to-client? (:transaction-rule/bank-account entity) + (:transaction-rule/client entity)))) + (field-validation-error "does not belong to client" + [:transaction-rule/bank-account] + :form entity)) + + + {:keys [tempids]} (audit-transact [[:upsert-entity entity]] + (:identity request)) + updated-account (dc/pull (dc/db conn) + default-read + (or (get tempids (:db/id entity)) (:db/id entity)))] + (html-response + (row* identity updated-account {:flash? true}) + :headers {"hx-trigger" "modalClosing" + "hx-retarget" (format "#transaction-rule-table tr[data-id=\"%d\"]" (:db/id updated-account))}))) + + + +(defn- location-select* + [{:keys [ name account-location client-locations value]}] + (com/select {:options (into [["" ""]] + (cond account-location + [[account-location account-location]] + + (seq client-locations) + (into [["Shared" "Shared"]] + (for [cl client-locations] + [cl cl])) + :else + [["Shared" "Shared"]])) + :name name + :value value})) + +(defn- account-typeahead* + [{:keys [name value client-id]}] + [:div.flex.flex-col + (com/typeahead {:name name + :placeholder "Search..." + :url (str (bidi/path-for ssr-routes/only-routes :account-search) "?client-id=" client-id) + :id name + :value value + :value-fn (some-fn :db/id identity) + :content-fn (fn [value] + (:account/name (d-accounts/clientize (cond->> value + (nat-int? value) (dc/pull (dc/db conn) d-accounts/default-read)) + client-id)))})]) + + + +(defn- transaction-rule-account-row* + [transaction-rule account] + (com/data-grid-row {} + (let [account-name (path->name2 :transaction-rule/accounts (:db/id account) :transaction-rule-account/account) + location-name (path->name2 :transaction-rule/accounts (:db/id account) :transaction-rule-account/location)] + (list + (com/data-grid-cell {} + [:div {:hx-trigger (hx/trigger-field-change :name "transaction-rule/client" + :from "#edit-form") + :hx-include "#edit-form" + :hx-vals (hx/vals {:name account-name}) + :hx-ext "rename-params" + :hx-rename-params-ex (hx/json {:transaction-rule/client "client-id" + :name "name" + account-name "value"}) + :hx-get (str (bidi/path-for ssr-routes/only-routes :admin-transaction-rule-account-typeahead)) + :hx-swap "innerHTML"} + (account-typeahead* {:value (:transaction-rule-account/account account) + :client-id (:db/id (:transaction-rule/client transaction-rule)) + :name account-name}) + (com/field-errors {:source account + :key :transaction-rule-account/account})]) + (com/data-grid-cell {} + [:div {:hx-trigger (hx/triggers + (hx/trigger-field-change :name "transaction-rule/client" + :from "#edit-form") + (hx/trigger-field-change :name account-name + :from "#edit-form")) + :hx-include "#edit-form" + :hx-vals (hx/vals {:name location-name}) + :hx-ext "rename-params" + :hx-rename-params-ex (hx/json {"transaction-rule/client" "client-id" + account-name "account-id" + "name" "name" + location-name "value"}) + :hx-get (bidi/path-for ssr-routes/only-routes :admin-transaction-rule-location-select) + :hx-swap "innerHTML"} + (location-select* {:name location-name + :account-location (:account/location (cond->> (:transaction-rule-account/account account) + (nat-int? (:transaction-rule-account/account account)) (dc/pull (dc/db conn) + '[:account/location]))) + :client-locations (:client/locations (:transaction-rule/client transaction-rule)) + :value (:transaction-rule-account/location account)}) + (com/field-errors {:source account + :key :transaction-rule-account/location})]) + (com/data-grid-cell (com/money-input {:name (format "transaction-rule/accounts[%s][transaction-rule-account/percentage]" (:db/id account)) + :class "w-16" + :value (some-> account + :transaction-rule-account/percentage + (* 100 ) + (long ))}) + (com/field-errors {:source account + :key :transaction-rule-account/percentage})))) + (com/data-grid-cell + (com/a-icon-button + {"_" (hiccup/raw "on click halt the event then transition the closest 's opacity to 0 then remove closest ") + :href "#"} + svg/x)))) + + + +(defn dialog* [& {:keys [ entity form-params]}] + (com/modal + {:modal-class "max-w-4xl"} + (com/modal-card + {} + [:div.flex [:div.p-2 "Transaction Rule"] ] + [:form#edit-form (merge {:hx-ext "response-targets" + :hx-swap "outerHTML swap:300ms" + :hx-target "#modal-holder" + :hx-target-400 "#form-errors .error-content"} + form-params) + [:fieldset {:class "hx-disable" :hx-disinherit "hx-target"} + + [:div.space-y-6 + (when-let [id (:db/id entity)] + (com/hidden {:name "db/id" + :value id})) + (com/field {:label "Client"} + [:div.w-96 + (com/typeahead {:name "transaction-rule/client" + :class "w-96" + :placeholder "Search..." + :url (bidi/path-for ssr-routes/only-routes :company-search) + :id (str "form-client-search") + :value (:transaction-rule/client entity) + :value-fn (some-fn :db/id identity) + :content-fn (fn [c] (cond->> c + (nat-int? c) (dc/pull (dc/db conn) '[:client/name]) + true :client/name))})]) + + + (com/field {:label "Bank Account"} + [:div#bank-account-spot.w-96 {:hx-get (bidi/path-for ssr-routes/only-routes :bank-account-typeahead) + :hx-trigger (hx/trigger-field-change :name "transaction-rule/client" + :from "#edit-form") + :hx-swap "innerHTML" + :hx-ext "rename-params" + :hx-include "#edit-form" + :hx-vals (hx/vals {:name "transaction-rule/bank-account"}) + :hx-rename-params-ex (cheshire/generate-string {"transaction-rule/client" "client-id" + "name" "name"})} + (bank-account-typeahead* {:client-id ((some-fn :db/id identity) (:transaction-rule/client entity)) + :name "transaction-rule/bank-account" + :value (:transaction-rule/bank-account entity)}) + (com/field-errors {:source entity + :key :transaction-rule/bank-account})]) + + (com/field {:label "Description" + :error-source entity + :error-key :transaction-rule/description} + (com/text-input {:name "transaction-rule/description" + "_" (hiccup/raw "on load call me.focus()") + :placeholder "HOME DEPOT" + :class "w-96" + :value (:transaction-rule/description entity)})) + + + + (com/field {:label "Amount"} + [:div.flex.gap-2 + (com/money-input {:name "transaction-rule/amount-gte" + :placeholder ">=" + :class "w-24" + :value (:transaction-rule/amount-gte entity)}) + (com/money-input {:name "transaction-rule/amount-lte" + :placeholder "<=" + :class "w-24" + :value (:transaction-rule/amount-lte entity)})]) + + (com/field {:label "Day of month"} + [:div.flex.gap-2 + (com/int-input {:name "transaction-rule/dom-gte" + :placeholder ">=" + :class "w-24" + :value (:transaction-rule/dom-gte entity)}) + (com/int-input {:name "transaction-rule/dom-lte" + :placeholder "<=" + :class "w-24" + :value (:transaction-rule/dom-lte entity)})]) + + [:h2.text-lg "Outcomes"] + (com/field {:label "Assign Vendor" + :error-source entity + :error-key :transaction-rule/vendor} + [:div.w-96 + (com/typeahead {:name "transaction-rule/vendor" + :placeholder "Search..." + :url (bidi/path-for ssr-routes/only-routes :vendor-search) + :id (str "form-vendor-search") + :value (:transaction-rule/vendor entity) + :value-fn (some-fn :db/id identity) + :content-fn (some-fn :vendor/name #(pull-attr (dc/db conn) :vendor/name %))})]) + + (com/data-grid {:headers [(com/data-grid-header {} + "Account") + (com/data-grid-header {:class "w-16"} "Location") + (com/data-grid-header {:class "w-16"} "%") + (com/data-grid-header {:class "w-16"})] + :id "transaction-rule-account-table"} + (for [tra (:transaction-rule/accounts entity)] + (transaction-rule-account-row* entity tra))) + (com/field-errors {:source entity + :key :transaction-rule/accounts}) + (com/a-button {:hx-get (bidi/path-for ssr-routes/only-routes + :admin-transaction-rule-new-account) + :hx-include "#edit-form" + :hx-ext "rename-params" + :hx-rename-params-ex (cheshire/generate-string {"transaction-rule/client" "client-id"}) + :hx-target "#transaction-rule-account-table tbody" + :hx-swap "beforeend"} + "New account") + (com/radio {:options (ref->radio-options "transaction-approval-status") + :value (:transaction-rule/transaction-approval-status entity) + :name (path->name2 :transaction-rule/transaction-approval-status)}) + + [:div#form-errors [:span.error-content + (com/field-errors {:source entity})]] + (com/button {:color :primary :form "edit-form" :type "submit"} + "Save")]]] + [:div]))) + +(defn new-account [{{:keys [client-id]} :query-params}] + (html-response + (transaction-rule-account-row* + {:transaction-rule/client (dc/pull (dc/db conn) '[:client/name :client/locations :db/id] + client-id)} + {:db/id (str (java.util.UUID/randomUUID)) + :transaction-rule-account/location "shared"}))) + +(defn location-select [{{:keys [name account-id client-id value] :as qp} :query-params}] + (html-response (location-select* {:name name + :value value + :account-location (some->> account-id + (pull-attr (dc/db conn) :account/location)) + :client-locations (some->> client-id + (pull-attr (dc/db conn) :client/locations))}))) + +(defn account-typeahead [{{:keys [name value client-id] :as qp} :query-params}] + (let [account (some->> value (dc/pull (dc/db conn) [:account/name :db/id + {:account/client-overrides [:db/id + :account-client-override/name + {:account-client-override/client [:db/id :client/name]}]}])) + client-id client-id] + (html-response (account-typeahead* {:name name + :value account + :client-id client-id})))) + +(defn transaction-rule-edit-dialog [request] + (let [entity (or + (some-> request :last-form) + (some-> request :route-params :db/id (#(dc/pull (dc/db conn) default-read %))))] + (html-response (dialog* :entity entity + :form-params {:hx-put (str (bidi/path-for ssr-routes/only-routes + :admin-transaction-rule-edit-save))}) + :headers {"hx-trigger" "modalOpening"}))) + +(defn transaction-rule-error [request] + (let [entity (some-> request :last-form)] + (html-response (dialog* :entity entity) + :headers {"hx-retarget" "#edit-form fieldset" + "hx-reselect" "#edit-form fieldset"}))) + + +(defn account-new-dialog [_] + (html-response (dialog* :account nil + :form-params {:hx-post (str (bidi/path-for ssr-routes/only-routes + :admin-account-new-save))}) + :headers {"hx-trigger" "modalOpening"})) + +(def transaction-rule-schema (mc/schema + [:map + [:db/id {:optional true} [:maybe entity-id]] + [:transaction-rule/client {:optional true} [:maybe entity-id]] + [:transaction-rule/description [:and regex + [:string {:min 3}]]] + [:transaction-rule/bank-account [:maybe entity-id]] + [:transaction-rule/amount-gte {:optional true} [:maybe money]] + [:transaction-rule/amount-lte {:optional true} [:maybe money]] + [:transaction-rule/dom-gte {:optional true} [:maybe :int]] + [:transaction-rule/dom-lte {:optional true} [:maybe :int]] + [:transaction-rule/vendor {:optional true} [:maybe entity-id]] + [:transaction-rule/transaction-approval-status (ref->enum-schema "transaction-approval-status")] + [:transaction-rule/accounts + (many-entity {:min 1} + [:db/id [:or entity-id temp-id]] + [:transaction-rule-account/account entity-id] + [:transaction-rule-account/location :string] + [:transaction-rule-account/percentage percentage])]])) + +(def key->handler + (apply-middleware-to-all-handlers + (->> + {:admin-transaction-rules (helper/page-route grid-page) + :admin-transaction-rule-table (helper/table-route grid-page) + :admin-transaction-rule-new-account (-> new-account + (wrap-schema-decode :query-schema [:map + [:client-id {:optional true} + [:maybe entity-id]]]) + wrap-admin wrap-client-redirect-unauthenticated) + :admin-transaction-rule-location-select (-> location-select + (wrap-schema-decode :query-schema [:map + [:name :string] + [:client-id {:optional true} + [:maybe entity-id]] + [:account-id {:optional true} + [:maybe entity-id]]])) + :admin-transaction-rule-account-typeahead (-> account-typeahead + (wrap-schema-decode :query-schema [:map + [:name :string] + [:client-id {:optional true} + [:maybe entity-id]] + [:value {:optional true} + [:maybe entity-id]]])) + :admin-transaction-rule-save (-> transaction-rule-save + (wrap-schema-decode :form-schema transaction-rule-schema) + (wrap-nested-form-params) + (wrap-form-4xx-2 transaction-rule-error)) + :admin-transaction-rule-edit-dialog (-> transaction-rule-edit-dialog + (wrap-schema-decode :route-schema [:map [:db/id entity-id]])) + :admin-transaction-rule-new-dialog account-new-dialog}) + (fn [h] + (-> h + (wrap-admin) + (wrap-client-redirect-unauthenticated))))) diff --git a/src/clj/auto_ap/ssr/company.clj b/src/clj/auto_ap/ssr/company.clj index 65e3b71d..3891bb3a 100644 --- a/src/clj/auto_ap/ssr/company.clj +++ b/src/clj/auto_ap/ssr/company.clj @@ -7,9 +7,9 @@ [auto-ap.ssr.components :as com] [auto-ap.ssr.svg :as svg] [auto-ap.ssr.ui :refer [base-page]] + [auto-ap.ssr.utils :refer [html-response]] [bidi.bidi :as bidi] [cemerick.url :as url] - [clojure.set :as set] [clojure.string :as str] [config.core :refer [env]] [datomic.api :as dc] @@ -72,17 +72,59 @@ (defn search [{:keys [clients query-params]}] (let [valid-client-ids (set (map :db/id clients)) - name-like-ids (when (not-empty (get query-params "q")) + name-like-ids (when (not-empty (get query-params "q")) (set (map (comp #(Long/parseLong %) :id) (solr/query solr/impl "clients" {"query" (format "_text_:(%s*)" (str/upper-case (solr/escape (get query-params "q")))) "fields" "id" "limit" 300})))) - valid-clients (for [n name-like-ids - :when (valid-client-ids n)] - {"value" n "label" (pull-attr (dc/db conn) :client/name n)} + valid-clients (for [n name-like-ids + :when (valid-client-ids n)] + {"value" n "label" (pull-attr (dc/db conn) :client/name n)} )] {:body (take 10 valid-clients)})) (def search (wrap-json-response search)) + +(defn bank-account-search [{:keys [route-params query-params clients]}] + (let [valid-client-ids (set (map :db/id clients)) + selected-client-id (Long/parseLong (get route-params :db/id)) + bank-accounts (when (valid-client-ids selected-client-id) + (->> (dc/pull (dc/db conn) [{:client/bank-accounts [:db/id :bank-account/name]}] + selected-client-id) + :client/bank-accounts + (filter (fn [{:keys [bank-account/name]}] + (str/includes? (str/upper-case name) + (or (some-> query-params + (get "q") + str/upper-case) + "__")))) + (map (fn [{:keys [db/id bank-account/name]}] + {"value" id "label" name}))))] + {:body (take 10 bank-accounts)})) + +(def bank-account-search (wrap-json-response bank-account-search)) + +(defn bank-account-typeahead* [{:keys [client-id name value]}] + (if client-id + (com/typeahead {:name name + :class "w-96" + :placeholder "Search..." + :url (bidi/path-for ssr-routes/only-routes :bank-account-search + :db/id client-id) + :id (str "form-bank-account-search") + :value value + :value-fn (some-fn :db/id identity) + :content-fn (some-fn :bank-account/name #(pull-attr (dc/db conn) :bank-account/name %))}) + [:span.text-xs.text-gray-500 "Please select a client before selecting a bank account." + [:input {:type "hidden" + :name name}]])) + +(defn bank-account-typeahead [{:keys [query-params clients]}] + (html-response (bank-account-typeahead* {:client-id ((set (map :db/id clients)) + (some->> "client-id" + (get query-params) + not-empty + Long/parseLong)) + :name (get query-params "name")}))) diff --git a/src/clj/auto_ap/ssr/components.clj b/src/clj/auto_ap/ssr/components.clj index cfa33748..eed6f5f4 100644 --- a/src/clj/auto_ap/ssr/components.clj +++ b/src/clj/auto_ap/ssr/components.clj @@ -26,10 +26,12 @@ (def text-input inputs/text-input-) (def money-input inputs/money-input-) +(def int-input inputs/int-input-) (def date-input inputs/date-input-) (def hidden inputs/hidden-) (def select inputs/select-) (def typeahead inputs/typeahead-) +(def field-errors inputs/field-errors-) (def field inputs/field-) (def left-aside aside/left-aside-) diff --git a/src/clj/auto_ap/ssr/components/data_grid.clj b/src/clj/auto_ap/ssr/components/data_grid.clj index 8e4000d8..caf91017 100644 --- a/src/clj/auto_ap/ssr/components/data_grid.clj +++ b/src/clj/auto_ap/ssr/components/data_grid.clj @@ -37,8 +37,8 @@ [:input {:id "checkbox-all", :type "checkbox", :class "w-4 h-4 bg-gray-100 border-gray-300 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"}] [:label {:for "checkbox-all", :class "sr-only"} "checkbox"]]]) -(defn data-grid- [{:keys [headers thead-params]} & rest] - [:table {:class "w-full text-sm text-left text-gray-500 dark:text-gray-400"} +(defn data-grid- [{:keys [headers thead-params id]} & rest] + [:table {:class "w-full text-sm text-left text-gray-500 dark:text-gray-400" :id id} [:thead (assoc thead-params :class "text-xs text-gray-800 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400") (into [:tr] diff --git a/src/clj/auto_ap/ssr/components/dialog.clj b/src/clj/auto_ap/ssr/components/dialog.clj index c0b8c0d3..e6e2e99e 100644 --- a/src/clj/auto_ap/ssr/components/dialog.clj +++ b/src/clj/auto_ap/ssr/components/dialog.clj @@ -2,43 +2,15 @@ (:require [hiccup2.core :as hiccup])) (defn modal- [params & children] - [:div - [:div#modal-holder { :tabindex "-1", :class "fixed top-0 left-0 right-0 z-50 w-full p-4 overflow-x-hidden overflow-y-auto md:inset-0 h-[calc(100%-1rem)] max-h-full flex justify-center hidden" :aria-hidden true - "_" (hiccup/raw "on closeModal transition <#modal-holder .modal-content /> opacity to 0.0 over 300ms then call hideModal() ")} - [:div {:class (str "relative w-full max-h-full " (or (:modal-class params) " max-w-2xl "))} - (into [:div#modal-content] - children)] - ] - [:script {:lang "text/javascript"} - (hiccup/raw " - var modal_element = document.getElementById('modal-holder'); - var modal_options = { - placement: 'center', - backdrop: 'dynamic', - backdropClasess: 'bg-gray-900 bg-opacity-50 dark:bg-opacity-80 fixed inset-0 z-40', - closable: true, - onOpen: function() { - modal_element.dispatchEvent('openModal'); - - }, - onHide: function() { - modal_element.outerHTML='
'; - }, - }; - var curModal = new Modal(modal_element, modal_options); -curModal.show(); -function hideModal() { -curModal.hide(); -} -") - - ]]) + [:div {:class (str "relative w-full max-h-full " (or (:modal-class params) " max-w-2xl "))} + (into [:div#modal-content] + children)] + ) (defn modal-card- [params header content footer] [:div#modal-card params - [:div {:class "relative bg-white rounded-lg shadow dark:bg-gray-700 dark:text-white fade-in slide-up duration-300 transition-all modal-content"} + [:div {:class "relative bg-white rounded-lg shadow dark:bg-gray-700 dark:text-white fade-in-settle slide-up-settle duration-300 transition-all modal-content"} [:div {:class "flex items-start justify-between p-4 border-b rounded-t dark:border-gray-600"} header] [:div {:class "p-6 space-y-6"} content] - [:div footer]] - ]) + [:div footer]]]) diff --git a/src/clj/auto_ap/ssr/components/inputs.clj b/src/clj/auto_ap/ssr/components/inputs.clj index e812faee..ac18fe6b 100644 --- a/src/clj/auto_ap/ssr/components/inputs.clj +++ b/src/clj/auto_ap/ssr/components/inputs.clj @@ -1,6 +1,7 @@ (ns auto-ap.ssr.components.inputs (:require - [hiccup2.core :as hiccup])) + [hiccup2.core :as hiccup] + [clojure.string :as str])) (defn select- [params & children] @@ -22,22 +23,24 @@ [:select (-> params (dissoc :url) (dissoc :value) - ) - (for [[k v] (if (:multiple params) - (:value params) + (dissoc :value-fn) + (dissoc :content-fn)) + (for [value (if (:multiple params) + (:value params) [(:value params)]) - :when k] - [:option {:value k :selected true} v] - ) + :when ((:value-fn params first) value)] + [:option {:value ((:value-fn params first) value) :selected true} ((:content-fn params second) value)]) [:script {:lang "javascript"} (hiccup/raw (format " (function () { var element = document.getElementById('%s'); var c = new Choices(element, {removeItems: true, removeItemButton:true, searchFloor: 3, searchPlaceholderValue: '%s'}); +let baseUrl = '%s'; element.addEventListener('search', function (e) { - let data = fetch('%s?q=' + e.detail.value) + let fullUrl = baseUrl + (baseUrl.includes(\"?\") ? \"&\" : \"?\") + \"q=\" + e.detail.value; + let data = fetch(fullUrl) .then(res => res.json()) .then(data => { c.setChoices(data, 'value', 'label', true) @@ -77,6 +80,17 @@ c.clearChoices(); (update :class #(str % (use-size size))) (assoc :type "number" :step "0.01") + (dissoc :size))]) + +(defn int-input- [{:keys [size] :as params}] + [:input + (-> params + (update + :class str " bg-gray-50 border border-gray-300 text-gray-900 rounded-lg focus:ring-blue-500 focus:border-blue-500 block dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500 text-right appearance-none" + ) + (update :class #(str % (use-size size))) + (assoc :type "number" + :step "1") (dissoc :size)) ]) @@ -93,12 +107,21 @@ c.clearChoices(); (update :class #(str % (use-size size))) (dissoc :size))]]) + + +(defn field-errors- [{:keys [source key]} & rest] + (if-let [errors (:errors (cond-> (meta source) + key (get key)))] + [:div.text-red-500 (str/join ", " errors)] + [:div (hiccup/raw " ")])) + (defn field- [params & rest] - (into - [:div {:id (:id params)} - [:label {:class "block mb-2 text-sm font-medium text-gray-900 dark:text-white"} (:label params)]] - rest)) + [:div {:id (:id params)} + [:label {:class "block mb-2 text-sm font-medium text-gray-900 dark:text-white"} (:label params)] + rest + (when (:error-source params) + (field-errors- {:source (:error-source params) + :key (:error-key params)}))]) (defn hidden- [{:keys [name value]}] - [:input {:type "hidden" :value value :name name}] - ) + [:input {:type "hidden" :value value :name name}]) diff --git a/src/clj/auto_ap/ssr/components/page.clj b/src/clj/auto_ap/ssr/components/page.clj index ebda28be..7afa7c3a 100644 --- a/src/clj/auto_ap/ssr/components/page.clj +++ b/src/clj/auto_ap/ssr/components/page.clj @@ -49,4 +49,4 @@ (into [:div.p-4] children)]] - [:div#modal-holder]]) + ]) diff --git a/src/clj/auto_ap/ssr/components/radio.clj b/src/clj/auto_ap/ssr/components/radio.clj index f9a701a3..a793a901 100644 --- a/src/clj/auto_ap/ssr/components/radio.clj +++ b/src/clj/auto_ap/ssr/components/radio.clj @@ -1,22 +1,23 @@ (ns auto-ap.ssr.components.radio) -(defn radio- [{:keys [options name title size] :or {size :medium}}] +(defn radio- [{:keys [options name title size] :or {size :medium} selected-value :value}] [:h3 {:class "mb-4 font-semibold text-gray-900 dark:text-white"} title] [:ul {:class "w-48 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-white"} (for [{:keys [value content]} options] [:li {:class "w-full border-b border-gray-200 rounded-t-lg dark:border-gray-600"} [:div {:class "flex items-center pl-3"} - [:input {:id (str "list-" name "-" value) - :type "radio", - :value value - :name name - :class - (cond-> "w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-700 dark:focus:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500" - (= size :small) - (str " " "text-xs") + [:input (cond-> {:id (str "list-" name "-" value) + :type "radio", + :value value + :name name + :class + (cond-> "w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-700 dark:focus:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500" + (= size :small) + (str " " "text-xs") - (= size :medium) - (str " " "text-sm"))}] + (= size :medium) + (str " " "text-sm"))} + (= (cond-> selected-value (keyword? selected-value) clojure.core/name) value) (assoc :checked true))] [:label {:for (str "list-" name "-" value) :class (cond-> "w-full ml-2 font-medium text-gray-900 dark:text-gray-300" diff --git a/src/clj/auto_ap/ssr/core.clj b/src/clj/auto_ap/ssr/core.clj index 230bf309..0935bbd0 100644 --- a/src/clj/auto_ap/ssr/core.clj +++ b/src/clj/auto_ap/ssr/core.clj @@ -19,10 +19,13 @@ [auto-ap.ssr.pos.refunds :as pos-refunds] [auto-ap.ssr.pos.sales-orders :as pos-sales] [auto-ap.ssr.pos.tenders :as pos-tenders] + [auto-ap.ssr.admin.transaction-rules :as admin-rules] + [auto-ap.ssr.account :as account] [auto-ap.ssr.search :as search] [auto-ap.ssr.transaction.insights :as insights] [auto-ap.ssr.users :as users] - [ring.middleware.json :refer [wrap-json-response]])) + [ring.middleware.json :refer [wrap-json-response]] + [auto-ap.ssr.vendor :as vendors])) ;; from auto-ap.ssr-routes, because they're shared @@ -38,6 +41,10 @@ (wrap-client-redirect-unauthenticated (wrap-secure (wrap-json-response company-dropdown/dropdown-search-results {}))) :company-search (wrap-client-redirect-unauthenticated (wrap-secure company/search)) + :bank-account-search (wrap-client-redirect-unauthenticated (wrap-secure company/bank-account-search)) + :account-search (wrap-client-redirect-unauthenticated (wrap-secure account/account-search)) + :bank-account-typeahead (wrap-client-redirect-unauthenticated (wrap-secure company/bank-account-typeahead)) + :company (wrap-client-redirect-unauthenticated (wrap-secure company/page)) :company-1099 (wrap-client-redirect-unauthenticated (wrap-secure company-1099/page)) :company-1099-vendor-table (wrap-client-redirect-unauthenticated (wrap-secure company-1099/vendor-table)) @@ -60,6 +67,7 @@ :invoice-glimpse-textract-invoice (wrap-client-redirect-unauthenticated (wrap-admin invoice-glimpse/textract-invoice)) :invoice-glimpse-create-invoice (wrap-client-redirect-unauthenticated (wrap-admin invoice-glimpse/create-invoice)) :invoice-glimpse-update-textract-invoice (wrap-client-redirect-unauthenticated (wrap-admin invoice-glimpse/update-textract-invoice)) + :vendor-search (wrap-client-redirect-unauthenticated (wrap-secure vendors/search)) :transaction-insights (wrap-client-redirect-unauthenticated (wrap-admin insights/page)) :transaction-insight-table (wrap-client-redirect-unauthenticated (wrap-admin insights/insight-table)) :transaction-insight-rows (wrap-client-redirect-unauthenticated (wrap-admin insights/transaction-rows)) @@ -75,5 +83,6 @@ (into pos-refunds/key->handler) (into users/key->handler) (into admin-accounts/key->handler) - (into admin-jobs/key->handler))) + (into admin-jobs/key->handler) + (into admin-rules/key->handler))) diff --git a/src/clj/auto_ap/ssr/hx.clj b/src/clj/auto_ap/ssr/hx.clj new file mode 100644 index 00000000..d5c59d85 --- /dev/null +++ b/src/clj/auto_ap/ssr/hx.clj @@ -0,0 +1,20 @@ +(ns auto-ap.ssr.hx + (:require [cheshire.core :as cheshire] + [clojure.string :as str])) + + +(defn vals [m] + (cheshire/generate-string m)) + + +(defn json [m] + (cheshire/generate-string m)) + +(defn trigger-field-change [& {:keys [name + from]}] + (format "change[target.name==\"%s\"] %s" + name + (when from (str "from:" from)))) + +(defn triggers [& triggers] + (str/join ", " triggers)) diff --git a/src/clj/auto_ap/ssr/ui.clj b/src/clj/auto_ap/ssr/ui.clj index 87ae5aae..41ba3fe2 100644 --- a/src/clj/auto_ap/ssr/ui.clj +++ b/src/clj/auto_ap/ssr/ui.clj @@ -42,6 +42,7 @@ [:script {:src "https://unpkg.com/dropzone@5.9.3/dist/min/dropzone.min.js"}] [:link {:rel "stylesheet" :href "https://unpkg.com/dropzone@5/dist/min/dropzone.min.css" :type "text/css"}] + #_[:script {:defer true :src "https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"}] [:style " input::-webkit-outer-spin-button, @@ -59,4 +60,31 @@ input[type=number] { ] [:body {:hx-ext "disable-submit"} contents - [:script {:src "/js/flowbite.min.js"}]]])) + [:script {:src "/js/flowbite.min.js"}] + [:div [:div#modal-holder + { :tabindex "-1", :class "fixed top-0 left-0 right-0 z-50 w-full p-4 overflow-x-hidden overflow-y-auto md:inset-0 h-[calc(100%-1rem)] max-h-full flex justify-center hidden" + :aria-hidden true + "_" (hiccup/raw "on \"modalClosed\" remove my children + on \"modalOpening\" from call curModal.show() + on \"modalClosing\" from call curModal.hide()") + }] + [:script {:lang "text/javascript"} + (hiccup/raw " + var modal_element = document.getElementById('modal-holder'); + var modal_options = { + placement: 'center', + backdrop: 'dynamic', + backdropClasess: 'bg-gray-900 bg-opacity-50 dark:bg-opacity-80 fixed inset-0 z-40', + closable: true, + onOpen: function() { + htmx.trigger(document.getElementById('modal-holder'), 'modalOpened', {}); + + }, + onHide: function() { + htmx.trigger(document.getElementById('modal-holder'), 'modalClosed', {}); + }, + }; + var curModal = new Modal(modal_element, modal_options); +") + + ]]]])) diff --git a/src/clj/auto_ap/ssr/users.clj b/src/clj/auto_ap/ssr/users.clj index 0c379d65..d1ba7b58 100644 --- a/src/clj/auto_ap/ssr/users.clj +++ b/src/clj/auto_ap/ssr/users.clj @@ -326,7 +326,9 @@ :form-schema (mc/schema [:map [:db/id entity-id] - [:user/clients (forced-vector entity-id)] + [:user/clients {:optional true} + [:maybe + (forced-vector entity-id)]] [:user/role (ref->enum-schema "user-role")]]))) :user-edit-dialog (-> user-edit-dialog (wrap-schema-decode diff --git a/src/clj/auto_ap/ssr/utils.clj b/src/clj/auto_ap/ssr/utils.clj index e587f5ac..11ec3da9 100644 --- a/src/clj/auto_ap/ssr/utils.clj +++ b/src/clj/auto_ap/ssr/utils.clj @@ -8,7 +8,8 @@ [malli.core :as mc] [malli.error :as me] [malli.transform :as mt2] - [slingshot.slingshot :refer [throw+ try+]])) + [slingshot.slingshot :refer [throw+ try+]] + [manifold.time :as mt])) (defn html-response [hiccup & {:keys [status headers oob] :or {status 200 headers {} oob []}}] {:status status @@ -83,8 +84,55 @@ )}} x]) -(def entity-id (mc/schema nat-int?)) +(defn empty->nil [v] + (if (and (string? v) (clojure.string/blank? v)) + nil + v)) + + (defn parse-empty-as-nil [] + (mt2/transformer + {:decoders + {:double empty->nil + :int empty->nil + :long empty->nil + 'nat-int? empty->nil}})) + +(def entity-id (mc/schema [nat-int? {:error/message "required"} ])) + (def temp-id (mc/schema :string)) +(def money (mc/schema [:double])) +(def percentage (mc/schema [:double {:decode/arbitrary (fn [x] (some-> x (* 0.01))) + :max 1.0 + :error/message "1-100"}])) + +(def regex (mc/schema [:fn {:error/message "not a regex"} + (fn check-regx [x] + (try + (and (string? x) + (. java.util.regex.Pattern (compile x java.util.regex.Pattern/CASE_INSENSITIVE))) + true + (catch Exception _ + false)))])) + +(def map->db-id-decoder + {:enter (fn [x] + (into [] + (for [[k v] x] + (assoc v :db/id (cond (and (string? k) (re-find #"^\d+$" k)) + (Long/parseLong k) + (keyword? k) + (name k) + :else + k)))))}) + +(defn many-entity [params & keys] + (mc/schema + [:vector (merge params {:decode/json map->db-id-decoder + :decode/arbitrary (fn [x] + (if (sequential? x) + x + [x]))}) + (into [:map] keys)])) (defn str->keyword [s] (if (string? s) @@ -97,65 +145,76 @@ (defn keyword->str [k] (subs (str k) 1)) -(defn validation-error [m & [data]] +(defn validation-error [m & {:as data}] (throw+ (ex-info m (merge data {:type :validation})))) +(defn field-validation-error [m path & {:as data}] + (throw+ (ex-info m (merge data {:type :field-validation + :field-validation-errors [{:path path + :message [m]}]})))) + +(defn form-validation-error [m & {:as data}] + (throw+ (ex-info m (merge data {:type :form-validation + :form-validation-errors [m]})))) + +(def main-transformer + (mt2/transformer + parse-empty-as-nil + (mt2/key-transformer {:encode keyword->str :decode str->keyword}) + mt2/string-transformer + mt2/json-transformer + (mt2/transformer {:name :arbitrary}))) + (defn wrap-schema-decode [handler & {:keys [form-schema query-schema route-schema params-schema]}] - (fn [{:keys [form-params query-params params] :as request}] + (fn [{:keys [form-params query-params params] :as request}] (let [request (try - (cond-> request - (and (:params request) params-schema) - (assoc :params - (mc/coerce - params-schema - (:params request) - (mt2/transformer - (mt2/key-transformer {:encode keyword->str :decode str->keyword}) - mt2/string-transformer - mt2/json-transformer) )) + (cond-> request + (and (:params request) params-schema) + (assoc :params + (mc/coerce + params-schema + (:params request) + main-transformer)) - (and (:route-params request) route-schema) - (assoc :route-params - (mc/coerce - route-schema - (:route-params request) - (mt2/transformer - (mt2/key-transformer {:encode keyword->str :decode str->keyword}) - mt2/string-transformer - mt2/json-transformer) )) + (and (:route-params request) route-schema) + (assoc :route-params + (mc/coerce + route-schema + (:route-params request) + main-transformer)) - (and form-schema form-params) - (assoc :form-params - (mc/coerce - form-schema - form-params - (mt2/transformer - (mt2/key-transformer {:encode keyword->str :decode str->keyword}) - mt2/string-transformer - mt2/json-transformer) )) + (and form-schema form-params) + (assoc :form-params + (mc/coerce + form-schema + form-params + main-transformer)) + + (and query-schema query-params) + (assoc :query-params + (mc/coerce + query-schema + query-params + main-transformer))) + (catch Exception e + (alog/warn ::validation-error :error e) + + (throw (ex-info (->> (-> e + (ex-data ) + :data + :explain + (me/humanize {:errors (assoc me/default-errors + ::mc/missing-key {:error/message {:en "required"}})})) + (map (fn [[k v]] + (str (if (keyword? k) + (name k) + k) ": " (str/join ", " v)))) + (str/join ", ")) + {:type :schema-validation + :decoded (:value (:data (ex-data e))) + :error (:data (ex-data e))}))))] + (handler request)))) - (and query-schema query-params) - (assoc :parsed-query-params - (mc/coerce - query-schema - query-params - (mt2/transformer - (mt2/key-transformer {:encode name :decode keyword}) - mt2/string-transformer - mt2/json-transformer) ))) - (catch Exception e - (alog/warn ::validation-error :error e) - (validation-error (str/join ", " - (->> e - (ex-data ) - :data - :explain - me/humanize - (map (fn [[k v]] - (str (if (keyword? k) - (name k) - k) ": " (str/join ", " v)) - )))))))] (handler request)))) (defn ref->enum-schema [n] (into [:enum {:decode/string #(keyword n %)}] (for [{:db/keys [ident]} (all-schema) @@ -170,16 +229,15 @@ :when (= n (namespace ident))] [(name ident) (str/replace (str/capitalize (name ident)) "-" " ")]))) -(def map->db-id-decoder - {:enter (fn [x] - (into [] - (for [[k v] x] - (assoc v :db/id (cond (and (string? k) (re-find #"^\d+$" k)) - (Long/parseLong k) - (keyword? k) - (name k) - :else - k)))))}) +(defn ref->radio-options [n & {:keys [allow-nil?]}] + (into (if allow-nil? + [{:value nil :content ""}] + []) + (for [{:db/keys [ident]} (all-schema) + :when (= n (namespace ident))] + {:value (name ident) :content (str/replace (str/capitalize (name ident)) "-" " ")}))) + + #_(defn namespaceize-decoder [n] {:exit (fn [m] @@ -202,8 +260,48 @@ (catch [:type :validation] e (alog/warn ::form-4xx :error e) (html-response [:span.error-content.text-red-500 (:message &throw-context)] - :status 400)))) - ) + :status 400))))) + +(defn assoc-errors-into-meta [entity errors] + (reduce + (fn add-error [entity {:keys [path message] :as se}] + (if (= (count path) 1) + (with-meta entity (assoc (meta entity) (last path) {:errors message})) + + (update-in entity (butlast path) + (fn [terminal] + (with-meta terminal (assoc (meta terminal) (last path) {:errors message})))))) + entity + errors)) + +(defn wrap-form-4xx-2 [handler form-handler] + (fn [request] + (try+ + (handler request) + (catch [:type :schema-validation] e + + (let [humanized (-> e :error :explain (me/humanize {:errors (assoc me/default-errors + ::mc/missing-key {:error/message {:en "required"}})})) + errors (map + (fn [e] + {:path (:in e) + :message (get-in humanized (:in e))}) + (:errors (:explain (:error e))))] + (alog/warn ::form-4xx :errors errors) + (form-handler (assoc request + :last-form (assoc-errors-into-meta (:decoded e) errors) + :field-validation-errors errors))) + #_(html-response [:span.error-content.text-red-500 (:message &throw-context)] + :status 400)) + (catch [:type :field-validation] e + (form-handler (assoc request + :last-form (assoc-errors-into-meta (:form e) (:field-validation-errors e)) + :field-validation-errors (:field-validation-errors e)))) + (catch [:type :form-validation] e + (form-handler (assoc request + :last-form (with-meta (:form e) {:errors (:form-validation-errors e)}) + :form-validation-errors (:form-validation-errors e))))))) + (defn apply-middleware-to-all-handlers [key->handler f] (->> key->handler @@ -212,3 +310,14 @@ (assoc key-handler k (f v))) key->handler) )) + +(defn path->name2 [k & rest] + (let [k->n (fn [k] + (if (keyword? k) + (str (namespace k) "/" (name k)) + k))] + (str (k->n k) + (str/join "" + (map (fn [k] + (str "[" (k->n k) "]")) + rest))))) diff --git a/src/clj/auto_ap/ssr/vendor.clj b/src/clj/auto_ap/ssr/vendor.clj new file mode 100644 index 00000000..01b0a544 --- /dev/null +++ b/src/clj/auto_ap/ssr/vendor.clj @@ -0,0 +1,29 @@ +(ns auto-ap.ssr.vendor + (:require + [auto-ap.datomic :refer [conn pull-attr]] + [auto-ap.graphql.utils :refer [is-admin?]] + [auto-ap.solr :as solr] + [clojure.string :as str] + [datomic.api :as dc] + [ring.middleware.json :refer [wrap-json-response]])) + +(defn search [{:keys [clients query-params identity]}] + + (doto (let [name-like-ids (when (not-empty (get query-params "q")) + (set (map (comp #(Long/parseLong %) :id) + (doto + (solr/query solr/impl "vendors" + (doto + {"query" (cond-> (format "name:(%s*)" (str/upper-case (solr/escape (get query-params "q")))) + (not (is-admin? identity)) (str " hidden:false")) + "fields" "id" + "limit" 300} + clojure.pprint/pprint)) + clojure.pprint/pprint)))) + _ (clojure.pprint/pprint name-like-ids) + valid-clients (for [n name-like-ids] + {"value" n "label" (pull-attr (dc/db conn) :vendor/name n)})] + {:body (take 10 valid-clients)}) + clojure.pprint/pprint)) + +(def search (wrap-json-response search)) diff --git a/src/cljc/auto_ap/shared_views/admin/side_bar.cljc b/src/cljc/auto_ap/shared_views/admin/side_bar.cljc index 1d2a0d20..94a4c69b 100644 --- a/src/cljc/auto_ap/shared_views/admin/side_bar.cljc +++ b/src/cljc/auto_ap/shared_views/admin/side_bar.cljc @@ -62,9 +62,9 @@ :icon-style {:font-size "25px"}}) (menu-item {:label "Rules" :icon-class "icon icon-cog-play-1" - :test-route #{:admin-rules} + :test-route #{:admin-transaction-rules} :active-route active-route - :route :admin-rules + :route :admin-transaction-rules :icon-style {:font-size "25px"}}) (menu-item {:label "History" :icon-class "icon icon-cog-play-1" diff --git a/src/cljc/auto_ap/ssr_routes.cljc b/src/cljc/auto_ap/ssr_routes.cljc index 30708ecb..cad4d193 100644 --- a/src/cljc/auto_ap/ssr_routes.cljc +++ b/src/cljc/auto_ap/ssr_routes.cljc @@ -8,6 +8,7 @@ ["/" [#"\w+" :textract-invoice-id]] {:get :invoice-glimpse-textract-invoice "/create" {:post :invoice-glimpse-create-invoice} "/update" {:patch :invoice-glimpse-update-textract-invoice}}}}} + "account" {"/search" {:get :account-search}} "admin" {"/history" {"" :admin-history "/" :admin-history #"/search/?" :admin-history-search @@ -30,7 +31,17 @@ "/table" :admin-job-table "/new" {:get :admin-job-start-dialog} "/subform" :admin-job-subform} - "/ezcater-xls" :admin-ezcater-xls} + "/ezcater-xls" :admin-ezcater-xls + "/transaction-rule" {"" {:get :admin-transaction-rules + :put :admin-transaction-rule-save + :post :admin-transaction-rule-save} + "/table" :admin-transaction-rule-table + "/account/new" :admin-transaction-rule-new-account + "/account/location-select" :admin-transaction-rule-location-select + "/account/typeahead" :admin-transaction-rule-account-typeahead + "/new" {:get :admin-transaction-rule-new-dialog} + [[#"\d+" :db/id] "/edit"] :admin-transaction-rule-edit-dialog + }} "transaction" {"/insights" {"" :transaction-insights "/table" :transaction-insight-table ["/code/" [#"\d+" :transaction-id]] {:post :transaction-insight-code} @@ -48,9 +59,12 @@ "/cash-drawer-shifts" {"" {:get :pos-cash-drawer-shifts} "/table" {:get :pos-cash-drawer-shift-table}}} + "vendor" {"/search" :vendor-search} "company" {"" :company "/dropdown" :company-dropdown-search-results "/search" :company-search + "/bank-account/typeahead" :bank-account-typeahead + ["/" [#"\d+" :db/id] "/bank-account"] {"/search" :bank-account-search} "/active" {:put :active-client} "/1099" :company-1099 "/1099/table" {:get :company-1099-vendor-table}