refactor(ssr): Phase 3 — full Selmer migration of Transaction Bulk Code; remove the wizard

Migrates the Transaction Bulk Code modal (a single-step form wearing a full
wizard costume) to a plain Selmer form, cold-applying the ssr-form-migration
skill. Almost entirely reuse of the Phase-2 work: the whole `sc/*` Selmer
component library, `account-typeahead*` / `location-select*`, and the
`edit-modal` / `transitioner` chrome are imported wholesale.

What changed
- Wizard removed: deleted `BulkCodeWizard` / `AccountsStep` records,
  `MultiStepFormState`, the `step-params[...]` prefix, and all `mm/*`
  middleware. Replaced with a plain handler + flat `wrap-bulk-state` (decode
  straight into `bulk-code-schema`, no snapshot round-trip).
- Selection round-trip: the non-editable transaction 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 EDN snapshot, no
  filter re-query, and more correct (codes exactly the rows the user saw).
- 100% Selmer render path (only the shared terminal `com/success-modal` keeps
  Hiccup — heuristic-9 exception). New shared component `sc/select`
  (`location-select.html` generalized) for the status dropdown.
- Routes 4 -> 3: GET `bulk-code` (open), POST `bulk-code-submit`, POST
  `bulk-code-form-changed` (one whole-form op dispatcher folding the old
  `new-account` + `vendor-changed` routes). Location swap moved off `find *`
  onto explicit `#account-location-<index>` + `hx-select`.
- Fixed a latent correctness bug surfaced by the migration: the vendor
  typeahead needs `:id` (value-keyed `:key`) or its value-bound hidden goes
  stale across a whole-form swap and posts blank.

Scorecard delta (transaction/bulk_code.clj): mm coupling 19->0, snapshot
merges 4->0, wizard records 3->0, step-params 10->0, routes 4->3, OOB 0,
Hiccup-in-render ->0 (bar success-modal). LOC 420->506 (documented exception:
the wizard was a thin shell over mm/* defaults, so explicitness moves shared
plumbing into the file). Cookbook: reused the entire Phase-2 sc/* lib + chrome,
added sc/select.

Verification: bulk-code-transactions.spec.ts 13/13; full Playwright suite
39/39; cljfmt clean.

Skill fed: scorecard row + narrative + LOC exception; gotchas (value-bound
typeahead keying, selection-as-ids round-trip); cookbook (sc/select).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 19:38:09 -07:00
parent 70c178de83
commit 03620e9d42
10 changed files with 515 additions and 333 deletions

View File

@@ -164,6 +164,7 @@ the interop bridge; dynamic HTMX/Alpine attrs go through `sc/attrs->str` →
| Wrapper | Partial | Notes | | Wrapper | Partial | Notes |
|---------|---------|-------| |---------|---------|-------|
| `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/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 `<select>`; `options [[value label] …]`, `:value` (string/keyword) marks selected, extra hx-/x- attrs ride through. `location-select.html` generalized — reach for this before `com/select`. Added for the bulk-code status field. |
| `sc/validated-field` | `validated-field.html` | label + body + always-present error `<p>`; pass-through attrs land on the wrapping div (the per-row location cell hangs its swap wiring here) | | `sc/validated-field` | `validated-field.html` | label + body + always-present error `<p>`; pass-through attrs land on the wrapping div (the per-row location cell hangs its swap wiring here) |
| `sc/button` / `sc/a-button` / `sc/a-icon-button` | `button`/`a-button`/`a-icon-button`.html | spinner via `{% include "spinner.html" %}`; class via `btn/bg-colors` | | `sc/button` / `sc/a-button` / `sc/a-icon-button` | `button`/`a-button`/`a-icon-button`.html | spinner via `{% include "spinner.html" %}`; class via `btn/bg-colors` |
| `sc/badge` / `sc/link` | `badge`/`link`.html | | | `sc/badge` / `sc/link` | `badge`/`link`.html | |

View File

@@ -198,8 +198,45 @@ Post-adoption baseline is **39 pass / 0 fail** — the previously-flaky
`transaction-navigation.spec.ts` date-range test is now green, because `/test-reset` removes `transaction-navigation.spec.ts` date-range test is now green, because `/test-reset` removes
the residual mutation it was tripping over. the residual mutation it was tripping over.
## A value-bound typeahead hidden goes stale across a whole-form swap unless keyed
A typeahead (`sc/typeahead`) posts its value through a hidden `<input :value="value.value">`
whose DOM `.value` is set by Alpine, not by the server-rendered static `value` attr. After a
**whole-form `outerHTML` swap** that re-renders the typeahead, Alpine may preserve the *previous*
component's empty `.value` instead of binding the new server value — so the field posts blank
on the next submit. Fix: pass **`:id`** to `sc/typeahead` (the account typeahead already does).
`:id` makes the wrapper emit `:key (str id "--" value)`, and the value-keyed `:key` forces a
clean Alpine re-init that lands the server value. The bulk-code *vendor* typeahead hit this
(account rows didn't, because they pass `:id`) — symptom: "vendor not preserved on a validation
re-render." Note the testing trap: reading the hidden's `.value` in isolation
(`inputValue()` / `toHaveValue`) is an unreliable probe — it lags Alpine. Assert what the form
**actually posts** instead: `new FormData(form).get('vendor')` (wrap in `expect.poll`).
## Round-trip a multi-row selection as `ids[]`, not as an EDN/filter snapshot
A bulk modal acts on a *selection* of N entities (bulk-code: the checked transactions), the
analog of a single modal's one `db/id`. The wizard stashed the whole search-params blob (filters
+ `selected` + `all-selected`) in the EDN snapshot and re-ran the filter query on every post.
Don't carry that forward. Instead **resolve the selection to a concrete id vector once at open**
(`selected->ids` → the not-locked set) and ride it back in hidden `ids[0..n]` fields; re-read it
on each post (`[:vector {:coerce? true} entity-id]` + the `coerce-vector` transformer turns the
`{"0" "123"}` index-map into `[123]`). No snapshot, no filter round-trip, and it's *more* correct
— you code exactly the rows the user saw, immune to data changing between open and submit. This
is heuristic 2 → 0 for a multi-select modal.
## Scorecard exceptions (ratchet violations with a reason) ## Scorecard exceptions (ratchet violations with a reason)
**Heuristic 4 (LOC net ↓) — exception (Phase 3, Transaction Bulk Code: 420→506).** When the
modal's wizard was a *thin* shell that delegated almost everything to `mm/*` defaults
(`default-render-step`, `default-render-wizard`, `submit-handler`, `open-wizard-handler`),
ripping the wizard out moves that previously-shared plumbing **into the file** as explicit
render/decode/submit/handler code, so the single-file LOC rises even though total system
complexity drops. This is the opposite of a fat wizard (edit went 1608→1548). The trade is
intended and every other heuristic improved sharply (mm coupling 19→0, snapshot merges 4→0,
wizard records 3→0, routes 4→3, `find *`→explicit-id swap). Watch for it on the small
"single-step wearing a wizard costume" modals — LOC is the wrong headline metric there;
the mm-coupling / snapshot / route counts are.
**Heuristic 9 (Hiccup in render path) — partial exception (Phase 2-final).** The post-save **Heuristic 9 (Hiccup in render path) — partial exception (Phase 2-final).** The post-save
`com/success-modal` confirmation dialogs in `save-handler` keep ~6 `[:p …]` Hiccup lines. `com/success-modal` confirmation dialogs in `save-handler` keep ~6 `[:p …]` Hiccup lines.
They are terminal responses (shown after the form closes), reuse a shared dialog component, They are terminal responses (shown after the form closes), reuse a shared dialog component,

View File

@@ -41,6 +41,9 @@ Each migration appends one row (after-numbers), referencing the before in the di
| 1 (baseline) | Transaction Edit `transaction/edit.clj` | 1608 | ~12 | 1 | 2 | ~75 | 0 | 8 | — / seeded 7 entries | | 1 (baseline) | Transaction Edit `transaction/edit.clj` | 1608 | ~12 | 1 | 2 | ~75 | 0 | 8 | — / seeded 7 entries |
| 2 | Transaction Edit `transaction/edit.clj` | 1593 | **~5** | **0** | **0** | **0 round-trip** | 0 | 8 (shared) | location-select / **1 Selmer** | | 2 | Transaction Edit `transaction/edit.clj` | 1593 | **~5** | **0** | **0** | **0 round-trip** | 0 | 8 (shared) | location-select / **1 Selmer** |
| 2-final | Transaction Edit (full Selmer + wizard removed) | 1548 | **5** | 0 | 0 | 0 | 0 | **0** | full `sc/*` lib / **~30 partials** | | 2-final | Transaction Edit (full Selmer + wizard removed) | 1548 | **5** | 0 | 0 | 0 | 0 | **0** | full `sc/*` lib / **~30 partials** |
| 3 | Transaction Bulk Code `transaction/bulk_code.clj` | 506 (was 420 — see exception) | **3** | 0 | 0 | **0** | 0 | 0† | reused **all** of Phase-2's `sc/*` lib + `account-typeahead*`/`location-select*` + `edit-modal`/`transitioner` chrome / added **`sc/select`** |
† The one `"hx-..."` string hit is a response-header map (`{"hx-trigger" "refreshTable, reset-selection"}`), not a mixed attribute encoding. mm coupling 19→**0**, wizard records 3→**0**, step-params 10→**0** (the 2 hits are comments), Hiccup-in-render → **0** except the shared `com/success-modal` (heuristic-9 exception, as in Phase 2).
### New heuristics introduced at 2-final (full Selmer) ### New heuristics introduced at 2-final (full Selmer)
@@ -82,3 +85,26 @@ Each migration appends one row (after-numbers), referencing the before in the di
> shared component — out of the form's render path). See `form-vs-wizard.md` (drop-the- > shared component — out of the form's render path). See `form-vs-wizard.md` (drop-the-
> wizard test), `selmer-conventions.md` (composition mechanics), and `gotchas.md` > wizard test), `selmer-conventions.md` (composition mechanics), and `gotchas.md`
> (stray-field decode leak; jetty reload staleness). > (stray-field decode leak; jetty reload staleness).
> **Phase 3 — Transaction Bulk Code (first cold apply of the mature skill).** Single-step
> form wearing a full wizard costume (`BulkCodeWizard`/`AccountsStep`, `MultiStepFormState`,
> the `step-params[...]` prefix, the old `find *` location swap). Migrated to a plain form by
> mirroring Phase 2 — and it was mostly **reuse**: the entire `sc/*` Selmer component library,
> `account-typeahead*`/`location-select*`, and the `edit-modal`/`transitioner` chrome were
> imported wholesale; the only new shared component was **`sc/select`** (the status dropdown —
> `location-select.html` generalized). Parity held: bulk-code spec **13/13**, full suite
> **39/39** (up from the Phase-2 baseline of 3839). mm coupling 19→0, snapshot merges 4→0,
> wizard records 3→0, routes 4→3 (open / submit / `form-changed` — the per-op `new-account` +
> `vendor-changed` routes folded into one `form-changed` op dispatcher), the location swap moved
> off `find *` onto explicit `#account-location-<index>` + `hx-select`.
>
> **The one regression — LOC 420→506 (documented exception, see `gotchas.md`).** Unlike edit
> (whose wizard held real custom code), bulk-code's wizard was a *thin* shell that delegated
> almost everything to `mm/*` defaults (`default-render-step`, `default-render-wizard`,
> `submit-handler`, `open-wizard-handler`). Ripping the wizard out moves that
> previously-shared plumbing **into the file** as explicit render/decode/submit/handler code.
> The trade is intended: every other heuristic improved and the modal is now self-contained
> and wizard-free. New patterns added to the cookbook: the **selection-as-`ids[]` round-trip**
> (resolve the non-editable selection to a concrete id vector at open, ride it in hidden
> fields — the bulk analog of edit's single `db/id`), and the **`:id`-keyed vendor typeahead**
> (a value-bound hidden must be keyed or its posted value goes stale across a whole-form swap).

View File

@@ -40,7 +40,7 @@ async function openBulkCodeModal(page: any) {
const codeButton = page.locator('button:has-text("Code")').first(); const codeButton = page.locator('button:has-text("Code")').first();
await codeButton.click(); await codeButton.click();
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' }); await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
await page.waitForSelector('#wizardmodal'); await page.waitForSelector('#bulkcodemodal');
} }
async function closeBulkCodeModal(page: any) { async function closeBulkCodeModal(page: any) {
@@ -156,7 +156,7 @@ async function addNewAccount(page: any) {
} }
async function submitBulkCodeForm(page: any) { async function submitBulkCodeForm(page: any) {
const form = page.locator('#wizard-form'); const form = page.locator('#bulk-code-form');
await form.evaluate((el: HTMLFormElement) => { await form.evaluate((el: HTMLFormElement) => {
el.dispatchEvent(new Event('submit', { bubbles: true })); el.dispatchEvent(new Event('submit', { bubbles: true }));
}); });
@@ -184,7 +184,7 @@ test.describe('Bulk Code Transactions - Happy Path', () => {
await expect(page.locator('text=Bulk editing 1 transactions')).toBeVisible(); await expect(page.locator('text=Bulk editing 1 transactions')).toBeVisible();
// Select vendor // Select vendor
const vendorHidden = page.locator('input[type="hidden"][name="step-params[vendor]"]').first(); const vendorHidden = page.locator('input[type="hidden"][name="vendor"]').first();
const testInfo = await getTestInfo(page); const testInfo = await getTestInfo(page);
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => { await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
const newInput = document.createElement('input'); const newInput = document.createElement('input');
@@ -196,7 +196,7 @@ test.describe('Bulk Code Transactions - Happy Path', () => {
await page.waitForTimeout(300); await page.waitForTimeout(300);
// Select approval status // Select approval status
const statusSelect = page.locator('select[name="step-params[approval-status]"]').first(); const statusSelect = page.locator('select[name="approval-status"]').first();
await statusSelect.selectOption('approved'); await statusSelect.selectOption('approved');
// Add account // Add account
@@ -278,7 +278,7 @@ test.describe('Bulk Code Transactions - Validation', () => {
const testInfo = await getTestInfo(page); const testInfo = await getTestInfo(page);
const vendorId = testInfo.accounts.vendor; const vendorId = testInfo.accounts.vendor;
const vendorContainer = page.locator('div[hx-post*="vendor-changed"]').first(); const vendorContainer = page.locator('div[hx-vals*="vendor-changed"]').first();
const vendorHidden = vendorContainer.locator('input[type="hidden"]').first(); const vendorHidden = vendorContainer.locator('input[type="hidden"]').first();
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => { await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
@@ -293,11 +293,11 @@ test.describe('Bulk Code Transactions - Validation', () => {
el.dispatchEvent(new Event('change', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true }));
}); });
await page.waitForResponse(response => response.url().includes('/vendor-changed') && response.status() === 200); await page.waitForResponse(response => response.url().includes('/form-changed') && response.status() === 200);
await page.waitForTimeout(500); await page.waitForTimeout(500);
// Select approval status // Select approval status
const statusSelect = page.locator('select[name="step-params[approval-status]"]').first(); const statusSelect = page.locator('select[name="approval-status"]').first();
await statusSelect.selectOption('approved'); await statusSelect.selectOption('approved');
// Vendor selection pre-populated a default account row at 100%. // Vendor selection pre-populated a default account row at 100%.
@@ -310,11 +310,15 @@ test.describe('Bulk Code Transactions - Validation', () => {
// Modal should still be open // Modal should still be open
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible(); await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
// Vendor should still be selected // Vendor should still be selected. The vendor typeahead is Alpine-managed and posts
const vendorHiddenAfter = page.locator('input[type="hidden"][name="step-params[vendor]"]').first(); // its value via an x-bound hidden input, so the right correctness check is what the
const vendorValueAfter = await vendorHiddenAfter.inputValue(); // form actually submits (the value that gets saved), not the lagging DOM .value of the
expect(vendorValueAfter).toBe(vendorId.toString()); // hidden read in isolation.
await expect.poll(async () =>
page.locator('#bulk-code-form').evaluate((f: HTMLFormElement) =>
new FormData(f).get('vendor'))
).toBe(vendorId.toString());
// Status should still be selected // Status should still be selected
const statusValueAfter = await statusSelect.inputValue(); const statusValueAfter = await statusSelect.inputValue();
expect(statusValueAfter).toBe('approved'); expect(statusValueAfter).toBe('approved');
@@ -464,7 +468,7 @@ test.describe('Bulk Code Transactions - Vendor Pre-population', () => {
// The vendor typeahead dispatches change from its parent div // The vendor typeahead dispatches change from its parent div
// We need to set the hidden input and dispatch change on the container // We need to set the hidden input and dispatch change on the container
const vendorContainer = page.locator('div[hx-post*="vendor-changed"]').first(); const vendorContainer = page.locator('div[hx-vals*="vendor-changed"]').first();
const vendorHidden = vendorContainer.locator('input[type="hidden"]').first(); const vendorHidden = vendorContainer.locator('input[type="hidden"]').first();
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => { await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
@@ -481,7 +485,7 @@ test.describe('Bulk Code Transactions - Vendor Pre-population', () => {
}); });
// Wait for HTMX response // Wait for HTMX response
await page.waitForResponse(response => response.url().includes('/vendor-changed') && response.status() === 200); await page.waitForResponse(response => response.url().includes('/form-changed') && response.status() === 200);
await page.waitForTimeout(500); await page.waitForTimeout(500);
// Account should be pre-populated - check for account row // Account should be pre-populated - check for account row
@@ -521,7 +525,7 @@ test.describe('Bulk Code Transactions - Vendor Pre-population', () => {
const testInfo = await getTestInfo(page); const testInfo = await getTestInfo(page);
const vendorId = testInfo.accounts.vendor; const vendorId = testInfo.accounts.vendor;
const vendorContainer = page.locator('div[hx-post*="vendor-changed"]').first(); const vendorContainer = page.locator('div[hx-vals*="vendor-changed"]').first();
const vendorHidden = vendorContainer.locator('input[type="hidden"]').first(); const vendorHidden = vendorContainer.locator('input[type="hidden"]').first();
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => { await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
@@ -538,7 +542,7 @@ test.describe('Bulk Code Transactions - Vendor Pre-population', () => {
}); });
// Wait for HTMX response // Wait for HTMX response
await page.waitForResponse(response => response.url().includes('/vendor-changed') && response.status() === 200); await page.waitForResponse(response => response.url().includes('/form-changed') && response.status() === 200);
await page.waitForTimeout(500); await page.waitForTimeout(500);
// Should pre-populate the vendor's default account (non-clientized) plus the "New account" row // Should pre-populate the vendor's default account (non-clientized) plus the "New account" row

View File

@@ -0,0 +1,4 @@
{# Generic <select>. options = [{value, label, selected}]. Plain-HTML attributes -- the
Selmer migration target (no Hiccup keyword/string attribute ambiguity). Extra attrs
(hx-*, x-*) ride through {{ attrs|safe }}. #}
<select name="{{ name }}" class="{{ classes }}"{{ attrs|safe }}>{% for opt in options %}<option value="{{ opt.value }}"{% if opt.selected %} selected{% endif %}>{{ opt.label }}</option>{% endfor %}</select>

View File

@@ -0,0 +1,3 @@
{# Bulk-code modal body: vendor field (a change repopulates the default account via a
whole-form swap), status select, and the expense-account grid. #}
<div class="space-y-4 p-4"><div class="grid grid-cols-2 gap-4"><div{{ vendor_changed_attrs|safe }}>{{ vendor_field|safe }}</div><div>{{ status_field|safe }}</div><div class="col-span-2 pt-4"><h3 class="text-lg font-medium mb-3">Expense Accounts</h3>{{ accounts_field|safe }}</div></div></div>

View File

@@ -0,0 +1,5 @@
{# Top-level plain form for bulk-code (no wizard). The resolved (not-locked) transaction
id set rides in hidden ids[] fields -- the analog of the edit modal's single db/id
hidden -- so the selection survives form-changed / submit posts without an EDN snapshot
or a filter round-trip. #}
<form id="bulk-code-form"{{ form_attrs|safe }}>{{ ids_hidden|safe }}{{ modal|safe }}</form>

View File

@@ -81,6 +81,24 @@
(assoc :type "number" :step "0.01"))] (assoc :type "number" :step "0.01"))]
(render "templates/components/money-input.html" {:attrs (attrs->str attrs)}))) (render "templates/components/money-input.html" {:attrs (attrs->str attrs)})))
(defn select
"Generic <select> rendered from a Selmer partial (the location-select.html shape,
generalized). options = [[value label] ...]; `value` (string or keyword) marks the
selected option. Class defaults to the standard input classes, like com/select. Extra
attrs (hx-*, x-*) ride through onto the element."
[{:keys [name value options class] :as params}]
(let [classes (-> ""
(hh/add-class inputs/default-input-classes)
(hh/add-class (or class "")))
sel (cond-> value (keyword? value) clojure.core/name)
attrs (dissoc params :name :value :options :class)]
(render "templates/components/select.html"
{:name name
:classes classes
:attrs (attrs->str attrs)
:options (for [[v label] options]
{:value v :label label :selected (= (str v) (str sel))})})))
;; --- field wrapper --------------------------------------------------------------- ;; --- field wrapper ---------------------------------------------------------------
(defn validated-field (defn validated-field

View File

@@ -10,86 +10,74 @@
[auto-ap.routes.utils :refer [wrap-client-redirect-unauthenticated]] [auto-ap.routes.utils :refer [wrap-client-redirect-unauthenticated]]
[auto-ap.rule-matching :as rm] [auto-ap.rule-matching :as rm]
[auto-ap.ssr-routes :as ssr-routes] [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 :as com]
[auto-ap.ssr.components.multi-modal :as mm :refer [wrap-wizard]] [auto-ap.ssr.components.selmer :as sc]
[auto-ap.ssr.form-cursor :as fc]
[auto-ap.ssr.grid-page-helper :refer [wrap-apply-sort]] [auto-ap.ssr.grid-page-helper :refer [wrap-apply-sort]]
[auto-ap.ssr.hx :as hx] [auto-ap.ssr.hx :as hx]
[auto-ap.ssr.ledger :refer [wrap-ensure-bank-account-belongs]] [auto-ap.ssr.ledger :refer [wrap-ensure-bank-account-belongs]]
[auto-ap.ssr.svg :as svg] [auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
[auto-ap.ssr.selmer :as sel]
[auto-ap.ssr.transaction.common :refer [grid-page query-schema [auto-ap.ssr.transaction.common :refer [grid-page query-schema
selected->ids selected->ids
wrap-status-from-source]] wrap-status-from-source]]
[auto-ap.ssr.transaction.edit :as edit :refer [account-typeahead* [auto-ap.ssr.transaction.edit :as edit :refer [account-typeahead*
location-select*]] location-select*]]
[auto-ap.ssr.utils [auto-ap.ssr.utils
:refer [apply-middleware-to-all-handlers entity-id :refer [->db-id apply-middleware-to-all-handlers assert-schema entity-id
form-validation-error html-response percentage form-validation-error html-response main-transformer modal-response
ref->enum-schema wrap-merge-prior-hx wrap-schema-enforce]] path->name2 percentage ref->enum-schema wrap-form-4xx-2
wrap-merge-prior-hx wrap-schema-enforce]]
[bidi.bidi :as bidi] [bidi.bidi :as bidi]
[clojure.string :as str]
[datomic.api :as dc] [datomic.api :as dc]
[iol-ion.query :refer [dollars=]] [iol-ion.query :refer [dollars=]]
[iol-ion.tx :refer [random-tempid]] [iol-ion.tx :refer [random-tempid]]
[malli.core :as mc])) [malli.core :as mc]))
(defn transaction-account-row* [{:keys [value client-id]}] ;; ---------------------------------------------------------------------------
(com/data-grid-row ;; Field-name / error helpers (no step-params[...] prefix -- posted fields
(-> {:x-data (hx/json {:show (boolean (not (fc/field-value (:new? value)))) ;; decode straight into bulk-code-schema, mirroring transaction/edit.clj).
: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
{}
(com/validated-field
{:errors (fc/field-errors)}
(account-typeahead* {:value (fc/field-value)
:client-id client-id
:name (fc/field-name)
:x-model "accountId"}))))
(fc/with-field :location
(com/data-grid-cell
{}
(com/validated-field
{:errors (fc/field-errors)
:x-hx-val:account-id "accountId"
:hx-vals (hx/json (cond-> {:name (fc/field-name)}
client-id (assoc :client-id client-id)))
: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 (let [account-id (:account @value)]
(when (nat-int? account-id)
(:account/location (dc/pull (dc/db conn) '[:account/location] account-id))))
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
: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"
: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))))
(defn initial-bulk-edit-state [request] (def ^:dynamic *errors*
(mm/->MultiStepFormState {:search-params (:query-params request) "Humanized form errors for the current render, keyed by bulk-code-schema paths
:accounts []} (e.g. {:accounts {0 {:location [\"required\"]}}}). Bound by render-form from the
[] request's :form-errors. Plain map -- no wizard, no cursor."
{:search-params (:query-params request) {})
:accounts []}))
(defn- ferr
"Field errors at a schema path, read from *errors* (no step-params prefix)."
[& path]
(get-in *errors* (vec path)))
(defn- account-field-name [index field]
(path->name2 :accounts index field))
(defn- account-field-errors [index field]
(ferr :accounts index field))
;; ---------------------------------------------------------------------------
;; Schema + decode
;; ---------------------------------------------------------------------------
(def bulk-code-schema
(mc/schema [:map
[:vendor {:optional true} [:maybe entity-id]]
[:approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]]
[:ids {:optional true} [:maybe [:vector {:coerce? true} entity-id]]]
[: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-code-form-keys
"Editable top-level keys (vendor/status/accounts). The transaction selection (:ids)
is non-editable -- it is threaded separately by wrap-bulk-state."
[:vendor :approval-status :accounts])
(defn all-ids-not-locked (defn all-ids-not-locked
"Filters transaction IDs to only those that aren't locked (client locked date earlier than transaction date)" "Filters transaction IDs to only those that aren't locked (client locked date earlier than transaction date)"
@@ -105,16 +93,281 @@
(dc/db conn)) (dc/db conn))
(map first))) (map first)))
(def bulk-code-schema (defn wrap-bulk-state
(mc/schema [:map "Replaces the wizard's MultiStepFormState/snapshot round-trip. Parses the posted
[:vendor {:optional true} [:maybe entity-id]] (nested) form params, decodes them straight into bulk-code-schema, and resolves the
[:approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]] target transaction id set. On open (GET) the selection comes from the grid's
[:accounts {:optional true} query-params (selected / all-selected + filters); on every post the concrete
[:maybe (not-locked) id list rides back in hidden ids[] fields, so no EDN snapshot / filter
[:vector {:coerce? true} round-trip is needed -- and we code exactly the transactions the user saw."
[:map [:account entity-id] [handler]
[:location [:string {:min 1 :error/message "required"}]] (-> (fn [request]
[:percentage percentage]]]]]])) (let [parsed (:form-params request)
decoded (mc/decode bulk-code-schema parsed 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)))))]
(handler (assoc request :bulk-state (assoc (select-keys decoded bulk-code-form-keys) :ids ids)))))
(wrap-nested-form-params)))
(defn- single-client-id
"Returns the client ID if the user has access to exactly one client, nil otherwise."
[request]
(when (= 1 (count (:clients request)))
(-> request :clients first :db/id)))
;; ---------------------------------------------------------------------------
;; Render (100% Selmer -- reuses the transaction/edit.clj sc/* component library
;; and the shared edit-modal / transitioner chrome).
;; ---------------------------------------------------------------------------
(defn transaction-account-row*
"One row of the bulk-code account grid, from a plain account map (no cursor). The
location cell swaps just itself (#account-location-<index>, Rule 2); remove swaps the
whole #bulk-code-form (Rule 3). Percentage rides along to submit (Rule 1, no request)."
[{: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-code-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
{}
(sc/validated-field
{:errors (account-field-errors index :account)}
(account-typeahead* {:value account-val
:client-id client-id
: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)
(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)))
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
:value (:location value)})))
(sc/data-grid-cell
{}
(sc/validated-field
{:errors (account-field-errors index :percentage)}
(sc/money-input {:name (account-field-name index :percentage)
:class "w-16"
:value (some-> (:percentage value) (* 100) long)})))
(sc/data-grid-cell
{:class "align-top"}
(sc/a-icon-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-code-form-changed)
:hx-vals (hx/json {:op "remove-account" :row-index (or index 0)})
:hx-target "#bulk-code-form"
:hx-select "#bulk-code-form"
:hx-swap "outerHTML"
:hx-include "closest form"
:class "account-remove-action"}
(sc/render "templates/components/svg-x.html" {}))))))
(defn- account-grid* [request]
(let [client-id (single-client-id request)
accounts (vec (: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"})]}
(concat
(map-indexed
(fn [index account]
(transaction-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-code-form-changed)
:hx-vals (hx/json {:op "new-account"})
:hx-target "#bulk-code-form"
:hx-select "#bulk-code-form"
:hx-swap "outerHTML"
:hx-include "closest form"
:color :secondary}
"New account")))]))))
(defn- bulk-code-body* [request]
(let [bulk-state (:bulk-state request)
vendor-val (:vendor bulk-state)
status-val (some-> (:approval-status bulk-state) name)]
(sel/render->hiccup
"templates/transaction-bulk-code/bulk-code-body.html"
{:vendor_changed_attrs (sc/attrs->str {:hx-trigger "change"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-code-form-changed)
:hx-vals (hx/json {:op "vendor-changed"})
:hx-target "#bulk-code-form"
:hx-select "#bulk-code-form"
:hx-swap "outerHTML"
:hx-sync "this:replace"
:hx-include "closest form"})
:vendor_field (str (sc/validated-field
{:label "Vendor" :errors (ferr :vendor)}
(sc/typeahead {:name (path->name2 :vendor)
:id (path->name2 :vendor)
:error? (boolean (seq (ferr :vendor)))
:class "w-96"
:placeholder "Search for vendor..."
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
:value vendor-val
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})))
:status_field (str (sc/validated-field
{:label "Status" :errors (ferr :approval-status)}
(sc/select {:name (path->name2 :approval-status)
:value status-val
:options [["" "No Change"]
["approved" "Approved"]
["unapproved" "Unapproved"]
["suppressed" "Suppressed"]
["requires-feedback" "Client Review"]]})))
:accounts_field (str (sc/validated-field
{:errors (ferr :accounts)}
(sel/raw (str "<div id=\"account-entries\" class=\"space-y-3\">"
(str (account-grid* request))
"</div>"))))})))
(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 wizard-save-action"} "Save"))
"</div></div>")))
(defn render-form
"Renders the whole plain bulk-code form (no wizard). Binds *errors* from the request's
:form-errors so the field-level error lookups (ferr) resolve. Reuses the edit modal's
chrome (edit-modal.html), with no side panel."
[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))
modal-card (sel/render "templates/transaction-edit/edit-modal.html"
{:head (str "<div class=\"p-2\">Bulk editing " (count ids) " transactions</div>")
:side_panel nil
:body (str (bulk-code-body* request))
:footer (str (footer* request))})]
(sel/render->hiccup
"templates/transaction-bulk-code/bulk-code-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-post (bidi/path-for ssr-routes/only-routes ::route/bulk-code-submit)})
:modal (str (sc/modal {:id "bulkcodemodal"} (sel/raw modal-card)))}))))
;; ---------------------------------------------------------------------------
;; edit-form-changed ops (whole-form re-render). Replaces the per-operation
;; bulk-code-new-account / bulk-code-vendor-changed routes.
;; ---------------------------------------------------------------------------
(defn- vendor-default-account
"Returns the vendor's standard default account. For single-client contexts,
the account name is clientized (tailored to the customer). For multi-client
contexts, the raw account name is used."
[vendor-id client-id]
(when vendor-id
(let [vendor (edit/get-vendor vendor-id)
account (:vendor/default-account vendor)]
(if client-id
(d-accounts/clientize account client-id)
account))))
(defn- build-default-account-row [account]
{:db/id (str (java.util.UUID/randomUUID))
:account (:db/id account)
:location (or (:account/location account) "Shared")
:percentage 1.0})
(defn apply-vendor-changed
"bulk-code-form-changed op: when the accounts are empty and a vendor with a default
account is chosen, pre-populate a single 100% default-account row."
[request]
(let [bulk-state (:bulk-state request)
client-id (single-client-id request)
vendor-id (->db-id (:vendor bulk-state))
accounts (:accounts bulk-state)]
(if (and (empty? accounts) vendor-id)
(if-let [default-account (vendor-default-account vendor-id client-id)]
(assoc-in request [:bulk-state :accounts] [(build-default-account-row default-account)])
request)
request)))
(defn apply-new-account
"bulk-code-form-changed op: append a fresh (blank, Shared) account row."
[request]
(let [accounts (vec (:accounts (:bulk-state request)))
new-account {:db/id (str (java.util.UUID/randomUUID))
:new? true
:location "Shared"}]
(assoc-in request [:bulk-state :accounts] (conj accounts new-account))))
(defn apply-remove-account
"bulk-code-form-changed op: remove the account row at form-param row-index."
[request]
(let [row-index (some-> request :form-params (get "row-index") Integer/parseInt)
accounts (vec (: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 :accounts] updated-accounts)))
(defn bulk-code-form-changed-handler
"Single whole-form re-render endpoint. Dispatches on the `op` form-param (vendor
change, add/remove row), then re-renders the whole form. A missing/unknown op (e.g.
an account selection driving the location swap) just re-renders."
[request]
(let [op (get-in request [:form-params "op"])
request' (case op
"vendor-changed" (apply-vendor-changed request)
"new-account" (apply-new-account request)
"remove-account" (apply-remove-account request)
request)]
(html-response (render-form request'))))
;; ---------------------------------------------------------------------------
;; Submit
;; ---------------------------------------------------------------------------
(defn maybe-code-accounts [transaction account-rules valid-locations] (defn maybe-code-accounts [transaction account-rules valid-locations]
(with-precision 2 (with-precision 2
@@ -151,263 +404,95 @@
[])] [])]
accounts))) accounts)))
(defrecord AccountsStep [linear-wizard]
mm/ModalWizardStep
(step-name [_]
"Bulk Code")
(step-key [_]
:accounts)
(edit-path [_ _]
[])
(step-schema [_]
(mm/form-schema linear-wizard))
(render-step [this {{:keys [snapshot] :as multi-form-state} :multi-form-state :as request}]
(let [_ (alog/peek ::SEARCH_PARAMS (:search-params snapshot))
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) " transactions"]
:body (mm/default-step-body
{}
[:div
#_(com/hidden {:name "ids" :value (pr-str ids)})
[:div.space-y-4.p-4
[:div.grid.grid-cols-2.gap-4
;; Vendor field
[:div {:hx-trigger "change"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-code-vendor-changed)
:hx-target "#account-entries"
:hx-swap "innerHTML"
:hx-include "closest form"}
(fc/with-field :vendor
(com/validated-field {:label "Vendor"
:errors (fc/field-errors)}
(com/typeahead {:name (fc/field-name)
:placeholder "Search for vendor..."
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
:value (fc/field-value)
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})))]
;; Status field
[:div
(fc/with-field :approval-status
(com/validated-field {:label "Status"
:errors (fc/field-errors)}
(com/select {:name (fc/field-name)
:value (some-> (fc/field-value)
name)
:options [["" "No Change"]
["approved" "Approved"]
["unapproved" "Unapproved"]
["suppressed" "Suppressed"]
["requires-feedback" "Client Review"]]})))]
;; Accounts section
[:div.col-span-2.pt-4
[:h3.text-lg.font-medium.mb-3 "Expense Accounts"]
[:div#account-entries.space-y-3
(fc/with-field :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 #(transaction-account-row* {:value %}))
(com/data-grid-new-row {:colspan 4
:hx-get (bidi/path-for ssr-routes/only-routes
::route/bulk-code-new-account)
:row-offset 0
:index (count (fc/field-value))}
"New account"))))]]]]])
;; Button to add more accounts
: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 assert-percentages-add-up [{:keys [accounts]}] (defn assert-percentages-add-up [{:keys [accounts]}]
(let [account-total (reduce + 0 (map (fn [x] (:percentage x)) accounts))] (let [account-total (reduce + 0 (map (fn [x] (:percentage x)) accounts))]
(when-not (dollars= 1.0 account-total) (when-not (dollars= 1.0 account-total)
(form-validation-error (str "Expense account total (" account-total ") does not equal 100%"))))) (form-validation-error (str "Expense account total (" account-total ") does not equal 100%")))))
(defrecord BulkCodeWizard [_ current-step] (defn submit
mm/LinearModalWizard "Validates the posted bulk-code form (schema field errors via wrap-form-4xx-2, then the
(hydrate-from-request percentage-sum and per-account location checks as form errors), then applies the chosen
[this request] vendor / status / account-coding across every selected (not-locked) transaction."
this) [request]
(navigate [this step-key] (let [{:keys [ids vendor approval-status accounts]} (:bulk-state request)]
(assoc this :current-step step-key)) (assert-schema bulk-code-schema (select-keys (:bulk-state request) bulk-code-form-keys))
(get-current-step [this] (when (seq accounts)
(if current-step (assert-percentages-add-up {:accounts accounts}))
(mm/get-step this current-step) (let [db (dc/db conn)
(mm/get-step this :accounts))) transactions (pull-many db [:db/id :transaction/amount {:transaction/client [:db/id]}] (vec ids))
(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-code-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 [_]
bulk-code-schema)
(submit [this {:keys [multi-form-state request-method identity] :as request}]
(let [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 ids)
vendor (-> request :multi-form-state :snapshot :vendor)
approval-status (-> request :multi-form-state :snapshot :approval-status)
accounts (-> request :multi-form-state :snapshot :accounts)]
(when (seq accounts)
(assert-percentages-add-up (:snapshot multi-form-state)))
(alog/peek ::ACCOUNTS (-> request :multi-form-state :snapshot))
;; Get transactions and filter for locked ones ;; Get client locations
(let [db (dc/db conn) client->locations (->> (map (comp :db/id :transaction/client) transactions)
transactions (pull-many db [:db/id :transaction/amount {:transaction/client [:db/id]}] (vec all-ids)) (distinct)
(dc/q '[:find (pull ?e [:db/id :client/locations])
:in $ [?e ...]]
db)
(map (fn [[client]]
[(:db/id client) (:client/locations client)]))
(into {}))]
;; Get client locations ;; Validate account locations
client->locations (->> (map (comp :db/id :transaction/client) transactions) (doseq [a accounts
(distinct) :let [{:keys [:account/location :account/name]} (dc/pull db
(dc/q '[:find (pull ?e [:db/id :client/locations]) [:account/location :account/name]
:in $ [?e ...]] (:account a))]]
db) (when (and location (not= location (:location a)))
(map (fn [[client]] (form-validation-error (str "Account " name " uses location " (:location a) ", but is supposed to be " location)))
[(:db/id client) (:client/locations client)])) (doseq [[_ locations] client->locations]
(into {}))] (when (and (not location)
(not (get (into #{"Shared"} locations)
(:location a))))
(form-validation-error (str "Account " name " uses location " (:location a) ", but doesn't belong to the client.")))))
;; Validate account locations (audit-transact-batch
(doseq [a accounts (map (fn [t]
:let [{:keys [:account/location :account/name]} (dc/pull db (let [locations (client->locations (-> t :transaction/client :db/id))]
[:account/location :account/name] [:upsert-transaction (cond-> t
(:account a))]] approval-status
(when (and location (not= location (:location a))) (assoc :transaction/approval-status approval-status)
(form-validation-error (str "Account " name " uses location " (:location a) ", but is supposed to be " location)))
(doseq [[_ locations] client->locations]
(when (and (not location)
(not (get (into #{"Shared"} locations)
(:location a))))
(form-validation-error (str "Account " name " uses location " (:location a) ", but doesn't belong to the client.")))))
(audit-transact-batch vendor
(map (fn [t] (assoc :transaction/vendor vendor)
(let [locations (client->locations (-> t :transaction/client :db/id))]
[:upsert-transaction (cond-> t
approval-status
(assoc :transaction/approval-status approval-status)
vendor (seq accounts)
(assoc :transaction/vendor vendor) (assoc :transaction/accounts
(maybe-code-accounts t accounts locations)))]))
transactions)
(:identity request))
(seq accounts) ;; Return success modal
(assoc :transaction/accounts (html-response
(maybe-code-accounts t accounts locations)))])) (com/success-modal {:title "Transactions Coded"}
transactions) [:p (str "Successfully coded " (count ids) " transactions.")])
(:identity request)) :headers {"hx-trigger" "refreshTable, reset-selection"}))))
;; Return success modal ;; ---------------------------------------------------------------------------
(html-response ;; Handlers + routes
(com/success-modal {:title "Transactions Coded"} ;; ---------------------------------------------------------------------------
[:p (str "Successfully coded " (count all-ids) " transactions.")])
:headers {"hx-trigger" "refreshTable, reset-selection"})))))
(defn- vendor-default-account [vendor-id client-id] (defn open-handler
"Returns the vendor's standard default account. For single-client contexts, "Initial modal open (GET). Wraps the rendered form in the #transitioner shell expected
the account name is clientized (tailored to the customer). For multi-client by the modal stack (reuses the edit modal's transitioner)."
contexts, the raw account name is used." [request]
(when vendor-id (modal-response
(let [vendor (edit/get-vendor vendor-id) (sel/render->hiccup "templates/transaction-edit/transitioner.html"
account (:vendor/default-account vendor)] {:body (str (render-form request))})))
(if client-id
(d-accounts/clientize account client-id)
account))))
(defn- build-default-account-row [account] (defn- render-form-response
{:db/id (str (java.util.UUID/randomUUID)) "wrap-form-4xx-2 form-handler: re-render the whole form with field/form errors."
:account (:db/id account) [request]
:location (or (:account/location account) "Shared") (html-response (render-form request)
:percentage 1.0}) :headers {"HX-reswap" "outerHTML"}))
(defn- render-accounts-section [request]
(let [multi-form-state (:multi-form-state request)]
(html-response
[:div
(fc/start-form multi-form-state
(when (:form-errors request) {:step-params (:form-errors request)})
(fc/with-field :step-params
(fc/with-field :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 #(transaction-account-row* {:value %}))
(com/data-grid-new-row {:colspan 4
:hx-get (bidi/path-for ssr-routes/only-routes
::route/bulk-code-new-account)
:row-offset 0
:index (count (fc/field-value))}
"New account"))))))])))
(defn- single-client-id [request]
"Returns the client ID if the user has access to exactly one client, nil otherwise."
(when (= 1 (count (:clients request)))
(-> request :clients first :db/id)))
(defn vendor-changed-handler [request]
(let [snapshot (:snapshot (:multi-form-state request))
step-params (:step-params (:multi-form-state request))
client-id (single-client-id request)
vendor-id (or (:vendor step-params) (:vendor snapshot))
updated-step-params (if (and (empty? (:accounts step-params))
vendor-id)
(if-let [default-account (vendor-default-account vendor-id client-id)]
(assoc step-params :accounts [(build-default-account-row default-account)])
step-params)
step-params)]
(render-accounts-section (assoc-in request [:multi-form-state :step-params] updated-step-params))))
(def bulk-code-wizard (->BulkCodeWizard nil nil))
(def key->handler (def key->handler
(apply-middleware-to-all-handlers (apply-middleware-to-all-handlers
{::route/bulk-code (-> mm/open-wizard-handler {::route/bulk-code (-> open-handler
(mm/wrap-wizard bulk-code-wizard) (wrap-bulk-state))
(mm/wrap-init-multi-form-state initial-bulk-edit-state)) ::route/bulk-code-form-changed (-> bulk-code-form-changed-handler
::route/bulk-code-new-account (-> (wrap-bulk-state))
(add-new-entity-handler [:step-params :accounts] ::route/bulk-code-submit (-> submit
(fn render [cursor request] (wrap-form-4xx-2 render-form-response)
(transaction-account-row* (wrap-bulk-state))}
{:value cursor}))
(fn build-new-row [base _]
(assoc base :location "Shared")))
(wrap-schema-enforce :query-schema [:map
[:client-id {:optional true}
[:maybe entity-id]]]))
::route/bulk-code-vendor-changed (-> vendor-changed-handler
(mm/wrap-wizard bulk-code-wizard)
(mm/wrap-decode-multi-form-state))
::route/bulk-code-submit (-> mm/submit-handler
(wrap-wizard bulk-code-wizard)
(mm/wrap-decode-multi-form-state))}
(fn [h] (fn [h]
(-> h (-> h
(wrap-copy-qp-pqp) (wrap-copy-qp-pqp)
@@ -418,4 +503,4 @@
(wrap-schema-enforce :query-schema query-schema) (wrap-schema-enforce :query-schema query-schema)
(wrap-schema-enforce :hx-schema query-schema) (wrap-schema-enforce :hx-schema query-schema)
(wrap-must {:activity :bulk-code :subject :transaction}) (wrap-must {:activity :bulk-code :subject :transaction})
(wrap-client-redirect-unauthenticated))))) (wrap-client-redirect-unauthenticated)))))

View File

@@ -7,9 +7,8 @@
"/bulk-delete" ::bulk-delete "/bulk-delete" ::bulk-delete
"/bulk-suppress" ::bulk-suppress "/bulk-suppress" ::bulk-suppress
"/bulk-code" {:get ::bulk-code "/bulk-code" {:get ::bulk-code
:put ::bulk-code-submit :post ::bulk-code-submit
"/new-account" ::bulk-code-new-account "/form-changed" ::bulk-code-form-changed}}
"/vendor-changed" ::bulk-code-vendor-changed}}
"/new" {:get ::new "/new" {:get ::new
:post ::new-submit :post ::new-submit
"/location-select" ::location-select "/location-select" ::location-select