From 599b849e6fee745316f572cb719294959a0e3f32 Mon Sep 17 00:00:00 2001 From: Bryce Date: Wed, 24 Jun 2026 22:13:19 -0700 Subject: [PATCH] =?UTF-8?q?refactor(ssr):=20Phase=204=20=E2=80=94=20full?= =?UTF-8?q?=20Selmer=20migration=20of=20POS=20Sales=20Summary;=20remove=20?= =?UTF-8?q?the=20wizard;=20fix=20add-item=20+=20totals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../reference/component-cookbook.md | 22 + .../ssr-form-migration/reference/gotchas.md | 29 + .../ssr-form-migration/reference/scorecard.md | 25 + e2e/sales-summary-edit.spec.ts | 49 +- .../templates/sales-summary/edit-form.html | 4 + .../templates/sales-summary/summary-body.html | 4 + src/clj/auto_ap/ssr/pos/sales_summaries.clj | 864 ++++++++---------- .../auto_ap/routes/pos/sales_summaries.cljc | 11 +- 8 files changed, 524 insertions(+), 484 deletions(-) create mode 100644 resources/templates/sales-summary/edit-form.html create mode 100644 resources/templates/sales-summary/summary-body.html diff --git a/.claude/skills/ssr-form-migration/reference/component-cookbook.md b/.claude/skills/ssr-form-migration/reference/component-cookbook.md index 79919252..1a6ae6ef 100644 --- a/.claude/skills/ssr-form-migration/reference/component-cookbook.md +++ b/.claude/skills/ssr-form-migration/reference/component-cookbook.md @@ -165,6 +165,28 @@ the interop bridge; dynamic HTMX/Alpine attrs go through `sc/attrs->str` → |---------|---------|-------| | `sc/hidden` / `sc/text-input` / `sc/money-input` | `hidden`/`text-input`/`money-input`.html | leaf inputs; class via `inputs/default-input-classes` + `use-size` | | `sc/select` (Phase 3) | `select.html` | generic `{{ modal|safe }} diff --git a/resources/templates/sales-summary/summary-body.html b/resources/templates/sales-summary/summary-body.html new file mode 100644 index 00000000..16cc4e50 --- /dev/null +++ b/resources/templates/sales-summary/summary-body.html @@ -0,0 +1,4 @@ +{# Sales-summary modal body: a read-only Debits | Credits two-column view of the auto + items (each account is inline-editable), a swappable totals/balance block, and an + editable Manual Items section with a working "New Summary Item" add. #} +
Debits
{{ debit_rows|safe }}
Credits
{{ credit_rows|safe }}
{{ totals|safe }}
Manual Items
{{ manual_rows|safe }}
{{ new_item_button|safe }}
diff --git a/src/clj/auto_ap/ssr/pos/sales_summaries.clj b/src/clj/auto_ap/ssr/pos/sales_summaries.clj index 575dda3f..95774393 100644 --- a/src/clj/auto_ap/ssr/pos/sales_summaries.clj +++ b/src/clj/auto_ap/ssr/pos/sales_summaries.clj @@ -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 "
" + (str (sc/hidden {:name (item-field-name index :ledger-mapped/account) + :value (or account-id "")})) + (if account-name + (str "" (hu/escape-html account-name) "") + (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)) + "
"))) - (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 "
" + (str (account-typeahead* {:name (item-field-name index :ledger-mapped/account) + :value account-id + :client-id client-id})) + "
" + (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)) + "
")) - (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 "
" + (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)})) + "" (hu/escape-html (str (:sales-summary-item/category item))) "" + (str (account-display-cell* {:index index + :account-id (:ledger-mapped/account item) + :client-id client-id})) + "" (format "$%,.2f" (or amount 0.0)) "" + "
"))) - (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 "
" + (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)) + "
")) + +(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 "
" + "
Total" + "
" (format "$%,.2f" td) "" + "" (format "$%,.2f" tc) "
" + (if balanced? + "
Balanced
" + (str "
Unbalanced" + "" (format "$%,.2f" (Math/abs delta)) " " + (if (pos? delta) "Debit over" "Credit over") "
")) + "
"))) + +(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 "
" + (when (seq errors) + (str "

" + (str/join ", " (filter string? errors)) + "

")) + "
")) + +(defn- footer* [request] + (sel/raw + (str "
" + (form-errors-html (:errors (:form-errors request))) + (str (sc/button {:color :primary :x-ref "next" :class "w-32" :type "submit"} "Save")) + "
"))) + +(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 "
Edit Summary
" + :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) diff --git a/src/cljc/auto_ap/routes/pos/sales_summaries.cljc b/src/cljc/auto_ap/routes/pos/sales_summaries.cljc index a5b7c32a..b5028eda 100644 --- a/src/cljc/auto_ap/routes/pos/sales_summaries.cljc +++ b/src/cljc/auto_ap/routes/pos/sales_summaries.cljc @@ -1,10 +1,9 @@ (ns auto-ap.routes.pos.sales-summaries) -(def routes {"" {:get ::page - :put ::edit-wizard-submit} +(def routes {"" {:get ::page + :put ::edit-wizard-submit} "/table" ::table ["/" [#"\d+" :db/id]] {:get ::edit-wizard} - "/edit/navigate" ::edit-wizard-navigate - "/edit/sales-summary-item" ::new-summary-item - "/edit/item-account" ::edit-item-account - "/edit/save-item-account" ::save-item-account + "/edit/form-changed" ::form-changed + "/edit/item-account" ::edit-item-account + "/edit/save-item-account" ::save-item-account "/edit/cancel-item-account" ::cancel-item-account})