refactor(ssr): Phase 4 — full Selmer migration of POS Sales Summary; remove the wizard; fix add-item + totals

Migrates the POS Sales Summary edit modal off the wizard to a plain Selmer form,
building on the parity gate committed earlier. Largest migration so far and the
first with no prior test coverage.

What changed
- Wizard removed: deleted MainStep/EditWizard records, MultiStepFormState, the
  step-params[...] prefix, the EDN snapshot round-trip, and all mm/* middleware.
  Replaced with a plain handler + flat wrap-decode/wrap-derive-state. The 51 fc/
  cursor refs are de-cursored into explicit data + Selmer templates.
- db/id-keyed item merge: wrap-derive-state overlays posted items onto the
  persisted items by :db/id, so read-only fields the form doesn't post
  (ledger-side, amount) survive a re-render and the debit/credit split + totals
  stay correct. New manual rows (temp db/id) ride through as-is.
- Inline click-to-edit account cell preserved as three small targeted
  .account-cell-swap routes (edit/save/cancel-item-account), ported to Selmer
  with the new field-name scheme.
- 100% Selmer modal render path (the remaining Hiccup / hx-swap-oob / "hx-"
  strings are all grid-page code — grid render lambdas, the filters form, and the
  submit response-header map — not the modal).
- Routes: dropped edit-wizard-navigate + new-summary-item; added form-changed.

Fixes (two pre-existing bugs, per request)
- "New Summary Item" add button (was throwing `newRowIndex is not defined` and
  targeting a non-existent `.new-row`) is now a whole-form-swap op=new-item that
  adds an editable manual row (category + account typeahead + debit/credit money
  inputs + remove).
- The dead totals/balance display (malformed Hiccup that discarded its labels) is
  replaced by a proper #summary-totals block showing running Total +
  Balanced/Unbalanced, refreshed via a Rule-4 targeted swap on manual amount edits.

Scorecard delta (pos/sales_summaries.clj): LOC 790->732, mm coupling 20->0,
wizard records 4->0, fc/ cursor 51->0, step-params 27->0 (2 comments), modal
routes 8->6. (hx-swap-oob 1 and mixed-hx live in the grid page, not the modal.)

Verification: sales-summary spec 7/7 (incl. the two fixes); full Playwright suite
46/46; cljfmt clean. Skill fed: scorecard row + narrative; gotchas (parity-gate-
first, characterize-then-fix, keyup-trigger tests); cookbook (inline click-to-edit
cell, db/id-keyed item merge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 22:13:19 -07:00
parent a289ff2557
commit 599b849e6f
8 changed files with 524 additions and 484 deletions

View File

@@ -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 `<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. |
## inline click-to-edit cell (Phase 4) — targeted `.account-cell` swap, not a whole-form op
A "display value + pencil → edit-in-place → check/cancel" cell. Three tiny **stateless** routes,
each swapping just the cell (`hx-target="closest .account-cell"`, `outerHTML`): a `display` cell
(value + pencil `hx-get edit`), an `edit` cell (typeahead + check `hx-put save` / cancel
`hx-get cancel`). State rides in the request (item index + current value via `hx-vals`), so no
server-side "which cell is editing" flag is needed. Keep it as its own routes — it is a distinct
feature, *not* folded into the whole-form `form-changed` dispatcher (that would lose the targeted
swap and re-render the whole modal on every pencil click). The cells are assembled with `sc/*` +
`sel/raw` strings (like `edit.clj`'s `footer*`); SVGs ride in as `svg/*` Hiccup via the
`sc/a-icon-button` body (no `[:svg]` literal lands in the modal file).
## db/id-keyed item merge (Phase 4) — for rows the form posts only partially
When a row renders some fields read-only (so they aren't posted) but the entity holds them
(sales-summary auto items post only db/id/category/account — not ledger-side/amount), the flat
`wrap-derive-state` must **overlay posted items onto the persisted items by `:db/id`** so the
unposted fields survive a re-render: `(merge (by-id (:db/id posted)) posted)`. New rows (temp
`:db/id` not in the entity) ride through as-is. This is the row-level analog of edit's
"entity-only fields always from the entity"; without it, a re-render drops ledger-side/amount and
the debit/credit split + totals break.
| `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/badge` / `sc/link` | `badge`/`link`.html | |

View File

@@ -224,6 +224,35 @@ on each post (`[:vector {:coerce? true} entity-id]` + the `coerce-vector` transf
— 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.
## No parity gate? Build one first — seed + characterization spec, before touching code
A modal with **no e2e coverage** (and no test-server seed for its domain) cannot be migrated
safely — "behavior parity is proven by tests, not by reading" is the skill's #1 non-negotiable.
Phase 4 (POS Sales Summary) had zero coverage. The fix: (1) seed a representative entity in
`test_server.clj`'s `seed-test-data` and surface its id via `/test-info`; (2) write a
characterization spec against the **unmodified** modal and confirm it green; (3) commit the gate
*separately, ahead of the rewrite*. Reach the modal the real way (grid → row's edit button), not
a direct fragment URL. To discover the actual rendered structure (field names, ids, swap targets)
— especially when the code has dead/buggy render fns — dump the live modal HTML with a throwaway
spec first; assert against what *renders*, not what the code looks like.
## Characterize before you fix; never assert a bug as working
Writing the gate often surfaces pre-existing bugs (Phase 4: a "New Summary Item" button that
threw `newRowIndex is not defined`, and a totals display whose malformed Hiccup discarded its
own labels). Do **not** assert the broken behavior as if it works, and do **not** silently "fix"
it mid-refactor — surface it and let the user decide fix-vs-preserve. If they choose *fix*: the
spec first documents the break (a passing test of the *current* inert behavior or an explicit
note), then is rewritten to assert the *fixed* behavior as part of the migration commit.
## htmx `keyup`-triggered inputs need real keystrokes in tests
A money/text input wired `hx-trigger="keyup changed delay:300ms"` does **not** fire on Playwright
`.fill()` + `dispatchEvent('change')` — `fill` sets the value without keyup events. Use
`.click()` then `.pressSequentially('500')` (types char-by-char, firing keyup) so the targeted
swap actually triggers. (A `change`-triggered control is the opposite — `dispatchEvent('change')`
is fine there.)
## Scorecard exceptions (ratchet violations with a reason)
**Heuristic 4 (LOC net ↓) — exception (Phase 3, Transaction Bulk Code: 420→506).** When the

View File

@@ -44,6 +44,9 @@ Each migration appends one row (after-numbers), referencing the before in the di
| 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).
| 4 | POS Sales Summary `pos/sales_summaries.clj` | **732** (was 790) | **6** modal | 0 | 0 | **0** | 1✦ | 0✦ | reused `sc/*` lib + `edit-modal`/`transitioner` chrome / added the inline click-to-edit **account-cell** + **manual-items** patterns |
✦ The residual 1 `hx-swap-oob` and the `"hx-..."` string hits all live in the **grid page** code (the `grid-page` render lambdas + the `filters` form + the submit response-header map) — none are in the migrated **modal** render path, which is 100% Selmer. `defrecord` count **0** (all 4 wizard records gone), `fc/` cursor refs 51→**0**, mm coupling 20→**0**, step-params 27→**0** (2 comments). LOC dropped (this wizard held real custom code, unlike bulk-code's thin shell). **Two pre-existing bugs fixed** (per the user's call): the "New Summary Item" add button (was throwing `newRowIndex is not defined`) and the dead totals/balance display.
### New heuristics introduced at 2-final (full Selmer)
@@ -108,3 +111,25 @@ Each migration appends one row (after-numbers), referencing the before in the di
> (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).
> **Phase 4 — POS Sales Summary (first modal with no prior test coverage).** The largest
> migration so far and the first that required **building the parity gate first**: the modal
> had zero e2e/clj tests and the test server seeded no POS data, so the work began by seeding a
> balanced sales summary + writing a 7-test characterization spec (committed separately, ahead
> of the rewrite). Then the standard wizard→plain-Selmer migration: `MainStep`/`EditWizard` +
> `MultiStepFormState` deleted, the 51 `fc/` cursor refs de-cursored into explicit data +
> Selmer, `step-params` dropped, the EDN snapshot replaced by flat `wrap-decode`/`wrap-derive-state`
> (with a **db/id-keyed item merge** so the read-only fields the form doesn't post —
> ledger-side, amount — survive a re-render). The **inline click-to-edit account cell** (pencil →
> typeahead editor → check/cancel) was preserved as three small targeted `.account-cell`-swap
> routes (a distinct feature, not folded into the form-changed dispatcher). LOC 790→**732** (net
> ↓ — a fat wizard, opposite of bulk-code).
>
> **Characterize-then-fix.** Writing the gate surfaced two pre-existing bugs: the "New Summary
> Item" button threw `newRowIndex is not defined` (dead since forever) and the totals/balance
> display was dead code (malformed Hiccup that discarded its labels). The spec first *documented*
> them as broken (never assert a bug as working); then, on the user's call, the migration **fixed
> both** — add-item is now a whole-form-swap `op=new-item` adding an editable manual row, and a
> 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.

View File

@@ -45,11 +45,12 @@ test.describe('Sales Summary Edit (characterization)', () => {
expect(await modal.locator('[hx-get*="edit/item-account"]').count()).toBe(2);
});
test('seeded summary is balanced (no out-of-balance indicator)', async ({ page }) => {
test('seeded summary is balanced (shows Balanced totals, no out-of-balance)', async ({ page }) => {
await openEditModal(page);
const modal = page.locator('#wizardmodal');
// balanced: $500 debit == $500 credit, so no unbalanced warning text
await expect(modal).not.toContainText('Out of balance');
// balanced: $500 debit == $500 credit -> the (now-fixed) totals block shows Balanced
await expect(modal.locator('#summary-totals')).toContainText('Balanced');
await expect(modal.locator('#summary-totals')).toContainText('$500.00');
await expect(modal).not.toContainText('Unbalanced');
});
@@ -92,22 +93,36 @@ test.describe('Sales Summary Edit (characterization)', () => {
await expect(display.locator('[hx-get*="edit/item-account"]')).toBeVisible();
});
// NOTE: the "New Summary Item" button is currently BROKEN in this modal and is
// therefore intentionally not characterized as working. Its Alpine handler
// (`@click="$dispatch('newRow', {index: (newRowIndex++)})"`) throws
// "newRowIndex is not defined" (the modal's x-data is empty), and even if it
// fired, `hx-target="closest .new-row"` matches no ancestor in this div layout,
// so the `new-summary-item` route never fires. We assert the *current* behavior:
// the button is present but adds nothing. The migration may fix or preserve this.
test('New Summary Item button is present but currently inert (documents the break)', async ({ page }) => {
// The "New Summary Item" button was broken in the pre-migration wizard (its Alpine
// handler threw "newRowIndex is not defined"); the migration fixes it as a whole-form
// swap (op=new-item). This asserts the FIXED behavior: clicking adds an editable manual
// row (category + account typeahead + debit/credit money inputs).
test('New Summary Item adds an editable manual row (fixed)', async ({ page }) => {
await openEditModal(page);
const modal = page.locator('#wizardmodal');
const before = await modal.locator('input').count();
await modal.locator('[hx-get*="sales-summary-item"]').first().click();
await page.waitForTimeout(500);
// no manual row was added (no category input appeared, input count unchanged)
expect(await modal.locator('input[placeholder="Category/Explanation"]').count()).toBe(0);
expect(await modal.locator('input').count()).toBe(before);
expect(await modal.locator('.manual-item-row').count()).toBe(0);
await modal.locator('a[hx-vals*="new-item"]').click();
await expect(modal.locator('.manual-item-row').first()).toBeVisible();
expect(await modal.locator('.manual-item-row').count()).toBe(1);
const row = modal.locator('.manual-item-row').first();
await expect(row.locator('input[placeholder="Category/Explanation"]')).toBeVisible();
expect(await row.locator('input[name*="[debit]"]').count()).toBe(1);
expect(await row.locator('input[name*="[credit]"]').count()).toBe(1);
});
test('a manual debit amount recomputes the totals to Unbalanced (fixed)', async ({ page }) => {
await openEditModal(page);
const modal = page.locator('#wizardmodal');
await expect(modal.locator('#summary-totals')).toContainText('Balanced');
await modal.locator('a[hx-vals*="new-item"]').click();
await expect(modal.locator('.manual-item-row').first()).toBeVisible();
// adding a $500 debit -> $1000 debit vs $500 credit -> the totals block recomputes
const debit = modal.locator('.manual-item-row input[name*="[debit]"]').first();
await debit.click();
await debit.pressSequentially('500'); // fires keyup -> hx-trigger "keyup changed delay:300ms"
await expect(modal.locator('#summary-totals')).toContainText('Unbalanced', { timeout: 5000 });
});
test('Save closes the modal and the summary stays in the grid', async ({ page }) => {

View File

@@ -0,0 +1,4 @@
{# Plain sales-summary edit form (no wizard). db/id rides in a hidden field; all other
state is the live form, re-derived against the entity each request (no EDN snapshot,
no step-params). #}
<form id="summary-edit-form"{{ form_attrs|safe }}><input type="hidden" name="db/id" value="{{ db_id }}">{{ modal|safe }}</form>

View File

@@ -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. #}
<div class="space-y-4 p-2"><div class="grid grid-cols-2 gap-6"><div><div class="font-semibold text-sm mb-2">Debits</div><div class="space-y-1">{{ debit_rows|safe }}</div></div><div><div class="font-semibold text-sm mb-2">Credits</div><div class="space-y-1">{{ credit_rows|safe }}</div></div></div><div id="summary-totals">{{ totals|safe }}</div><div class="mt-4 border-t pt-3"><div class="font-semibold text-sm mb-2">Manual Items</div><div class="space-y-2" id="manual-items">{{ manual_rows|safe }}</div><div class="mt-2 flex justify-center">{{ new_item_button|safe }}</div></div></div>

View File

@@ -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 "<div class=\"account-cell flex items-center gap-2\">"
(str (sc/hidden {:name (item-field-name index :ledger-mapped/account)
:value (or account-id "")}))
(if account-name
(str "<span class=\"text-sm\">" (hu/escape-html account-name) "</span>")
(str (sel/hiccup->html (com/pill {:color :red} "Missing acct"))))
(str (sc/a-icon-button {:class "p-1"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-item-account)
:hx-target "closest .account-cell"
:hx-swap "outerHTML"
:hx-vals (hx/json {:item-index index
:client-id client-id
:current-account-id (or account-id "")})}
svg/pencil))
"</div>")))
(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 "<div class=\"account-cell flex flex-col gap-2\">"
(str (account-typeahead* {:name (item-field-name index :ledger-mapped/account)
:value account-id
:client-id client-id}))
"<div class=\"flex gap-1\">"
(str (sc/a-icon-button {:class "p-1"
:hx-put (bidi/path-for ssr-routes/only-routes ::route/save-item-account)
:hx-target "closest .account-cell"
:hx-swap "outerHTML"
:hx-include "closest .account-cell"
:hx-vals (hx/json {:item-index index :client-id client-id})}
svg/check))
(str (sc/a-icon-button {:class "p-1"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/cancel-item-account)
:hx-target "closest .account-cell"
:hx-swap "outerHTML"
:hx-vals (hx/json {:item-index index
:client-id client-id
:current-account-id (or account-id "")})}
svg/x))
"</div></div>"))
(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 "<div class=\"flex items-center gap-2 text-sm\">"
(str (sc/hidden {:name (item-field-name index :db/id) :value (:db/id item)}))
(str (sc/hidden {:name (item-field-name index :sales-summary-item/category)
:value (:sales-summary-item/category item)}))
"<span class=\"text-gray-500 flex-1\">" (hu/escape-html (str (:sales-summary-item/category item))) "</span>"
(str (account-display-cell* {:index index
:account-id (:ledger-mapped/account item)
:client-id client-id}))
"<span class=\"ml-auto font-mono tabular-nums text-gray-900\">" (format "$%,.2f" (or amount 0.0)) "</span>"
"</div>")))
(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 "<div class=\"manual-item-row flex items-center gap-2\">"
(str (sc/hidden {:name (item-field-name index :db/id) :value (:db/id item)}))
(str (sc/hidden {:name (item-field-name index :sales-summary-item/manual?) :value "true"}))
(str (sc/validated-field
{:errors (item-field-errors index :sales-summary-item/category) :class "flex-1"}
(sc/text-input {:name (item-field-name index :sales-summary-item/category)
:value (:sales-summary-item/category item)
:placeholder "Category/Explanation"})))
(str (sc/validated-field
{:errors (item-field-errors index :ledger-mapped/account)}
(account-typeahead* {:name (item-field-name index :ledger-mapped/account)
:value (:ledger-mapped/account item)
:client-id client-id})))
(str (manual-amount-input* index :debit item))
(str (manual-amount-input* index :credit item))
(str (sc/a-icon-button {:class "p-1 account-remove-action"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/form-changed)
:hx-vals (hx/json {:op "remove-item" :row-index index})
:hx-target "#summary-edit-form"
:hx-select "#summary-edit-form"
:hx-swap "outerHTML"
:hx-include "closest form"}
svg/x))
"</div>"))
(defn- totals*
"Swappable totals + balance block (#summary-totals). Fixes the previously-dead total /
balance display: shows the running debit/credit totals and a Balanced / Unbalanced
indicator."
[items]
(let [td (sum-debits items)
tc (sum-credits items)
balanced? (dollars= td tc)
delta (- td tc)]
(str "<div class=\"border-t pt-2 mt-2 space-y-1\">"
"<div class=\"flex justify-between text-sm font-semibold\"><span>Total</span>"
"<div class=\"flex gap-8\"><span class=\"font-mono\">" (format "$%,.2f" td) "</span>"
"<span class=\"font-mono\">" (format "$%,.2f" tc) "</span></div></div>"
(if balanced?
"<div class=\"text-sm text-emerald-700 font-semibold\">Balanced</div>"
(str "<div class=\"text-sm text-red-600 font-semibold flex justify-between\"><span>Unbalanced</span>"
"<span class=\"font-mono\">" (format "$%,.2f" (Math/abs delta)) " "
(if (pos? delta) "Debit over" "Credit over") "</span></div>"))
"</div>")))
(defn- new-item-button* []
(sc/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/form-changed)
:hx-vals (hx/json {:op "new-item"})
:hx-target "#summary-edit-form"
:hx-select "#summary-edit-form"
:hx-swap "outerHTML"
:hx-include "closest form"
:color :secondary}
"New Summary Item"))
(defn- form-errors-html [errors]
(str "<div id=\"form-errors\">"
(when (seq errors)
(str "<span class=\"error-content\"><p class=\"mt-2 text-xs text-red-600 dark:text-red-500 h-4\">"
(str/join ", " (filter string? errors))
"</p></span>"))
"</div>"))
(defn- footer* [request]
(sel/raw
(str "<div class=\"flex justify-end\"><div class=\"flex items-baseline gap-x-4\">"
(form-errors-html (:errors (:form-errors request)))
(str (sc/button {:color :primary :x-ref "next" :class "w-32" :type "submit"} "Save"))
"</div></div>")))
(defn render-form
"Renders the whole plain sales-summary edit form (no wizard). Binds *errors* so the
field-level lookups (item-field-errors) resolve. Reuses the edit modal chrome."
[request]
(binding [*errors* (or (:form-errors request) {})]
(let [{tx-id :db/id client :sales-summary/client items :sales-summary/items} (:edit-state request)
client-id (:db/id client)
indexed (map-indexed vector items)
auto (remove (fn [[_ it]] (:sales-summary-item/manual? it)) indexed)
manual (filter (fn [[_ it]] (:sales-summary-item/manual? it)) indexed)
debit-rows (->> auto
(filter (fn [[_ it]] (= :debit (item-side it))))
(map (fn [[i it]] (auto-item-row* i it client-id)))
(apply str))
credit-rows (->> auto
(filter (fn [[_ it]] (= :credit (item-side it))))
(map (fn [[i it]] (auto-item-row* i it client-id)))
(apply str))
manual-rows (->> manual
(map (fn [[i it]] (manual-item-row* i it client-id)))
(apply str))
body (sel/render "templates/sales-summary/summary-body.html"
{:debit_rows debit-rows
:credit_rows credit-rows
:totals (totals* items)
:manual_rows manual-rows
:new_item_button (str (new-item-button*))})
modal-card (sel/render "templates/transaction-edit/edit-modal.html"
{:head "<div class=\"p-2\">Edit Summary</div>"
:side_panel nil
:body body
:footer (str (footer* request))})]
(sel/render->hiccup
"templates/sales-summary/edit-form.html"
{:db_id tx-id
:form_attrs (sc/attrs->str {:hx-ext "response-targets"
:hx-swap "outerHTML"
:hx-target-400 "#form-errors .error-content"
:hx-trigger "submit"
:hx-target "this"
:hx-put (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-submit)})
:modal (str (sc/modal {:id "wizardmodal"} (sel/raw modal-card)))}))))
;; ---------------------------------------------------------------------------
;; State: derive the flat edit-state from the entity overlaid with the posted
;; form (replaces MultiStepFormState + the EDN snapshot round-trip).
;; ---------------------------------------------------------------------------
(defn entity->edit-state
"The persisted sales summary, shaped like the form's state: each item gets a :credit or
:debit field derived from its ledger-side/amount (what initial-edit-wizard-state did)."
[tx-id]
(let [e (dc/pull (dc/db conn) default-read tx-id)
items (->> (:sales-summary/items e)
sort-items
(mapv (fn [x]
(if (= :ledger-side/debit (:ledger-mapped/ledger-side x))
(assoc x :debit (:ledger-mapped/amount x))
(assoc x :credit (:ledger-mapped/amount x))))))]
{:db/id (:db/id e)
:sales-summary/client (:sales-summary/client e)
:sales-summary/items items}))
(defn- merge-items
"Overlay the posted items onto the persisted items by :db/id, so read-only fields the
form doesn't post (ledger-side, amount, the credit/debit shaping for auto items)
survive while edited fields (category, account, manual credit/debit) win. New manual
rows (temp db/id) have no persisted match and ride through as-is."
[entity-items posted-items]
(let [by-id (into {} (map (juxt :db/id identity)) entity-items)]
(mapv (fn [pi] (merge (get by-id (:db/id pi)) pi)) posted-items)))
(defn wrap-decode
"Parses the posted (nested) form params and decodes them straight into edit-schema --
no step-params[...] prefix. Strips to the editable top-level keys."
[handler]
(-> (fn [request]
(let [decoded (mc/decode edit-schema (:form-params request) main-transformer)
decoded (if (map? decoded) (select-keys decoded [:db/id :sales-summary/items]) {})]
(handler (assoc request :posted decoded))))
(wrap-nested-form-params)))
(defn wrap-derive-state
"Builds :edit-state from the entity (db/id hidden, or the route on initial open) overlaid
with the live posted items -- no serialized snapshot. db/id + client always come from
the entity; items are the merged posted items when present, else the entity's."
[handler]
(fn [request]
(let [tx-id (->db-id (or (some-> request :form-params (get "db/id"))
(-> request :route-params :db/id)))
base (entity->edit-state tx-id)
posted (:posted request)
items (if (contains? posted :sales-summary/items)
(merge-items (:sales-summary/items base) (:sales-summary/items posted))
(:sales-summary/items base))]
(handler (assoc request :edit-state (assoc base :sales-summary/items items))))))
(defn attach-ledger [i]
(cond-> i
@@ -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)

View File

@@ -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})