Files
integreat/src/clj/auto_ap/ssr/pos/sales_summaries.clj
Bryce 599b849e6f 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>
2026-06-24 22:13:19 -07:00

733 lines
38 KiB
Clojure

(ns auto-ap.ssr.pos.sales-summaries
(:require
[auto-ap.datomic
:refer [apply-pagination apply-sort-3 conn merge-query pull-many
query2]]
[auto-ap.datomic.accounts :as d-accounts]
[auto-ap.graphql.utils :refer [extract-client-ids]]
[auto-ap.query-params :refer [wrap-copy-qp-pqp]]
[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.components :as com]
[auto-ap.ssr.components.link-dropdown :refer [link-dropdown]]
[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 [->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=]]
[malli.core :as mc]))
(def query-schema (mc/schema
[:maybe
(into [:map {:date-range [:date-range :start-date :end-date]}
[:start-date {:optional true}
[:maybe clj-date-schema]]
[:end-date {:optional true}
[:maybe clj-date-schema]]]
default-grid-fields-schema)]))
(defn filters [request]
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes
::route/table)
"hx-target" "#entity-table"
"hx-indicator" "#entity-table"}
[:fieldset.space-y-6
(date-range-field* request)]])
(def default-read '[:db/id
*
[:sales-summary/date :xform clj-time.coerce/from-date]
{:sales-summary/client [:client/code :client/name :db/id]}
{:sales-summary/items [{[:ledger-mapped/ledger-side :xform iol-ion.query/ident] [:db/ident]}
:ledger-mapped/account
:ledger-mapped/amount
:sales-summary-item/category
:sales-summary-item/sort-order
:db/id
:sales-summary-item/manual?]}
{:journal-entry/original-entity [:db/id]}])
(defn fetch-ids [db request]
(let [query-params (:query-params request)
valid-clients (extract-client-ids (:clients request)
(:client request)
(:client-id query-params)
(when (:client-code query-params)
[:client/code (:client-code query-params)]))
query (cond-> {:query {:find []
:in '[$ [?client ...]]
:where '[[?e :sales-summary/client ?client]]}
:args [db valid-clients]}
(or (:start-date query-params)
(:end-date query-params))
(merge-query {:query '{:where [[?e :sales-summary/date ?d]]}})
(:start-date query-params)
(merge-query {:query '{:in [?start-date]
:where [[(>= ?d ?start-date)]]}
:args [(-> query-params :start-date c/to-date)]})
(:end-date query-params)
(merge-query {:query '{:in [?end-date]
:where [[(< ?d ?end-date)]]}
:args [(-> query-params :end-date c/to-date)]})
true
(merge-query {:query {:find ['?sort-default '?e]
:where ['[?e :sales-summary/date ?sort-default]]}}))]
(cond->> (query2 query)
true (apply-sort-3 query-params)
true (apply-pagination query-params))))
(defn hydrate-results [ids db _]
(let [results (->> (pull-many db default-read ids)
(group-by :db/id))
refunds (->> ids
(map results)
(map first))]
refunds))
(defn fetch-page [request]
(let [db (dc/db conn)
{ids-to-retrieve :ids matching-count :count} (fetch-ids db request)]
[(->> (hydrate-results ids-to-retrieve db request))
matching-count]))
(defn sort-items [ss]
(sort-by (juxt :ledger-mapped/ledger-side :sales-summary-item/sort-order :sales-summary-item/category) ss))
(defn total-debits [items]
(->> items
(filter #(= :ledger-side/debit (:ledger-mapped/ledger-side %)))
(map #(:ledger-mapped/amount % 0.0))
(reduce + 0.0)))
(defn total-credits [items]
(->> items
(filter #(= :ledger-side/credit (:ledger-mapped/ledger-side %)))
(map #(:ledger-mapped/amount % 0.0))
(reduce + 0.0)))
(defn truncate [s max-len]
(if (> (count s) max-len)
(str (subs s 0 (- max-len 3)) "...")
s))
(def grid-page
(helper/build {:id "entity-table"
:id-fn :db/id
:nav com/main-aside-nav
:fetch-page fetch-page
:page-specific-nav filters
:query-schema query-schema
:row-buttons (fn [_ entity]
[(com/icon-button {:hx-get (bidi/path-for ssr-routes/only-routes
::route/edit-wizard
:db/id (:db/id entity))}
svg/pencil)])
:oob-render
(fn [request]
[(assoc-in (date-range-field* request) [1 :hx-swap-oob] true)])
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes
:company)}
"POS"]
[:a {:href (bidi/path-for ssr-routes/only-routes
::route/page)}
"Sales Summaries"]]
:title "Sales Summaries"
:entity-name "Daily Summary"
:route ::route/table
:headers [{:key "client"
:name "Client"
:sort-key "client"
:hide? (fn [args]
(= (count (:clients args)) 1))
:render #(-> % :sales-summary/client :client/code)}
{:key "date"
:name "Date"
:sort-key "date"
:render #(some-> % :sales-summary/date (atime/unparse-local atime/normal-date))}
{:key "debits"
:name "Debits"
:sort-key "debits"
:class "w-72 align-top"
:render (fn [ss]
(let [items (:sales-summary/items ss)
debit-items (filter #(= :ledger-side/debit (:ledger-mapped/ledger-side %)) (sort-items items))
credit-count (count (filter #(= :ledger-side/credit (:ledger-mapped/ledger-side %)) items))
total-debits (total-debits items)]
[:div.flex.flex-col.h-full
[:ul.flex-grow
(for [si debit-items]
[:li.flex.items-baseline.gap-2.py-0.5.text-sm.text-gray-700
[:span.flex-1.min-w-0.truncate.text-gray-600
(:sales-summary-item/category si)]
(when-not (:ledger-mapped/account si)
[:span.shrink-0 (com/pill {:color :red} "?")])
[: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 " "])]
[: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
(format "$%,.2f" total-debits)]]]))}
{:key "credits"
:name "Credits"
:sort-key "credits"
:class "w-72 align-top"
:render (fn [ss]
(let [items (:sales-summary/items ss)
credit-items (filter #(= :ledger-side/credit (:ledger-mapped/ledger-side %)) (sort-items items))
debit-count (count (filter #(= :ledger-side/debit (:ledger-mapped/ledger-side %)) items))
total-credits (total-credits items)]
[:div.flex.flex-col.h-full
[:ul.flex-grow
(for [si credit-items]
[:li.flex.items-baseline.gap-2.py-0.5.text-sm.text-gray-700
[:span.flex-1.min-w-0.truncate.text-gray-600
(:sales-summary-item/category si)]
(when-not (:ledger-mapped/account si)
[:span.shrink-0 (com/pill {:color :red} "?")])
[: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 " "])]
[: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
(format "$%,.2f" total-credits)]]]))}
{:key "balance"
:name "Status"
:sort-key "balance"
:class "w-28 align-top"
:render (fn [ss]
(let [items (:sales-summary/items ss)
total-debits (total-debits items)
total-credits (total-credits items)
delta (- total-debits total-credits)
balanced? (dollars= total-debits total-credits)
missing-account? (some #(not (:ledger-mapped/account %)) items)]
[:div.flex.flex-col.items-center.gap-1.pt-2
(when missing-account?
[:span.inline-block.text-xs.font-semibold.uppercase.tracking-wider.text-amber-800.bg-amber-100.border.border-amber-300.rounded-sm.px-1.5.py-0.5
"Missing acct"])
(if balanced?
(when-not missing-account?
[:span.inline-block.text-xs.font-semibold.uppercase.tracking-wider.text-emerald-800.bg-emerald-100.border.border-emerald-300.rounded-sm.px-1.5.py-0.5
"Balanced"])
[:div.flex.flex-col.items-center
[:span.font-mono.tabular-nums.text-red-700.font-bold.text-sm
(format "$%,.2f" (Math/abs delta))]
[:span.text-xs.uppercase.tracking-wider.text-red-600.font-medium.mt-0.5
(if (> total-debits total-credits) "Debit over" "Credit over")]])]))}
{:key "links"
:name "Links"
:show-starting "lg"
:class "w-8"
:render (fn [ss]
(let [ledger-entry (:journal-entry/original-entity ss)]
(when (seq ledger-entry)
(link-dropdown
[{:link (hu/url (bidi/path-for client-routes/routes :ledger)
{:exact-match-id (:db/id (first ledger-entry))})
:color :yellow
:content "Ledger entry"}]))))}]}))
(def row* (partial helper/row* grid-page))
(def table* (partial helper/table* grid-page))
(def edit-schema
[:map
[:db/id entity-id]
[:sales-summary/client [:map [:db/id entity-id]]]
[:sales-summary/items
[:vector {:coerce? true}
[:and
[:map
[:db/id [:or entity-id temp-id]]
[:sales-summary-item/category [:string {:decode/string strip}]]
[:sales-summary-item/manual? {:default false :decode/arbitrary (fn [x] (cond
(boolean? x)
x
(nil? x)
false
(str/blank? x)
false
:else
true))} :boolean]
[:ledger-mapped/account entity-id]
[:credit {:optional true} [:maybe money]]
[:debit {:optional true} [:maybe money]]]
[:fn {:error/message "Must choose one of credit/debit"
:error/path [:credit]}
(fn [x]
(not (and (:credit x)
(:debit x))))]]]]])
;; ---------------------------------------------------------------------------
;; Field-name / error helpers (no step-params[...] prefix) + item helpers.
;; Mirrors transaction/edit.clj and transaction/bulk_code.clj.
;; ---------------------------------------------------------------------------
(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- ferr [& path]
(get-in *errors* (vec path)))
(defn- item-field-name [index field]
(path->name2 :sales-summary/items index field))
(defn- item-field-errors [index field]
(ferr :sales-summary/items index field))
(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- sum-debits [items]
(->> items (keep :debit) (filter number?) (reduce + 0.0)))
(defn- sum-credits [items]
(->> items (keep :credit) (filter number?) (reduce + 0.0)))
;; ---------------------------------------------------------------------------
;; Render (Selmer): account typeahead, inline account cell (display/edit),
;; the read-only auto rows, the editable manual rows, totals/balance.
;; ---------------------------------------------------------------------------
(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)))}))
(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>")))
(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>"))
(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>")))
(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"}))
(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
(:credit i) (assoc :ledger-mapped/ledger-side :ledger-side/credit
:ledger-mapped/amount (:credit i))
(:debit i) (assoc :ledger-mapped/ledger-side :ledger-side/debit
:ledger-mapped/amount (:debit i))
true (dissoc :credit :debit :new? :item-index)
true (assoc :sales-summary-item/manual? true)))
;; ---------------------------------------------------------------------------
;; 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).
;; ---------------------------------------------------------------------------
(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 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)))
(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)
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
(sel/raw (account-edit-cell* {:index idx :account-id account-id :client-id (->db-id client-id)})))))
(defn save-item-account [request]
(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
(sel/raw (account-display-cell* {:index idx :account-id account-id :client-id client-id})))))
(defn cancel-item-account [request]
(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
(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 (-> 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
[: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)
(wrap-apply-sort grid-page)
(wrap-merge-prior-hx)
(wrap-schema-enforce :query-schema query-schema)
(wrap-schema-enforce :hx-schema query-schema)))))