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