Add vendor pre-population for bulk code and individual edit forms

- Add vendor-changed HTMX handlers for both bulk code and individual edit
- Pre-populate default account at 100% when vendor is selected and no accounts exist
- Fix render-accounts-section to render from step-params correctly
- Change bulk code vendor-changed from hx-get to hx-post to include form data
- Add routes for vendor-changed endpoints
- Update e2e tests to cover vendor pre-population
- Run lein cljfmt fix across codebase
This commit is contained in:
2026-05-21 14:45:19 -07:00
parent 8bd0cee1b1
commit ba87805d4c
210 changed files with 8694 additions and 9627 deletions

View File

@@ -1,7 +1,7 @@
(ns auto-ap.ssr.transaction.bulk-code
(:require
[auto-ap.datomic
:refer [audit-transact-batch conn pull-attr pull-many]]
:refer [audit-transact-batch conn pull-attr pull-many]]
[auto-ap.logging :as alog]
[auto-ap.permissions :refer [wrap-must]]
[auto-ap.query-params :refer [wrap-copy-qp-pqp]]
@@ -23,9 +23,9 @@
[auto-ap.ssr.transaction.edit :as edit :refer [account-typeahead*
location-select*]]
[auto-ap.ssr.utils
:refer [apply-middleware-to-all-handlers entity-id
form-validation-error html-response percentage
ref->enum-schema wrap-merge-prior-hx wrap-schema-enforce]]
:refer [apply-middleware-to-all-handlers entity-id
form-validation-error html-response percentage
ref->enum-schema wrap-merge-prior-hx wrap-schema-enforce]]
[bidi.bidi :as bidi]
[datomic.api :as dc]
[iol-ion.query :refer [dollars=]]
@@ -34,52 +34,52 @@
(defn transaction-account-row* [{:keys [value client-id]}]
(com/data-grid-row
(-> {:x-data (hx/json {:show (boolean (not (fc/field-value (:new? value))))
:accountId (fc/field-value (:account value))})
(-> {:x-data (hx/json {:show (boolean (not (fc/field-value (:new? value))))
:accountId (fc/field-value (:account value))})
:data-key "show"
:x-ref "p"}
:x-ref "p"}
hx/alpine-mount-then-appear)
(fc/with-field :db/id
(com/hidden {:name (fc/field-name)
(com/hidden {:name (fc/field-name)
:value (fc/field-value)}))
(fc/with-field :account
(com/data-grid-cell
{}
(com/validated-field
{:errors (fc/field-errors)}
(account-typeahead* {:value (fc/field-value)
(account-typeahead* {:value (fc/field-value)
:client-id client-id
:name (fc/field-name)
:x-model "accountId"}))))
:name (fc/field-name)
:x-model "accountId"}))))
(fc/with-field :location
(com/data-grid-cell
{}
(com/validated-field
{:errors (fc/field-errors)
:x-hx-val:account-id "accountId"
:hx-vals (hx/json (cond-> {:name (fc/field-name) }
:hx-vals (hx/json (cond-> {:name (fc/field-name)}
client-id (assoc :client-id client-id)))
:x-dispatch:changed "accountId"
:hx-trigger "changed"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select)
:hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select)
:hx-target "find *"
:hx-swap "outerHTML"}
(location-select* {:name (fc/field-name)
:account-location (:account/location (cond->> (:account @value)
(nat-int? (:account @value)) (dc/pull (dc/db conn)
'[:account/location])))
(location-select* {:name (fc/field-name)
:account-location (let [account-id (:account @value)]
(when (nat-int? account-id)
(:account/location (dc/pull (dc/db conn) '[:account/location] account-id))))
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
:value (fc/field-value)}))))
:value (fc/field-value)}))))
(fc/with-field :percentage
(com/data-grid-cell
{}
(com/validated-field
{:errors (fc/field-errors)}
(com/money-input {:name (fc/field-name)
(com/money-input {:name (fc/field-name)
:class "w-16"
:value (some-> (fc/field-value)
(* 100)
(long))}))))
(* 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))))
@@ -90,64 +90,58 @@
{:search-params (:query-params request)
:accounts []}))
(defn all-ids-not-locked
"Filters transaction IDs to only those that aren't locked (client locked date earlier than transaction date)"
[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))
: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)))
(def bulk-code-schema
(def bulk-code-schema
(mc/schema [:map
[:vendor {:optional true} [:maybe entity-id]]
[:approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")] ]
[:approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]]
[:accounts {:optional true}
[:maybe
[:maybe
[:vector {:coerce? true}
[:map [:account entity-id]
[:location [:string {:min 1 :error/message "required"}]]
[:percentage percentage]]]] ]]))
[:percentage percentage]]]]]]))
(defn maybe-code-accounts [transaction account-rules valid-locations]
(with-precision 2
(let [accounts (vec (mapcat
(fn [ar]
(let [cents-to-distribute (int (Math/round (Math/abs (* (:percentage ar)
(:transaction/amount transaction)
100))))]
(if (= "Shared" (:location ar))
(->> valid-locations
(map
(fn [cents location]
{:db/id (random-tempid)
:transaction-account/account (:account ar)
:transaction-account/amount (* 0.01 cents)
:transaction-account/location location})
(rm/spread-cents cents-to-distribute (count valid-locations))))
[(cond-> {:db/id (random-tempid)
:transaction-account/account (:account ar)
:transaction-account/amount (* 0.01 cents-to-distribute)}
(:location ar) (assoc :transaction-account/location (:location ar)))])))
account-rules))
(fn [ar]
(let [cents-to-distribute (int (Math/round (Math/abs (* (:percentage ar)
(:transaction/amount transaction)
100))))]
(if (= "Shared" (:location ar))
(->> valid-locations
(map
(fn [cents location]
{:db/id (random-tempid)
:transaction-account/account (:account ar)
:transaction-account/amount (* 0.01 cents)
:transaction-account/location location})
(rm/spread-cents cents-to-distribute (count valid-locations))))
[(cond-> {:db/id (random-tempid)
:transaction-account/account (:account ar)
:transaction-account/amount (* 0.01 cents-to-distribute)}
(:location ar) (assoc :transaction-account/location (:location ar)))])))
account-rules))
accounts (mapv
(fn [a]
(update a :transaction-account/amount
#(with-precision 2
(double (.setScale (bigdec %) 2 java.math.RoundingMode/HALF_UP)))))
accounts)
(fn [a]
(update a :transaction-account/amount
#(with-precision 2
(double (.setScale (bigdec %) 2 java.math.RoundingMode/HALF_UP)))))
accounts)
leftover (with-precision 2 (.round (bigdec (- (Math/abs (:transaction/amount transaction))
(Math/abs (reduce + 0.0 (map #(:transaction-account/amount %) accounts)))))
*math-context*))
@@ -167,74 +161,76 @@
[])
(step-schema [_]
(mm/form-schema linear-wizard))
(mm/form-schema linear-wizard))
(render-step [this {{:keys [snapshot] :as multi-form-state} :multi-form-state :as request}]
(let [_ (alog/peek ::SEARCH_PARAMS (:search-params snapshot))
selected-ids (selected->ids (assoc request :query-params (:search-params snapshot)) (:search-params snapshot))
all-ids (all-ids-not-locked selected-ids)]
(mm/default-render-step
linear-wizard this
:head [:div.p-2 "Bulk editing " (count all-ids) " transactions"]
:body (mm/default-step-body
{}
[:div
#_(com/hidden {:name "ids" :value (pr-str ids)})
[:div.space-y-4.p-4
[:div.grid.grid-cols-2.gap-4
;; Vendor field
[:div
(fc/with-field :vendor
(com/validated-field {:label "Vendor"
:errors (fc/field-errors)}
(com/typeahead {:name (fc/field-name)
:placeholder "Search for vendor..."
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})))]
;; Status field
[:div
(fc/with-field :approval-status
(com/validated-field {:label "Status"
:errors (fc/field-errors)}
(com/select {:name (fc/field-name)
:options [["" "No Change"]
["approved" "Approved"]
["unapproved" "Unapproved"]
["suppressed" "Suppressed"]
["requires_feedback" "Requires Feedback"]]})))]
;; Accounts section
[:div.col-span-2.pt-4
[:h3.text-lg.font-medium.mb-3 "Expense Accounts"]
[:div#account-entries.space-y-3
(fc/with-field :accounts
(com/validated-field
{:errors (fc/field-errors)}
(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-account-row* {:value %}))
(com/data-grid-new-row {:colspan 4
:hx-get (bidi/path-for ssr-routes/only-routes
::route/bulk-code-new-account)
:row-offset 0
:index (count (fc/field-value))}
"New account")
)))
;; Button to add more accounts
]]]]])
:footer
(mm/default-step-footer linear-wizard this :validation-route ::route/new-wizard-navigate
:next-button (com/button {:color :primary :x-ref "next" :class "w-32"} "Save"))
:validation-route ::route/new-wizard-navigate))))
(let [_ (alog/peek ::SEARCH_PARAMS (:search-params snapshot))
selected-ids (selected->ids (assoc request :query-params (:search-params snapshot)) (:search-params snapshot))
all-ids (all-ids-not-locked selected-ids)]
(mm/default-render-step
linear-wizard this
:head [:div.p-2 "Bulk editing " (count all-ids) " transactions"]
:body (mm/default-step-body
{}
[:div
#_(com/hidden {:name "ids" :value (pr-str ids)})
[:div.space-y-4.p-4
[:div.grid.grid-cols-2.gap-4
;; Vendor field
[:div {:hx-trigger "change"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-code-vendor-changed)
:hx-target "#account-entries"
:hx-swap "innerHTML"
:hx-include "closest form"}
(fc/with-field :vendor
(com/validated-field {:label "Vendor"
:errors (fc/field-errors)}
(com/typeahead {:name (fc/field-name)
:placeholder "Search for vendor..."
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})))]
;; Status field
[:div
(fc/with-field :approval-status
(com/validated-field {:label "Status"
:errors (fc/field-errors)}
(com/select {:name (fc/field-name)
:options [["" "No Change"]
["approved" "Approved"]
["unapproved" "Unapproved"]
["suppressed" "Suppressed"]
["requires_feedback" "Requires Feedback"]]})))]
;; Accounts section
[:div.col-span-2.pt-4
[:h3.text-lg.font-medium.mb-3 "Expense Accounts"]
[:div#account-entries.space-y-3
(fc/with-field :accounts
(com/validated-field
{:errors (fc/field-errors)}
(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-account-row* {:value %}))
(com/data-grid-new-row {:colspan 4
:hx-get (bidi/path-for ssr-routes/only-routes
::route/bulk-code-new-account)
:row-offset 0
:index (count (fc/field-value))}
"New account"))))]]]]])
;; Button to add more accounts
:footer
(mm/default-step-footer linear-wizard this :validation-route ::route/new-wizard-navigate
:next-button (com/button {:color :primary :x-ref "next" :class "w-32"} "Save"))
:validation-route ::route/new-wizard-navigate))))
(defn assert-percentages-add-up [{:keys [accounts]}]
(let [account-total (reduce + 0 (map (fn [x] (:percentage x)) accounts))]
@@ -263,78 +259,128 @@
(steps [_]
[:accounts])
(get-step [this step-key]
(let [step-key-result (mc/parse mm/step-key-schema step-key)
(let [step-key-result (mc/parse mm/step-key-schema step-key)
[step-key-type step-key] step-key-result]
(get {:accounts (->AccountsStep this)}
step-key)))
(form-schema [_]
bulk-code-schema)
(submit [this {:keys [multi-form-state request-method identity] :as request}]
(let [ ids (selected->ids (assoc request :query-params (:search-params (:snapshot multi-form-state))) (:search-params (:snapshot multi-form-state)))
all-ids (all-ids-not-locked ids)
vendor (-> request :multi-form-state :snapshot :vendor)
approval-status (-> request :multi-form-state :snapshot :approval-status)
accounts (-> request :multi-form-state :snapshot :accounts) ]
(when (seq accounts)
(assert-percentages-add-up (:snapshot multi-form-state)))
(alog/peek ::ACCOUNTS (-> request :multi-form-state :snapshot))
(let [ids (selected->ids (assoc request :query-params (:search-params (:snapshot multi-form-state))) (:search-params (:snapshot multi-form-state)))
all-ids (all-ids-not-locked ids)
vendor (-> request :multi-form-state :snapshot :vendor)
approval-status (-> request :multi-form-state :snapshot :approval-status)
accounts (-> request :multi-form-state :snapshot :accounts)]
(when (seq accounts)
(assert-percentages-add-up (:snapshot multi-form-state)))
(alog/peek ::ACCOUNTS (-> request :multi-form-state :snapshot))
;; Get transactions and filter for locked ones
(let [db (dc/db conn)
transactions (pull-many db [:db/id :transaction/amount {:transaction/client [:db/id]}] (vec all-ids))
(let [db (dc/db conn)
transactions (pull-many db [:db/id :transaction/amount {:transaction/client [:db/id]}] (vec all-ids))
;; Get client locations
client->locations (->> (map (comp :db/id :transaction/client) transactions)
(distinct)
(dc/q '[:find (pull ?e [:db/id :client/locations])
:in $ [?e ...]]
db)
(map (fn [[client]]
[(:db/id client) (:client/locations client)]))
(into {}))]
client->locations (->> (map (comp :db/id :transaction/client) transactions)
(distinct)
(dc/q '[:find (pull ?e [:db/id :client/locations])
:in $ [?e ...]]
db)
(map (fn [[client]]
[(:db/id client) (:client/locations client)]))
(into {}))]
;; Validate account locations
(doseq [a accounts
:let [{:keys [:account/location :account/name]} (dc/pull db
[:account/location :account/name]
(:account a))]]
(when (and location (not= location (:location a)))
(form-validation-error (str "Account " name " uses location " (:location a) ", but is supposed to be " location)))
(doseq [[_ locations] client->locations]
(when (and (not location)
(not (get (into #{"Shared"} locations)
(:location a))))
(form-validation-error (str "Account " name " uses location " (:location a) ", but doesn't belong to the client.")))))
(doseq [a accounts
:let [{:keys [:account/location :account/name]} (dc/pull db
[:account/location :account/name]
(:account a))]]
(when (and location (not= location (:location a)))
(form-validation-error (str "Account " name " uses location " (:location a) ", but is supposed to be " location)))
(doseq [[_ locations] client->locations]
(when (and (not location)
(not (get (into #{"Shared"} locations)
(:location a))))
(form-validation-error (str "Account " name " uses location " (:location a) ", but doesn't belong to the client.")))))
(audit-transact-batch
(map (fn [t]
(let [locations (client->locations (-> t :transaction/client :db/id))]
[:upsert-transaction (cond-> t
approval-status
(assoc :transaction/approval-status approval-status)
(audit-transact-batch
(map (fn [t]
(let [locations (client->locations (-> t :transaction/client :db/id))]
[:upsert-transaction (cond-> t
approval-status
(assoc :transaction/approval-status approval-status)
vendor
(assoc :transaction/vendor vendor)
vendor
(assoc :transaction/vendor vendor)
(seq accounts)
(assoc :transaction/accounts
(maybe-code-accounts t accounts locations)))]))
transactions)
(:identity request))
(seq accounts)
(assoc :transaction/accounts
(maybe-code-accounts t accounts locations)))]))
transactions)
(:identity request))
;; Return success modal
(html-response
(com/success-modal {:title "Transactions Coded"}
[:p (str "Successfully coded " (count all-ids) " transactions.")])
:headers {"hx-trigger" "refreshTable"})))))
(html-response
(com/success-modal {:title "Transactions Coded"}
[:p (str "Successfully coded " (count all-ids) " transactions.")])
:headers {"hx-trigger" "refreshTable"})))))
(defn- get-client-id [request]
(-> request :clients first :db/id))
(defn- vendor-default-account [vendor-id client-id]
(when vendor-id
(let [vendor (edit/get-vendor vendor-id)
clientized (edit/clientize-vendor vendor client-id)]
(:vendor/default-account clientized))))
(defn- build-default-account-row [account]
{:db/id (str (java.util.UUID/randomUUID))
:account (:db/id account)
:location (or (:account/location account) "Shared")
:percentage 1.0})
(defn- render-accounts-section [request]
(let [step-params (:step-params (:multi-form-state request))]
(html-response
[:div
(fc/start-form step-params
(when (:form-errors request) {:step-params (:form-errors request)})
(fc/with-field :accounts
(com/validated-field
{:errors (fc/field-errors)}
(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-account-row* {:value %}))
(com/data-grid-new-row {:colspan 4
:hx-get (bidi/path-for ssr-routes/only-routes
::route/bulk-code-new-account)
:row-offset 0
:index (count (fc/field-value))}
"New account")))))])))
(defn vendor-changed-handler [request]
(let [snapshot (:snapshot (:multi-form-state request))
step-params (:step-params (:multi-form-state request))
client-id (get-client-id request)
vendor-id (or (:vendor step-params) (:vendor snapshot))
updated-step-params (if (and (empty? (:accounts step-params))
vendor-id
client-id)
(if-let [default-account (vendor-default-account vendor-id client-id)]
(assoc step-params :accounts [(build-default-account-row default-account)])
step-params)
step-params)]
(render-accounts-section (assoc-in request [:multi-form-state :step-params] updated-step-params))))
(def bulk-code-wizard (->BulkCodeWizard nil nil))
(def key->handler
(apply-middleware-to-all-handlers
{::route/bulk-code (-> mm/open-wizard-handler
(mm/wrap-wizard bulk-code-wizard)
(mm/wrap-init-multi-form-state initial-bulk-edit-state))
{::route/bulk-code (-> mm/open-wizard-handler
(mm/wrap-wizard bulk-code-wizard)
(mm/wrap-init-multi-form-state initial-bulk-edit-state))
::route/bulk-code-new-account (->
(add-new-entity-handler [:step-params :accounts]
(fn render [cursor request]
@@ -345,9 +391,12 @@
(wrap-schema-enforce :query-schema [:map
[:client-id {:optional true}
[:maybe entity-id]]]))
::route/bulk-code-submit (-> mm/submit-handler
(wrap-wizard bulk-code-wizard)
(mm/wrap-decode-multi-form-state))}
::route/bulk-code-vendor-changed (-> vendor-changed-handler
(mm/wrap-wizard bulk-code-wizard)
(mm/wrap-decode-multi-form-state))
::route/bulk-code-submit (-> mm/submit-handler
(wrap-wizard bulk-code-wizard)
(mm/wrap-decode-multi-form-state))}
(fn [h]
(-> h
(wrap-copy-qp-pqp)