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:
@@ -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/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/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/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 | |
|
||||||
|
|||||||
@@ -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
|
— 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.
|
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)
|
## Scorecard exceptions (ratchet violations with a reason)
|
||||||
|
|
||||||
**Heuristic 4 (LOC net ↓) — exception (Phase 3, Transaction Bulk Code: 420→506).** When the
|
**Heuristic 4 (LOC net ↓) — exception (Phase 3, Transaction Bulk Code: 420→506).** When the
|
||||||
|
|||||||
@@ -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`** |
|
| 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).
|
† 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)
|
### 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
|
> (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**
|
> 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).
|
> (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.
|
||||||
|
|||||||
@@ -45,11 +45,12 @@ test.describe('Sales Summary Edit (characterization)', () => {
|
|||||||
expect(await modal.locator('[hx-get*="edit/item-account"]').count()).toBe(2);
|
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);
|
await openEditModal(page);
|
||||||
const modal = page.locator('#wizardmodal');
|
const modal = page.locator('#wizardmodal');
|
||||||
// balanced: $500 debit == $500 credit, so no unbalanced warning text
|
// balanced: $500 debit == $500 credit -> the (now-fixed) totals block shows Balanced
|
||||||
await expect(modal).not.toContainText('Out of balance');
|
await expect(modal.locator('#summary-totals')).toContainText('Balanced');
|
||||||
|
await expect(modal.locator('#summary-totals')).toContainText('$500.00');
|
||||||
await expect(modal).not.toContainText('Unbalanced');
|
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();
|
await expect(display.locator('[hx-get*="edit/item-account"]')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
// NOTE: the "New Summary Item" button is currently BROKEN in this modal and is
|
// The "New Summary Item" button was broken in the pre-migration wizard (its Alpine
|
||||||
// therefore intentionally not characterized as working. Its Alpine handler
|
// handler threw "newRowIndex is not defined"); the migration fixes it as a whole-form
|
||||||
// (`@click="$dispatch('newRow', {index: (newRowIndex++)})"`) throws
|
// swap (op=new-item). This asserts the FIXED behavior: clicking adds an editable manual
|
||||||
// "newRowIndex is not defined" (the modal's x-data is empty), and even if it
|
// row (category + account typeahead + debit/credit money inputs).
|
||||||
// fired, `hx-target="closest .new-row"` matches no ancestor in this div layout,
|
test('New Summary Item adds an editable manual row (fixed)', async ({ page }) => {
|
||||||
// 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 }) => {
|
|
||||||
await openEditModal(page);
|
await openEditModal(page);
|
||||||
const modal = page.locator('#wizardmodal');
|
const modal = page.locator('#wizardmodal');
|
||||||
const before = await modal.locator('input').count();
|
expect(await modal.locator('.manual-item-row').count()).toBe(0);
|
||||||
await modal.locator('[hx-get*="sales-summary-item"]').first().click();
|
|
||||||
await page.waitForTimeout(500);
|
await modal.locator('a[hx-vals*="new-item"]').click();
|
||||||
// no manual row was added (no category input appeared, input count unchanged)
|
await expect(modal.locator('.manual-item-row').first()).toBeVisible();
|
||||||
expect(await modal.locator('input[placeholder="Category/Explanation"]').count()).toBe(0);
|
expect(await modal.locator('.manual-item-row').count()).toBe(1);
|
||||||
expect(await modal.locator('input').count()).toBe(before);
|
|
||||||
|
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 }) => {
|
test('Save closes the modal and the summary stays in the grid', async ({ page }) => {
|
||||||
|
|||||||
4
resources/templates/sales-summary/edit-form.html
Normal file
4
resources/templates/sales-summary/edit-form.html
Normal 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>
|
||||||
4
resources/templates/sales-summary/summary-body.html
Normal file
4
resources/templates/sales-summary/summary-body.html
Normal 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>
|
||||||
@@ -9,29 +9,29 @@
|
|||||||
[auto-ap.client-routes :as client-routes]
|
[auto-ap.client-routes :as client-routes]
|
||||||
[auto-ap.routes.pos.sales-summaries :as route]
|
[auto-ap.routes.pos.sales-summaries :as route]
|
||||||
[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.link-dropdown :refer [link-dropdown]]
|
[auto-ap.ssr.components.link-dropdown :refer [link-dropdown]]
|
||||||
[auto-ap.ssr.components.multi-modal :as mm]
|
[auto-ap.ssr.components.selmer :as sc]
|
||||||
[auto-ap.ssr.form-cursor :as fc]
|
|
||||||
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
|
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
|
||||||
[auto-ap.ssr.hx :as hx]
|
[auto-ap.ssr.hx :as hx]
|
||||||
|
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
|
||||||
[auto-ap.ssr.pos.common
|
[auto-ap.ssr.pos.common
|
||||||
:refer [date-range-field*]]
|
:refer [date-range-field*]]
|
||||||
|
[auto-ap.ssr.selmer :as sel]
|
||||||
[auto-ap.ssr.svg :as svg]
|
[auto-ap.ssr.svg :as svg]
|
||||||
[auto-ap.ssr.utils
|
[auto-ap.ssr.utils
|
||||||
:refer [apply-middleware-to-all-handlers clj-date-schema
|
:refer [->db-id apply-middleware-to-all-handlers assert-schema
|
||||||
default-grid-fields-schema entity-id html-response money
|
clj-date-schema default-grid-fields-schema entity-id html-response
|
||||||
strip temp-id wrap-merge-prior-hx wrap-schema-enforce]]
|
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]
|
[auto-ap.time :as atime]
|
||||||
[bidi.bidi :as bidi]
|
[bidi.bidi :as bidi]
|
||||||
[clj-time.coerce :as c]
|
[clj-time.coerce :as c]
|
||||||
[clojure.string :as str]
|
[clojure.string :as str]
|
||||||
[datomic.api :as dc]
|
[datomic.api :as dc]
|
||||||
[hiccup.util :as hu]
|
[hiccup.util :as hu]
|
||||||
[iol-ion.query :refer [dollars= dollars-0?]]
|
[iol-ion.query :refer [dollars=]]
|
||||||
[malli.core :as mc]
|
[malli.core :as mc]))
|
||||||
[malli.util :as mut]))
|
|
||||||
|
|
||||||
(def query-schema (mc/schema
|
(def query-schema (mc/schema
|
||||||
[:maybe
|
[:maybe
|
||||||
@@ -133,63 +133,6 @@
|
|||||||
(str (subs s 0 (- max-len 3)) "...")
|
(str (subs s 0 (- max-len 3)) "...")
|
||||||
s))
|
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
|
(def grid-page
|
||||||
(helper/build {:id "entity-table"
|
(helper/build {:id "entity-table"
|
||||||
:id-fn :db/id
|
:id-fn :db/id
|
||||||
@@ -247,7 +190,7 @@
|
|||||||
[:span.shrink-0.font-mono.tabular-nums.text-right.text-gray-900.whitespace-nowrap
|
[:span.shrink-0.font-mono.tabular-nums.text-right.text-gray-900.whitespace-nowrap
|
||||||
(format "$%,.2f" (:ledger-mapped/amount si))]])
|
(format "$%,.2f" (:ledger-mapped/amount si))]])
|
||||||
(for [_ (range (max 0 (- credit-count (count debit-items))))]
|
(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
|
[: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.text-xs.uppercase.tracking-wider.font-semibold.text-gray-500 "Total"]
|
||||||
[:span.font-mono.tabular-nums.font-bold.text-gray-900
|
[: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
|
[:span.shrink-0.font-mono.tabular-nums.text-right.text-gray-900.whitespace-nowrap
|
||||||
(format "$%,.2f" (:ledger-mapped/amount si))]])
|
(format "$%,.2f" (:ledger-mapped/amount si))]])
|
||||||
(for [_ (range (max 0 (- debit-count (count credit-items))))]
|
(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
|
[: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.text-xs.uppercase.tracking-wider.font-semibold.text-gray-500 "Total"]
|
||||||
[:span.font-mono.tabular-nums.font-bold.text-gray-900
|
[:span.font-mono.tabular-nums.font-bold.text-gray-900
|
||||||
@@ -348,296 +291,308 @@
|
|||||||
(not (and (:credit x)
|
(not (and (:credit x)
|
||||||
(:debit x))))]]]]])
|
(:debit x))))]]]]])
|
||||||
|
|
||||||
(defn summary-total-row* [request]
|
;; ---------------------------------------------------------------------------
|
||||||
(let [total-credits (-> request
|
;; Field-name / error helpers (no step-params[...] prefix) + item helpers.
|
||||||
:multi-form-state
|
;; Mirrors transaction/edit.clj and transaction/bulk_code.clj.
|
||||||
:step-params
|
;; ---------------------------------------------------------------------------
|
||||||
:sales-summary/items
|
|
||||||
(total-credits))
|
|
||||||
total-debits (-> request
|
|
||||||
:multi-form-state
|
|
||||||
:step-params
|
|
||||||
:sales-summary/items
|
|
||||||
(total-debits))]
|
|
||||||
|
|
||||||
(com/data-grid-row {:id "total-row"
|
(def ^:dynamic *errors*
|
||||||
:class "bg-slate-50 border-t-2 border-slate-300"}
|
"Humanized form errors for the current render, keyed by edit-schema paths. Bound by
|
||||||
(com/data-grid-cell {})
|
render-form from the request's :form-errors. Plain map -- no wizard, no cursor."
|
||||||
(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 {}))))
|
|
||||||
|
|
||||||
(defn unbalanced-row* [request]
|
(defn- ferr [& path]
|
||||||
(let [total-credits (-> request
|
(get-in *errors* (vec path)))
|
||||||
: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))]
|
|
||||||
|
|
||||||
(com/data-grid-row {:id "unbalanced-row"
|
(defn- item-field-name [index field]
|
||||||
:class (when unbalanced? "bg-red-50 border-t border-red-200")}
|
(path->name2 :sales-summary/items index field))
|
||||||
(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 summary-total-display [request]
|
(defn- item-field-errors [index field]
|
||||||
(let [total-credits (-> request
|
(ferr :sales-summary/items index field))
|
||||||
: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 unbalanced-display [request]
|
(defn- item-side
|
||||||
(let [total-credits (-> request
|
"Which column an item belongs to: its persisted ledger-side for auto items, else the
|
||||||
:multi-form-state
|
filled-in credit/debit for a manual row (nil for a brand-new blank manual row)."
|
||||||
:step-params
|
[item]
|
||||||
:sales-summary/items
|
(cond
|
||||||
(total-credits))
|
(= :ledger-side/debit (:ledger-mapped/ledger-side item)) :debit
|
||||||
total-debits (-> request
|
(= :ledger-side/credit (:ledger-mapped/ledger-side item)) :credit
|
||||||
:multi-form-state
|
(:debit item) :debit
|
||||||
:step-params
|
(:credit item) :credit
|
||||||
:sales-summary/items
|
:else nil))
|
||||||
(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 sales-summary-item-row* [{:keys [value client-id]}]
|
(defn- sum-debits [items]
|
||||||
(let [manual? (fc/field-value (:sales-summary-item/manual? value))]
|
(->> items (keep :debit) (filter number?) (reduce + 0.0)))
|
||||||
(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)}))
|
|
||||||
|
|
||||||
(list
|
(defn- sum-credits [items]
|
||||||
(com/hidden {:name (fc/field-name)
|
(->> items (keep :credit) (filter number?) (reduce + 0.0)))
|
||||||
: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"}
|
|
||||||
|
|
||||||
(if manual?
|
;; ---------------------------------------------------------------------------
|
||||||
(fc/with-field :debit
|
;; Render (Selmer): account typeahead, inline account cell (display/edit),
|
||||||
(com/validated-field {:errors (fc/field-errors)}
|
;; the read-only auto rows, the editable manual rows, totals/balance.
|
||||||
(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"}
|
|
||||||
|
|
||||||
(if manual?
|
(defn account-typeahead* [{:keys [name value client-id]}]
|
||||||
(fc/with-field :credit
|
(sc/typeahead {:name name
|
||||||
(com/validated-field {:errors (fc/field-errors)}
|
:id name
|
||||||
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
|
:placeholder "Search..."
|
||||||
:name (fc/field-name)
|
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
|
||||||
:value (fc/field-value)})))
|
{:client-id client-id
|
||||||
(when (= (fc/field-value (:ledger-mapped/ledger-side value))
|
:purpose "invoice"})
|
||||||
:ledger-side/credit)
|
:value value
|
||||||
[:span.font-mono.tabular-nums.text-gray-900.text-sm.whitespace-nowrap
|
:content-fn (fn [value]
|
||||||
(format "$%,.2f" (fc/field-value (:ledger-mapped/amount value)))])))
|
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
|
||||||
(com/data-grid-cell {:class "align-top"}
|
client-id)))}))
|
||||||
(when manual?
|
|
||||||
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x))))))
|
|
||||||
|
|
||||||
(defrecord MainStep [linear-wizard]
|
(defn account-display-cell*
|
||||||
mm/ModalWizardStep
|
"Account name + inline-edit pencil. The pencil swaps just this `.account-cell`
|
||||||
(step-name [_]
|
(#account-cell, Rule 2) into the edit cell."
|
||||||
"Main")
|
[{:keys [index account-id client-id]}]
|
||||||
(step-key [_]
|
(let [account-id (when (and account-id (not= account-id "")) (->db-id account-id))
|
||||||
:main)
|
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 [_]
|
(defn- auto-item-row*
|
||||||
(mut/select-keys (mm/form-schema linear-wizard) #{:db/id :sales-summary/items}))
|
"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
|
(defn- manual-amount-input* [index field item]
|
||||||
[this {:keys [multi-form-state] :as request}]
|
(sc/money-input {:name (item-field-name index field)
|
||||||
(let [client-id (:db/id (:sales-summary/client (:snapshot multi-form-state)))
|
:value (get item field)
|
||||||
items (:sales-summary/items (:step-params multi-form-state))
|
:class "w-24 text-right font-mono tabular-nums"
|
||||||
sorted-items (sort-items items)
|
:placeholder (str/capitalize (clojure.core/name field))
|
||||||
indexed-items (map-indexed vector sorted-items)
|
:hx-post (bidi/path-for ssr-routes/only-routes ::route/form-changed)
|
||||||
debit-items (filter #(= :ledger-side/debit (:ledger-mapped/ledger-side (second %))) indexed-items)
|
:hx-target "#summary-totals"
|
||||||
credit-items (filter #(= :ledger-side/credit (:ledger-mapped/ledger-side (second %))) indexed-items)
|
:hx-select "#summary-totals"
|
||||||
max-rows (max (count debit-items) (count credit-items))
|
:hx-swap "outerHTML"
|
||||||
padded-debits (concat debit-items (repeat (- max-rows (count debit-items)) nil))
|
:hx-trigger "keyup changed delay:300ms"
|
||||||
padded-credits (concat credit-items (repeat (- max-rows (count credit-items)) nil))]
|
:hx-include "closest form"}))
|
||||||
(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"))]])
|
|
||||||
|
|
||||||
:footer
|
(defn- manual-item-row*
|
||||||
(mm/default-step-footer linear-wizard this :validation-route ::route/edit-wizard-navigate)
|
"An editable manual item: category + account typeahead + debit + credit money inputs +
|
||||||
:validation-route ::route/edit-wizard-navigate
|
remove. The amount inputs swap the totals block (Rule 4); remove swaps the whole form."
|
||||||
:width-height-class "lg:w-[900px] lg:h-[600px]"))))
|
[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]
|
(defn attach-ledger [i]
|
||||||
(cond-> i
|
(cond-> i
|
||||||
@@ -645,142 +600,129 @@
|
|||||||
:ledger-mapped/amount (:credit i))
|
:ledger-mapped/amount (:credit i))
|
||||||
(:debit i) (assoc :ledger-mapped/ledger-side :ledger-side/debit
|
(:debit i) (assoc :ledger-mapped/ledger-side :ledger-side/debit
|
||||||
:ledger-mapped/amount (:debit i))
|
:ledger-mapped/amount (:debit i))
|
||||||
true (dissoc :credit :debit)
|
true (dissoc :credit :debit :new? :item-index)
|
||||||
true (assoc :sales-summary-item/manual? true)))
|
true (assoc :sales-summary-item/manual? true)))
|
||||||
|
|
||||||
(defrecord EditWizard [_ current-step]
|
;; ---------------------------------------------------------------------------
|
||||||
mm/LinearModalWizard
|
;; form-changed ops (whole-form re-render): add / remove a manual item. A bare
|
||||||
(hydrate-from-request
|
;; re-render (no op) refreshes the totals block (manual amount edits).
|
||||||
[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"})))))
|
|
||||||
|
|
||||||
(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]
|
(defn apply-remove-item [request]
|
||||||
(let [entity (dc/pull (dc/db conn) default-read (:db/id (:route-params request)))
|
(let [row-index (some-> request :form-params (get "row-index") Integer/parseInt)
|
||||||
entity (select-keys entity (mut/keys edit-schema))
|
items (vec (:sales-summary/items (:edit-state request)))
|
||||||
entity (update entity :sales-summary/items (comp #(map (fn [x]
|
updated (if (and row-index (< row-index (count items)))
|
||||||
(if (= :ledger-side/debit (:ledger-mapped/ledger-side x))
|
(vec (concat (subvec items 0 row-index)
|
||||||
(assoc x :debit (:ledger-mapped/amount x))
|
(subvec items (inc row-index))))
|
||||||
(assoc x :credit (:ledger-mapped/amount x))))
|
items)]
|
||||||
%) sort-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]
|
(defn edit-item-account [request]
|
||||||
(let [{:keys [item-index client-id current-account-id]} (:query-params 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)
|
idx (if (string? item-index) (Integer/parseInt item-index) item-index)
|
||||||
field-name-prefix (str "step-params[sales-summary/items][" item-index "]")
|
account-id (when (and current-account-id (not= current-account-id "")) (->db-id current-account-id))]
|
||||||
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)]
|
|
||||||
(html-response
|
(html-response
|
||||||
(account-edit-cell {:field-name-prefix field-name-prefix
|
(sel/raw (account-edit-cell* {:index idx :account-id account-id :client-id (->db-id client-id)})))))
|
||||||
:client-id client-id
|
|
||||||
:current-account-id current-account-id}))))
|
|
||||||
|
|
||||||
(defn save-item-account [request]
|
(defn save-item-account [request]
|
||||||
(let [field-name-prefix (get-in request [:params "field-name-prefix"])
|
(let [item-index (get-in request [:params "item-index"])
|
||||||
client-id (get-in request [:params "client-id"])
|
idx (if (string? item-index) (Integer/parseInt item-index) item-index)
|
||||||
account-input-name (str field-name-prefix "[ledger-mapped/account]")
|
client-id (->db-id (get-in request [:params "client-id"]))
|
||||||
account-id-str (get-in request [:form-params account-input-name])
|
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 ""))
|
account-id (when (and account-id-str (not= account-id-str "")) (->db-id 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)]
|
|
||||||
(html-response
|
(html-response
|
||||||
(account-display-cell {:item item
|
(sel/raw (account-display-cell* {:index idx :account-id account-id :client-id client-id})))))
|
||||||
:field-name-prefix field-name-prefix
|
|
||||||
:client-id client-id}))))
|
|
||||||
|
|
||||||
(defn cancel-item-account [request]
|
(defn cancel-item-account [request]
|
||||||
(let [{:keys [field-name-prefix client-id current-account-id]} (:query-params request)
|
(let [{:keys [item-index client-id current-account-id]} (:query-params request)
|
||||||
account-id (when (and current-account-id (not= current-account-id ""))
|
idx (if (string? item-index) (Integer/parseInt item-index) item-index)
|
||||||
(if (string? current-account-id)
|
account-id (when (and current-account-id (not= current-account-id "")) (->db-id 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)]
|
|
||||||
(html-response
|
(html-response
|
||||||
(account-display-cell {:item item
|
(sel/raw (account-display-cell* {:index idx :account-id account-id :client-id (->db-id client-id)})))))
|
||||||
:field-name-prefix field-name-prefix
|
|
||||||
:client-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
|
(def key->handler
|
||||||
(apply-middleware-to-all-handlers
|
(apply-middleware-to-all-handlers
|
||||||
(->>
|
{::route/page (helper/page-route grid-page)
|
||||||
{::route/page (helper/page-route grid-page)
|
::route/table (helper/table-route grid-page)
|
||||||
::route/table (helper/table-route grid-page)
|
::route/edit-wizard (-> open-handler
|
||||||
::route/edit-wizard (-> mm/open-wizard-handler
|
(wrap-derive-state)
|
||||||
(mm/wrap-wizard edit-wizard)
|
(wrap-decode)
|
||||||
(mm/wrap-init-multi-form-state initial-edit-wizard-state)
|
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
|
||||||
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
|
::route/form-changed (-> form-changed-handler
|
||||||
::route/edit-wizard-navigate (-> mm/next-handler
|
(wrap-derive-state)
|
||||||
(mm/wrap-wizard edit-wizard)
|
(wrap-decode))
|
||||||
(mm/wrap-decode-multi-form-state))
|
::route/edit-item-account (-> edit-item-account
|
||||||
::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)))
|
|
||||||
(wrap-schema-enforce :query-schema [:map
|
(wrap-schema-enforce :query-schema [:map
|
||||||
[:client-id {:optional true}
|
[:item-index nat-int?]
|
||||||
[:maybe entity-id]]]))
|
[:client-id {:optional true} [:maybe entity-id]]
|
||||||
::route/edit-item-account (-> edit-item-account
|
[:current-account-id {:optional true} [:maybe :string]]]))
|
||||||
(wrap-schema-enforce :query-schema [:map
|
::route/save-item-account save-item-account
|
||||||
[:item-index nat-int?]
|
::route/cancel-item-account cancel-item-account
|
||||||
[:client-id {:optional true} [:maybe entity-id]]
|
::route/edit-wizard-submit (-> submit
|
||||||
[:current-account-id {:optional true} [:maybe :string]]]))
|
(wrap-form-4xx-2 render-form-response)
|
||||||
::route/save-item-account save-item-account
|
(wrap-derive-state)
|
||||||
::route/cancel-item-account cancel-item-account
|
(wrap-decode))}
|
||||||
::route/edit-wizard-submit (-> mm/submit-handler
|
|
||||||
(mm/wrap-wizard edit-wizard)
|
|
||||||
(mm/wrap-decode-multi-form-state))})
|
|
||||||
(fn [h]
|
(fn [h]
|
||||||
(-> h
|
(-> h
|
||||||
(wrap-copy-qp-pqp)
|
(wrap-copy-qp-pqp)
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
(ns auto-ap.routes.pos.sales-summaries)
|
(ns auto-ap.routes.pos.sales-summaries)
|
||||||
(def routes {"" {:get ::page
|
(def routes {"" {:get ::page
|
||||||
:put ::edit-wizard-submit}
|
:put ::edit-wizard-submit}
|
||||||
"/table" ::table
|
"/table" ::table
|
||||||
["/" [#"\d+" :db/id]] {:get ::edit-wizard}
|
["/" [#"\d+" :db/id]] {:get ::edit-wizard}
|
||||||
"/edit/navigate" ::edit-wizard-navigate
|
"/edit/form-changed" ::form-changed
|
||||||
"/edit/sales-summary-item" ::new-summary-item
|
"/edit/item-account" ::edit-item-account
|
||||||
"/edit/item-account" ::edit-item-account
|
"/edit/save-item-account" ::save-item-account
|
||||||
"/edit/save-item-account" ::save-item-account
|
|
||||||
"/edit/cancel-item-account" ::cancel-item-account})
|
"/edit/cancel-item-account" ::cancel-item-account})
|
||||||
|
|||||||
Reference in New Issue
Block a user