Add $/% toggle for transaction account amounts in manual edit

When editing a transaction manually, users can now toggle between viewing
account amounts as dollar values or percentages. The toggle appears in the
table header as a radio button group ($ / %).

Key features:
- Global toggle switches all accounts simultaneously
- $→%: amounts are converted to percentages of the transaction total
- %→$: uses percentages->dollars with spread-cents for accurate cent distribution
- Form state preserves vendor, memo, approval status when toggling
- Save handler converts % back to $ before persisting to Datomic
- Uses HTMX to re-render only the account grid body on toggle

New route: /toggle-amount-mode
New functions: ->percentage, percentages->dollars, convert-accounts-mode,
              account-grid-body*, toggle-amount-mode
This commit is contained in:
2026-05-20 23:07:11 -07:00
parent dbfa04c766
commit 567db50a66
2 changed files with 456 additions and 391 deletions

View File

@@ -80,6 +80,7 @@
[:transaction/memo {:optional true} [:maybe [:string {:decode/string strip}]]] [:transaction/memo {:optional true} [:maybe [:string {:decode/string strip}]]]
[:transaction/vendor {:optional true} [:maybe entity-id]] [:transaction/vendor {:optional true} [:maybe entity-id]]
[:transaction/approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]] [:transaction/approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]]
[:amount-mode {:optional true} [:maybe [:enum "$" "%"]]]
[:transaction/accounts {:optional true} [:transaction/accounts {:optional true}
[:maybe [:maybe
[:vector {:coerce? true} [:vector {:coerce? true}
@@ -102,17 +103,14 @@
[:unlink-payment [:map [:unlink-payment [:map
[:transaction-id entity-id]]] [:transaction-id entity-id]]]
[:link-unpaid-invoices [:map [:link-unpaid-invoices [:map
[:unpaid-invoice-ids {:decode/string (fn [x] (edn/read-string x))} [:unpaid-invoice-ids {:decode/string (fn [x] (edn/read-string x))}
[:vector {:coerce? true} entity-id]]]] [:vector {:coerce? true} entity-id]]]]
[:link-autopay-invoices [:map [:link-autopay-invoices [:map
[:autopay-invoice-ids {:decode/string (fn [x] (edn/read-string x))} [:vector {:coerce? true} entity-id]]]] [:autopay-invoice-ids {:decode/string (fn [x] (edn/read-string x))} [:vector {:coerce? true} entity-id]]]]
[:link-payment [:map [:link-payment [:map
[:payment-id entity-id]]] [:payment-id entity-id]]]
[:manual (require-approval [:map])]]])) [:manual (require-approval [:map])]]]))
(defn clientize-vendor [{:vendor/keys [terms-overrides automatically-paid-when-due default-account account-overrides] :as vendor} client-id] (defn clientize-vendor [{:vendor/keys [terms-overrides automatically-paid-when-due default-account account-overrides] :as vendor} client-id]
(if (nil? vendor) (if (nil? vendor)
nil nil
@@ -175,7 +173,7 @@
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value) (:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
client-id)))})]) client-id)))})])
(defn transaction-account-row* [{:keys [value client-id]}] (defn transaction-account-row* [{:keys [value client-id amount-mode total]}]
(com/data-grid-row (com/data-grid-row
(-> {:x-data (hx/json {:show (boolean (not (fc/field-value (:new? value)))) (-> {:x-data (hx/json {:show (boolean (not (fc/field-value (:new? value))))
:accountId (fc/field-value (:transaction-account/account value))}) :accountId (fc/field-value (:transaction-account/account value))})
@@ -218,9 +216,15 @@
{} {}
(com/validated-field (com/validated-field
{:errors (fc/field-errors)} {:errors (fc/field-errors)}
(if (= "%" amount-mode)
(com/text-input {:name (fc/field-name)
:class "w-16"
:value (fc/field-value)
:type "number"
:step "0.01"})
(com/money-input {:name (fc/field-name) (com/money-input {:name (fc/field-name)
:class "w-16" :class "w-16"
:value (fc/field-value)})))) :value (fc/field-value)})))))
(com/data-grid-cell {:class "align-top"} (com/data-grid-cell {:class "align-top"}
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x)))) (com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x))))
@@ -263,6 +267,103 @@
(defn account-balance [request] (defn account-balance [request]
(html-response (account-balance* request))) (html-response (account-balance* request)))
(defn ->percentage [amount total]
(when (and amount total (not= total 0))
(* 100.0 (/ amount total))))
(defn percentages->dollars [percentages total]
(let [total-cents (int (* 100 (Math/abs total)))
pct-sum (reduce + 0 percentages)
normalized-pcts (if (zero? pct-sum)
(repeat (count percentages) 0)
(map #(* (/ % pct-sum) 100) percentages))
individual-cents (map #(int (* total-cents (/ % 100))) normalized-pcts)
short-by (- total-cents (reduce + 0 individual-cents))
adjustments (concat (take short-by (repeat 1)) (repeat 0))
final-cents (map + individual-cents adjustments)]
(map #(* 0.01 %) final-cents)))
(defn convert-accounts-mode [accounts old-mode new-mode total]
(if (= old-mode new-mode)
accounts
(let [amounts (map :transaction-account/amount accounts)]
(map #(assoc %1 :transaction-account/amount %2)
accounts
(case [old-mode new-mode]
["$" "%"] (map #(->percentage % total) amounts)
["%" "$"] (percentages->dollars amounts total)
amounts)))))
(defn account-grid-body* [request]
(let [snapshot (-> request :multi-form-state :snapshot)
amount-mode (or (:amount-mode snapshot) "$")
total (Math/abs (:transaction/amount snapshot))]
(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/radio-card {:options [{:value "$" :content "$"}
{:value "%" :content "%"}]
:value amount-mode
:name "step-params[amount-mode]"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/toggle-amount-mode)
:hx-target "#account-grid-body"
:hx-swap "outerHTML"
:hx-include "closest form"}))
(com/data-grid-header {:class "w-16"})]}
(fc/cursor-map #(transaction-account-row* {:value %
:client-id (-> request :entity :transaction/client :db/id)
:amount-mode amount-mode
:total total}))
(com/data-grid-new-row {:colspan 4
:hx-get (bidi/path-for ssr-routes/only-routes
::route/edit-wizard-new-account)
:row-offset 0
:index (count (:transaction/accounts snapshot))
:tr-params {:hx-vals (hx/json {:client-id (:transaction/client snapshot)})}}
"New account")
(com/data-grid-row {}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"])
(com/data-grid-cell {:id "total"
:class "text-right"
:hx-trigger "change from:closest form target:.amount-field"
:hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/account-total)
:hx-target "this"
:hx-swap "innerHTML"}
(account-total* request))
(com/data-grid-cell {}))
(com/data-grid-row {}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"])
(com/data-grid-cell {:id "total"
:class "text-right"
:hx-trigger "change from:closest form target:.amount-field"
:hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/account-balance)
:hx-target "this"
:hx-swap "innerHTML"}
(account-balance* request))
(com/data-grid-cell {}))
(com/data-grid-row {}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TRANSACTION TOTAL"])
(com/data-grid-cell {:class "text-right"}
(format "$%,.2f" total))
(com/data-grid-cell {})))))
(defn toggle-amount-mode [request]
(let [snapshot (-> request :multi-form-state :snapshot)
old-mode (or (:amount-mode snapshot) "$")
new-mode (or (get-in request [:form-params :amount-mode]) "$")
total (Math/abs (:transaction/amount snapshot))
accounts (convert-accounts-mode (:transaction/accounts snapshot) old-mode new-mode total)]
(html-response
[:div#account-grid-body
(account-grid-body* (assoc-in request [:multi-form-state :snapshot :transaction/accounts] accounts
[:multi-form-state :snapshot :amount-mode] new-mode))])))
(defrecord BasicDetailsStep [linear-wizard] (defrecord BasicDetailsStep [linear-wizard]
mm/ModalWizardStep mm/ModalWizardStep
(step-name [_] (step-name [_]
@@ -330,8 +431,6 @@
[:div.text-sm.font-medium.text-gray-500 "Transaction Type"] [:div.text-sm.font-medium.text-gray-500 "Transaction Type"]
[:div.text-base (or (some-> tx :transaction/type) "-")]]]] [:div.text-base (or (some-> tx :transaction/type) "-")]]]]
;; Transaction Links Section ;; Transaction Links Section
#_[:div.mb-6.border.rounded-lg.p-4.bg-gray-50 #_[:div.mb-6.border.rounded-lg.p-4.bg-gray-50
[:h3.text-lg.font-semibold.mb-2 "Transaction Links"] [:h3.text-lg.font-semibold.mb-2 "Transaction Links"]
@@ -378,13 +477,11 @@
;; Invoices section ;; Invoices section
;; Hidden ID field ;; Hidden ID field
(fc/with-field :db/id (fc/with-field :db/id
(com/hidden {:name (fc/field-name) (com/hidden {:name (fc/field-name)
:value (fc/field-value)})) :value (fc/field-value)}))
;; Editable fields section ;; Editable fields section
;; Vendor field ;; Vendor field
) )
@@ -414,8 +511,6 @@
client-id))] client-id))]
(filter #(dollars= (Math/abs (:transaction/amount tx)) (:payment/amount %)) payments))) (filter #(dollars= (Math/abs (:transaction/amount tx)) (:payment/amount %)) payments)))
(defn get-available-autopay-invoices [request] (defn get-available-autopay-invoices [request]
(let [tx-id (or (-> request :multi-form-state :snapshot :db/id) (let [tx-id (or (-> request :multi-form-state :snapshot :db/id)
(get-in request [:route-params :db/id])) (get-in request [:route-params :db/id]))
@@ -448,8 +543,7 @@
[:span.block.text-sm.text-gray-500 (-> invoice :invoice/vendor :vendor/name)] [:span.block.text-sm.text-gray-500 (-> invoice :invoice/vendor :vendor/name)]
[:span.block.text-sm.font-medium (format "$%.2f" (:invoice/outstanding-balance invoice))]]))}) [:span.block.text-sm.font-medium (format "$%.2f" (:invoice/outstanding-balance invoice))]]))})
:name (fc/with-field :autopay-invoice-ids (fc/field-name)) :name (fc/with-field :autopay-invoice-ids (fc/field-name))
:width "w-full"})] :width "w-full"})]]
]
[:div.text-center.py-4.text-gray-500 "No matching autopay invoices available for this transaction."])])) [:div.text-center.py-4.text-gray-500 "No matching autopay invoices available for this transaction."])]))
(defn get-available-unpaid-invoices [request] (defn get-available-unpaid-invoices [request]
@@ -495,8 +589,7 @@
[:span.block.text-sm.text-gray-500 (-> invoice :invoice/vendor :vendor/name)] [:span.block.text-sm.text-gray-500 (-> invoice :invoice/vendor :vendor/name)]
[:span.block.text-sm.font-medium (format "$%.2f" (:invoice/outstanding-balance invoice))]]))}) [:span.block.text-sm.font-medium (format "$%.2f" (:invoice/outstanding-balance invoice))]]))})
:name (fc/with-field :unpaid-invoice-ids (fc/field-name)) :name (fc/with-field :unpaid-invoice-ids (fc/field-name))
:width "w-full"}) :width "w-full"})]
]
#_(com/a-button {:color :primary "@click" "$dispatch('linkUnpaidInvoices')"} "Link")]] #_(com/a-button {:color :primary "@click" "$dispatch('linkUnpaidInvoices')"} "Link")]]
[:div.text-center.py-4.text-gray-500 "No matching unpaid invoices available for this transaction."])])) [:div.text-center.py-4.text-gray-500 "No matching unpaid invoices available for this transaction."])]))
@@ -526,7 +619,6 @@
(-> rule (-> rule
(update :transaction-rule/description #(some-> % iol-ion.query/->pattern)))))))))) (update :transaction-rule/description #(some-> % iol-ion.query/->pattern))))))))))
(defn transaction-rules-view [request] (defn transaction-rules-view [request]
(let [matching-rules (get-available-rules request)] (let [matching-rules (get-available-rules request)]
[:div [:div
@@ -721,7 +813,6 @@
;; Memo field ;; Memo field
;; Approval status field ;; Approval status field
(fc/with-field :transaction/approval-status (fc/with-field :transaction/approval-status
(com/validated-field (com/validated-field
@@ -734,51 +825,8 @@
(fc/with-field :transaction/accounts (fc/with-field :transaction/accounts
(com/validated-field (com/validated-field
{:errors (fc/field-errors)} {:errors (fc/field-errors)}
(com/data-grid {:headers [(com/data-grid-header {} "Account") [:div#account-grid-body
(com/data-grid-header {:class "w-32"} "Location") (account-grid-body* request)]))]]]])
(com/data-grid-header {:class "w-16"} "$")
(com/data-grid-header {:class "w-16"})]}
(fc/cursor-map #(transaction-account-row* {:value %
:client-id (-> request :entity :transaction/client :db/id)}))
(com/data-grid-new-row {:colspan 4
:hx-get (bidi/path-for ssr-routes/only-routes
::route/edit-wizard-new-account)
:row-offset 0
:index (count (fc/field-value))
:tr-params {:hx-vals (hx/json {:client-id (:transaction/client snapshot)})}}
"New account")
(com/data-grid-row {}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"])
(com/data-grid-cell {:id "total"
:class "text-right"
:hx-trigger "change from:closest form target:.amount-field"
:hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/account-total)
:hx-target "this"
:hx-swap "innerHTML"}
(account-total* request))
(com/data-grid-cell {}))
(com/data-grid-row {}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"])
(com/data-grid-cell {:id "total"
:class "text-right"
:hx-trigger "change from:closest form target:.amount-field"
:hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/account-balance)
:hx-target "this"
:hx-swap "innerHTML"}
(account-balance* request))
(com/data-grid-cell {}))
(com/data-grid-row {}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TRANSACTION TOTAL"])
(com/data-grid-cell {:class "text-right"}
(format "$%,.2f" (Math/abs (:transaction/amount snapshot))))
(com/data-grid-cell {})))))]]]])
:footer :footer
(mm/default-step-footer linear-wizard this :validation-route ::route/edit-wizard-navigate (mm/default-step-footer linear-wizard this :validation-route ::route/edit-wizard-navigate
:next-button (com/button {:color :primary :x-ref "next" :class "w-32"} "Done")) :next-button (com/button {:color :primary :x-ref "next" :class "w-32"} "Done"))
@@ -1060,6 +1108,16 @@
tx-id (:db/id tx-data) tx-id (:db/id tx-data)
client-id (->db-id (:transaction/client tx-data)) client-id (->db-id (:transaction/client tx-data))
existing-tx (d-transactions/get-by-id tx-id) existing-tx (d-transactions/get-by-id tx-id)
amount-mode (or (:amount-mode tx-data) "$")
total (Math/abs (:transaction/amount existing-tx))
tx-data (if (= "%" amount-mode)
(update tx-data :transaction/accounts
#(map (fn [account dollar-amount]
(assoc account :transaction-account/amount dollar-amount))
%
(percentages->dollars (map :transaction-account/amount %) total)))
tx-data)
tx-data (dissoc tx-data :amount-mode)
transaction [:upsert-transaction (maybe-spread-locations (assoc tx-data :db/id tx-id))]] transaction [:upsert-transaction (maybe-spread-locations (assoc tx-data :db/id tx-id))]]
(alog/info ::transaction transaction :entity transaction) (alog/info ::transaction transaction :entity transaction)
@@ -1159,7 +1217,6 @@
(html-response (fc/with-field :step-params (payment-matches-view request)) (html-response (fc/with-field :step-params (payment-matches-view request))
:headers {"hx-trigger" "unlinked"})))) :headers {"hx-trigger" "unlinked"}))))
(defrecord EditWizard [_ current-step] (defrecord EditWizard [_ current-step]
mm/LinearModalWizard mm/LinearModalWizard
(hydrate-from-request (hydrate-from-request
@@ -1193,8 +1250,7 @@
(form-schema [_] (form-schema [_]
edit-form-schema) edit-form-schema)
(submit [this {:keys [multi-form-state request-method identity] :as request}] (submit [this {:keys [multi-form-state request-method identity] :as request}]
(save-handler request) (save-handler request)))
))
(def edit-wizard (->EditWizard nil nil)) (def edit-wizard (->EditWizard nil nil))
@@ -1250,12 +1306,20 @@
::route/account-balance (-> account-balance ::route/account-balance (-> account-balance
(mm/wrap-wizard edit-wizard) (mm/wrap-wizard edit-wizard)
(mm/wrap-decode-multi-form-state)) (mm/wrap-decode-multi-form-state))
::route/toggle-amount-mode (-> toggle-amount-mode
(mm/wrap-wizard edit-wizard)
(mm/wrap-decode-multi-form-state))
::route/edit-wizard-new-account (-> ::route/edit-wizard-new-account (->
(add-new-entity-handler [:step-params :transaction/accounts] (add-new-entity-handler [:step-params :transaction/accounts]
(fn render [cursor request] (fn render [cursor request]
(let [snapshot (-> request :multi-form-state :snapshot)
amount-mode (or (:amount-mode snapshot) "$")
total (Math/abs (:transaction/amount snapshot))]
(transaction-account-row* (transaction-account-row*
{:value cursor {:value cursor
:client-id (:client-id (:query-params request))})) :client-id (:client-id (:query-params request))
:amount-mode amount-mode
:total total})))
(fn build-new-row [base _] (fn build-new-row [base _]
(assoc base :transaction-account/location "Shared"))) (assoc base :transaction-account/location "Shared")))
(wrap-schema-enforce :query-schema [:map (wrap-schema-enforce :query-schema [:map

View File

@@ -31,6 +31,7 @@
"/location-select" ::location-select "/location-select" ::location-select
"/account-total" ::account-total "/account-total" ::account-total
"/account-balance" ::account-balance "/account-balance" ::account-balance
"/toggle-amount-mode" ::toggle-amount-mode
"/edit-wizard-new-account" ::edit-wizard-new-account "/edit-wizard-new-account" ::edit-wizard-new-account
"/match-payment" ::link-payment "/match-payment" ::link-payment
"/match-autopay-invoices" ::link-autopay-invoices "/match-autopay-invoices" ::link-autopay-invoices