(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 remove-nils]] [auto-ap.datomic.accounts :as d-accounts] [auto-ap.datomic.transactions :as d-transactions] [auto-ap.graphql.utils :refer [extract-client-ids]] [auto-ap.logging :as alog] [auto-ap.query-params :as query-params] [auto-ap.routes.admin.transaction-rules :as route] [auto-ap.routes.utils :refer [wrap-admin wrap-client-redirect-unauthenticated]] [auto-ap.rule-matching :as rm] [auto-ap.solr :as solr] [auto-ap.ssr-routes :as ssr-routes] [auto-ap.ssr.common-handlers :refer [add-new-entity-handler]] [auto-ap.ssr.company :refer [bank-account-typeahead*]] [auto-ap.ssr.components :as com] [auto-ap.ssr.components.multi-modal :as mm] [auto-ap.ssr.form-cursor :as fc] [auto-ap.ssr.grid-page-helper :as helper] [auto-ap.ssr.hx :as hx] [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 modal-response money percentage ref->enum-schema ref->radio-options regex temp-id wrap-entity wrap-schema-enforce]] [auto-ap.time :as atime] [auto-ap.utils :refer [dollars=]] [bidi.bidi :as bidi] [clj-time.coerce :as coerce] [clojure.set :as set] [clojure.string :as str] [datomic.api :as dc] [malli.core :as mc] [malli.util :as mut])) (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 ::route/table) "hx-target" "#entity-table" "hx-indicator" "#entity-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 (:vendor (:parsed-query-params request)) :value-fn :db/id :content-fn :vendor/name})) (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})) (com/field {:label "Client group"} (com/text-input {:name "client-group" :id "client-group" :class "hot-filter" :value (:client-group (:parsed-query-params request)) :placeholder "NTG" :size :small}))]]) (def default-read '[:db/id :transaction-rule/description :transaction-rule/note :transaction-rule/amount-lte :transaction-rule/client-group :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) (= 1 (count valid-clients)) (merge-query {:query {:in '[?x] :where '[[?e :transaction-rule/client ?x]]} :args [(first 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))]}) (not (str/blank? (:client-group query-params))) (merge-query {:query {:in ['?client-group] :where ['[?e :transaction-rule/client-group ?client-group] ]} :args [(clojure.string/upper-case (:client-group 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 "entity-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 ::route/new-dialog)) :color :primary} "New Transaction Rule")]) :row-buttons (fn [request entity] [(com/icon-button {:hx-delete (bidi/path-for ssr-routes/only-routes ::route/delete :db/id (:db/id entity)) :hx-confirm "Are you sure you want to delete?"} svg/trash) (com/icon-button {:hx-get (bidi/path-for ssr-routes/only-routes ::route/execute-dialog :db/id (:db/id entity))} svg/play) (com/icon-button {:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-dialog :db/id (:db/id entity))} svg/pencil)]) :breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes :admin)} "Admin"] [:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)} "Transaction Rules"]] :title "Rules" :entity-name "Rule" :route ::route/table :headers [{:key "client" :name "Client" :sort-key "client" :render #(or (-> % :transaction-rule/client :client/name) (some->> % :transaction-rule/client-group (str "group ") (com/pill {:color :primary})))} {:key "bank-account" :name "Bank account" :sort-key "bank-account" :render #(-> % :transaction-rule/bank-account :bank-account/name) :show-starting "lg"} {: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)))]) :show-starting "md"} {: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 validate-transaction-rule [form-params] (doseq [[{:transaction-rule-account/keys [account location]} i] (map vector (:transaction-rule/accounts form-params) (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-params form-params)) (let [total (reduce + 0.0 (map :transaction-rule-account/percentage (:transaction-rule/accounts form-params)))] (when-not (dollars= 1.0 total) (form-validation-error (format "Expense accounts total (%d%%) must add to 100%%" (int (* 100.0 total))) :form-params form-params))) (when (and (:transaction-rule/bank-account form-params) (not (bank-account-belongs-to-client? (:transaction-rule/bank-account form-params) (:transaction-rule/client form-params)))) (field-validation-error "does not belong to client" [:transaction-rule/bank-account] :form-params form-params))) (def transaction-read '[{:transaction/client [:client/name] :transaction/bank-account [:bank-account/name]} :transaction/description-original :db/id [:transaction/date :xform clj-time.coerce/from-date]]) (defn transactions-matching-rule [{{:transaction-rule/keys [description client bank-account amount-lte amount-gte dom-lte dom-gte client-group]} :entity clients :clients only-uncoded? :only-uncoded?}] (let [valid-clients (extract-client-ids clients client) bank-account (or (:db/id bank-account) bank-account) query (cond-> {:query {:find ['(pull ?e read)] :in ['$ 'read] :where []} :args [(dc/db conn) transaction-read]} only-uncoded? (merge-query {:query {:where ['[?e :transaction/approval-status :transaction-approval-status/unapproved]]}}) (not only-uncoded?) (merge-query {:query {:where '[[(iol-ion.query/recent-date 60) ?start-date] [?e :transaction/date ?d] [(>= ?d ?start-date)]]}}) description (merge-query {:query {:in ['?descr] :where ['[(iol-ion.query/->pattern ?descr) ?description-regex]]} :args [description]}) valid-clients (merge-query {:query {:in ['[?xx ...]] :where ['[?e :transaction/client ?xx]]} :args [(set valid-clients)]}) bank-account (merge-query {:query {:in ['?bank-account-id] :where ['[?e :transaction/bank-account ?bank-account-id]]} :args [bank-account]}) description (merge-query {:query {:where ['[?e :transaction/description-original ?do] '[(re-find ?description-regex ?do)]]}}) client-group (merge-query {:query {:in ['?client-group] :where ['[?e :transaction/client ?client-group-client] '[?client-group-client :client/groups ?client-group]]} :args [client-group]}) amount-gte (merge-query {:query {:in ['?amount-gte] :where ['[?e :transaction/amount ?ta] '[(>= ?ta ?amount-gte)]]} :args [amount-gte]}) amount-lte (merge-query {:query {:in ['?amount-lte] :where ['[?e :transaction/amount ?ta] '[(<= ?ta ?amount-lte)]]} :args [amount-lte]}) dom-lte (merge-query {:query {:in ['?dom-lte] :where ['[?e :transaction/date ?transaction-date] '[(iol-ion.query/dom ?transaction-date) ?dom] '[(<= ?dom ?dom-lte)]]} :args [dom-lte]}) dom-gte (merge-query {:query {:in ['?dom-gte] :where ['[?e :transaction/date ?transaction-date] '[(iol-ion.query/dom ?transaction-date) ?dom] '[(>= ?dom ?dom-gte)]]} :args [dom-gte]}) true (merge-query {:query {:where ['[?e :transaction/id]]}})) results (->> (query2 query) (map first))] results)) (defn transaction-rule-test-table* [{:keys [entity clients checkboxes? only-uncoded?]}] (let [results (transactions-matching-rule {:entity entity :clients clients :only-uncoded? only-uncoded?})] [:div#transaction-test-results [:h2.my-4.text-lg.flex {:x-data (hx/json {:resultCount (count results)})} "Matching transactions" [:div.ml-4.relative (com/badge {:class "text-[0.6rem]"} (let [cnt (count results)] (if (>= cnt 99) "99+" cnt)))] [:div.flex.justify-end.flex-1 [:div.gutter]]] (com/data-grid {:headers [(when checkboxes? (com/data-grid-checkbox-header {:name "all"})) (com/data-grid-header {} "Client") (com/data-grid-header {} "Bank") (com/data-grid-header {} "Date") (com/data-grid-header {} "Description")]} (for [r (take 15 results)] (com/data-grid-row {} (when checkboxes? (com/data-grid-cell {} (com/checkbox {:name "transaction-id" :value (:db/id r)}))) (com/data-grid-cell {} (-> r :transaction/client :client/name)) (com/data-grid-cell {} (-> r :transaction/bank-account :bank-account/name)) (com/data-grid-cell {} (some-> r :transaction/date (atime/unparse-local atime/normal-date))) (com/data-grid-cell {} (some-> r :transaction/description-original)))))])) (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 :class "w-full"})) (defn- account-typeahead* [{:keys [name value client-id x-model]}] [: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 :x-model x-model :value value :content-fn (fn [value] (let [a (dc/pull (dc/db conn) d-accounts/default-read value)] (when value (str (:account/numeric-code a) " - " (:account/name (d-accounts/clientize a client-id))))))})]) (defn- transaction-rule-account-row* [account client-id client-locations] (com/data-grid-row (-> {:x-data (hx/json {:accountId (or (:db/id (fc/field-value (:transaction-rule-account/account account))) (fc/field-value (:transaction-rule-account/account account))) :location (fc/field-value (:transaction-rule-account/location account)) :show (boolean (not (fc/field-value (:new? account))))}) :data-key "show" :x-ref "p"} hx/alpine-mount-then-appear) (let [account-name (fc/field-name (:transaction-rule-account/account account))] (list (fc/with-field :db/id (com/hidden {:name (fc/field-name) :value (fc/field-value)})) (fc/with-field :transaction-rule-account/account (com/data-grid-cell {} (com/validated-field {:errors (fc/field-errors)} [:div {:hx-trigger "changed" :hx-target "next div" :hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId || '', value: event.detail.accountId || ''}" account-name) :hx-get (str (bidi/path-for ssr-routes/only-routes ::route/account-typeahead)) :x-init "$watch('clientId', cid => $dispatch('changed', $data));"}] (account-typeahead* {:value (fc/field-value) :client-id client-id :name (fc/field-name) :x-model "accountId"})))) (fc/with-field :transaction-rule-account/location (com/data-grid-cell {} (com/validated-field {:errors (fc/field-errors) :x-data (hx/json {:location (fc/field-value)})} ;; TODO make this thing into a component [:div {:hx-trigger "changed" :hx-target "next *" :hx-swap "outerHTML" :hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId || '', 'account-id': event.detail.accountId || '', value: event.detail.location || ''}" (fc/field-name)) :hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select) :x-init "$watch('clientId', cid => $dispatch('changed', $data)); $watch('accountId', cid => $dispatch('changed', $data) )"}] (location-select* {:name (fc/field-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 :x-model "location" :value (fc/field-value)})))) (fc/with-field :transaction-rule-account/percentage (com/data-grid-cell {} (com/validated-field {:errors (fc/field-errors)} (com/money-input {:name (fc/field-name) :class "w-16" :value (some-> (fc/field-value) (* 100) (long))})))))) (com/data-grid-cell {:class "align-top"} (com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x)))) (defn all-ids-not-locked [all-ids] (->> all-ids (dc/q '[:find ?t :in $ [?t ...] :where [?t :transaction/client ?c] [(get-else $ ?c :client/locked-until #inst "2000-01-01") ?lu] [?t :transaction/date ?d] [(>= ?d ?lu)]] (dc/db conn)) (map first))) (defn execute [{:keys [form-params clients entity identity]}] (let [all-results (->> (transactions-matching-rule {:entity entity :clients clients :only-uncoded? true}) (map :db/id) (into #{})) ids (if (not-empty (:all form-params)) all-results (set/intersection (into #{} (:transaction-id form-params)) all-results)) ids (all-ids-not-locked ids) transactions (transduce (comp (map d-transactions/get-by-id) (map #(update % :transaction/date coerce/to-date))) conj [] ids) entity (update entity :transaction-rule/description #(some-> % iol-ion.query/->pattern)) ;; TODO #_#_x (doseq [transaction transactions] (when (not (rm/rule-applies? transaction entity)) (throw (ex-info "Transaction rule does not apply" {:validation-error "Transaction rule does not apply" :transaction-rule entity :transaction transaction}))) (when (:transaction/payment transaction) (throw (ex-info "Transaction already associated with a payment" {:validation-error "Transaction already associated with a payment"}))))] (audit-transact (mapv (fn [t] [:upsert-transaction (remove-nils (rm/apply-rule {:db/id (:db/id t) :transaction/amount (:transaction/amount t)} entity (or (-> t :transaction/bank-account :bank-account/locations) (-> t :transaction/client :client/locations))))]) transactions) identity) (doseq [n transactions] (solr/touch-with-ledger (:db/id n))) (html-response [:div] :headers {"hx-trigger" (hx/json {:modalclose "" :notification (format "Successfully coded %d of %d transactions!" (count ids) (count all-results))})}))) (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}] (html-response (account-typeahead* {:name name :value value :client-id client-id :x-model "accountId"}))) (def form-schema (mc/schema [:map [:db/id {:optional true} [:maybe entity-id]] [:transaction-rule/client {:optional true} [:maybe entity-id]] [:transaction-rule/client-group {:optional true} [:maybe :string]] [: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 {:min 1 :error/message "required"}]] [:transaction-rule-account/percentage percentage])]])) (defn check-badges [{query-params :query-params}] (html-response [:div (if (not-empty (:all query-params)) (com/pill {:color :secondary} [:span "All " [:span {:x-text "resultCount" :x-data "{}"}] " transactions"]) (com/pill {:color :primary} (str (count (:transaction-id query-params)) " transactions")))])) (defn execute-dialog [{:keys [entity clients]}] (modal-response (com/modal {} (com/modal-card-advanced {} (com/modal-header {} [:div.p-2.flex.space-x-4 [:div "Transaction Rule"] [:div ">"] [:div "Results"]]) (com/modal-body {} [:form#my-form {:hx-post (bidi/path-for ssr-routes/only-routes ::route/execute :db/id (:db/id entity)) :hx-indicator "#code"} [:div {:hx-get (bidi/path-for ssr-routes/only-routes ::route/check-badges) :hx-trigger "change" :hx-target "#transaction-test-results .gutter" :hx-include "this"} (transaction-rule-test-table* {:entity entity :clients clients :checkboxes? true :only-uncoded? true})]]) (com/modal-footer {} [:div.flex.justify-end (com/validated-save-button {:form "my-form" :id "code"} "Code transactions")]))) :headers (-> {} (assoc "hx-trigger-after-settle" "modalnext") (assoc "hx-retarget" ".modal-stack") (assoc "hx-reswap" "beforeend")))) (defn delete [{:keys [entity] :as request}] @(dc/transact conn [[:db/retractEntity (:db/id entity)]]) (html-response (row* (:identity request) entity {:delete-after-settle? true :class "live-removed"}) :headers {"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id entity))})) (defrecord EditModal [linear-wizard] mm/ModalWizardStep (step-name [_] "Edit") (step-key [_] :edit) (edit-path [_ _] []) (step-schema [_] (mm/form-schema linear-wizard)) (render-step [this request] (mm/default-render-step linear-wizard this :head "Transaction rule" :body (mm/default-step-body {} [:form#my-form {:hx-ext "response-targets" :hx-target-400 "#form-errors .error-content" :hx-indicator "#submit" :x-trap "true" (if (:db/id (fc/field-value)) :hx-put :hx-post) (str (bidi/path-for ssr-routes/only-routes ::route/save))} [:fieldset {:class "hx-disable" :x-data (hx/json {:clientId (or (:db/id (:transaction-rule/client (fc/field-value))) (:transaction-rule/client (fc/field-value)))})} [:div.space-y-1 (when-let [id (:db/id (fc/field-value))] (com/hidden {:name "db/id" :value id})) (fc/with-field :transaction-rule/description (com/validated-field {:label "Description" :errors (fc/field-errors)} (com/text-input {:name (fc/field-name) :error? (fc/error?) :x-init "$el.focus()" :placeholder "HOME DEPOT" :class "w-96" :value (fc/field-value)}))) [:div.filters {:x-data (hx/json {:clientFilter (boolean (fc/field-value (:transaction-rule/client fc/*current*))) :clientGroupFilter (boolean (fc/field-value (:transaction-rule/client-group fc/*current*))) :bankAccountFilter (boolean (fc/field-value (:transaction-rule/bank-account fc/*current*))) :amountFilter (boolean (or (fc/field-value (:transaction-rule/amount-gte fc/*current*)) (fc/field-value (:transaction-rule/amount-lte fc/*current*)))) :domFilter (boolean (or (fc/field-value (:transaction-rule/dom-gte fc/*current*)) (fc/field-value (:transaction-rule/dom-lte fc/*current*))))})} [:div.flex.gap-2.mb-2 (com/a-button {"@click" "clientFilter=true" "x-show" "!clientFilter"} "Filter client") (com/a-button {"@click" "clientGroupFilter=true" "x-show" "!clientGroupFilter"} "Filter client group") (com/a-button {"@click" "bankAccountFilter=true" "x-show" "clientFilter && !bankAccountFilter"} "Filter bank account") (com/a-button {"@click" "amountFilter=true" "x-show" "!amountFilter"} "Filter amount") (com/a-button {"@click" "domFilter=true" "x-show" "!domFilter"} "Filter day of month")] (fc/with-field :transaction-rule/client (com/validated-field (-> {:label "Client" :errors (fc/field-errors) :x-show "clientFilter"} (hx/alpine-appear)) [:div.w-96 (com/typeahead {:name (fc/field-name) :error? (fc/error?) :class "w-96" :placeholder "Search..." :url (bidi/path-for ssr-routes/only-routes :company-search) :x-model "clientId" :value (fc/field-value) :content-fn (fn [c] (pull-attr (dc/db conn) :client/name c))})])) (fc/with-field :transaction-rule/client-group (com/validated-field (-> {:label "Client Group" :errors (fc/field-errors) :x-show "clientGroupFilter"} (hx/alpine-appear)) [:div.w-96 (com/text-input {:name (fc/field-name) :error? (fc/error?) :class "w-24" :placeholder "NTG" :value (fc/field-value)})])) (fc/with-field :transaction-rule/bank-account (com/validated-field (-> {:label "Bank Account" :errors (fc/field-errors) :x-show "bankAccountFilter"} hx/alpine-appear) [:div.w-96 [:div#bank-account-changer {:hx-get (bidi/path-for ssr-routes/only-routes :bank-account-typeahead) :hx-trigger "changed" :hx-target "next *" :hx-include "#bank-account-changer" :hx-swap "innerHTML" :hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId}" (fc/field-name)) :x-init "$watch('clientId', cid => $dispatch('changed', $data))"}] (bank-account-typeahead* {:client-id (:transaction-rule/client (fc/field-value)) :name (fc/field-name) :value (fc/field-value)})])) (com/field (-> {:label "Amount" :x-show "amountFilter"} hx/alpine-appear) [:div.flex.gap-2 (fc/with-field :transaction-rule/amount-gte [:div.flex.flex-col (com/money-input {:name (fc/field-name) :placeholder ">=" :class "w-24" :value (fc/field-value)}) (com/errors {:errors (fc/field-errors)})]) (fc/with-field :transaction-rule/amount-lte [:div.flex.flex-col (com/money-input {:name (fc/field-name) :placeholder "<=" :class "w-24" :value (fc/field-value)}) (com/errors {:errors (fc/field-errors)})])]) (com/field (-> {:label "Day of month" :x-show "domFilter"} hx/alpine-appear) [:div.flex.gap-2 (fc/with-field :transaction-rule/dom-gte (com/validated-field {:errors (fc/field-errors)} (com/int-input {:name (fc/field-name) :placeholder ">=" :class "w-24" :value (fc/field-value)}))) (fc/with-field :transaction-rule/dom-lte (com/validated-field {:errors (fc/field-errors)} (com/int-input {:name (fc/field-name) :placeholder ">=" :class "w-24" :value (fc/field-value)})))])] [:h2.text-lg "Outcomes"] (fc/with-field :transaction-rule/vendor (com/validated-field {:label "Assign Vendor" :errors (fc/field-errors)} [:div.w-96 (com/typeahead {:name (fc/field-name) :placeholder "Search..." :url (bidi/path-for ssr-routes/only-routes :vendor-search) :class "w-96" :value (fc/field-value) :content-fn #(pull-attr (dc/db conn) :vendor/name %)})])) (fc/with-field :transaction-rule/accounts (com/validated-field {:errors (fc/field-errors)} (let [client-locations (some->> (fc/field-value) :transaction-rule/client (pull-attr (dc/db conn) :client/locations))] (com/data-grid {:headers [(com/data-grid-header {} "Account") (com/data-grid-header {:class "w-32"} "Location") (com/data-grid-header {:class "w-16"} "%") (com/data-grid-header {:class "w-16"})]} (fc/cursor-map #(transaction-rule-account-row* % (:transaction-rule/client (fc/field-value)) client-locations)) (com/data-grid-new-row {:colspan 4 :hx-get (bidi/path-for ssr-routes/only-routes ::route/new-account) :index (count (fc/field-value)) :tr-params (hx/bind-alpine-vals {} {"client-id" "clientId"})} "New account"))))) (fc/with-field :transaction-rule/transaction-approval-status (com/validated-field {:label "Approval status" :errors (fc/field-errors)} (com/radio-card {:options (ref->radio-options "transaction-approval-status") :value (fc/field-value) :name (fc/field-name) :size :small :orientation :horizontal})))]]]) :footer (mm/default-step-footer linear-wizard this :validation-route ::route/navigate) :validation-route ::route/navigate))) (defrecord TestModal [linear-wizard] mm/ModalWizardStep (step-name [_] "Test") (step-key [_] :test) (edit-path [_ _] []) (step-schema [_] (mut/select-keys (mm/form-schema linear-wizard) #{})) (render-step [this request] (mm/default-render-step linear-wizard this :head [:div.p-2.flex.space-x-4 [:div "Transaction Rule"] [:div ">"] [:div "Results"]] :body [:div.space-y-1 {:class "w-[850px] h-[600px]"} (transaction-rule-test-table* {:entity (:snapshot (:multi-form-state request)) :clients (:clients request)})] :footer (mm/default-step-footer linear-wizard this :validation-route ::route/navigate) :validation-route ::route/navigate))) (defrecord TransactionRuleWizard [transaction-rule current-step entity] mm/LinearModalWizard (hydrate-from-request [this request] this #_(assoc this :entity (:entity request))) (navigate [this step-key] (assoc this :current-step step-key)) (get-current-step [this] (if current-step (mm/get-step this current-step) (mm/get-step this :edit))) (render-wizard [this {:keys [multi-form-state] :as request}] (mm/default-render-wizard this request :form-params (-> mm/default-form-props (assoc (if (get-in multi-form-state [:snapshot :db/id]) :hx-put :hx-post) (str (bidi/path-for ssr-routes/only-routes ::route/save)))))) (steps [_] [:edit :test]) (get-step [this step-key] (let [step-key-result (mc/parse mm/step-key-schema step-key) [step-key-type step-key] step-key-result] (if (= :step step-key-type) (get {:edit (->EditModal this) :test (->TestModal this)} step-key) nil))) (form-schema [_] form-schema) (submit [_ {:keys [multi-form-state request-method identity] :as request}] (let [transaction-rule (:snapshot multi-form-state) _ (validate-transaction-rule transaction-rule) entity (cond-> transaction-rule (:transaction-rule/client-group transaction-rule) (update :transaction-rule/client-group str/upper-case) (= :post request-method) (assoc :db/id "new") true (assoc :transaction-rule/note (entity->note transaction-rule))) {:keys [tempids]} (audit-transact [[:upsert-entity entity]] (:identity request)) updated-rule (dc/pull (dc/db conn) default-read (or (get tempids (:db/id entity)) (:db/id entity)))] (html-response (row* identity updated-rule {:flash? true}) :headers (cond-> {"hx-trigger" "modalclose"} (= :post request-method) (assoc "hx-retarget" "#entity-table tbody" "hx-reswap" "afterbegin") (= :put request-method) (assoc "hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id updated-rule)) "hx-reswap" "outerHTML")))))) (def rule-wizard (->TransactionRuleWizard nil nil nil)) (def key->handler (apply-middleware-to-all-handlers (->> {::route/page (helper/page-route grid-page) ::route/table (helper/table-route grid-page) ::route/delete (-> delete (wrap-entity [:route-params :db/id] default-read) (wrap-schema-enforce :route-params [:map [:db/id entity-id]])) ::route/new-account (-> (add-new-entity-handler [:step-params :transaction-rule/accounts] (fn render [cursor request] (transaction-rule-account-row* cursor (:client-id (:query-params request)) (some->> (:client-id (:query-params request)) (pull-attr (dc/db conn) :client/locations)))) (fn build-new-row [base _] (assoc base :transaction-rule-account/location "Shared"))) (wrap-schema-enforce :query-schema [:map [:client-id {:optional true} [:maybe entity-id]]])) ::route/location-select (-> location-select (wrap-schema-enforce :query-schema [:map [:name :string] [:client-id {:optional true} [:maybe entity-id]] [:account-id {:optional true} [:maybe entity-id]]])) ::route/account-typeahead (-> account-typeahead (wrap-schema-enforce :query-schema [:map [:name :string] [:client-id {:optional true} [:maybe entity-id]] [:value {:optional true} [:maybe entity-id]]])) ::route/save (-> mm/submit-handler (mm/wrap-wizard rule-wizard) (mm/wrap-decode-multi-form-state) (wrap-entity [:form-params :db/id] default-read)) ::route/execute (-> execute (wrap-entity [:route-params :db/id] default-read) (wrap-schema-enforce :route-schema [:map [:db/id entity-id]]) (wrap-schema-enforce :form-schema [:map [:transaction-id {:optional true} [:maybe [:vector {:decode/arbitrary (fn [x] ;; TODO make this easier (if (sequential? x) x [x]))} entity-id]]] [:all {:optional true} [:maybe :string]]]) #_(wrap-form-4xx-2 (-> edit-dialog ;; TODO for example not having a single one checked (wrap-entity [:form-params :db/id] default-read)))) ::route/check-badges (-> check-badges (wrap-schema-enforce :query-schema [:map [:transaction-id {:optional true} [:maybe [:vector {:decode/arbitrary (fn [x] (if (sequential? x) x [x]))} entity-id]]] [:all {:optional true} [:maybe :string]]])) ::route/execute-dialog (-> execute-dialog (wrap-entity [:route-params :db/id] default-read) (wrap-schema-enforce :route-schema [:map [:db/id entity-id]])) ::route/navigate (-> mm/next-handler (mm/wrap-wizard rule-wizard) (mm/wrap-decode-multi-form-state)) ::route/edit-dialog (-> mm/open-wizard-handler (mm/wrap-wizard rule-wizard) (mm/wrap-init-multi-form-state (fn [request] (mm/->MultiStepFormState (:entity request) [] (:entity request)))) (wrap-entity [:route-params :db/id] default-read) (wrap-schema-enforce :route-schema [:map [:db/id entity-id]])) ::route/new-dialog (-> mm/open-wizard-handler (mm/wrap-wizard rule-wizard) (mm/wrap-init-multi-form-state (fn [_] (mm/->MultiStepFormState {} [] {}))))}) (fn [h] (-> h (wrap-admin) (wrap-client-redirect-unauthenticated)))))