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