diff --git a/resources/templates/transaction-bulk-code/account-entries.html b/resources/templates/transaction-bulk-code/account-entries.html
new file mode 100644
index 00000000..b0bd047e
--- /dev/null
+++ b/resources/templates/transaction-bulk-code/account-entries.html
@@ -0,0 +1,2 @@
+{# Wrapper around the expense-account grid (the body of the accounts validated-field). #}
+
+
+
+
+
{% with x_data=row.account.x_data x_model=row.account.x_model key=row.account.key disabled=row.account.disabled a_class=row.account.a_class a_xinit=row.account.a_xinit search_class=row.account.search_class placeholder=row.account.placeholder hidden_attrs=row.account.hidden_attrs %}{% include "templates/components/typeahead.html" %}{% endwith %}
+
{{ row.account_error }}
+
+
+
+
+
+ {% for opt in row.location.options %}{{ opt.label }} {% endfor %}
+
+
{{ row.location_error }}
+
+
+
+
+
+
{{ row.pct_error }}
+
+
+
+
+ {% include "templates/components/svg-x.html" %}
+
+
+
diff --git a/resources/templates/transaction-bulk-code/footer.html b/resources/templates/transaction-bulk-code/footer.html
new file mode 100644
index 00000000..8f2c5bab
--- /dev/null
+++ b/resources/templates/transaction-bulk-code/footer.html
@@ -0,0 +1,5 @@
+{# Modal footer: the form-errors sink on the left, the Save button on the right.
+ Both are pre-rendered fragments (errors sink = form-errors.html, save = sc/button). #}
+Successfully coded {{ count }} transactions.
diff --git a/src/clj/auto_ap/ssr/components/selmer.clj b/src/clj/auto_ap/ssr/components/selmer.clj
index e1330219..3b441bc8 100644
--- a/src/clj/auto_ap/ssr/components/selmer.clj
+++ b/src/clj/auto_ap/ssr/components/selmer.clj
@@ -72,14 +72,19 @@
(update :class #(str % (inputs/use-size size))))]
(render "templates/components/text-input.html" {:attrs (attrs->str attrs)})))
-(defn money-input [{:keys [size] :as params}]
- (let [attrs (-> params
+(defn money-input-attrs
+ "The serialized attribute string for a money input. Split out so a template-driven
+ grid can inline ` rendered from a Selmer partial (the location-select.html shape,
@@ -101,23 +106,34 @@
;; --- field wrapper ---------------------------------------------------------------
+(defn validated-field-classes
+ "The wrapping-div class string for a validated field (group + optional has-error +
+ caller class). Split out so a template-driven row can stamp the same classes."
+ [{:keys [errors] :as params}]
+ (cond-> (or (:class params) "")
+ (sequential? errors) (hh/add-class "has-error")
+ :always (hh/add-class "group")))
+
+(defn errors-str
+ "Comma-join the string errors at a field (nil/empty -> empty string), matching the
+ validated-field error ."
+ [errors]
+ (or (when (sequential? errors)
+ (str/join ", " (filter string? errors)))
+ ""))
+
(defn validated-field
"Selmer port of com/validated-field (the errors- variant of field-): label + body +
an always-present error
. Pass-through attrs land on the wrapping div (the account
row's location cell hangs its swap wiring here)."
[{:keys [label errors] :as params} & body]
- (let [classes (cond-> (or (:class params) "")
- (sequential? errors) (hh/add-class "has-error")
- :always (hh/add-class "group"))
- attrs (dissoc params :label :errors :error-source :error-key :class)
- errors-str (when (sequential? errors)
- (str/join ", " (filter string? errors)))]
+ (let [attrs (dissoc params :label :errors :error-source :error-key :class)]
(render "templates/components/validated-field.html"
{:label label
- :classes classes
+ :classes (validated-field-classes params)
:attrs (attrs->str attrs)
:body (body->html body)
- :errors_str (or errors-str "")})))
+ :errors_str (errors-str errors)})))
;; --- buttons / badges / links ----------------------------------------------------
@@ -274,10 +290,12 @@
:attrs (attrs->str (dissoc params :handle-unexpected-error? :class))
:body (body->html children)}))
-(defn typeahead
- "Selmer port of com/typeahead. Resolves the initial {value,label} server-side via
- value-fn/content-fn (DB lookups), builds the Alpine x-data, and serializes the
- hidden posting-input attributes. Preserves every tippy?. null-guard."
+(defn typeahead-ctx
+ "Build the plain-data context map for templates/components/typeahead.html. Resolves the
+ initial {value,label} server-side via value-fn/content-fn (DB lookups), builds the
+ Alpine x-data, and serializes the hidden posting-input attributes. Split out from
+ `typeahead` so a fully template-driven grid can feed the same partial per row (via
+ {% with %}) without re-deriving any of this logic. Every value is a string/boolean."
[{:keys [value value-fn content-fn x-model x-init id class placeholder disabled url]
:as params}]
(let [vf (or value-fn identity)
@@ -298,13 +316,17 @@
(dissoc :class :value-fn :content-fn :placeholder :x-model)
(assoc "x-ref" "hidden" :type "hidden" ":value" "value.value"
:x-init "$watch('value', v => { $el.value = (v && v.value != null) ? v.value : ''; $nextTick(() => $dispatch('change')); }); "))]
- (render "templates/components/typeahead.html"
- {:x_data x-data
- :x_model x-model
- :key (when id (str id "--" vval))
- :disabled disabled
- :a_class a-class
- :a_xinit a-xinit
- :search_class search-class
- :placeholder placeholder
- :hidden_attrs (attrs->str hidden-attrs)})))
+ {:x_data x-data
+ :x_model x-model
+ :key (when id (str id "--" vval))
+ :disabled disabled
+ :a_class a-class
+ :a_xinit a-xinit
+ :search_class search-class
+ :placeholder placeholder
+ :hidden_attrs (attrs->str hidden-attrs)}))
+
+(defn typeahead
+ "Selmer port of com/typeahead. Preserves every tippy?. null-guard. See typeahead-ctx."
+ [params]
+ (render "templates/components/typeahead.html" (typeahead-ctx params)))
diff --git a/src/clj/auto_ap/ssr/transaction/bulk_code.clj b/src/clj/auto_ap/ssr/transaction/bulk_code.clj
index a58c1d4c..74a66a1f 100644
--- a/src/clj/auto_ap/ssr/transaction/bulk_code.clj
+++ b/src/clj/auto_ap/ssr/transaction/bulk_code.clj
@@ -20,8 +20,8 @@
[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.transaction.edit :as edit :refer [account-typeahead-ctx
+ location-select-ctx]]
[auto-ap.ssr.utils
:refer [->db-id apply-middleware-to-all-handlers assert-schema entity-id
form-validation-error html-response main-transformer modal-response
@@ -123,13 +123,22 @@
;; 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-, Rule 2); remove swaps the
- whole #bulk-code-form (Rule 3). Percentage rides along to submit (Rule 1, no request)."
+(defn- account-row-vm
+ "Build the plain-data view-model for one expense-account row (rendered by
+ account-row.html). Every dynamic attribute is pre-serialized to an HTML attribute
+ string here; the structure (cells, controls, the loop) lives in the templates. The
+ account typeahead and location contexts come from the shared edit/* ctx
+ builders so the URL / content-fn / options logic is not duplicated."
[{:keys [value client-id index]}]
(let [account-val (let [av (:account value)]
(if (map? av) (:db/id av) av))
+ tr-map (hx/alpine-mount-then-appear
+ {: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"})
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)))
@@ -140,79 +149,61 @@
: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" {}))))))
+ {:tr_classes (str (:class tr-map) " border-b dark:border-gray-600 group hover:bg-gray-100 dark:hover:bg-gray-700")
+ :tr_attrs (sc/attrs->str (dissoc tr-map :class))
+ :db_id_name (account-field-name index :db/id)
+ :db_id_value (:db/id value)
+ :account_field_classes (sc/validated-field-classes {:errors (account-field-errors index :account)})
+ :account_error (sc/errors-str (account-field-errors index :account))
+ :account (account-typeahead-ctx {:value account-val
+ :client-id client-id
+ :name (account-field-name index :account)
+ :x-model "accountId"})
+ :location_cell_id (str "account-location-" index)
+ :location_field_classes (sc/validated-field-classes {:errors (account-field-errors index :location)})
+ :location_field_attrs (sc/attrs->str location-attrs)
+ :location_error (sc/errors-str (account-field-errors index :location))
+ :location (location-select-ctx {: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)})
+ :pct_attrs (sc/money-input-attrs {:name (account-field-name index :percentage)
+ :class "w-16"
+ :value (some-> (:percentage value) (* 100) long)})
+ :pct_field_classes (sc/validated-field-classes {:errors (account-field-errors index :percentage)})
+ :pct_error (sc/errors-str (account-field-errors index :percentage))
+ :remove_attrs (sc/attrs->str {: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"
+ :href ""})}))
-(defn- account-grid* [request]
+(defn- account-grid*
+ "Render the whole expense-account grid from account-grid.html: a {% for %} over the
+ per-row view-models, plus the pre-rendered New-account button. All grid structure is
+ in the template -- the Clojure here only assembles data."
+ [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")))]))))
+ (sel/render->hiccup
+ "templates/transaction-bulk-code/account-grid.html"
+ {:rows (map-indexed
+ (fn [index account]
+ (account-row-vm {:value account
+ :client-id client-id
+ :index index}))
+ accounts)
+ :new_account_button (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)
@@ -249,24 +240,18 @@
["requires-feedback" "Client Review"]]})))
:accounts_field (str (sc/validated-field
{:errors (ferr :accounts)}
- (sel/raw (str ""
- (str (account-grid* request))
- "
"))))})))
+ (sc/render "templates/transaction-bulk-code/account-entries.html"
+ {:grid (str (account-grid* request))})))})))
(defn- form-errors-html [errors]
- (str ""))
+ (sc/render "templates/transaction-bulk-code/form-errors.html"
+ {:errors_str (when (seq errors)
+ (str/join ", " (filter string? errors)))}))
(defn- footer* [request]
- (sel/raw
- (str ""
- (form-errors-html (:errors (:form-errors request)))
- (str (sc/button {:color :primary :x-ref "next" :class "w-32 wizard-save-action"} "Save"))
- "
")))
+ (sc/render "templates/transaction-bulk-code/footer.html"
+ {:form_errors (form-errors-html (:errors (:form-errors request)))
+ :save_button (sc/button {:color :primary :x-ref "next" :class "w-32 wizard-save-action"} "Save")}))
(defn render-form
"Renders the whole plain bulk-code form (no wizard). Binds *errors* from the request's
@@ -280,7 +265,7 @@
(str (sc/hidden {:name (path->name2 :ids i) :value id})))
ids))
modal-card (sel/render "templates/transaction-edit/edit-modal.html"
- {:head (str "Bulk editing " (count ids) " transactions
")
+ {:head (sc/render "templates/transaction-bulk-code/head.html" {:count (count ids)})
:side_panel nil
:body (str (bulk-code-body* request))
:footer (str (footer* request))})]
@@ -463,7 +448,8 @@
;; Return success modal
(html-response
(com/success-modal {:title "Transactions Coded"}
- [:p (str "Successfully coded " (count ids) " transactions.")])
+ (sel/render->hiccup "templates/transaction-bulk-code/success-body.html"
+ {:count (count ids)}))
:headers {"hx-trigger" "refreshTable, reset-selection"}))))
;; ---------------------------------------------------------------------------
diff --git a/src/clj/auto_ap/ssr/transaction/edit.clj b/src/clj/auto_ap/ssr/transaction/edit.clj
index 6b3c7569..daebe7fd 100644
--- a/src/clj/auto_ap/ssr/transaction/edit.clj
+++ b/src/clj/auto_ap/ssr/transaction/edit.clj
@@ -159,11 +159,10 @@
clientized (clientize-vendor vendor client-id)]
(:vendor/default-account clientized))))
-(defn location-select*
- "The location for an account row, rendered from a Selmer template
- (templates/components/location-select.html) -- the first interactive modal component
- migrated off Hiccup. Same options/selection/styling as the old com/select, emitted as
- plain HTML and embedded back into the Hiccup row via the interop bridge."
+(defn location-select-ctx
+ "Plain-data context for templates/components/location-select.html: {:name :classes
+ :options [{:value :label :selected}]}. Split out from location-select* so a fully
+ template-driven account grid can stamp the same from its own row loop."
[{:keys [name account-location client-locations value]}]
(let [options (cond account-location
[[account-location account-location]]
@@ -177,28 +176,48 @@
[["Shared" "Shared"]])
selected (or value (ffirst options))
classes (str/join " " (conj (vec inputs/default-input-classes) "w-full"))]
- (sel/render->hiccup
- "templates/components/location-select.html"
- {:name name
- :classes classes
- :options (for [[v label] options]
- {:value v :label label :selected (= v selected)})})))
+ {:name name
+ :classes classes
+ :options (for [[v label] options]
+ {:value v :label label :selected (= v selected)})}))
+
+(defn location-select*
+ "The location for an account row, rendered from a Selmer template
+ (templates/components/location-select.html) -- the first interactive modal component
+ migrated off Hiccup. Same options/selection/styling as the old com/select, emitted as
+ plain HTML and embedded back into the Hiccup row via the interop bridge."
+ [params]
+ (sel/render->hiccup
+ "templates/components/location-select.html"
+ (location-select-ctx params)))
+
+(defn- account-typeahead-params
+ "Shared param map for the account typeahead (account-search url + clientized label
+ content-fn). Used by both account-typeahead* (renders) and account-typeahead-ctx
+ (returns the typeahead context for a template-driven grid)."
+ [{:keys [name value client-id x-model]}]
+ {:name name
+ :placeholder "Search..."
+ :url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
+ (cond-> {:purpose "transaction"}
+ client-id (assoc :client-id client-id)))
+ :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)))})
+
+(defn account-typeahead-ctx
+ "Plain-data typeahead context (sc/typeahead-ctx) for the account cell -- no flex-col
+ wrapper. Lets a template-driven row feed templates/components/typeahead.html via
+ {% with %} without re-deriving the url/content-fn."
+ [params]
+ (sc/typeahead-ctx (account-typeahead-params params)))
(defn account-typeahead*
- [{:keys [name value client-id x-model]}]
- (wrap-div
- "flex flex-col"
- (sc/typeahead {:name name
- :placeholder "Search..."
- :url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
- (cond-> {:purpose "transaction"}
- client-id (assoc :client-id client-id)))
- :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)))})))
+ [params]
+ (wrap-div "flex flex-col" (sc/typeahead (account-typeahead-params params))))
(def ^:dynamic *errors*
"Humanized form errors for the current render, keyed by edit-form-schema paths (e.g.