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>
733 lines
38 KiB
Clojure
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)))))
|