refactor(ssr): Phase 3 — full Selmer migration of Transaction Bulk Code; remove the wizard

Migrates the Transaction Bulk Code modal (a single-step form wearing a full
wizard costume) to a plain Selmer form, cold-applying the ssr-form-migration
skill. Almost entirely reuse of the Phase-2 work: the whole `sc/*` Selmer
component library, `account-typeahead*` / `location-select*`, and the
`edit-modal` / `transitioner` chrome are imported wholesale.

What changed
- Wizard removed: deleted `BulkCodeWizard` / `AccountsStep` records,
  `MultiStepFormState`, the `step-params[...]` prefix, and all `mm/*`
  middleware. Replaced with a plain handler + flat `wrap-bulk-state` (decode
  straight into `bulk-code-schema`, no snapshot round-trip).
- Selection round-trip: the non-editable transaction 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 EDN snapshot, no
  filter re-query, and more correct (codes exactly the rows the user saw).
- 100% Selmer render path (only the shared terminal `com/success-modal` keeps
  Hiccup — heuristic-9 exception). New shared component `sc/select`
  (`location-select.html` generalized) for the status dropdown.
- Routes 4 -> 3: GET `bulk-code` (open), POST `bulk-code-submit`, POST
  `bulk-code-form-changed` (one whole-form op dispatcher folding the old
  `new-account` + `vendor-changed` routes). Location swap moved off `find *`
  onto explicit `#account-location-<index>` + `hx-select`.
- Fixed a latent correctness bug surfaced by the migration: the vendor
  typeahead needs `:id` (value-keyed `:key`) or its value-bound hidden goes
  stale across a whole-form swap and posts blank.

Scorecard delta (transaction/bulk_code.clj): mm coupling 19->0, snapshot
merges 4->0, wizard records 3->0, step-params 10->0, routes 4->3, OOB 0,
Hiccup-in-render ->0 (bar success-modal). LOC 420->506 (documented exception:
the wizard was a thin shell over mm/* defaults, so explicitness moves shared
plumbing into the file). Cookbook: reused the entire Phase-2 sc/* lib + chrome,
added sc/select.

Verification: bulk-code-transactions.spec.ts 13/13; full Playwright suite
39/39; cljfmt clean.

Skill fed: scorecard row + narrative + LOC exception; gotchas (value-bound
typeahead keying, selection-as-ids round-trip); cookbook (sc/select).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 19:38:09 -07:00
parent 70c178de83
commit 03620e9d42
10 changed files with 515 additions and 333 deletions

View File

@@ -81,6 +81,24 @@
(assoc :type "number" :step "0.01"))]
(render "templates/components/money-input.html" {:attrs (attrs->str attrs)})))
(defn select
"Generic <select> rendered from a Selmer partial (the location-select.html shape,
generalized). options = [[value label] ...]; `value` (string or keyword) marks the
selected option. Class defaults to the standard input classes, like com/select. Extra
attrs (hx-*, x-*) ride through onto the element."
[{:keys [name value options class] :as params}]
(let [classes (-> ""
(hh/add-class inputs/default-input-classes)
(hh/add-class (or class "")))
sel (cond-> value (keyword? value) clojure.core/name)
attrs (dissoc params :name :value :options :class)]
(render "templates/components/select.html"
{:name name
:classes classes
:attrs (attrs->str attrs)
:options (for [[v label] options]
{:value v :label label :selected (= (str v) (str sel))})})))
;; --- field wrapper ---------------------------------------------------------------
(defn validated-field

View File

@@ -10,86 +10,74 @@
[auto-ap.routes.utils :refer [wrap-client-redirect-unauthenticated]]
[auto-ap.rule-matching :as rm]
[auto-ap.ssr-routes :as ssr-routes]
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler]]
[auto-ap.ssr.components :as com]
[auto-ap.ssr.components.multi-modal :as mm :refer [wrap-wizard]]
[auto-ap.ssr.form-cursor :as fc]
[auto-ap.ssr.components.selmer :as sc]
[auto-ap.ssr.grid-page-helper :refer [wrap-apply-sort]]
[auto-ap.ssr.hx :as hx]
[auto-ap.ssr.ledger :refer [wrap-ensure-bank-account-belongs]]
[auto-ap.ssr.svg :as svg]
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
[auto-ap.ssr.selmer :as sel]
[auto-ap.ssr.transaction.common :refer [grid-page query-schema
selected->ids
wrap-status-from-source]]
[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 [->db-id apply-middleware-to-all-handlers assert-schema entity-id
form-validation-error html-response main-transformer modal-response
path->name2 percentage ref->enum-schema wrap-form-4xx-2
wrap-merge-prior-hx wrap-schema-enforce]]
[bidi.bidi :as bidi]
[clojure.string :as str]
[datomic.api :as dc]
[iol-ion.query :refer [dollars=]]
[iol-ion.tx :refer [random-tempid]]
[malli.core :as mc]))
(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))})
: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
{}
(com/validated-field
{:errors (fc/field-errors)}
(account-typeahead* {:value (fc/field-value)
:client-id client-id
: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)}
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-target "find *"
:hx-swap "outerHTML"}
(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)}))))
(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"
: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))))
;; ---------------------------------------------------------------------------
;; Field-name / error helpers (no step-params[...] prefix -- posted fields
;; decode straight into bulk-code-schema, mirroring transaction/edit.clj).
;; ---------------------------------------------------------------------------
(defn initial-bulk-edit-state [request]
(mm/->MultiStepFormState {:search-params (:query-params request)
:accounts []}
[]
{:search-params (:query-params request)
:accounts []}))
(def ^:dynamic *errors*
"Humanized form errors for the current render, keyed by bulk-code-schema paths
(e.g. {:accounts {0 {:location [\"required\"]}}}). Bound by render-form from the
request's :form-errors. Plain map -- no wizard, no cursor."
{})
(defn- ferr
"Field errors at a schema path, read from *errors* (no step-params prefix)."
[& path]
(get-in *errors* (vec path)))
(defn- account-field-name [index field]
(path->name2 :accounts index field))
(defn- account-field-errors [index field]
(ferr :accounts index field))
;; ---------------------------------------------------------------------------
;; Schema + decode
;; ---------------------------------------------------------------------------
(def bulk-code-schema
(mc/schema [:map
[:vendor {:optional true} [:maybe entity-id]]
[:approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]]
[:ids {:optional true} [:maybe [:vector {:coerce? true} entity-id]]]
[: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-code-form-keys
"Editable top-level keys (vendor/status/accounts). The transaction selection (:ids)
is non-editable -- it is threaded separately by wrap-bulk-state."
[:vendor :approval-status :accounts])
(defn all-ids-not-locked
"Filters transaction IDs to only those that aren't locked (client locked date earlier than transaction date)"
@@ -105,16 +93,281 @@
(dc/db conn))
(map first)))
(def bulk-code-schema
(mc/schema [:map
[:vendor {:optional true} [:maybe entity-id]]
[:approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]]
[:accounts {:optional true}
[:maybe
[:vector {:coerce? true}
[:map [:account entity-id]
[:location [:string {:min 1 :error/message "required"}]]
[:percentage percentage]]]]]]))
(defn wrap-bulk-state
"Replaces the wizard's MultiStepFormState/snapshot round-trip. Parses the posted
(nested) form params, decodes them straight into bulk-code-schema, and resolves the
target transaction id set. On open (GET) the selection comes from the grid's
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 -- and we code exactly the transactions the user saw."
[handler]
(-> (fn [request]
(let [parsed (:form-params request)
decoded (mc/decode bulk-code-schema parsed 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)))))]
(handler (assoc request :bulk-state (assoc (select-keys decoded bulk-code-form-keys) :ids ids)))))
(wrap-nested-form-params)))
(defn- single-client-id
"Returns the client ID if the user has access to exactly one client, nil otherwise."
[request]
(when (= 1 (count (:clients request)))
(-> request :clients first :db/id)))
;; ---------------------------------------------------------------------------
;; Render (100% Selmer -- reuses the transaction/edit.clj sc/* component library
;; and the shared edit-modal / transitioner chrome).
;; ---------------------------------------------------------------------------
(defn transaction-account-row*
"One row of the bulk-code account grid, from a plain account map (no cursor). The
location cell swaps just itself (#account-location-<index>, Rule 2); remove swaps the
whole #bulk-code-form (Rule 3). Percentage rides along to submit (Rule 1, no request)."
[{: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-code-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
{}
(sc/validated-field
{:errors (account-field-errors index :account)}
(account-typeahead* {:value account-val
:client-id client-id
: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)
(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)))
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
:value (:location value)})))
(sc/data-grid-cell
{}
(sc/validated-field
{:errors (account-field-errors index :percentage)}
(sc/money-input {:name (account-field-name index :percentage)
:class "w-16"
:value (some-> (:percentage value) (* 100) long)})))
(sc/data-grid-cell
{:class "align-top"}
(sc/a-icon-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-code-form-changed)
:hx-vals (hx/json {:op "remove-account" :row-index (or index 0)})
:hx-target "#bulk-code-form"
:hx-select "#bulk-code-form"
:hx-swap "outerHTML"
:hx-include "closest form"
:class "account-remove-action"}
(sc/render "templates/components/svg-x.html" {}))))))
(defn- account-grid* [request]
(let [client-id (single-client-id request)
accounts (vec (: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"})]}
(concat
(map-indexed
(fn [index account]
(transaction-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-code-form-changed)
:hx-vals (hx/json {:op "new-account"})
:hx-target "#bulk-code-form"
:hx-select "#bulk-code-form"
:hx-swap "outerHTML"
:hx-include "closest form"
:color :secondary}
"New account")))]))))
(defn- bulk-code-body* [request]
(let [bulk-state (:bulk-state request)
vendor-val (:vendor bulk-state)
status-val (some-> (:approval-status bulk-state) name)]
(sel/render->hiccup
"templates/transaction-bulk-code/bulk-code-body.html"
{:vendor_changed_attrs (sc/attrs->str {:hx-trigger "change"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-code-form-changed)
:hx-vals (hx/json {:op "vendor-changed"})
:hx-target "#bulk-code-form"
:hx-select "#bulk-code-form"
:hx-swap "outerHTML"
:hx-sync "this:replace"
:hx-include "closest form"})
:vendor_field (str (sc/validated-field
{:label "Vendor" :errors (ferr :vendor)}
(sc/typeahead {:name (path->name2 :vendor)
:id (path->name2 :vendor)
:error? (boolean (seq (ferr :vendor)))
:class "w-96"
:placeholder "Search for vendor..."
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
:value vendor-val
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})))
:status_field (str (sc/validated-field
{:label "Status" :errors (ferr :approval-status)}
(sc/select {:name (path->name2 :approval-status)
:value status-val
:options [["" "No Change"]
["approved" "Approved"]
["unapproved" "Unapproved"]
["suppressed" "Suppressed"]
["requires-feedback" "Client Review"]]})))
:accounts_field (str (sc/validated-field
{:errors (ferr :accounts)}
(sel/raw (str "<div id=\"account-entries\" class=\"space-y-3\">"
(str (account-grid* request))
"</div>"))))})))
(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>"))
(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 wizard-save-action"} "Save"))
"</div></div>")))
(defn render-form
"Renders the whole plain bulk-code form (no wizard). Binds *errors* from the request's
:form-errors so the field-level error lookups (ferr) resolve. Reuses the edit modal's
chrome (edit-modal.html), with no side panel."
[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))
modal-card (sel/render "templates/transaction-edit/edit-modal.html"
{:head (str "<div class=\"p-2\">Bulk editing " (count ids) " transactions</div>")
:side_panel nil
:body (str (bulk-code-body* request))
:footer (str (footer* request))})]
(sel/render->hiccup
"templates/transaction-bulk-code/bulk-code-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-post (bidi/path-for ssr-routes/only-routes ::route/bulk-code-submit)})
:modal (str (sc/modal {:id "bulkcodemodal"} (sel/raw modal-card)))}))))
;; ---------------------------------------------------------------------------
;; edit-form-changed ops (whole-form re-render). Replaces the per-operation
;; bulk-code-new-account / bulk-code-vendor-changed routes.
;; ---------------------------------------------------------------------------
(defn- vendor-default-account
"Returns the vendor's standard default account. For single-client contexts,
the account name is clientized (tailored to the customer). For multi-client
contexts, the raw account name is used."
[vendor-id client-id]
(when vendor-id
(let [vendor (edit/get-vendor vendor-id)
account (:vendor/default-account vendor)]
(if client-id
(d-accounts/clientize account client-id)
account))))
(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 apply-vendor-changed
"bulk-code-form-changed op: when the accounts are empty and a vendor with a default
account is chosen, pre-populate a single 100% default-account row."
[request]
(let [bulk-state (:bulk-state request)
client-id (single-client-id request)
vendor-id (->db-id (:vendor bulk-state))
accounts (:accounts bulk-state)]
(if (and (empty? accounts) vendor-id)
(if-let [default-account (vendor-default-account vendor-id client-id)]
(assoc-in request [:bulk-state :accounts] [(build-default-account-row default-account)])
request)
request)))
(defn apply-new-account
"bulk-code-form-changed op: append a fresh (blank, Shared) account row."
[request]
(let [accounts (vec (:accounts (:bulk-state request)))
new-account {:db/id (str (java.util.UUID/randomUUID))
:new? true
:location "Shared"}]
(assoc-in request [:bulk-state :accounts] (conj accounts new-account))))
(defn apply-remove-account
"bulk-code-form-changed op: remove the account row at form-param row-index."
[request]
(let [row-index (some-> request :form-params (get "row-index") Integer/parseInt)
accounts (vec (: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 :accounts] updated-accounts)))
(defn bulk-code-form-changed-handler
"Single whole-form re-render endpoint. Dispatches on the `op` form-param (vendor
change, add/remove row), then re-renders the whole form. A missing/unknown op (e.g.
an account selection driving the location swap) just re-renders."
[request]
(let [op (get-in request [:form-params "op"])
request' (case op
"vendor-changed" (apply-vendor-changed request)
"new-account" (apply-new-account request)
"remove-account" (apply-remove-account request)
request)]
(html-response (render-form request'))))
;; ---------------------------------------------------------------------------
;; Submit
;; ---------------------------------------------------------------------------
(defn maybe-code-accounts [transaction account-rules valid-locations]
(with-precision 2
@@ -151,263 +404,95 @@
[])]
accounts)))
(defrecord AccountsStep [linear-wizard]
mm/ModalWizardStep
(step-name [_]
"Bulk Code")
(step-key [_]
:accounts)
(edit-path [_ _]
[])
(step-schema [_]
(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 {: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)
:value (fc/field-value)
: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)
:value (some-> (fc/field-value)
name)
:options [["" "No Change"]
["approved" "Approved"]
["unapproved" "Unapproved"]
["suppressed" "Suppressed"]
["requires-feedback" "Client Review"]]})))]
;; 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))]
(when-not (dollars= 1.0 account-total)
(form-validation-error (str "Expense account total (" account-total ") does not equal 100%")))))
(defrecord BulkCodeWizard [_ 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-code-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 [_]
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))
(defn submit
"Validates the posted bulk-code form (schema field errors via wrap-form-4xx-2, then the
percentage-sum and per-account location checks as form errors), then applies the chosen
vendor / status / account-coding across every selected (not-locked) transaction."
[request]
(let [{:keys [ids vendor approval-status accounts]} (:bulk-state request)]
(assert-schema bulk-code-schema (select-keys (:bulk-state request) bulk-code-form-keys))
(when (seq accounts)
(assert-percentages-add-up {:accounts accounts}))
(let [db (dc/db conn)
transactions (pull-many db [:db/id :transaction/amount {:transaction/client [:db/id]}] (vec ids))
;; 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))
;; 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 {}))]
;; 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 {}))]
;; 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.")))))
;; 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.")))))
(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 ids) " transactions.")])
:headers {"hx-trigger" "refreshTable, reset-selection"}))))
;; Return success modal
(html-response
(com/success-modal {:title "Transactions Coded"}
[:p (str "Successfully coded " (count all-ids) " transactions.")])
:headers {"hx-trigger" "refreshTable, reset-selection"})))))
;; ---------------------------------------------------------------------------
;; Handlers + routes
;; ---------------------------------------------------------------------------
(defn- vendor-default-account [vendor-id client-id]
"Returns the vendor's standard default account. For single-client contexts,
the account name is clientized (tailored to the customer). For multi-client
contexts, the raw account name is used."
(when vendor-id
(let [vendor (edit/get-vendor vendor-id)
account (:vendor/default-account vendor)]
(if client-id
(d-accounts/clientize account client-id)
account))))
(defn open-handler
"Initial modal open (GET). Wraps the rendered form in the #transitioner shell expected
by the modal stack (reuses the edit modal's transitioner)."
[request]
(modal-response
(sel/render->hiccup "templates/transaction-edit/transitioner.html"
{:body (str (render-form request))})))
(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 [multi-form-state (:multi-form-state request)]
(html-response
[:div
(fc/start-form multi-form-state
(when (:form-errors request) {:step-params (:form-errors request)})
(fc/with-field :step-params
(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- single-client-id [request]
"Returns the client ID if the user has access to exactly one client, nil otherwise."
(when (= 1 (count (:clients request)))
(-> request :clients first :db/id)))
(defn vendor-changed-handler [request]
(let [snapshot (:snapshot (:multi-form-state request))
step-params (:step-params (:multi-form-state request))
client-id (single-client-id request)
vendor-id (or (:vendor step-params) (:vendor snapshot))
updated-step-params (if (and (empty? (:accounts step-params))
vendor-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))
(defn- render-form-response
"wrap-form-4xx-2 form-handler: re-render the whole form with field/form errors."
[request]
(html-response (render-form request)
:headers {"HX-reswap" "outerHTML"}))
(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-new-account (->
(add-new-entity-handler [:step-params :accounts]
(fn render [cursor request]
(transaction-account-row*
{:value cursor}))
(fn build-new-row [base _]
(assoc base :location "Shared")))
(wrap-schema-enforce :query-schema [:map
[:client-id {:optional true}
[:maybe entity-id]]]))
::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))}
{::route/bulk-code (-> open-handler
(wrap-bulk-state))
::route/bulk-code-form-changed (-> bulk-code-form-changed-handler
(wrap-bulk-state))
::route/bulk-code-submit (-> submit
(wrap-form-4xx-2 render-form-response)
(wrap-bulk-state))}
(fn [h]
(-> h
(wrap-copy-qp-pqp)
@@ -418,4 +503,4 @@
(wrap-schema-enforce :query-schema query-schema)
(wrap-schema-enforce :hx-schema query-schema)
(wrap-must {:activity :bulk-code :subject :transaction})
(wrap-client-redirect-unauthenticated)))))
(wrap-client-redirect-unauthenticated)))))