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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user