From 2bf87056d76213c9c31e2a40a5691d15f1b84238 Mon Sep 17 00:00:00 2001 From: Bryce Date: Wed, 24 Jun 2026 23:09:37 -0700 Subject: [PATCH] =?UTF-8?q?refactor(ssr):=20Phase=205=20=E2=80=94=20full?= =?UTF-8?q?=20Selmer=20migration=20of=20Invoice=20Bulk=20Edit;=20remove=20?= =?UTF-8?q?the=20wizard;=20implement=20live=20totals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates the Invoice Bulk Edit modal off the wizard to a plain Selmer form, building on the parity gate. Structurally Phase 3's bulk-code applied to invoices (selected entities -> expense-account rows), so near-pure reuse of bulk-code's flat-state plumbing + edit's account-totals-tbody. What changed - Wizard removed: deleted BulkEditWizard/AccountsStep records, MultiStepFormState, the step-params[...] prefix, the EDN snapshot, and all mm/* for this modal. Replaced with a plain handler + flat wrap-bulk-state (decode straight into bulk-edit-schema, no snapshot). - Selection-as-ids round-trip: the non-editable invoice selection is resolved to a concrete not-locked id vector at open and ridden back in hidden ids[] fields (the bulk analog of edit's single db/id) -- no filter re-query. - De-cursored bulk-edit-account-row* to Selmer (sc/*), explicit-id location swap (#account-location-, replacing the old find * swap), reusing tx-edit/location-select*. - 100% Selmer modal render path; the surgical edit was done with the text-based Edit tool (the clojure-mcp structural tools reformat the whole 1812-line file), so the diff is contained to the requires + the bulk-edit region. - Routes 5 -> 3: GET bulk-edit, PUT bulk-edit-submit, POST bulk-edit-form-changed (one whole-form op dispatcher folding the old new-account route). Implemented the dead totals - The wizard's TOTAL/BALANCE percentage rows were commented out (#_(...)) with a duplicate id="total". Implemented as a #expense-totals sibling- refreshed by a Rule-4 percentage-keyup swap (the new-account + total + balance routes all fold into form-changed / the sibling-tbody). Scorecard delta (bulk-edit modal): wizard records 2->0, bulk-edit routes 5->3, step-params/fc-cursor (modal) ->0, location swap find *-> explicit-id, totals dead->implemented. Verification: invoice-bulk-edit spec 5/5 (incl. add-row, save, validation, the implemented totals); full Playwright suite 50/50; cljfmt clean; diff confined to the modal region. Skill fed: scorecard row + settled repeated-row target-selector convention; gotcha (structural tools reformat large files -> use text Edit). Co-Authored-By: Claude Opus 4.8 --- .../ssr-form-migration/reference/gotchas.md | 12 + .../ssr-form-migration/reference/scorecard.md | 28 + e2e/invoice-bulk-edit.spec.ts | 27 +- .../invoice-bulk-edit/edit-form.html | 4 + .../invoice-bulk-edit/expense-totals.html | 5 + src/clj/auto_ap/ssr/invoices.clj | 560 ++++++++++-------- src/cljc/auto_ap/routes/invoice.cljc | 4 +- 7 files changed, 390 insertions(+), 250 deletions(-) create mode 100644 resources/templates/invoice-bulk-edit/edit-form.html create mode 100644 resources/templates/invoice-bulk-edit/expense-totals.html diff --git a/.claude/skills/ssr-form-migration/reference/gotchas.md b/.claude/skills/ssr-form-migration/reference/gotchas.md index ca52cfe8..8881fbf6 100644 --- a/.claude/skills/ssr-form-migration/reference/gotchas.md +++ b/.claude/skills/ssr-form-migration/reference/gotchas.md @@ -253,6 +253,18 @@ A money/text input wired `hx-trigger="keyup changed delay:300ms"` does **not** f swap actually triggers. (A `change`-triggered control is the opposite — `dispatchEvent('change')` is fine there.) +## clojure-mcp structural edits reformat the whole file — use text Edit in big shared files + +`clojure_edit` / `clojure_edit_replace_sexp` re-emit the **entire file** through the formatter. +In a small single-modal file that's fine (cljfmt-clean output). In a **large multi-modal file** +(Phase 5: `invoices.clj`, 1812 lines) a one-line require addition produced a **650-line spurious +whitespace diff** that buries the real change and makes review impossible. For a surgical +migration inside a big shared file, use the **text-based Edit tool** (exact-string match — no +reformat); this is the AGENTS.md "edit Clojure with file tools only when absolutely necessary" +carve-out. Verify with `load-file` (compile) + `lein cljfmt check`, not by eyeballing. Confirm the +diff is contained with `git diff -U0 | grep '^@@'` — the hunks should cluster only where you +edited (requires + the modal region), nothing else. + ## Scorecard exceptions (ratchet violations with a reason) **Heuristic 4 (LOC net ↓) — exception (Phase 3, Transaction Bulk Code: 420→506).** When the diff --git a/.claude/skills/ssr-form-migration/reference/scorecard.md b/.claude/skills/ssr-form-migration/reference/scorecard.md index b069d654..706a7c95 100644 --- a/.claude/skills/ssr-form-migration/reference/scorecard.md +++ b/.claude/skills/ssr-form-migration/reference/scorecard.md @@ -133,3 +133,31 @@ Each migration appends one row (after-numbers), referencing the before in the di > proper `#summary-totals` block shows running Total + Balanced/Unbalanced (a Rule-4 targeted swap > on manual amount edits). The spec was updated to assert the fixed behavior. New cookbook entries: > the **inline click-to-edit cell** and the **db/id-keyed item merge** for partially-posted rows. + +> **Phase 5 — Invoice Bulk Edit (cold apply in a 1812-line shared file).** Structurally +> Phase 3's bulk-code applied to invoices (selected entities → expense-account rows: +> account/location/percentage), so it was near-pure reuse: bulk-code's flat-state plumbing +> (ids round-trip, `wrap-bulk-state`, schema/decode) + edit's `account-totals-tbody` for the +> live totals. `BulkEditWizard`/`AccountsStep` + `MultiStepFormState` deleted, `step-params` +> dropped, the rows de-cursored to Selmer with the explicit-id location swap, bulk-edit +> routes 5→**3** (the `new-account` + `total` + `balance` routes folded into one +> `form-changed` op dispatcher + the sibling-`` totals swap). **Implemented the dead +> TOTAL/BALANCE display** (the wizard had them commented out with a duplicate `id="total"`) +> as a `#expense-totals` sibling-`` refreshed by a Rule-4 percentage-keyup swap. +> Parity held: invoice-bulk-edit spec 5/5, full suite 50/50. +> +> **Editing a wizard buried in a large shared file:** the clojure-mcp structural tools +> (`clojure_edit` / `replace_sexp`) **reformat the whole file** — here that was a spurious +> 650-line whitespace diff that would bury the real change. For a surgical migration inside a +> big multi-modal file, use the **text-based Edit tool** instead (the AGENTS.md "absolutely +> necessary" carve-out), then `load-file` + `cljfmt` to verify. The resulting diff was fully +> contained to the requires + the bulk-edit region. +> +> **Repeated-row target-selector convention — settled (the Phase 5 exit criterion).** Across +> edit / bulk-code / sales-summary / invoice-bulk-edit the convention converged on: **explicit +> per-row ids** (`#account-location-`, `#account-row-`) for a cell-local swap +> (Rule 2), and a **single stable-id sibling-``** (`#account-totals` / `#expense-totals`) +> for running totals (Rule 4) — *not* data-attribute selectors or a `form-path→selector` +> helper. Per-row ids are generated from the row index the form already uses for field names +> (`path->name2`), so server and markup agree by construction. Whole-form swap (Rule 3) covers +> structural changes (add/remove row). This is now the cookbook default; see `swap-doctrine.md`. diff --git a/e2e/invoice-bulk-edit.spec.ts b/e2e/invoice-bulk-edit.spec.ts index 444cc2f3..1e73eb3b 100644 --- a/e2e/invoice-bulk-edit.spec.ts +++ b/e2e/invoice-bulk-edit.spec.ts @@ -36,7 +36,7 @@ async function addNewAccount(page: any) { // one (Solr-backed typeahead is unavailable in tests), then dispatching the location // reload -- the same approach the bulk-code spec uses. async function setRowAccount(page: any, rowIndex: number, accountId: string) { - const rows = page.locator('#wizard-form tbody tr'); + const rows = page.locator('#bulk-edit-form tbody tr'); const row = rows.nth(rowIndex); const hidden = row.locator('input[type="hidden"][name*="[account]"]').first(); await hidden.evaluate((el: HTMLInputElement, value: string) => { @@ -53,7 +53,7 @@ async function setRowAccount(page: any, rowIndex: number, accountId: string) { } async function setRowPercentage(page: any, rowIndex: number, pct: string) { - const row = page.locator('#wizard-form tbody tr').nth(rowIndex); + const row = page.locator('#bulk-edit-form tbody tr').nth(rowIndex); const input = row.locator('input.amount-field, input[name*="percentage"]').first(); await input.fill(pct); await input.dispatchEvent('change'); @@ -61,7 +61,7 @@ async function setRowPercentage(page: any, rowIndex: number, pct: string) { } async function submitForm(page: any) { - await page.locator('#wizard-form').evaluate((f: HTMLFormElement) => + await page.locator('#bulk-edit-form').evaluate((f: HTMLFormElement) => f.dispatchEvent(new Event('submit', { bubbles: true }))); } @@ -80,7 +80,7 @@ test.describe('Invoice Bulk Edit (characterization)', () => { await expect(modal).toContainText('TOTAL'); await expect(modal).toContainText('BALANCE'); // a default expense-account row is present, plus the New account button - expect(await modal.locator('input[name*="[expense-accounts]"][name*="[account]"]').count()).toBeGreaterThanOrEqual(1); + expect(await modal.locator('input[name*="expense-accounts"][name*="[account]"]').count()).toBeGreaterThanOrEqual(1); await expect(modal.locator('a:has-text("New account")')).toBeVisible(); }); @@ -89,7 +89,7 @@ test.describe('Invoice Bulk Edit (characterization)', () => { await selectFirstInvoice(page); await openBulkEditModal(page); - const accountRows = () => page.locator('#wizard-form input[name*="[expense-accounts]"][name*="[account]"]'); + const accountRows = () => page.locator('#bulk-edit-form input[name*="expense-accounts"][name*="[account]"]'); const before = await accountRows().count(); await addNewAccount(page); expect(await accountRows().count()).toBe(before + 1); @@ -110,6 +110,23 @@ test.describe('Invoice Bulk Edit (characterization)', () => { await expect(page.locator('#wizardmodal')).toBeHidden({ timeout: 8000 }); }); + // The TOTAL/BALANCE percentage rows were dead code in the wizard (commented out, with a + // duplicate id="total"); the migration implements them as a sibling- Rule-4 swap. + test('TOTAL/BALANCE percentages render and recompute on edit (implemented)', async ({ page }) => { + await navigateToInvoices(page); + await selectFirstInvoice(page); + await openBulkEditModal(page); + + // default row is 100% -> TOTAL 100.0% + await expect(page.locator('#expense-totals')).toContainText('100.0%'); + // edit to 50% -> the totals tbody refreshes via the targeted swap + const pct = page.locator('#bulk-edit-form input.amount-field').first(); + await pct.click(); + await pct.fill(''); + await pct.pressSequentially('50'); // keyup -> Rule-4 swap of #expense-totals + await expect(page.locator('#expense-totals')).toContainText('50.0%', { timeout: 5000 }); + }); + test('rejects when account percentages do not total 100%', async ({ page }) => { const info = await getTestInfo(page); await navigateToInvoices(page); diff --git a/resources/templates/invoice-bulk-edit/edit-form.html b/resources/templates/invoice-bulk-edit/edit-form.html new file mode 100644 index 00000000..204e515d --- /dev/null +++ b/resources/templates/invoice-bulk-edit/edit-form.html @@ -0,0 +1,4 @@ +{# Top-level plain bulk-edit form (no wizard). The resolved (not-locked) invoice id set + rides in hidden ids[] fields so the selection survives form-changed / submit posts + without an EDN snapshot or a filter round-trip. #} +
{{ ids_hidden|safe }}{{ modal|safe }}
diff --git a/resources/templates/invoice-bulk-edit/expense-totals.html b/resources/templates/invoice-bulk-edit/expense-totals.html new file mode 100644 index 00000000..98883251 --- /dev/null +++ b/resources/templates/invoice-bulk-edit/expense-totals.html @@ -0,0 +1,5 @@ +{# Running TOTAL / BALANCE percentage rows in their own swappable , a sibling of + the input rows, so a percentage edit refreshes them with a targeted swap (Rule 4) and + never replaces the input-bearing rows above. Replaces the old per-cell bulk-edit-total + / bulk-edit-balance routes. #} +{{ rows|safe }} diff --git a/src/clj/auto_ap/ssr/invoices.clj b/src/clj/auto_ap/ssr/invoices.clj index 3ebb2e89..2293e6b3 100644 --- a/src/clj/auto_ap/ssr/invoices.clj +++ b/src/clj/auto_ap/ssr/invoices.clj @@ -31,8 +31,12 @@ [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.components.selmer :as sc] [auto-ap.ssr.form-cursor :as fc] [auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]] + [auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]] + [auto-ap.ssr.selmer :as sel] + [auto-ap.ssr.transaction.edit :as tx-edit] [auto-ap.ssr.hiccup-helper :as hh] [auto-ap.ssr.hx :as hx] [auto-ap.ssr.invoice.common :refer [default-read]] @@ -41,11 +45,11 @@ [auto-ap.ssr.components.date-range :as dr] [auto-ap.ssr.svg :as svg] [auto-ap.ssr.utils - :refer [apply-middleware-to-all-handlers assert-schema + :refer [->db-id apply-middleware-to-all-handlers assert-schema clj-date-schema dissoc-nil-transformer entity-id form-validation-error html-response main-transformer - many-entity modal-response money percentage - ref->enum-schema round-money strip wrap-entity + many-entity modal-response money path->name2 percentage + ref->enum-schema round-money strip wrap-entity wrap-form-4xx-2 wrap-implied-route-param wrap-merge-prior-hx wrap-schema-enforce]] [auto-ap.time :as atime] @@ -1433,32 +1437,83 @@ target-route) (:query-params request)))}})) -(defn initial-bulk-edit-state [request] - (mm/->MultiStepFormState {:search-params (:query-params request) - :expense-accounts [{:db/id "123" - :location "Shared" - :account nil - :percentage 1.0}]} - [] - {:search-params (:query-params request) - :expense-accounts [{:db/id "123" - :location "Shared" - :account nil - :percentage 1.0}]})) +;; --------------------------------------------------------------------------- +;; Flat state plumbing for the bulk-edit modal (replaces the wizard + +;; MultiStepFormState + the EDN snapshot). Mirrors transaction/bulk_code.clj. +;; --------------------------------------------------------------------------- + +(declare all-ids-not-locked) + +(def ^:dynamic *errors* + "Humanized form errors for the current bulk-edit render, keyed by schema paths + (e.g. {:expense-accounts {0 {:location [\"required\"]}}}). Bound by render-form." + {}) + +(defn- ferr [& path] + (get-in *errors* (vec path))) + +(defn- account-field-name [index field] + (path->name2 :expense-accounts index field)) + +(defn- account-field-errors [index field] + (ferr :expense-accounts index field)) + +(def bulk-edit-schema + (mc/schema [:map + [:ids {:optional true} [:maybe [:vector {:coerce? true} entity-id]]] + [:expense-accounts {:optional true} + [:maybe + [:vector {:coerce? true} + [:map + [:db/id {:optional true} [:maybe :string]] + [:account entity-id] + [:location [:string {:min 1 :error/message "required"}]] + [:percentage percentage]]]]]])) + +(def ^:private bulk-edit-form-keys [:expense-accounts]) + +(defn- default-expense-row [] + {:db/id (str (java.util.UUID/randomUUID)) + :location "Shared" + :account nil + :percentage 1.0}) + +(defn wrap-bulk-state + "Decodes the posted form into the flat bulk-edit state and resolves the target invoice + id set. On open (GET) the selection comes from the grid query-params (selected / + all-selected + filters); on every post the concrete (not-locked) id list rides back in + hidden ids[] fields, so no EDN snapshot / filter round-trip is needed." + [handler] + (-> (fn [request] + (let [decoded (mc/decode bulk-edit-schema (:form-params request) main-transformer) + decoded (if (map? decoded) decoded {}) + posted-ids (some->> (:ids decoded) (keep ->db-id) vec) + ids (if (seq posted-ids) + posted-ids + (vec (all-ids-not-locked (selected->ids request (:query-params request))))) + accounts (or (seq (:expense-accounts decoded)) [(default-expense-row)])] + (handler (assoc request :bulk-state {:ids ids :expense-accounts (vec accounts)})))) + (wrap-nested-form-params))) + +(defn- single-client-id + "The client id if the user has access to exactly one client, nil otherwise (the bulk + set may span clients)." + [request] + (when (= 1 (count (:clients request))) + (-> request :clients first :db/id))) (defn- account-typeahead* [{:keys [name value client-id x-model]}] - [:div.flex.flex-col - (com/typeahead {:name name - :placeholder "Search..." - :url (hu/url (bidi/path-for ssr-routes/only-routes :account-search) - {:purpose "invoice"}) - :id name - :x-model x-model - :value value - :content-fn (fn [value] - (:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value) - client-id)))})]) + (sc/typeahead {:name name + :placeholder "Search..." + :url (hu/url (bidi/path-for ssr-routes/only-routes :account-search) + {:purpose "invoice"}) + :id name + :x-model x-model + :value value + :content-fn (fn [value] + (:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value) + client-id)))})) ;; TODO clientize (defn all-ids-not-locked [all-ids] @@ -1472,121 +1527,135 @@ [(>= ?d ?lu)]] (dc/db conn)) (map first))) -(defn- bulk-edit-account-row* [{:keys [value client-id]}] - - (com/data-grid-row - (-> {:x-data (hx/json {:show (boolean (not (fc/field-value (:new? value)))) - :accountId (fc/field-value (:account value))}) - :data-key "show" - :x-ref "p"} - hx/alpine-mount-then-appear) - (fc/with-field :db/id - (com/hidden {:name (fc/field-name) - :value (fc/field-value)})) - (fc/with-field :account - (com/data-grid-cell +(defn- bulk-edit-account-row* + "One expense-account row (no cursor). The location cell swaps just itself + (#account-location-, Rule 2); the percentage swaps only #expense-totals + (Rule 4); remove swaps the whole #bulk-edit-form (Rule 3)." + [{:keys [value client-id index]}] + (let [account-val (let [av (:account value)] + (if (map? av) (:db/id av) av)) + location-attrs {:x-hx-val:account-id "accountId" + :hx-vals (hx/json (cond-> {:name (account-field-name index :location)} + client-id (assoc :client-id client-id))) + :x-dispatch:changed "accountId" + :hx-trigger "changed" + :hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-form-changed) + :hx-target (str "#account-location-" index) + :hx-select (str "#account-location-" index) + :hx-swap "outerHTML" + :hx-include "closest form"}] + (sc/data-grid-row + (-> {:class "account-row" + :id (str "account-row-" index) + :x-data (hx/json {:show (boolean (not (:new? value))) + :accountId account-val}) + :data-key "show" + :x-ref "p"} + hx/alpine-mount-then-appear) + (sc/hidden {:name (account-field-name index :db/id) + :value (:db/id value)}) + (sc/data-grid-cell {} - (com/validated-field - {:errors (fc/field-errors)} - (account-typeahead* {:value (fc/field-value) + (sc/validated-field + {:errors (account-field-errors index :account)} + (account-typeahead* {:value account-val :client-id client-id - :name (fc/field-name) - :x-model "accountId"})))) - (fc/with-field :location - (com/data-grid-cell + :name (account-field-name index :account) + :x-model "accountId"}))) + (sc/data-grid-cell + {:id (str "account-location-" index)} + (sc/validated-field + (merge {:errors (account-field-errors index :location)} location-attrs) + (tx-edit/location-select* {:name (account-field-name index :location) + :account-location (:account/location (when (nat-int? account-val) + (dc/pull (dc/db conn) '[:account/location] account-val))) + :value (:location value)}))) + (sc/data-grid-cell {} - (com/validated-field - {:errors (fc/field-errors) - :x-hx-val:account-id "accountId" - :hx-vals (hx/json {:name (fc/field-name)}) - :x-dispatch:changed "accountId" - :hx-trigger "changed" - :hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select) - :hx-target "find *" - :hx-swap "outerHTML"} - (location-select* {:name (fc/field-name) - :account-location (:account/location (cond->> (:account @value) - (nat-int? (:account @value)) (dc/pull (dc/db conn) - '[:account/location]))) - :value (fc/field-value)})))) - (fc/with-field :percentage - (com/data-grid-cell - {} - (com/validated-field - {:errors (fc/field-errors)} - (com/money-input {:name (fc/field-name) - :class "w-16 amount-field" - :value (some-> (fc/field-value) - (* 100) - (long))})))) - (com/data-grid-cell {:class "align-top"} - (com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x)))) + (sc/validated-field + {:errors (account-field-errors index :percentage)} + (sc/money-input {:name (account-field-name index :percentage) + :class "w-16 amount-field" + :value (some-> (:percentage value) (* 100) long) + :hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-form-changed) + :hx-target "#expense-totals" + :hx-select "#expense-totals" + :hx-swap "outerHTML" + :hx-trigger "keyup changed delay:300ms" + :hx-include "closest form"}))) + (sc/data-grid-cell + {:class "align-top"} + (sc/a-icon-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-form-changed) + :hx-vals (hx/json {:op "remove-account" :row-index (or index 0)}) + :hx-target "#bulk-edit-form" + :hx-select "#bulk-edit-form" + :hx-swap "outerHTML" + :hx-include "closest form" + :class "account-remove-action"} + svg/x))))) -(defrecord AccountsStep [linear-wizard] - mm/ModalWizardStep - (step-name [_] - "Expense Accounts") - (step-key [_] - :accounts) +(defn- expense-total* [request] + (let [total (->> (-> request :bulk-state :expense-accounts) + (map (fnil :percentage 0.0)) + (filter number?) + (reduce + 0.0))] + (format "%.1f%%" (* 100.0 total)))) - (edit-path [_ _] - []) +(defn- expense-balance* [request] + (let [total (->> (-> request :bulk-state :expense-accounts) + (map (fnil :percentage 0.0)) + (filter number?) + (reduce + 0.0)) + balance (- 100.0 (* 100.0 total))] + (sel/raw (str "" (format "%.1f%%" balance) "")))) - (step-schema [_] - (mut/select-keys (mm/form-schema linear-wizard) #{:expense-accounts})) +(defn- expense-totals-tbody* + "The separately-swappable TOTAL/BALANCE (#expense-totals, Rule 4 target)." + [request] + (sel/render->hiccup + "templates/invoice-bulk-edit/expense-totals.html" + {:rows (str + (sc/data-grid-row {} + (sc/data-grid-cell {}) + (sc/data-grid-cell {:class "text-right"} (sel/raw "TOTAL")) + (sc/data-grid-cell {:id "expense-total" :class "text-right"} (expense-total* request)) + (sc/data-grid-cell {})) + (sc/data-grid-row {} + (sc/data-grid-cell {}) + (sc/data-grid-cell {:class "text-right"} (sel/raw "BALANCE")) + (sc/data-grid-cell {:id "expense-balance" :class "text-right"} (expense-balance* request)) + (sc/data-grid-cell {})))})) - (render-step [this {{:keys [snapshot] :as multi-form-state} :multi-form-state :as request}] - (let [selected-ids (selected->ids (assoc request :query-params (:search-params snapshot)) (:search-params snapshot)) - all-ids (all-ids-not-locked selected-ids)] - (mm/default-render-step - linear-wizard this - :head [:div.p-2 "Bulk editing " (count all-ids) " invoices"] - :body (mm/default-step-body - {} - [:div {} - (fc/with-field :expense-accounts - (com/validated-field - {:errors (fc/field-errors)} - (com/data-grid {:headers [(com/data-grid-header {} "Account") - (com/data-grid-header {:class "w-32"} "Location") - (com/data-grid-header {:class "w-16"} "%") - (com/data-grid-header {:class "w-16"})]} - (fc/cursor-map #(bulk-edit-account-row* {:value % - :client-id (:invoice/client snapshot)})) - - (com/data-grid-new-row {:colspan 4 - :hx-get (bidi/path-for ssr-routes/only-routes - ::route/bulk-edit-new-account) - :row-offset 0 - :index (count (fc/field-value))} - "New account") - (com/data-grid-row {} - (com/data-grid-cell {}) - (com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"]) - (com/data-grid-cell {:id "total" - :class "text-right" - :hx-trigger "change from:closest form target:.amount-field" - :hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/bulk-edit-total) - :hx-target "this" - :hx-swap "innerHTML"} - #_(invoice-expense-account-total* request)) - (com/data-grid-cell {})) - - (com/data-grid-row {} - (com/data-grid-cell {}) - (com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"]) - (com/data-grid-cell {:id "total" - :class "text-right" - :hx-trigger "change from:closest form target:.amount-field" - :hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/bulk-edit-balance) - :hx-target "this" - :hx-swap "innerHTML"} - #_(invoice-expense-account-balance* request)) - (com/data-grid-cell {})))))]) - :footer - (mm/default-step-footer linear-wizard this :validation-route ::route/new-wizard-navigate - :next-button (com/button {:color :primary :x-ref "next" :class "w-32"} "Save")) - :validation-route ::route/new-wizard-navigate)))) +(defn- account-grid* [request] + (let [client-id (single-client-id request) + accounts (vec (:expense-accounts (:bulk-state request)))] + (apply + sc/data-grid + {:headers [(sc/data-grid-header {} "Account") + (sc/data-grid-header {:class "w-32"} "Location") + (sc/data-grid-header {:class "w-16"} "%") + (sc/data-grid-header {:class "w-16"})] + :footer-tbody (expense-totals-tbody* request)} + (concat + (map-indexed + (fn [index account] + (bulk-edit-account-row* {:value account + :client-id client-id + :index index})) + accounts) + [(sc/data-grid-row + {:class "new-row"} + (sc/data-grid-cell {:colspan 4} + (sc/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-form-changed) + :hx-vals (hx/json {:op "new-account"}) + :hx-target "#bulk-edit-form" + :hx-select "#bulk-edit-form" + :hx-swap "outerHTML" + :hx-include "closest form" + :color :secondary} + "New account")))])))) (defn maybe-code-accounts [invoice account-rules valid-locations] (with-precision 2 @@ -1629,96 +1698,121 @@ (when-not (dollars= 1.0 expense-account-total) (form-validation-error (str "Expense account total (" expense-account-total ") does not equal 100%"))))) -(defrecord BulkEditWizard [_ current-step] - mm/LinearModalWizard - (hydrate-from-request - [this request] - this) - (navigate [this step-key] - (assoc this :current-step step-key)) - (get-current-step [this] - (if current-step - (mm/get-step this current-step) - (mm/get-step this :accounts))) - (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/bulk-edit-submit)))) - :render-timeline? false)) - (steps [_] - [:accounts]) - (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] - (get {:accounts (->AccountsStep this)} - step-key))) - (form-schema [_] - (mc/schema [:map - [:expense-accounts - (many-entity {:min 1} - [:account entity-id] - [:location [:string {:min 1 :error/message "required"}]] - [:percentage percentage])]])) - (submit [this {:keys [multi-form-state request-method identity] :as request}] - (let [selected-ids (selected->ids (assoc request :query-params (:search-params (:snapshot multi-form-state))) (:search-params (:snapshot multi-form-state))) - all-ids (all-ids-not-locked selected-ids) - invoices (pull-many (dc/db conn) '[:db/id :invoice/total {:invoice/client [:client/locations]}] (vec all-ids))] - (assert-percentages-add-up (:snapshot multi-form-state)) +(defn- form-errors-html [errors] + (str "
" + (when (seq errors) + (str "

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

")) + "
")) - (doseq [a (-> multi-form-state :snapshot :expense-accounts) - :let [{:keys [:account/location :account/name]} (dc/pull (dc/db conn) [:account/location :account/name] (:account a))]] - (when (and location (not= location (:location a))) - (let [err (str "Account " name " uses location " (:location a) ", but is supposed to be " location)] - (throw (ex-info err {:validation-error err}))))) - (alog/info ::bulk-code :count (count all-ids)) - (audit-transact-batch - (map (fn [i] - [:upsert-invoice {:db/id (:db/id i) - :invoice/expense-accounts (maybe-code-accounts i (-> multi-form-state :snapshot :expense-accounts) (-> i :invoice/client :client/locations))}]) - invoices) - (:identity request)) +(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")) + "
"))) - (html-response - [:div] - :headers (cond-> {"hx-trigger" (hx/json {"modalclose" "" - "invalidated" "" - "notification" (str "Successfully coded " (count all-ids) " invoices.")}) - "hx-reswap" "outerHTML"}))))) +(defn render-form + "Renders the whole plain bulk-edit form (no wizard). Binds *errors* so the field-level + lookups resolve. Reuses the edit modal chrome." + [request] + (binding [*errors* (or (:form-errors request) {})] + (let [ids (:ids (:bulk-state request)) + ids-hidden (apply str + (map-indexed (fn [i id] + (str (sc/hidden {:name (path->name2 :ids i) :value id}))) + ids)) + body (str "
" + (str (sc/validated-field + {:errors (ferr :expense-accounts)} + (sel/raw (str (account-grid* request))))) + "
") + modal-card (sel/render "templates/transaction-edit/edit-modal.html" + {:head (str "
Bulk editing " (count ids) " invoices
") + :side_panel nil + :body body + :footer (str (footer* request))})] + (sel/render->hiccup + "templates/invoice-bulk-edit/edit-form.html" + {:ids_hidden ids-hidden + :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/bulk-edit-submit)}) + :modal (str (sc/modal {:id "wizardmodal"} (sel/raw modal-card)))})))) -(def bulk-edit-wizard (->BulkEditWizard nil nil)) +(defn apply-new-account + "bulk-edit-form-changed op: append a fresh (blank, Shared) expense-account row." + [request] + (let [accounts (vec (:expense-accounts (:bulk-state request))) + new-account {:db/id (str (java.util.UUID/randomUUID)) + :new? true + :location "Shared" + :percentage nil}] + (assoc-in request [:bulk-state :expense-accounts] (conj accounts new-account)))) -(defn bulk-edit-total* [request] - (let [total (->> (-> request - :multi-form-state - :step-params - :expense-accounts) - (map (fnil :percentage 0.0)) - (filter number?) - (reduce + 0.0))] - (format "%.1f%%" (* 100.0 total)))) +(defn apply-remove-account + "bulk-edit-form-changed op: remove the expense-account row at form-param row-index." + [request] + (let [row-index (some-> request :form-params (get "row-index") Integer/parseInt) + accounts (vec (:expense-accounts (:bulk-state request))) + updated-accounts (if (and row-index (< row-index (count accounts))) + (vec (concat (subvec accounts 0 row-index) + (subvec accounts (inc row-index)))) + accounts)] + (assoc-in request [:bulk-state :expense-accounts] updated-accounts))) -(defn bulk-edit-balance* [request] - (let [total (->> (-> request - :multi-form-state - :step-params - :expense-accounts) - (map (fnil :percentage 0.0)) - (filter number?) - (reduce + 0.0)) - balance (- 100.0 - (* 100.0 total))] - [:span {:class (when-not (dollars= 0.0 balance) - "text-red-300")} - (format "%.1f%%" balance)])) +(defn bulk-edit-form-changed-handler + "Single whole-form re-render endpoint. Dispatches on `op` (add/remove a row); a missing + op (an account-selection location swap or a percentage keyup) just re-renders, and the + caller's hx-select picks the cell / #expense-totals it needs." + [request] + (let [op (get-in request [:form-params "op"]) + request' (case op + "new-account" (apply-new-account request) + "remove-account" (apply-remove-account request) + request)] + (html-response (render-form request')))) -(defn bulk-edit-total [request] - (html-response (bulk-edit-total* request))) +(defn open-handler [request] + (modal-response + (sel/render->hiccup "templates/transaction-edit/transitioner.html" + {:body (str (render-form request))}))) -(defn bulk-edit-balance [request] - (html-response (bulk-edit-balance* request))) +(defn- render-form-response [request] + (html-response (render-form request) + :headers {"HX-reswap" "outerHTML"})) + +(defn submit + "Validates the posted expense-account coding (schema field errors + the percentage-sum + and per-account location checks), then applies it across every selected (not-locked) + invoice." + [request] + (let [{:keys [ids expense-accounts]} (:bulk-state request) + invoices (pull-many (dc/db conn) '[:db/id :invoice/total {:invoice/client [:client/locations]}] (vec ids))] + (assert-schema bulk-edit-schema (select-keys (:bulk-state request) bulk-edit-form-keys)) + (assert-percentages-add-up {:expense-accounts expense-accounts}) + (doseq [a expense-accounts + :let [{:keys [:account/location :account/name]} (dc/pull (dc/db conn) [:account/location :account/name] (:account a))]] + (when (and location (not= location (:location a))) + (let [err (str "Account " name " uses location " (:location a) ", but is supposed to be " location)] + (throw (ex-info err {:validation-error err}))))) + (alog/info ::bulk-code :count (count ids)) + (audit-transact-batch + (map (fn [i] + [:upsert-invoice {:db/id (:db/id i) + :invoice/expense-accounts (maybe-code-accounts i expense-accounts (-> i :invoice/client :client/locations))}]) + invoices) + (:identity request)) + (html-response + [:div] + :headers {"hx-trigger" (hx/json {"modalclose" "" + "invalidated" "" + "notification" (str "Successfully coded " (count ids) " invoices.")}) + "hx-reswap" "outerHTML"}))) (def key->handler (apply-middleware-to-all-handlers @@ -1737,32 +1831,14 @@ ::route/legacy-paid-invoices (redirect-handler ::route/paid-page) ::route/legacy-voided-invoices (redirect-handler ::route/voided-page) ::route/legacy-new-invoice (redirect-handler ::route/new-wizard) - ::route/bulk-edit (-> mm/open-wizard-handler - (mm/wrap-wizard bulk-edit-wizard) - (mm/wrap-init-multi-form-state initial-bulk-edit-state)) - ::route/bulk-edit-submit (-> mm/submit-handler - (mm/wrap-wizard bulk-edit-wizard) - (mm/wrap-decode-multi-form-state) + ::route/bulk-edit (-> open-handler + (wrap-bulk-state)) + ::route/bulk-edit-submit (-> submit + (wrap-form-4xx-2 render-form-response) + (wrap-bulk-state) (wrap-must {:subject :invoice :activity :bulk-edit})) - ::route/bulk-edit-total (-> bulk-edit-total - (mm/wrap-wizard bulk-edit-wizard) - (mm/wrap-decode-multi-form-state) - (wrap-must {:subject :invoice :activity :bulk-edit})) - ::route/bulk-edit-balance (-> bulk-edit-balance - - (mm/wrap-wizard bulk-edit-wizard) - (mm/wrap-decode-multi-form-state) - (wrap-must {:subject :invoice :activity :bulk-edit})) - ::route/bulk-edit-new-account (-> - (add-new-entity-handler [:step-params :expense-accounts] - (fn render [cursor request] - (bulk-edit-account-row* - {:value cursor})) - (fn build-new-row [base _] - (assoc base :invoice-expense-account/location "Shared"))) - (wrap-schema-enforce :query-schema [:map - [:client-id {:optional true} - [:maybe entity-id]]])) + ::route/bulk-edit-form-changed (-> bulk-edit-form-changed-handler + (wrap-bulk-state)) ::route/undo-autopay (-> undo-autopay (wrap-entity [:route-params :db/id] default-read) diff --git a/src/cljc/auto_ap/routes/invoice.cljc b/src/cljc/auto_ap/routes/invoice.cljc index 4f46578c..462ed92c 100644 --- a/src/cljc/auto_ap/routes/invoice.cljc +++ b/src/cljc/auto_ap/routes/invoice.cljc @@ -33,9 +33,7 @@ :delete ::bulk-delete-confirm} "/bulk-edit" {:get ::bulk-edit :put ::bulk-edit-submit - "/account" ::bulk-edit-new-account - "/total" ::bulk-edit-total - "/balance" ::bulk-edit-balance} + "/form-changed" ::bulk-edit-form-changed} ["/" [#"\d+" :db/id]] {:delete ::delete "/undo-autopay" ::undo-autopay "/unvoid" ::unvoid