Files
integreat/src/clj/auto_ap/ssr/admin/transaction_rules.clj
Bryce d0028f403c fix(ssr): allow picking bank account when editing a transaction rule
The bank-account filter rendered "Please select a client" even when a
client was set on the rule. Two causes:

- Inside (fc/with-field :transaction-rule/bank-account ...) the cursor is
  rebound to the bank-account field, so (:transaction-rule/client
  (fc/field-value)) read the nil bank-account value and the server
  rendered the placeholder. The clientId watcher only fires on change, so
  when editing (client preset, unchanged) the htmx swap never corrected
  it. Read the client from the form root before entering the field.
- The clientId-change swap used innerHTML, nesting a fresh typeahead
  inside the stale one and breaking its Alpine refs. Use outerHTML so the
  typeahead is replaced in place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:32:30 -07:00

1001 lines
59 KiB
Clojure

(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.query-params :as query-params :refer [wrap-copy-qp-pqp]]
[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 :refer [wrap-apply-sort]]
[auto-ap.ssr.hx :as hx]
[auto-ap.ssr.svg :as svg]
[auto-ap.ssr.utils
:refer [apply-middleware-to-all-handlers
default-grid-fields-schema 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-merge-prior-hx 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]))
(def query-schema (mc/schema
[:maybe
(into [:map {}
[:vendor {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :vendor/name]}]]]]
default-grid-fields-schema)]))
(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 (: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 (: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 (: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 (: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 (: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
: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"
:query-schema query-schema
: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 {}
[:div#my-form {:x-trap "true"}
[: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)})]))
(let [rule-client (fc/field-value (:transaction-rule/client fc/*current*))]
(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 "outerHTML"
: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 (or (:db/id rule-client) rule-client)
: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-copy-qp-pqp)
(wrap-apply-sort grid-page)
(wrap-merge-prior-hx)
(wrap-schema-enforce :query-schema query-schema)
(wrap-schema-enforce :hx-schema query-schema)
(wrap-admin)
(wrap-client-redirect-unauthenticated)))))