refactor(ssr): Phase 4 — full Selmer migration of POS Sales Summary; remove the wizard; fix add-item + totals

Migrates the POS Sales Summary edit modal off the wizard to a plain Selmer form,
building on the parity gate committed earlier. Largest migration so far and the
first with no prior test coverage.

What changed
- Wizard removed: deleted MainStep/EditWizard records, MultiStepFormState, the
  step-params[...] prefix, the EDN snapshot round-trip, and all mm/* middleware.
  Replaced with a plain handler + flat wrap-decode/wrap-derive-state. The 51 fc/
  cursor refs are de-cursored into explicit data + Selmer templates.
- db/id-keyed item merge: wrap-derive-state overlays posted items onto the
  persisted items by :db/id, so read-only fields the form doesn't post
  (ledger-side, amount) survive a re-render and the debit/credit split + totals
  stay correct. New manual rows (temp db/id) ride through as-is.
- Inline click-to-edit account cell preserved as three small targeted
  .account-cell-swap routes (edit/save/cancel-item-account), ported to Selmer
  with the new field-name scheme.
- 100% Selmer modal render path (the remaining Hiccup / hx-swap-oob / "hx-"
  strings are all grid-page code — grid render lambdas, the filters form, and the
  submit response-header map — not the modal).
- Routes: dropped edit-wizard-navigate + new-summary-item; added form-changed.

Fixes (two pre-existing bugs, per request)
- "New Summary Item" add button (was throwing `newRowIndex is not defined` and
  targeting a non-existent `.new-row`) is now a whole-form-swap op=new-item that
  adds an editable manual row (category + account typeahead + debit/credit money
  inputs + remove).
- The dead totals/balance display (malformed Hiccup that discarded its labels) is
  replaced by a proper #summary-totals block showing running Total +
  Balanced/Unbalanced, refreshed via a Rule-4 targeted swap on manual amount edits.

Scorecard delta (pos/sales_summaries.clj): LOC 790->732, mm coupling 20->0,
wizard records 4->0, fc/ cursor 51->0, step-params 27->0 (2 comments), modal
routes 8->6. (hx-swap-oob 1 and mixed-hx live in the grid page, not the modal.)

Verification: sales-summary spec 7/7 (incl. the two fixes); full Playwright suite
46/46; cljfmt clean. Skill fed: scorecard row + narrative; gotchas (parity-gate-
first, characterize-then-fix, keyup-trigger tests); cookbook (inline click-to-edit
cell, db/id-keyed item merge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 22:13:19 -07:00
parent a289ff2557
commit 599b849e6f
8 changed files with 524 additions and 484 deletions

View File

@@ -9,29 +9,29 @@
[auto-ap.client-routes :as client-routes]
[auto-ap.routes.pos.sales-summaries :as route]
[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.link-dropdown :refer [link-dropdown]]
[auto-ap.ssr.components.multi-modal :as mm]
[auto-ap.ssr.form-cursor :as fc]
[auto-ap.ssr.components.selmer :as sc]
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
[auto-ap.ssr.hx :as hx]
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
[auto-ap.ssr.pos.common
:refer [date-range-field*]]
[auto-ap.ssr.selmer :as sel]
[auto-ap.ssr.svg :as svg]
[auto-ap.ssr.utils
:refer [apply-middleware-to-all-handlers clj-date-schema
default-grid-fields-schema entity-id html-response money
strip temp-id wrap-merge-prior-hx wrap-schema-enforce]]
:refer [->db-id apply-middleware-to-all-handlers assert-schema
clj-date-schema default-grid-fields-schema entity-id html-response
main-transformer modal-response money path->name2 strip temp-id
wrap-form-4xx-2 wrap-merge-prior-hx wrap-schema-enforce]]
[auto-ap.time :as atime]
[bidi.bidi :as bidi]
[clj-time.coerce :as c]
[clojure.string :as str]
[datomic.api :as dc]
[hiccup.util :as hu]
[iol-ion.query :refer [dollars= dollars-0?]]
[malli.core :as mc]
[malli.util :as mut]))
[iol-ion.query :refer [dollars=]]
[malli.core :as mc]))
(def query-schema (mc/schema
[:maybe
@@ -133,63 +133,6 @@
(str (subs s 0 (- max-len 3)) "...")
s))
(defn account-typeahead*
[{:keys [name value client-id]}]
[:div.flex.flex-col
(com/typeahead {:name name
:placeholder "Search..."
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
{:client-id client-id
:purpose "invoice"})
: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-display-cell [{:keys [item field-name-prefix client-id]}]
(let [account-id (:ledger-mapped/account item)
account-name (when account-id
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read account-id)
client-id)))]
[:div.account-cell.flex.items-center.gap-2
(com/hidden {:name (str field-name-prefix "[ledger-mapped/account]")
:value (or account-id "")})
(if account-id
[:span.text-sm account-name]
(com/pill {:color :red} "Missing acct"))
(com/a-icon-button {:class "p-1"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-item-account)
:hx-target "closest .account-cell"
:hx-swap "outerHTML"
:hx-vals (hx/json {:item-index (or (:item-index item) 0)
:client-id client-id
:current-account-id (or account-id "")})}
svg/pencil)]))
(defn account-edit-cell [{:keys [field-name-prefix client-id current-account-id]}]
(let [account-input-name (str field-name-prefix "[ledger-mapped/account]")]
[:div.account-cell.flex.flex-col.gap-2
(account-typeahead* {:name account-input-name
:value current-account-id
:client-id client-id})
[:div.flex.gap-1
(com/a-icon-button {:class "p-1"
:hx-put (bidi/path-for ssr-routes/only-routes ::route/save-item-account)
:hx-target "closest .account-cell"
:hx-swap "outerHTML"
:hx-include "closest .account-cell"
:hx-vals (hx/json {:field-name-prefix field-name-prefix
:client-id client-id})}
svg/check)
(com/a-icon-button {:class "p-1"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/cancel-item-account)
:hx-target "closest .account-cell"
:hx-swap "outerHTML"
:hx-vals (hx/json {:field-name-prefix field-name-prefix
:client-id client-id
:current-account-id (or current-account-id "")})}
svg/x)]]))
(def grid-page
(helper/build {:id "entity-table"
:id-fn :db/id
@@ -247,7 +190,7 @@
[:span.shrink-0.font-mono.tabular-nums.text-right.text-gray-900.whitespace-nowrap
(format "$%,.2f" (:ledger-mapped/amount si))]])
(for [_ (range (max 0 (- credit-count (count debit-items))))]
[:li.py-0.5.text-sm " "])]
[:li.py-0.5.text-sm " "])]
[:div.border-t-2.border-gray-300.mt-1.pt-1.flex.justify-between.items-baseline
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-gray-500 "Total"]
[:span.font-mono.tabular-nums.font-bold.text-gray-900
@@ -273,7 +216,7 @@
[:span.shrink-0.font-mono.tabular-nums.text-right.text-gray-900.whitespace-nowrap
(format "$%,.2f" (:ledger-mapped/amount si))]])
(for [_ (range (max 0 (- debit-count (count credit-items))))]
[:li.py-0.5.text-sm " "])]
[:li.py-0.5.text-sm " "])]
[:div.border-t-2.border-gray-300.mt-1.pt-1.flex.justify-between.items-baseline
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-gray-500 "Total"]
[:span.font-mono.tabular-nums.font-bold.text-gray-900
@@ -348,296 +291,308 @@
(not (and (:credit x)
(:debit x))))]]]]])
(defn summary-total-row* [request]
(let [total-credits (-> request
:multi-form-state
:step-params
:sales-summary/items
(total-credits))
total-debits (-> request
:multi-form-state
:step-params
:sales-summary/items
(total-debits))]
;; ---------------------------------------------------------------------------
;; Field-name / error helpers (no step-params[...] prefix) + item helpers.
;; Mirrors transaction/edit.clj and transaction/bulk_code.clj.
;; ---------------------------------------------------------------------------
(com/data-grid-row {:id "total-row"
:class "bg-slate-50 border-t-2 border-slate-300"}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"}
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-slate-600
"Total"])
(com/data-grid-cell {:class "text-right"}
[:span.font-mono.tabular-nums.font-bold.text-slate-900
(format "$%,.2f" total-debits)])
(com/data-grid-cell {:class "text-right"}
[:span.font-mono.tabular-nums.font-bold.text-slate-900
(format "$%,.2f" total-credits)])
(com/data-grid-cell {}))))
(def ^:dynamic *errors*
"Humanized form errors for the current render, keyed by edit-schema paths. Bound by
render-form from the request's :form-errors. Plain map -- no wizard, no cursor."
{})
(defn unbalanced-row* [request]
(let [total-credits (-> request
:multi-form-state
:step-params
:sales-summary/items
(total-credits))
total-debits (-> request
:multi-form-state
:step-params
:sales-summary/items
(total-debits))
unbalanced? (not (dollars= total-credits total-debits))
debit-over? (and unbalanced? (> total-debits total-credits))
credit-over? (and unbalanced? (> total-credits total-debits))]
(defn- ferr [& path]
(get-in *errors* (vec path)))
(com/data-grid-row {:id "unbalanced-row"
:class (when unbalanced? "bg-red-50 border-t border-red-200")}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"}
(when unbalanced?
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-red-700
"Out of balance"]))
(com/data-grid-cell {:class "text-right"}
(when debit-over?
[:span.font-mono.tabular-nums.font-bold.text-red-700
(format "$%,.2f" (- total-debits total-credits))]))
(com/data-grid-cell {:class "text-right"}
(when credit-over?
[:span.font-mono.tabular-nums.font-bold.text-red-700
(format "$%,.2f" (- total-credits total-debits))]))
(com/data-grid-cell {}))))
(defn- item-field-name [index field]
(path->name2 :sales-summary/items index field))
(defn summary-total-display [request]
(let [total-credits (-> request
:multi-form-state
:step-params
:sales-summary/items
(total-credits))
total-debits (-> request
:multi-form-state
:step-params
:sales-summary/items
(total-debits))]
[:div.flex.justify-between.text-sm.py-1.border-t.mt-1
{:id "total-display"}]
[:span.font-semibold "Total"]
[:div.flex.gap-8
[:span.font-mono (format "$%,.2f" total-debits)]
[:span.font-mono (format "$%,.2f" total-credits)]]))
(defn- item-field-errors [index field]
(ferr :sales-summary/items index field))
(defn unbalanced-display [request]
(let [total-credits (-> request
:multi-form-state
:step-params
:sales-summary/items
(total-credits))
total-debits (-> request
:multi-form-state
:step-params
:sales-summary/items
(total-debits))
delta (- total-debits total-credits)]
(when-not (dollars-0? delta)
[:div.flex.justify-between.text-sm.py-1
{:id "unbalanced-display"}
[:span.font-semibold.text-red-600 "Unbalanced"]
[:div.flex.gap-8
[:span.font-mono (when (pos? delta) (format "$%,.2f" delta))
[:span.font-mono (when (neg? delta) (format "$%,.2f" (Math/abs delta)))]]]])))
(defn- item-side
"Which column an item belongs to: its persisted ledger-side for auto items, else the
filled-in credit/debit for a manual row (nil for a brand-new blank manual row)."
[item]
(cond
(= :ledger-side/debit (:ledger-mapped/ledger-side item)) :debit
(= :ledger-side/credit (:ledger-mapped/ledger-side item)) :credit
(:debit item) :debit
(:credit item) :credit
:else nil))
(defn sales-summary-item-row* [{:keys [value client-id]}]
(let [manual? (fc/field-value (:sales-summary-item/manual? value))]
(com/data-grid-row (cond-> {:x-ref "p"
:x-data (hx/json {})
:class (when manual?
"bg-indigo-50/40 border-l-2 border-indigo-300")}
(fc/field-value (:new? value)) (hx/htmx-transition-appear))
(fc/with-field :db/id
(com/hidden {:name (fc/field-name)
:value (fc/field-value)}))
(when manual?
(fc/with-field :sales-summary-item/manual?
(com/hidden {:name (fc/field-name)
:value true})))
(com/data-grid-cell {:class "align-top"}
(fc/with-field :sales-summary-item/category
(if manual?
(com/validated-field {:errors (fc/field-errors)}
(com/text-input {:placeholder "Category/Explanation"
:name (fc/field-name)
:value (fc/field-value)}))
(defn- sum-debits [items]
(->> items (keep :debit) (filter number?) (reduce + 0.0)))
(list
(com/hidden {:name (fc/field-name)
:value (fc/field-value)})
[:span.text-sm.text-gray-700
(fc/field-value (:sales-summary-item/category value))]))))
(com/data-grid-cell {:class "align-top"}
(fc/with-field :ledger-mapped/account
(com/validated-field {:errors (fc/field-errors)}
(account-typeahead* {:value (fc/field-value)
:client-id client-id
:name (fc/field-name)}))))
(com/data-grid-cell {:class "text-right align-top"}
(defn- sum-credits [items]
(->> items (keep :credit) (filter number?) (reduce + 0.0)))
(if manual?
(fc/with-field :debit
(com/validated-field {:errors (fc/field-errors)}
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
:name (fc/field-name)
:value (fc/field-value)})))
(when (= (fc/field-value (:ledger-mapped/ledger-side value))
:ledger-side/debit)
[:span.font-mono.tabular-nums.text-gray-900.text-sm.whitespace-nowrap
(format "$%,.2f" (fc/field-value (:ledger-mapped/amount value)))])))
(com/data-grid-cell {:class "text-right align-top"}
;; ---------------------------------------------------------------------------
;; Render (Selmer): account typeahead, inline account cell (display/edit),
;; the read-only auto rows, the editable manual rows, totals/balance.
;; ---------------------------------------------------------------------------
(if manual?
(fc/with-field :credit
(com/validated-field {:errors (fc/field-errors)}
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
:name (fc/field-name)
:value (fc/field-value)})))
(when (= (fc/field-value (:ledger-mapped/ledger-side value))
:ledger-side/credit)
[:span.font-mono.tabular-nums.text-gray-900.text-sm.whitespace-nowrap
(format "$%,.2f" (fc/field-value (:ledger-mapped/amount value)))])))
(com/data-grid-cell {:class "align-top"}
(when manual?
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x))))))
(defn account-typeahead* [{:keys [name value client-id]}]
(sc/typeahead {:name name
:id name
:placeholder "Search..."
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
{:client-id client-id
:purpose "invoice"})
:value value
:content-fn (fn [value]
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
client-id)))}))
(defrecord MainStep [linear-wizard]
mm/ModalWizardStep
(step-name [_]
"Main")
(step-key [_]
:main)
(defn account-display-cell*
"Account name + inline-edit pencil. The pencil swaps just this `.account-cell`
(#account-cell, Rule 2) into the edit cell."
[{:keys [index account-id client-id]}]
(let [account-id (when (and account-id (not= account-id "")) (->db-id account-id))
account-name (when account-id
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read account-id)
client-id)))]
(str "<div class=\"account-cell flex items-center gap-2\">"
(str (sc/hidden {:name (item-field-name index :ledger-mapped/account)
:value (or account-id "")}))
(if account-name
(str "<span class=\"text-sm\">" (hu/escape-html account-name) "</span>")
(str (sel/hiccup->html (com/pill {:color :red} "Missing acct"))))
(str (sc/a-icon-button {:class "p-1"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-item-account)
:hx-target "closest .account-cell"
:hx-swap "outerHTML"
:hx-vals (hx/json {:item-index index
:client-id client-id
:current-account-id (or account-id "")})}
svg/pencil))
"</div>")))
(edit-path [_ _]
[])
(defn account-edit-cell*
"The account typeahead + check (save) / cancel buttons. Each swaps just the
`.account-cell` back to the display cell."
[{:keys [index account-id client-id]}]
(str "<div class=\"account-cell flex flex-col gap-2\">"
(str (account-typeahead* {:name (item-field-name index :ledger-mapped/account)
:value account-id
:client-id client-id}))
"<div class=\"flex gap-1\">"
(str (sc/a-icon-button {:class "p-1"
:hx-put (bidi/path-for ssr-routes/only-routes ::route/save-item-account)
:hx-target "closest .account-cell"
:hx-swap "outerHTML"
:hx-include "closest .account-cell"
:hx-vals (hx/json {:item-index index :client-id client-id})}
svg/check))
(str (sc/a-icon-button {:class "p-1"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/cancel-item-account)
:hx-target "closest .account-cell"
:hx-swap "outerHTML"
:hx-vals (hx/json {:item-index index
:client-id client-id
:current-account-id (or account-id "")})}
svg/x))
"</div></div>"))
(step-schema [_]
(mut/select-keys (mm/form-schema linear-wizard) #{:db/id :sales-summary/items}))
(defn- auto-item-row*
"A read-only auto item in its Debits/Credits column: category + inline-editable account
cell + the (read-only) amount. Posts db/id, category, and account."
[index item client-id]
(let [side (item-side item)
amount (if (= side :debit) (:debit item) (:credit item))]
(str "<div class=\"flex items-center gap-2 text-sm\">"
(str (sc/hidden {:name (item-field-name index :db/id) :value (:db/id item)}))
(str (sc/hidden {:name (item-field-name index :sales-summary-item/category)
:value (:sales-summary-item/category item)}))
"<span class=\"text-gray-500 flex-1\">" (hu/escape-html (str (:sales-summary-item/category item))) "</span>"
(str (account-display-cell* {:index index
:account-id (:ledger-mapped/account item)
:client-id client-id}))
"<span class=\"ml-auto font-mono tabular-nums text-gray-900\">" (format "$%,.2f" (or amount 0.0)) "</span>"
"</div>")))
(render-step
[this {:keys [multi-form-state] :as request}]
(let [client-id (:db/id (:sales-summary/client (:snapshot multi-form-state)))
items (:sales-summary/items (:step-params multi-form-state))
sorted-items (sort-items items)
indexed-items (map-indexed vector sorted-items)
debit-items (filter #(= :ledger-side/debit (:ledger-mapped/ledger-side (second %))) indexed-items)
credit-items (filter #(= :ledger-side/credit (:ledger-mapped/ledger-side (second %))) indexed-items)
max-rows (max (count debit-items) (count credit-items))
padded-debits (concat debit-items (repeat (- max-rows (count debit-items)) nil))
padded-credits (concat credit-items (repeat (- max-rows (count credit-items)) nil))]
(mm/default-render-step
linear-wizard this
:head [:div.p-2 "Edit Summary"]
:body (mm/default-step-body
{}
[:div
(fc/with-field :db/id
(com/hidden {:name (fc/field-name)
:value (fc/field-value)}))
[:div.grid.grid-cols-2.gap-6
[:div
[:div.font-semibold.text-sm.mb-2 "Debits"]
[:div.space-y-1
(for [[actual-idx item] padded-debits]
(if item
(let [manual? (:sales-summary-item/manual? item)]
(if manual?
[:div.flex.items-center.gap-2.text-sm {:x-ref "p" :x-data (hx/json {})}
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
:value (:db/id item)})
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/manual?]")
:value "true"})
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
item []
(fc/with-field :sales-summary-item/category
(com/text-input {:placeholder "Category"
:name (fc/field-name)
:value (fc/field-value)
:class "w-32 text-sm"})))
(account-typeahead* {:name (str "step-params[sales-summary/items][" actual-idx "][ledger-mapped/account]")
:value (:ledger-mapped/account item)
:client-id client-id})
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
item []
(fc/with-field :debit
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
:name (fc/field-name)
:value (fc/field-value)})))
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x)]
[:div.flex.items-center.gap-2.text-sm
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
:value (:db/id item)})
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/category]")
:value (:sales-summary-item/category item)})
[:span.text-gray-500 (truncate (:sales-summary-item/category item) 30)]
(account-display-cell {:item (assoc item :item-index actual-idx)
:field-name-prefix (str "step-params[sales-summary/items][" actual-idx "]")
:client-id client-id})
[:span.ml-auto.font-mono.tabular-nums.text-gray-900 (format "$%,.2f" (:ledger-mapped/amount item))]]))
[:div.h-6]))]
[:div.mt-2.border-t.pt-1
(summary-total-display request)
(unbalanced-display request)]]
[:div
[:div.font-semibold.text-sm.mb-2 "Credits"]
[:div.space-y-1
(for [[actual-idx item] padded-credits]
(if item
(let [manual? (:sales-summary-item/manual? item)]
(if manual?
[:div.flex.items-center.gap-2.text-sm {:x-ref "p" :x-data (hx/json {})}
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
:value (:db/id item)})
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/manual?]")
:value "true"})
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
item []
(fc/with-field :sales-summary-item/category
(com/text-input {:placeholder "Category"
:name (fc/field-name)
:value (fc/field-value)
:class "w-32 text-sm"})))
(account-typeahead* {:name (str "step-params[sales-summary/items][" actual-idx "][ledger-mapped/account]")
:value (:ledger-mapped/account item)
:client-id client-id})
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
item []
(fc/with-field :credit
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
:name (fc/field-name)
:value (fc/field-value)})))
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x)]
[:div.flex.items-center.gap-2.text-sm
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
:value (:db/id item)})
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/category]")
:value (:sales-summary-item/category item)})
[:span.text-gray-500 (truncate (:sales-summary-item/category item) 30)]
(account-display-cell {:item (assoc item :item-index actual-idx)
:field-name-prefix (str "step-params[sales-summary/items][" actual-idx "]")
:client-id client-id})
[:span.ml-auto.font-mono.tabular-nums.text-gray-900 (format "$%,.2f" (:ledger-mapped/amount item))]]))
[:div.h-6]))]
[:div.mt-2.border-t.pt-1
(summary-total-display request)
(unbalanced-display request)]]]
[:div.mt-4.border-t.pt-2
(fc/with-field :sales-summary/items
(com/data-grid-new-row {:colspan 2
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-summary-item)
:row-offset 0
:index (count (fc/field-value))
:tr-params {:hx-vals (hx/json {:client-id client-id})}}
"New Summary Item"))]])
(defn- manual-amount-input* [index field item]
(sc/money-input {:name (item-field-name index field)
:value (get item field)
:class "w-24 text-right font-mono tabular-nums"
:placeholder (str/capitalize (clojure.core/name field))
:hx-post (bidi/path-for ssr-routes/only-routes ::route/form-changed)
:hx-target "#summary-totals"
:hx-select "#summary-totals"
:hx-swap "outerHTML"
:hx-trigger "keyup changed delay:300ms"
:hx-include "closest form"}))
:footer
(mm/default-step-footer linear-wizard this :validation-route ::route/edit-wizard-navigate)
:validation-route ::route/edit-wizard-navigate
:width-height-class "lg:w-[900px] lg:h-[600px]"))))
(defn- manual-item-row*
"An editable manual item: category + account typeahead + debit + credit money inputs +
remove. The amount inputs swap the totals block (Rule 4); remove swaps the whole form."
[index item client-id]
(str "<div class=\"manual-item-row flex items-center gap-2\">"
(str (sc/hidden {:name (item-field-name index :db/id) :value (:db/id item)}))
(str (sc/hidden {:name (item-field-name index :sales-summary-item/manual?) :value "true"}))
(str (sc/validated-field
{:errors (item-field-errors index :sales-summary-item/category) :class "flex-1"}
(sc/text-input {:name (item-field-name index :sales-summary-item/category)
:value (:sales-summary-item/category item)
:placeholder "Category/Explanation"})))
(str (sc/validated-field
{:errors (item-field-errors index :ledger-mapped/account)}
(account-typeahead* {:name (item-field-name index :ledger-mapped/account)
:value (:ledger-mapped/account item)
:client-id client-id})))
(str (manual-amount-input* index :debit item))
(str (manual-amount-input* index :credit item))
(str (sc/a-icon-button {:class "p-1 account-remove-action"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/form-changed)
:hx-vals (hx/json {:op "remove-item" :row-index index})
:hx-target "#summary-edit-form"
:hx-select "#summary-edit-form"
:hx-swap "outerHTML"
:hx-include "closest form"}
svg/x))
"</div>"))
(defn- totals*
"Swappable totals + balance block (#summary-totals). Fixes the previously-dead total /
balance display: shows the running debit/credit totals and a Balanced / Unbalanced
indicator."
[items]
(let [td (sum-debits items)
tc (sum-credits items)
balanced? (dollars= td tc)
delta (- td tc)]
(str "<div class=\"border-t pt-2 mt-2 space-y-1\">"
"<div class=\"flex justify-between text-sm font-semibold\"><span>Total</span>"
"<div class=\"flex gap-8\"><span class=\"font-mono\">" (format "$%,.2f" td) "</span>"
"<span class=\"font-mono\">" (format "$%,.2f" tc) "</span></div></div>"
(if balanced?
"<div class=\"text-sm text-emerald-700 font-semibold\">Balanced</div>"
(str "<div class=\"text-sm text-red-600 font-semibold flex justify-between\"><span>Unbalanced</span>"
"<span class=\"font-mono\">" (format "$%,.2f" (Math/abs delta)) " "
(if (pos? delta) "Debit over" "Credit over") "</span></div>"))
"</div>")))
(defn- new-item-button* []
(sc/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/form-changed)
:hx-vals (hx/json {:op "new-item"})
:hx-target "#summary-edit-form"
:hx-select "#summary-edit-form"
:hx-swap "outerHTML"
:hx-include "closest form"
:color :secondary}
"New Summary Item"))
(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" :type "submit"} "Save"))
"</div></div>")))
(defn render-form
"Renders the whole plain sales-summary edit form (no wizard). Binds *errors* so the
field-level lookups (item-field-errors) resolve. Reuses the edit modal chrome."
[request]
(binding [*errors* (or (:form-errors request) {})]
(let [{tx-id :db/id client :sales-summary/client items :sales-summary/items} (:edit-state request)
client-id (:db/id client)
indexed (map-indexed vector items)
auto (remove (fn [[_ it]] (:sales-summary-item/manual? it)) indexed)
manual (filter (fn [[_ it]] (:sales-summary-item/manual? it)) indexed)
debit-rows (->> auto
(filter (fn [[_ it]] (= :debit (item-side it))))
(map (fn [[i it]] (auto-item-row* i it client-id)))
(apply str))
credit-rows (->> auto
(filter (fn [[_ it]] (= :credit (item-side it))))
(map (fn [[i it]] (auto-item-row* i it client-id)))
(apply str))
manual-rows (->> manual
(map (fn [[i it]] (manual-item-row* i it client-id)))
(apply str))
body (sel/render "templates/sales-summary/summary-body.html"
{:debit_rows debit-rows
:credit_rows credit-rows
:totals (totals* items)
:manual_rows manual-rows
:new_item_button (str (new-item-button*))})
modal-card (sel/render "templates/transaction-edit/edit-modal.html"
{:head "<div class=\"p-2\">Edit Summary</div>"
:side_panel nil
:body body
:footer (str (footer* request))})]
(sel/render->hiccup
"templates/sales-summary/edit-form.html"
{:db_id tx-id
: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/edit-wizard-submit)})
:modal (str (sc/modal {:id "wizardmodal"} (sel/raw modal-card)))}))))
;; ---------------------------------------------------------------------------
;; State: derive the flat edit-state from the entity overlaid with the posted
;; form (replaces MultiStepFormState + the EDN snapshot round-trip).
;; ---------------------------------------------------------------------------
(defn entity->edit-state
"The persisted sales summary, shaped like the form's state: each item gets a :credit or
:debit field derived from its ledger-side/amount (what initial-edit-wizard-state did)."
[tx-id]
(let [e (dc/pull (dc/db conn) default-read tx-id)
items (->> (:sales-summary/items e)
sort-items
(mapv (fn [x]
(if (= :ledger-side/debit (:ledger-mapped/ledger-side x))
(assoc x :debit (:ledger-mapped/amount x))
(assoc x :credit (:ledger-mapped/amount x))))))]
{:db/id (:db/id e)
:sales-summary/client (:sales-summary/client e)
:sales-summary/items items}))
(defn- merge-items
"Overlay the posted items onto the persisted items by :db/id, so read-only fields the
form doesn't post (ledger-side, amount, the credit/debit shaping for auto items)
survive while edited fields (category, account, manual credit/debit) win. New manual
rows (temp db/id) have no persisted match and ride through as-is."
[entity-items posted-items]
(let [by-id (into {} (map (juxt :db/id identity)) entity-items)]
(mapv (fn [pi] (merge (get by-id (:db/id pi)) pi)) posted-items)))
(defn wrap-decode
"Parses the posted (nested) form params and decodes them straight into edit-schema --
no step-params[...] prefix. Strips to the editable top-level keys."
[handler]
(-> (fn [request]
(let [decoded (mc/decode edit-schema (:form-params request) main-transformer)
decoded (if (map? decoded) (select-keys decoded [:db/id :sales-summary/items]) {})]
(handler (assoc request :posted decoded))))
(wrap-nested-form-params)))
(defn wrap-derive-state
"Builds :edit-state from the entity (db/id hidden, or the route on initial open) overlaid
with the live posted items -- no serialized snapshot. db/id + client always come from
the entity; items are the merged posted items when present, else the entity's."
[handler]
(fn [request]
(let [tx-id (->db-id (or (some-> request :form-params (get "db/id"))
(-> request :route-params :db/id)))
base (entity->edit-state tx-id)
posted (:posted request)
items (if (contains? posted :sales-summary/items)
(merge-items (:sales-summary/items base) (:sales-summary/items posted))
(:sales-summary/items base))]
(handler (assoc request :edit-state (assoc base :sales-summary/items items))))))
(defn attach-ledger [i]
(cond-> i
@@ -645,142 +600,129 @@
:ledger-mapped/amount (:credit i))
(:debit i) (assoc :ledger-mapped/ledger-side :ledger-side/debit
:ledger-mapped/amount (:debit i))
true (dissoc :credit :debit)
true (dissoc :credit :debit :new? :item-index)
true (assoc :sales-summary-item/manual? true)))
(defrecord EditWizard [_ current-step]
mm/LinearModalWizard
(hydrate-from-request
[this request]
this)
(navigate [this step-key]
(assoc this :current-step step-key))
(get-current-step
[this]
(mm/get-step this :main))
(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/edit-wizard-submit))))
:render-timeline? false))
(steps [_]
[:main])
(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]
(->MainStep this)))
(form-schema [_]
edit-schema)
(submit [this {:keys [multi-form-state request-method identity] :as request}]
(let [result (:snapshot multi-form-state)
transaction [:upsert-sales-summary {:db/id (:db/id result)
:sales-summary/items (map
(fn [i]
(if (:sales-summary-item/manual? i)
(attach-ledger i)
{:db/id (:db/id i)
:ledger-mapped/account (:ledger-mapped/account i)}))
(:sales-summary/items result))}]]
@(dc/transact conn [transaction])
(html-response
(row* identity (dc/pull (dc/db conn) default-read (:db/id result))
{:flash? true
:request request})
:headers (cond-> {"hx-trigger" "modalclose"
"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id result))
"hx-reswap" "outerHTML"})))))
;; ---------------------------------------------------------------------------
;; form-changed ops (whole-form re-render): add / remove a manual item. A bare
;; re-render (no op) refreshes the totals block (manual amount edits).
;; ---------------------------------------------------------------------------
(def edit-wizard (->EditWizard nil nil))
(defn apply-new-item [request]
(let [items (vec (:sales-summary/items (:edit-state request)))
new-item {:db/id (str (java.util.UUID/randomUUID))
:new? true
:sales-summary-item/manual? true
:sales-summary-item/category ""}]
(assoc-in request [:edit-state :sales-summary/items] (conj items new-item))))
(defn initial-edit-wizard-state [request]
(let [entity (dc/pull (dc/db conn) default-read (:db/id (:route-params request)))
entity (select-keys entity (mut/keys edit-schema))
entity (update entity :sales-summary/items (comp #(map (fn [x]
(if (= :ledger-side/debit (:ledger-mapped/ledger-side x))
(assoc x :debit (:ledger-mapped/amount x))
(assoc x :credit (:ledger-mapped/amount x))))
%) sort-items))]
(defn apply-remove-item [request]
(let [row-index (some-> request :form-params (get "row-index") Integer/parseInt)
items (vec (:sales-summary/items (:edit-state request)))
updated (if (and row-index (< row-index (count items)))
(vec (concat (subvec items 0 row-index)
(subvec items (inc row-index))))
items)]
(assoc-in request [:edit-state :sales-summary/items] updated)))
(mm/->MultiStepFormState entity [] entity)))
(defn form-changed-handler
"Single whole-form re-render endpoint. Dispatches on `op` (add/remove a manual item);
a missing op (a manual amount keyup) just re-renders (hx-select picks #summary-totals)."
[request]
(let [op (get-in request [:form-params "op"])
request' (case op
"new-item" (apply-new-item request)
"remove-item" (apply-remove-item request)
request)]
(html-response (render-form request'))))
;; ---------------------------------------------------------------------------
;; Inline account editor (targeted .account-cell swaps -- a distinct click-to-edit
;; feature, kept as its own three small stateless routes).
;; ---------------------------------------------------------------------------
(defn edit-item-account [request]
(let [{:keys [item-index client-id current-account-id]} (:query-params request)
item-index (if (string? item-index) (Integer/parseInt item-index) item-index)
field-name-prefix (str "step-params[sales-summary/items][" item-index "]")
current-account-id (when (and current-account-id (not= current-account-id ""))
(if (string? current-account-id)
(Long/parseLong current-account-id)
current-account-id))
client-id (if (string? client-id) (Long/parseLong client-id) client-id)]
idx (if (string? item-index) (Integer/parseInt item-index) item-index)
account-id (when (and current-account-id (not= current-account-id "")) (->db-id current-account-id))]
(html-response
(account-edit-cell {:field-name-prefix field-name-prefix
:client-id client-id
:current-account-id current-account-id}))))
(sel/raw (account-edit-cell* {:index idx :account-id account-id :client-id (->db-id client-id)})))))
(defn save-item-account [request]
(let [field-name-prefix (get-in request [:params "field-name-prefix"])
client-id (get-in request [:params "client-id"])
account-input-name (str field-name-prefix "[ledger-mapped/account]")
account-id-str (get-in request [:form-params account-input-name])
account-id (when (and account-id-str (not= account-id-str ""))
(Long/parseLong account-id-str))
item {:ledger-mapped/account account-id
:item-index (second (re-find #"\[(\d+)\]" (or field-name-prefix "")))}
client-id (if (string? client-id) (Long/parseLong client-id) client-id)]
(let [item-index (get-in request [:params "item-index"])
idx (if (string? item-index) (Integer/parseInt item-index) item-index)
client-id (->db-id (get-in request [:params "client-id"]))
account-id-str (get-in request [:form-params (item-field-name idx :ledger-mapped/account)])
account-id (when (and account-id-str (not= account-id-str "")) (->db-id account-id-str))]
(html-response
(account-display-cell {:item item
:field-name-prefix field-name-prefix
:client-id client-id}))))
(sel/raw (account-display-cell* {:index idx :account-id account-id :client-id client-id})))))
(defn cancel-item-account [request]
(let [{:keys [field-name-prefix client-id current-account-id]} (:query-params request)
account-id (when (and current-account-id (not= current-account-id ""))
(if (string? current-account-id)
(Long/parseLong current-account-id)
current-account-id))
item {:ledger-mapped/account account-id
:item-index (second (re-find #"\[(\d+)\]" (or field-name-prefix "")))}
client-id (if (string? client-id) (Long/parseLong client-id) client-id)]
(let [{:keys [item-index client-id current-account-id]} (:query-params request)
idx (if (string? item-index) (Integer/parseInt item-index) item-index)
account-id (when (and current-account-id (not= current-account-id "")) (->db-id current-account-id))]
(html-response
(account-display-cell {:item item
:field-name-prefix field-name-prefix
:client-id client-id}))))
(sel/raw (account-display-cell* {:index idx :account-id account-id :client-id (->db-id client-id)})))))
;; ---------------------------------------------------------------------------
;; Open + submit
;; ---------------------------------------------------------------------------
(defn open-handler [request]
(modal-response
(sel/render->hiccup "templates/transaction-edit/transitioner.html"
{:body (str (render-form request))})))
(defn- render-form-response [request]
(html-response (render-form request)
:headers {"HX-reswap" "outerHTML"}))
(defn submit
"Validates the posted edit-state against edit-schema (field errors via wrap-form-4xx-2),
then upserts the sales summary: manual items attach-ledger (credit/debit -> ledger
side+amount), auto items update only their account."
[request]
(let [{tx-id :db/id items :sales-summary/items :as edit-state} (:edit-state request)]
(assert-schema edit-schema edit-state)
(let [transaction [:upsert-sales-summary
{:db/id tx-id
:sales-summary/items (map (fn [i]
(if (:sales-summary-item/manual? i)
(attach-ledger i)
{:db/id (:db/id i)
:ledger-mapped/account (:ledger-mapped/account i)}))
items)}]]
@(dc/transact conn [transaction])
(html-response
(row* (:identity request) (dc/pull (dc/db conn) default-read tx-id)
{:flash? true
:request request})
:headers {"hx-trigger" "modalclose"
"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" tx-id)
"hx-reswap" "outerHTML"}))))
(def key->handler
(apply-middleware-to-all-handlers
(->>
{::route/page (helper/page-route grid-page)
::route/table (helper/table-route grid-page)
::route/edit-wizard (-> mm/open-wizard-handler
(mm/wrap-wizard edit-wizard)
(mm/wrap-init-multi-form-state initial-edit-wizard-state)
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
::route/edit-wizard-navigate (-> mm/next-handler
(mm/wrap-wizard edit-wizard)
(mm/wrap-decode-multi-form-state))
::route/new-summary-item (-> (add-new-entity-handler [:step-params :sales-summary/items]
(fn render [cursor request]
(sales-summary-item-row*
{:value cursor
:client-id (:client-id (:query-params request))}))
(fn build-new-row [base _]
(assoc base :sales-summary-item/manual? true)))
{::route/page (helper/page-route grid-page)
::route/table (helper/table-route grid-page)
::route/edit-wizard (-> open-handler
(wrap-derive-state)
(wrap-decode)
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
::route/form-changed (-> form-changed-handler
(wrap-derive-state)
(wrap-decode))
::route/edit-item-account (-> edit-item-account
(wrap-schema-enforce :query-schema [:map
[:client-id {:optional true}
[:maybe entity-id]]]))
::route/edit-item-account (-> edit-item-account
(wrap-schema-enforce :query-schema [:map
[:item-index nat-int?]
[:client-id {:optional true} [:maybe entity-id]]
[:current-account-id {:optional true} [:maybe :string]]]))
::route/save-item-account save-item-account
::route/cancel-item-account cancel-item-account
::route/edit-wizard-submit (-> mm/submit-handler
(mm/wrap-wizard edit-wizard)
(mm/wrap-decode-multi-form-state))})
[:item-index nat-int?]
[:client-id {:optional true} [:maybe entity-id]]
[:current-account-id {:optional true} [:maybe :string]]]))
::route/save-item-account save-item-account
::route/cancel-item-account cancel-item-account
::route/edit-wizard-submit (-> submit
(wrap-form-4xx-2 render-form-response)
(wrap-derive-state)
(wrap-decode))}
(fn [h]
(-> h
(wrap-copy-qp-pqp)