refactor(ssr): Phase 5 — full Selmer migration of Invoice Bulk Edit; remove the wizard; implement live totals

Migrates the Invoice Bulk Edit modal off the wizard to a plain Selmer form,
building on the parity gate. Structurally Phase 3's bulk-code applied to invoices
(selected entities -> expense-account rows), so near-pure reuse of bulk-code's
flat-state plumbing + edit's account-totals-tbody.

What changed
- Wizard removed: deleted BulkEditWizard/AccountsStep records, MultiStepFormState,
  the step-params[...] prefix, the EDN snapshot, and all mm/* for this modal.
  Replaced with a plain handler + flat wrap-bulk-state (decode straight into
  bulk-edit-schema, no snapshot).
- Selection-as-ids round-trip: the non-editable invoice selection is resolved to a
  concrete not-locked id vector at open and ridden back in hidden ids[] fields (the
  bulk analog of edit's single db/id) -- no filter re-query.
- De-cursored bulk-edit-account-row* to Selmer (sc/*), explicit-id location swap
  (#account-location-<index>, replacing the old find * swap), reusing
  tx-edit/location-select*.
- 100% Selmer modal render path; the surgical edit was done with the text-based
  Edit tool (the clojure-mcp structural tools reformat the whole 1812-line file),
  so the diff is contained to the requires + the bulk-edit region.
- Routes 5 -> 3: GET bulk-edit, PUT bulk-edit-submit, POST bulk-edit-form-changed
  (one whole-form op dispatcher folding the old new-account route).

Implemented the dead totals
- The wizard's TOTAL/BALANCE percentage rows were commented out (#_(...)) with a
  duplicate id="total". Implemented as a #expense-totals sibling-<tbody> refreshed by
  a Rule-4 percentage-keyup swap (the new-account + total + balance routes all fold
  into form-changed / the sibling-tbody).

Scorecard delta (bulk-edit modal): wizard records 2->0, bulk-edit routes 5->3,
step-params/fc-cursor (modal) ->0, location swap find *-> explicit-id, totals
dead->implemented.

Verification: invoice-bulk-edit spec 5/5 (incl. add-row, save, validation, the
implemented totals); full Playwright suite 50/50; cljfmt clean; diff confined to
the modal region. Skill fed: scorecard row + settled repeated-row target-selector
convention; gotcha (structural tools reformat large files -> use text Edit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 23:09:37 -07:00
parent 4139919036
commit 2bf87056d7
7 changed files with 390 additions and 250 deletions

View File

@@ -31,8 +31,12 @@
[auto-ap.ssr.components :as com]
[auto-ap.ssr.components.link-dropdown :refer [link-dropdown]]
[auto-ap.ssr.components.multi-modal :as mm]
[auto-ap.ssr.components.selmer :as sc]
[auto-ap.ssr.form-cursor :as fc]
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
[auto-ap.ssr.selmer :as sel]
[auto-ap.ssr.transaction.edit :as tx-edit]
[auto-ap.ssr.hiccup-helper :as hh]
[auto-ap.ssr.hx :as hx]
[auto-ap.ssr.invoice.common :refer [default-read]]
@@ -41,11 +45,11 @@
[auto-ap.ssr.components.date-range :as dr]
[auto-ap.ssr.svg :as svg]
[auto-ap.ssr.utils
:refer [apply-middleware-to-all-handlers assert-schema
:refer [->db-id apply-middleware-to-all-handlers assert-schema
clj-date-schema dissoc-nil-transformer entity-id
form-validation-error html-response main-transformer
many-entity modal-response money percentage
ref->enum-schema round-money strip wrap-entity
many-entity modal-response money path->name2 percentage
ref->enum-schema round-money strip wrap-entity wrap-form-4xx-2
wrap-implied-route-param wrap-merge-prior-hx
wrap-schema-enforce]]
[auto-ap.time :as atime]
@@ -1433,32 +1437,83 @@
target-route)
(:query-params request)))}}))
(defn initial-bulk-edit-state [request]
(mm/->MultiStepFormState {:search-params (:query-params request)
:expense-accounts [{:db/id "123"
:location "Shared"
:account nil
:percentage 1.0}]}
[]
{:search-params (:query-params request)
:expense-accounts [{:db/id "123"
:location "Shared"
:account nil
:percentage 1.0}]}))
;; ---------------------------------------------------------------------------
;; Flat state plumbing for the bulk-edit modal (replaces the wizard +
;; MultiStepFormState + the EDN snapshot). Mirrors transaction/bulk_code.clj.
;; ---------------------------------------------------------------------------
(declare all-ids-not-locked)
(def ^:dynamic *errors*
"Humanized form errors for the current bulk-edit render, keyed by schema paths
(e.g. {:expense-accounts {0 {:location [\"required\"]}}}). Bound by render-form."
{})
(defn- ferr [& path]
(get-in *errors* (vec path)))
(defn- account-field-name [index field]
(path->name2 :expense-accounts index field))
(defn- account-field-errors [index field]
(ferr :expense-accounts index field))
(def bulk-edit-schema
(mc/schema [:map
[:ids {:optional true} [:maybe [:vector {:coerce? true} entity-id]]]
[:expense-accounts {:optional true}
[:maybe
[:vector {:coerce? true}
[:map
[:db/id {:optional true} [:maybe :string]]
[:account entity-id]
[:location [:string {:min 1 :error/message "required"}]]
[:percentage percentage]]]]]]))
(def ^:private bulk-edit-form-keys [:expense-accounts])
(defn- default-expense-row []
{:db/id (str (java.util.UUID/randomUUID))
:location "Shared"
:account nil
:percentage 1.0})
(defn wrap-bulk-state
"Decodes the posted form into the flat bulk-edit state and resolves the target invoice
id set. On open (GET) the selection comes from the grid query-params (selected /
all-selected + filters); on every post the concrete (not-locked) id list rides back in
hidden ids[] fields, so no EDN snapshot / filter round-trip is needed."
[handler]
(-> (fn [request]
(let [decoded (mc/decode bulk-edit-schema (:form-params request) main-transformer)
decoded (if (map? decoded) decoded {})
posted-ids (some->> (:ids decoded) (keep ->db-id) vec)
ids (if (seq posted-ids)
posted-ids
(vec (all-ids-not-locked (selected->ids request (:query-params request)))))
accounts (or (seq (:expense-accounts decoded)) [(default-expense-row)])]
(handler (assoc request :bulk-state {:ids ids :expense-accounts (vec accounts)}))))
(wrap-nested-form-params)))
(defn- single-client-id
"The client id if the user has access to exactly one client, nil otherwise (the bulk
set may span clients)."
[request]
(when (= 1 (count (:clients request)))
(-> request :clients first :db/id)))
(defn- account-typeahead*
[{:keys [name value client-id x-model]}]
[:div.flex.flex-col
(com/typeahead {:name name
:placeholder "Search..."
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
{:purpose "invoice"})
:id name
:x-model x-model
:value value
:content-fn (fn [value]
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
client-id)))})])
(sc/typeahead {:name name
:placeholder "Search..."
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
{:purpose "invoice"})
:id name
:x-model x-model
:value value
:content-fn (fn [value]
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
client-id)))}))
;; TODO clientize
(defn all-ids-not-locked [all-ids]
@@ -1472,121 +1527,135 @@
[(>= ?d ?lu)]]
(dc/db conn))
(map first)))
(defn- bulk-edit-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))})
:data-key "show"
:x-ref "p"}
hx/alpine-mount-then-appear)
(fc/with-field :db/id
(com/hidden {:name (fc/field-name)
:value (fc/field-value)}))
(fc/with-field :account
(com/data-grid-cell
(defn- bulk-edit-account-row*
"One expense-account row (no cursor). The location cell swaps just itself
(#account-location-<index>, Rule 2); the percentage swaps only #expense-totals
(Rule 4); remove swaps the whole #bulk-edit-form (Rule 3)."
[{:keys [value client-id index]}]
(let [account-val (let [av (:account value)]
(if (map? av) (:db/id av) av))
location-attrs {:x-hx-val:account-id "accountId"
:hx-vals (hx/json (cond-> {:name (account-field-name index :location)}
client-id (assoc :client-id client-id)))
:x-dispatch:changed "accountId"
:hx-trigger "changed"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-form-changed)
:hx-target (str "#account-location-" index)
:hx-select (str "#account-location-" index)
:hx-swap "outerHTML"
:hx-include "closest form"}]
(sc/data-grid-row
(-> {:class "account-row"
:id (str "account-row-" index)
:x-data (hx/json {:show (boolean (not (:new? value)))
:accountId account-val})
:data-key "show"
:x-ref "p"}
hx/alpine-mount-then-appear)
(sc/hidden {:name (account-field-name index :db/id)
:value (:db/id value)})
(sc/data-grid-cell
{}
(com/validated-field
{:errors (fc/field-errors)}
(account-typeahead* {:value (fc/field-value)
(sc/validated-field
{:errors (account-field-errors index :account)}
(account-typeahead* {:value account-val
:client-id client-id
:name (fc/field-name)
:x-model "accountId"}))))
(fc/with-field :location
(com/data-grid-cell
:name (account-field-name index :account)
:x-model "accountId"})))
(sc/data-grid-cell
{:id (str "account-location-" index)}
(sc/validated-field
(merge {:errors (account-field-errors index :location)} location-attrs)
(tx-edit/location-select* {:name (account-field-name index :location)
:account-location (:account/location (when (nat-int? account-val)
(dc/pull (dc/db conn) '[:account/location] account-val)))
:value (:location value)})))
(sc/data-grid-cell
{}
(com/validated-field
{:errors (fc/field-errors)
:x-hx-val:account-id "accountId"
:hx-vals (hx/json {:name (fc/field-name)})
:x-dispatch:changed "accountId"
:hx-trigger "changed"
: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])))
: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)
:class "w-16 amount-field"
: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))))
(sc/validated-field
{:errors (account-field-errors index :percentage)}
(sc/money-input {:name (account-field-name index :percentage)
:class "w-16 amount-field"
:value (some-> (:percentage value) (* 100) long)
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-form-changed)
:hx-target "#expense-totals"
:hx-select "#expense-totals"
:hx-swap "outerHTML"
:hx-trigger "keyup changed delay:300ms"
:hx-include "closest form"})))
(sc/data-grid-cell
{:class "align-top"}
(sc/a-icon-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-form-changed)
:hx-vals (hx/json {:op "remove-account" :row-index (or index 0)})
:hx-target "#bulk-edit-form"
:hx-select "#bulk-edit-form"
:hx-swap "outerHTML"
:hx-include "closest form"
:class "account-remove-action"}
svg/x)))))
(defrecord AccountsStep [linear-wizard]
mm/ModalWizardStep
(step-name [_]
"Expense Accounts")
(step-key [_]
:accounts)
(defn- expense-total* [request]
(let [total (->> (-> request :bulk-state :expense-accounts)
(map (fnil :percentage 0.0))
(filter number?)
(reduce + 0.0))]
(format "%.1f%%" (* 100.0 total))))
(edit-path [_ _]
[])
(defn- expense-balance* [request]
(let [total (->> (-> request :bulk-state :expense-accounts)
(map (fnil :percentage 0.0))
(filter number?)
(reduce + 0.0))
balance (- 100.0 (* 100.0 total))]
(sel/raw (str "<span"
(when-not (dollars= 0.0 balance) " class=\"text-red-300\"")
">" (format "%.1f%%" balance) "</span>"))))
(step-schema [_]
(mut/select-keys (mm/form-schema linear-wizard) #{:expense-accounts}))
(defn- expense-totals-tbody*
"The separately-swappable TOTAL/BALANCE <tbody> (#expense-totals, Rule 4 target)."
[request]
(sel/render->hiccup
"templates/invoice-bulk-edit/expense-totals.html"
{:rows (str
(sc/data-grid-row {}
(sc/data-grid-cell {})
(sc/data-grid-cell {:class "text-right"} (sel/raw "<span class=\"font-bold text-right\">TOTAL</span>"))
(sc/data-grid-cell {:id "expense-total" :class "text-right"} (expense-total* request))
(sc/data-grid-cell {}))
(sc/data-grid-row {}
(sc/data-grid-cell {})
(sc/data-grid-cell {:class "text-right"} (sel/raw "<span class=\"font-bold text-right\">BALANCE</span>"))
(sc/data-grid-cell {:id "expense-balance" :class "text-right"} (expense-balance* request))
(sc/data-grid-cell {})))}))
(render-step [this {{:keys [snapshot] :as multi-form-state} :multi-form-state :as request}]
(let [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) " invoices"]
:body (mm/default-step-body
{}
[:div {}
(fc/with-field :expense-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 #(bulk-edit-account-row* {:value %
:client-id (:invoice/client snapshot)}))
(com/data-grid-new-row {:colspan 4
:hx-get (bidi/path-for ssr-routes/only-routes
::route/bulk-edit-new-account)
:row-offset 0
:index (count (fc/field-value))}
"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/bulk-edit-total)
:hx-target "this"
:hx-swap "innerHTML"}
#_(invoice-expense-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/bulk-edit-balance)
:hx-target "this"
:hx-swap "innerHTML"}
#_(invoice-expense-account-balance* request))
(com/data-grid-cell {})))))])
: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- account-grid* [request]
(let [client-id (single-client-id request)
accounts (vec (:expense-accounts (:bulk-state request)))]
(apply
sc/data-grid
{:headers [(sc/data-grid-header {} "Account")
(sc/data-grid-header {:class "w-32"} "Location")
(sc/data-grid-header {:class "w-16"} "%")
(sc/data-grid-header {:class "w-16"})]
:footer-tbody (expense-totals-tbody* request)}
(concat
(map-indexed
(fn [index account]
(bulk-edit-account-row* {:value account
:client-id client-id
:index index}))
accounts)
[(sc/data-grid-row
{:class "new-row"}
(sc/data-grid-cell {:colspan 4}
(sc/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-form-changed)
:hx-vals (hx/json {:op "new-account"})
:hx-target "#bulk-edit-form"
:hx-select "#bulk-edit-form"
:hx-swap "outerHTML"
:hx-include "closest form"
:color :secondary}
"New account")))]))))
(defn maybe-code-accounts [invoice account-rules valid-locations]
(with-precision 2
@@ -1629,96 +1698,121 @@
(when-not (dollars= 1.0 expense-account-total)
(form-validation-error (str "Expense account total (" expense-account-total ") does not equal 100%")))))
(defrecord BulkEditWizard [_ current-step]
mm/LinearModalWizard
(hydrate-from-request
[this request]
this)
(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 :accounts)))
(render-wizard [this {:keys [multi-form-state] :as request}]
(mm/default-render-wizard
this request
:form-params
(-> mm/default-form-props
(assoc :hx-put
(str (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-submit))))
:render-timeline? false))
(steps [_]
[:accounts])
(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]
(get {:accounts (->AccountsStep this)}
step-key)))
(form-schema [_]
(mc/schema [:map
[:expense-accounts
(many-entity {:min 1}
[:account entity-id]
[:location [:string {:min 1 :error/message "required"}]]
[:percentage percentage])]]))
(submit [this {:keys [multi-form-state request-method identity] :as request}]
(let [selected-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 selected-ids)
invoices (pull-many (dc/db conn) '[:db/id :invoice/total {:invoice/client [:client/locations]}] (vec all-ids))]
(assert-percentages-add-up (:snapshot multi-form-state))
(defn- form-errors-html [errors]
(str "<div id=\"form-errors\">"
(when (seq errors)
(str "<span class=\"error-content\"><p class=\"mt-2 text-xs text-red-600 dark:text-red-500 h-4\">"
(str/join ", " (filter string? errors))
"</p></span>"))
"</div>"))
(doseq [a (-> multi-form-state :snapshot :expense-accounts)
:let [{:keys [:account/location :account/name]} (dc/pull (dc/db conn) [:account/location :account/name] (:account a))]]
(when (and location (not= location (:location a)))
(let [err (str "Account " name " uses location " (:location a) ", but is supposed to be " location)]
(throw (ex-info err {:validation-error err})))))
(alog/info ::bulk-code :count (count all-ids))
(audit-transact-batch
(map (fn [i]
[:upsert-invoice {:db/id (:db/id i)
:invoice/expense-accounts (maybe-code-accounts i (-> multi-form-state :snapshot :expense-accounts) (-> i :invoice/client :client/locations))}])
invoices)
(:identity request))
(defn- footer* [request]
(sel/raw
(str "<div class=\"flex justify-end\"><div class=\"flex items-baseline gap-x-4\">"
(form-errors-html (:errors (:form-errors request)))
(str (sc/button {:color :primary :x-ref "next" :class "w-32" :type "submit"} "Save"))
"</div></div>")))
(html-response
[:div]
:headers (cond-> {"hx-trigger" (hx/json {"modalclose" ""
"invalidated" ""
"notification" (str "Successfully coded " (count all-ids) " invoices.")})
"hx-reswap" "outerHTML"})))))
(defn render-form
"Renders the whole plain bulk-edit form (no wizard). Binds *errors* so the field-level
lookups resolve. Reuses the edit modal chrome."
[request]
(binding [*errors* (or (:form-errors request) {})]
(let [ids (:ids (:bulk-state request))
ids-hidden (apply str
(map-indexed (fn [i id]
(str (sc/hidden {:name (path->name2 :ids i) :value id})))
ids))
body (str "<div class=\"space-y-4 p-4\">"
(str (sc/validated-field
{:errors (ferr :expense-accounts)}
(sel/raw (str (account-grid* request)))))
"</div>")
modal-card (sel/render "templates/transaction-edit/edit-modal.html"
{:head (str "<div class=\"p-2\">Bulk editing " (count ids) " invoices</div>")
:side_panel nil
:body body
:footer (str (footer* request))})]
(sel/render->hiccup
"templates/invoice-bulk-edit/edit-form.html"
{:ids_hidden ids-hidden
:form_attrs (sc/attrs->str {:hx-ext "response-targets"
:hx-swap "outerHTML"
:hx-target-400 "#form-errors .error-content"
:hx-trigger "submit"
:hx-target "this"
:hx-put (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-submit)})
:modal (str (sc/modal {:id "wizardmodal"} (sel/raw modal-card)))}))))
(def bulk-edit-wizard (->BulkEditWizard nil nil))
(defn apply-new-account
"bulk-edit-form-changed op: append a fresh (blank, Shared) expense-account row."
[request]
(let [accounts (vec (:expense-accounts (:bulk-state request)))
new-account {:db/id (str (java.util.UUID/randomUUID))
:new? true
:location "Shared"
:percentage nil}]
(assoc-in request [:bulk-state :expense-accounts] (conj accounts new-account))))
(defn bulk-edit-total* [request]
(let [total (->> (-> request
:multi-form-state
:step-params
:expense-accounts)
(map (fnil :percentage 0.0))
(filter number?)
(reduce + 0.0))]
(format "%.1f%%" (* 100.0 total))))
(defn apply-remove-account
"bulk-edit-form-changed op: remove the expense-account row at form-param row-index."
[request]
(let [row-index (some-> request :form-params (get "row-index") Integer/parseInt)
accounts (vec (:expense-accounts (:bulk-state request)))
updated-accounts (if (and row-index (< row-index (count accounts)))
(vec (concat (subvec accounts 0 row-index)
(subvec accounts (inc row-index))))
accounts)]
(assoc-in request [:bulk-state :expense-accounts] updated-accounts)))
(defn bulk-edit-balance* [request]
(let [total (->> (-> request
:multi-form-state
:step-params
:expense-accounts)
(map (fnil :percentage 0.0))
(filter number?)
(reduce + 0.0))
balance (- 100.0
(* 100.0 total))]
[:span {:class (when-not (dollars= 0.0 balance)
"text-red-300")}
(format "%.1f%%" balance)]))
(defn bulk-edit-form-changed-handler
"Single whole-form re-render endpoint. Dispatches on `op` (add/remove a row); a missing
op (an account-selection location swap or a percentage keyup) just re-renders, and the
caller's hx-select picks the cell / #expense-totals it needs."
[request]
(let [op (get-in request [:form-params "op"])
request' (case op
"new-account" (apply-new-account request)
"remove-account" (apply-remove-account request)
request)]
(html-response (render-form request'))))
(defn bulk-edit-total [request]
(html-response (bulk-edit-total* request)))
(defn open-handler [request]
(modal-response
(sel/render->hiccup "templates/transaction-edit/transitioner.html"
{:body (str (render-form request))})))
(defn bulk-edit-balance [request]
(html-response (bulk-edit-balance* request)))
(defn- render-form-response [request]
(html-response (render-form request)
:headers {"HX-reswap" "outerHTML"}))
(defn submit
"Validates the posted expense-account coding (schema field errors + the percentage-sum
and per-account location checks), then applies it across every selected (not-locked)
invoice."
[request]
(let [{:keys [ids expense-accounts]} (:bulk-state request)
invoices (pull-many (dc/db conn) '[:db/id :invoice/total {:invoice/client [:client/locations]}] (vec ids))]
(assert-schema bulk-edit-schema (select-keys (:bulk-state request) bulk-edit-form-keys))
(assert-percentages-add-up {:expense-accounts expense-accounts})
(doseq [a expense-accounts
:let [{:keys [:account/location :account/name]} (dc/pull (dc/db conn) [:account/location :account/name] (:account a))]]
(when (and location (not= location (:location a)))
(let [err (str "Account " name " uses location " (:location a) ", but is supposed to be " location)]
(throw (ex-info err {:validation-error err})))))
(alog/info ::bulk-code :count (count ids))
(audit-transact-batch
(map (fn [i]
[:upsert-invoice {:db/id (:db/id i)
:invoice/expense-accounts (maybe-code-accounts i expense-accounts (-> i :invoice/client :client/locations))}])
invoices)
(:identity request))
(html-response
[:div]
:headers {"hx-trigger" (hx/json {"modalclose" ""
"invalidated" ""
"notification" (str "Successfully coded " (count ids) " invoices.")})
"hx-reswap" "outerHTML"})))
(def key->handler
(apply-middleware-to-all-handlers
@@ -1737,32 +1831,14 @@
::route/legacy-paid-invoices (redirect-handler ::route/paid-page)
::route/legacy-voided-invoices (redirect-handler ::route/voided-page)
::route/legacy-new-invoice (redirect-handler ::route/new-wizard)
::route/bulk-edit (-> mm/open-wizard-handler
(mm/wrap-wizard bulk-edit-wizard)
(mm/wrap-init-multi-form-state initial-bulk-edit-state))
::route/bulk-edit-submit (-> mm/submit-handler
(mm/wrap-wizard bulk-edit-wizard)
(mm/wrap-decode-multi-form-state)
::route/bulk-edit (-> open-handler
(wrap-bulk-state))
::route/bulk-edit-submit (-> submit
(wrap-form-4xx-2 render-form-response)
(wrap-bulk-state)
(wrap-must {:subject :invoice :activity :bulk-edit}))
::route/bulk-edit-total (-> bulk-edit-total
(mm/wrap-wizard bulk-edit-wizard)
(mm/wrap-decode-multi-form-state)
(wrap-must {:subject :invoice :activity :bulk-edit}))
::route/bulk-edit-balance (-> bulk-edit-balance
(mm/wrap-wizard bulk-edit-wizard)
(mm/wrap-decode-multi-form-state)
(wrap-must {:subject :invoice :activity :bulk-edit}))
::route/bulk-edit-new-account (->
(add-new-entity-handler [:step-params :expense-accounts]
(fn render [cursor request]
(bulk-edit-account-row*
{:value cursor}))
(fn build-new-row [base _]
(assoc base :invoice-expense-account/location "Shared")))
(wrap-schema-enforce :query-schema [:map
[:client-id {:optional true}
[:maybe entity-id]]]))
::route/bulk-edit-form-changed (-> bulk-edit-form-changed-handler
(wrap-bulk-state))
::route/undo-autopay (-> undo-autopay
(wrap-entity [:route-params :db/id] default-read)