refactor(ssr): Phase 3 — full Selmer migration of Transaction Bulk Code; remove the wizard
Migrates the Transaction Bulk Code modal (a single-step form wearing a full wizard costume) to a plain Selmer form, cold-applying the ssr-form-migration skill. Almost entirely reuse of the Phase-2 work: the whole `sc/*` Selmer component library, `account-typeahead*` / `location-select*`, and the `edit-modal` / `transitioner` chrome are imported wholesale. What changed - Wizard removed: deleted `BulkCodeWizard` / `AccountsStep` records, `MultiStepFormState`, the `step-params[...]` prefix, and all `mm/*` middleware. Replaced with a plain handler + flat `wrap-bulk-state` (decode straight into `bulk-code-schema`, no snapshot round-trip). - Selection round-trip: the non-editable transaction selection is resolved to a concrete not-locked id vector at open and ridden back in hidden `ids[]` fields (the bulk analog of edit's single `db/id`) — no EDN snapshot, no filter re-query, and more correct (codes exactly the rows the user saw). - 100% Selmer render path (only the shared terminal `com/success-modal` keeps Hiccup — heuristic-9 exception). New shared component `sc/select` (`location-select.html` generalized) for the status dropdown. - Routes 4 -> 3: GET `bulk-code` (open), POST `bulk-code-submit`, POST `bulk-code-form-changed` (one whole-form op dispatcher folding the old `new-account` + `vendor-changed` routes). Location swap moved off `find *` onto explicit `#account-location-<index>` + `hx-select`. - Fixed a latent correctness bug surfaced by the migration: the vendor typeahead needs `:id` (value-keyed `:key`) or its value-bound hidden goes stale across a whole-form swap and posts blank. Scorecard delta (transaction/bulk_code.clj): mm coupling 19->0, snapshot merges 4->0, wizard records 3->0, step-params 10->0, routes 4->3, OOB 0, Hiccup-in-render ->0 (bar success-modal). LOC 420->506 (documented exception: the wizard was a thin shell over mm/* defaults, so explicitness moves shared plumbing into the file). Cookbook: reused the entire Phase-2 sc/* lib + chrome, added sc/select. Verification: bulk-code-transactions.spec.ts 13/13; full Playwright suite 39/39; cljfmt clean. Skill fed: scorecard row + narrative + LOC exception; gotchas (value-bound typeahead keying, selection-as-ids round-trip); cookbook (sc/select). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -164,6 +164,7 @@ the interop bridge; dynamic HTMX/Alpine attrs go through `sc/attrs->str` →
|
|||||||
| Wrapper | Partial | Notes |
|
| Wrapper | Partial | Notes |
|
||||||
|---------|---------|-------|
|
|---------|---------|-------|
|
||||||
| `sc/hidden` / `sc/text-input` / `sc/money-input` | `hidden`/`text-input`/`money-input`.html | leaf inputs; class via `inputs/default-input-classes` + `use-size` |
|
| `sc/hidden` / `sc/text-input` / `sc/money-input` | `hidden`/`text-input`/`money-input`.html | leaf inputs; class via `inputs/default-input-classes` + `use-size` |
|
||||||
|
| `sc/select` (Phase 3) | `select.html` | generic `<select>`; `options [[value label] …]`, `:value` (string/keyword) marks selected, extra hx-/x- attrs ride through. `location-select.html` generalized — reach for this before `com/select`. Added for the bulk-code status field. |
|
||||||
| `sc/validated-field` | `validated-field.html` | label + body + always-present error `<p>`; pass-through attrs land on the wrapping div (the per-row location cell hangs its swap wiring here) |
|
| `sc/validated-field` | `validated-field.html` | label + body + always-present error `<p>`; pass-through attrs land on the wrapping div (the per-row location cell hangs its swap wiring here) |
|
||||||
| `sc/button` / `sc/a-button` / `sc/a-icon-button` | `button`/`a-button`/`a-icon-button`.html | spinner via `{% include "spinner.html" %}`; class via `btn/bg-colors` |
|
| `sc/button` / `sc/a-button` / `sc/a-icon-button` | `button`/`a-button`/`a-icon-button`.html | spinner via `{% include "spinner.html" %}`; class via `btn/bg-colors` |
|
||||||
| `sc/badge` / `sc/link` | `badge`/`link`.html | |
|
| `sc/badge` / `sc/link` | `badge`/`link`.html | |
|
||||||
|
|||||||
@@ -198,8 +198,45 @@ Post-adoption baseline is **39 pass / 0 fail** — the previously-flaky
|
|||||||
`transaction-navigation.spec.ts` date-range test is now green, because `/test-reset` removes
|
`transaction-navigation.spec.ts` date-range test is now green, because `/test-reset` removes
|
||||||
the residual mutation it was tripping over.
|
the residual mutation it was tripping over.
|
||||||
|
|
||||||
|
## A value-bound typeahead hidden goes stale across a whole-form swap unless keyed
|
||||||
|
|
||||||
|
A typeahead (`sc/typeahead`) posts its value through a hidden `<input :value="value.value">`
|
||||||
|
whose DOM `.value` is set by Alpine, not by the server-rendered static `value` attr. After a
|
||||||
|
**whole-form `outerHTML` swap** that re-renders the typeahead, Alpine may preserve the *previous*
|
||||||
|
component's empty `.value` instead of binding the new server value — so the field posts blank
|
||||||
|
on the next submit. Fix: pass **`:id`** to `sc/typeahead` (the account typeahead already does).
|
||||||
|
`:id` makes the wrapper emit `:key (str id "--" value)`, and the value-keyed `:key` forces a
|
||||||
|
clean Alpine re-init that lands the server value. The bulk-code *vendor* typeahead hit this
|
||||||
|
(account rows didn't, because they pass `:id`) — symptom: "vendor not preserved on a validation
|
||||||
|
re-render." Note the testing trap: reading the hidden's `.value` in isolation
|
||||||
|
(`inputValue()` / `toHaveValue`) is an unreliable probe — it lags Alpine. Assert what the form
|
||||||
|
**actually posts** instead: `new FormData(form).get('vendor')` (wrap in `expect.poll`).
|
||||||
|
|
||||||
|
## Round-trip a multi-row selection as `ids[]`, not as an EDN/filter snapshot
|
||||||
|
|
||||||
|
A bulk modal acts on a *selection* of N entities (bulk-code: the checked transactions), the
|
||||||
|
analog of a single modal's one `db/id`. The wizard stashed the whole search-params blob (filters
|
||||||
|
+ `selected` + `all-selected`) in the EDN snapshot and re-ran the filter query on every post.
|
||||||
|
Don't carry that forward. Instead **resolve the selection to a concrete id vector once at open**
|
||||||
|
(`selected->ids` → the not-locked set) and ride it back in hidden `ids[0..n]` fields; re-read it
|
||||||
|
on each post (`[:vector {:coerce? true} entity-id]` + the `coerce-vector` transformer turns the
|
||||||
|
`{"0" "123"}` index-map into `[123]`). No snapshot, no filter round-trip, and it's *more* correct
|
||||||
|
— you code exactly the rows the user saw, immune to data changing between open and submit. This
|
||||||
|
is heuristic 2 → 0 for a multi-select modal.
|
||||||
|
|
||||||
## Scorecard exceptions (ratchet violations with a reason)
|
## Scorecard exceptions (ratchet violations with a reason)
|
||||||
|
|
||||||
|
**Heuristic 4 (LOC net ↓) — exception (Phase 3, Transaction Bulk Code: 420→506).** When the
|
||||||
|
modal's wizard was a *thin* shell that delegated almost everything to `mm/*` defaults
|
||||||
|
(`default-render-step`, `default-render-wizard`, `submit-handler`, `open-wizard-handler`),
|
||||||
|
ripping the wizard out moves that previously-shared plumbing **into the file** as explicit
|
||||||
|
render/decode/submit/handler code, so the single-file LOC rises even though total system
|
||||||
|
complexity drops. This is the opposite of a fat wizard (edit went 1608→1548). The trade is
|
||||||
|
intended and every other heuristic improved sharply (mm coupling 19→0, snapshot merges 4→0,
|
||||||
|
wizard records 3→0, routes 4→3, `find *`→explicit-id swap). Watch for it on the small
|
||||||
|
"single-step wearing a wizard costume" modals — LOC is the wrong headline metric there;
|
||||||
|
the mm-coupling / snapshot / route counts are.
|
||||||
|
|
||||||
**Heuristic 9 (Hiccup in render path) — partial exception (Phase 2-final).** The post-save
|
**Heuristic 9 (Hiccup in render path) — partial exception (Phase 2-final).** The post-save
|
||||||
`com/success-modal` confirmation dialogs in `save-handler` keep ~6 `[:p …]` Hiccup lines.
|
`com/success-modal` confirmation dialogs in `save-handler` keep ~6 `[:p …]` Hiccup lines.
|
||||||
They are terminal responses (shown after the form closes), reuse a shared dialog component,
|
They are terminal responses (shown after the form closes), reuse a shared dialog component,
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ Each migration appends one row (after-numbers), referencing the before in the di
|
|||||||
| 1 (baseline) | Transaction Edit `transaction/edit.clj` | 1608 | ~12 | 1 | 2 | ~75 | 0 | 8 | — / seeded 7 entries |
|
| 1 (baseline) | Transaction Edit `transaction/edit.clj` | 1608 | ~12 | 1 | 2 | ~75 | 0 | 8 | — / seeded 7 entries |
|
||||||
| 2 | Transaction Edit `transaction/edit.clj` | 1593 | **~5** | **0** | **0** | **0 round-trip** | 0 | 8 (shared) | location-select / **1 Selmer** |
|
| 2 | Transaction Edit `transaction/edit.clj` | 1593 | **~5** | **0** | **0** | **0 round-trip** | 0 | 8 (shared) | location-select / **1 Selmer** |
|
||||||
| 2-final | Transaction Edit (full Selmer + wizard removed) | 1548 | **5** | 0 | 0 | 0 | 0 | **0** | full `sc/*` lib / **~30 partials** |
|
| 2-final | Transaction Edit (full Selmer + wizard removed) | 1548 | **5** | 0 | 0 | 0 | 0 | **0** | full `sc/*` lib / **~30 partials** |
|
||||||
|
| 3 | Transaction Bulk Code `transaction/bulk_code.clj` | 506 (was 420 — see exception) | **3** | 0 | 0 | **0** | 0 | 0† | reused **all** of Phase-2's `sc/*` lib + `account-typeahead*`/`location-select*` + `edit-modal`/`transitioner` chrome / added **`sc/select`** |
|
||||||
|
|
||||||
|
† The one `"hx-..."` string hit is a response-header map (`{"hx-trigger" "refreshTable, reset-selection"}`), not a mixed attribute encoding. mm coupling 19→**0**, wizard records 3→**0**, step-params 10→**0** (the 2 hits are comments), Hiccup-in-render → **0** except the shared `com/success-modal` (heuristic-9 exception, as in Phase 2).
|
||||||
|
|
||||||
### New heuristics introduced at 2-final (full Selmer)
|
### New heuristics introduced at 2-final (full Selmer)
|
||||||
|
|
||||||
@@ -82,3 +85,26 @@ Each migration appends one row (after-numbers), referencing the before in the di
|
|||||||
> shared component — out of the form's render path). See `form-vs-wizard.md` (drop-the-
|
> shared component — out of the form's render path). See `form-vs-wizard.md` (drop-the-
|
||||||
> wizard test), `selmer-conventions.md` (composition mechanics), and `gotchas.md`
|
> wizard test), `selmer-conventions.md` (composition mechanics), and `gotchas.md`
|
||||||
> (stray-field decode leak; jetty reload staleness).
|
> (stray-field decode leak; jetty reload staleness).
|
||||||
|
|
||||||
|
> **Phase 3 — Transaction Bulk Code (first cold apply of the mature skill).** Single-step
|
||||||
|
> form wearing a full wizard costume (`BulkCodeWizard`/`AccountsStep`, `MultiStepFormState`,
|
||||||
|
> the `step-params[...]` prefix, the old `find *` location swap). Migrated to a plain form by
|
||||||
|
> mirroring Phase 2 — and it was mostly **reuse**: the entire `sc/*` Selmer component library,
|
||||||
|
> `account-typeahead*`/`location-select*`, and the `edit-modal`/`transitioner` chrome were
|
||||||
|
> imported wholesale; the only new shared component was **`sc/select`** (the status dropdown —
|
||||||
|
> `location-select.html` generalized). Parity held: bulk-code spec **13/13**, full suite
|
||||||
|
> **39/39** (up from the Phase-2 baseline of 38–39). mm coupling 19→0, snapshot merges 4→0,
|
||||||
|
> wizard records 3→0, routes 4→3 (open / submit / `form-changed` — the per-op `new-account` +
|
||||||
|
> `vendor-changed` routes folded into one `form-changed` op dispatcher), the location swap moved
|
||||||
|
> off `find *` onto explicit `#account-location-<index>` + `hx-select`.
|
||||||
|
>
|
||||||
|
> **The one regression — LOC 420→506 (documented exception, see `gotchas.md`).** Unlike edit
|
||||||
|
> (whose wizard held real custom code), bulk-code's wizard was a *thin* shell that delegated
|
||||||
|
> almost everything to `mm/*` defaults (`default-render-step`, `default-render-wizard`,
|
||||||
|
> `submit-handler`, `open-wizard-handler`). Ripping the wizard out moves that
|
||||||
|
> previously-shared plumbing **into the file** as explicit render/decode/submit/handler code.
|
||||||
|
> The trade is intended: every other heuristic improved and the modal is now self-contained
|
||||||
|
> and wizard-free. New patterns added to the cookbook: the **selection-as-`ids[]` round-trip**
|
||||||
|
> (resolve the non-editable selection to a concrete id vector at open, ride it in hidden
|
||||||
|
> fields — the bulk analog of edit's single `db/id`), and the **`:id`-keyed vendor typeahead**
|
||||||
|
> (a value-bound hidden must be keyed or its posted value goes stale across a whole-form swap).
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ async function openBulkCodeModal(page: any) {
|
|||||||
const codeButton = page.locator('button:has-text("Code")').first();
|
const codeButton = page.locator('button:has-text("Code")').first();
|
||||||
await codeButton.click();
|
await codeButton.click();
|
||||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||||
await page.waitForSelector('#wizardmodal');
|
await page.waitForSelector('#bulkcodemodal');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function closeBulkCodeModal(page: any) {
|
async function closeBulkCodeModal(page: any) {
|
||||||
@@ -156,7 +156,7 @@ async function addNewAccount(page: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitBulkCodeForm(page: any) {
|
async function submitBulkCodeForm(page: any) {
|
||||||
const form = page.locator('#wizard-form');
|
const form = page.locator('#bulk-code-form');
|
||||||
await form.evaluate((el: HTMLFormElement) => {
|
await form.evaluate((el: HTMLFormElement) => {
|
||||||
el.dispatchEvent(new Event('submit', { bubbles: true }));
|
el.dispatchEvent(new Event('submit', { bubbles: true }));
|
||||||
});
|
});
|
||||||
@@ -184,7 +184,7 @@ test.describe('Bulk Code Transactions - Happy Path', () => {
|
|||||||
await expect(page.locator('text=Bulk editing 1 transactions')).toBeVisible();
|
await expect(page.locator('text=Bulk editing 1 transactions')).toBeVisible();
|
||||||
|
|
||||||
// Select vendor
|
// Select vendor
|
||||||
const vendorHidden = page.locator('input[type="hidden"][name="step-params[vendor]"]').first();
|
const vendorHidden = page.locator('input[type="hidden"][name="vendor"]').first();
|
||||||
const testInfo = await getTestInfo(page);
|
const testInfo = await getTestInfo(page);
|
||||||
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
|
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
|
||||||
const newInput = document.createElement('input');
|
const newInput = document.createElement('input');
|
||||||
@@ -196,7 +196,7 @@ test.describe('Bulk Code Transactions - Happy Path', () => {
|
|||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
// Select approval status
|
// Select approval status
|
||||||
const statusSelect = page.locator('select[name="step-params[approval-status]"]').first();
|
const statusSelect = page.locator('select[name="approval-status"]').first();
|
||||||
await statusSelect.selectOption('approved');
|
await statusSelect.selectOption('approved');
|
||||||
|
|
||||||
// Add account
|
// Add account
|
||||||
@@ -278,7 +278,7 @@ test.describe('Bulk Code Transactions - Validation', () => {
|
|||||||
const testInfo = await getTestInfo(page);
|
const testInfo = await getTestInfo(page);
|
||||||
const vendorId = testInfo.accounts.vendor;
|
const vendorId = testInfo.accounts.vendor;
|
||||||
|
|
||||||
const vendorContainer = page.locator('div[hx-post*="vendor-changed"]').first();
|
const vendorContainer = page.locator('div[hx-vals*="vendor-changed"]').first();
|
||||||
const vendorHidden = vendorContainer.locator('input[type="hidden"]').first();
|
const vendorHidden = vendorContainer.locator('input[type="hidden"]').first();
|
||||||
|
|
||||||
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
|
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
|
||||||
@@ -293,11 +293,11 @@ test.describe('Bulk Code Transactions - Validation', () => {
|
|||||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.waitForResponse(response => response.url().includes('/vendor-changed') && response.status() === 200);
|
await page.waitForResponse(response => response.url().includes('/form-changed') && response.status() === 200);
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Select approval status
|
// Select approval status
|
||||||
const statusSelect = page.locator('select[name="step-params[approval-status]"]').first();
|
const statusSelect = page.locator('select[name="approval-status"]').first();
|
||||||
await statusSelect.selectOption('approved');
|
await statusSelect.selectOption('approved');
|
||||||
|
|
||||||
// Vendor selection pre-populated a default account row at 100%.
|
// Vendor selection pre-populated a default account row at 100%.
|
||||||
@@ -310,10 +310,14 @@ test.describe('Bulk Code Transactions - Validation', () => {
|
|||||||
// Modal should still be open
|
// Modal should still be open
|
||||||
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
|
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
|
||||||
|
|
||||||
// Vendor should still be selected
|
// Vendor should still be selected. The vendor typeahead is Alpine-managed and posts
|
||||||
const vendorHiddenAfter = page.locator('input[type="hidden"][name="step-params[vendor]"]').first();
|
// its value via an x-bound hidden input, so the right correctness check is what the
|
||||||
const vendorValueAfter = await vendorHiddenAfter.inputValue();
|
// form actually submits (the value that gets saved), not the lagging DOM .value of the
|
||||||
expect(vendorValueAfter).toBe(vendorId.toString());
|
// hidden read in isolation.
|
||||||
|
await expect.poll(async () =>
|
||||||
|
page.locator('#bulk-code-form').evaluate((f: HTMLFormElement) =>
|
||||||
|
new FormData(f).get('vendor'))
|
||||||
|
).toBe(vendorId.toString());
|
||||||
|
|
||||||
// Status should still be selected
|
// Status should still be selected
|
||||||
const statusValueAfter = await statusSelect.inputValue();
|
const statusValueAfter = await statusSelect.inputValue();
|
||||||
@@ -464,7 +468,7 @@ test.describe('Bulk Code Transactions - Vendor Pre-population', () => {
|
|||||||
|
|
||||||
// The vendor typeahead dispatches change from its parent div
|
// The vendor typeahead dispatches change from its parent div
|
||||||
// We need to set the hidden input and dispatch change on the container
|
// We need to set the hidden input and dispatch change on the container
|
||||||
const vendorContainer = page.locator('div[hx-post*="vendor-changed"]').first();
|
const vendorContainer = page.locator('div[hx-vals*="vendor-changed"]').first();
|
||||||
const vendorHidden = vendorContainer.locator('input[type="hidden"]').first();
|
const vendorHidden = vendorContainer.locator('input[type="hidden"]').first();
|
||||||
|
|
||||||
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
|
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
|
||||||
@@ -481,7 +485,7 @@ test.describe('Bulk Code Transactions - Vendor Pre-population', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Wait for HTMX response
|
// Wait for HTMX response
|
||||||
await page.waitForResponse(response => response.url().includes('/vendor-changed') && response.status() === 200);
|
await page.waitForResponse(response => response.url().includes('/form-changed') && response.status() === 200);
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Account should be pre-populated - check for account row
|
// Account should be pre-populated - check for account row
|
||||||
@@ -521,7 +525,7 @@ test.describe('Bulk Code Transactions - Vendor Pre-population', () => {
|
|||||||
const testInfo = await getTestInfo(page);
|
const testInfo = await getTestInfo(page);
|
||||||
const vendorId = testInfo.accounts.vendor;
|
const vendorId = testInfo.accounts.vendor;
|
||||||
|
|
||||||
const vendorContainer = page.locator('div[hx-post*="vendor-changed"]').first();
|
const vendorContainer = page.locator('div[hx-vals*="vendor-changed"]').first();
|
||||||
const vendorHidden = vendorContainer.locator('input[type="hidden"]').first();
|
const vendorHidden = vendorContainer.locator('input[type="hidden"]').first();
|
||||||
|
|
||||||
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
|
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
|
||||||
@@ -538,7 +542,7 @@ test.describe('Bulk Code Transactions - Vendor Pre-population', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Wait for HTMX response
|
// Wait for HTMX response
|
||||||
await page.waitForResponse(response => response.url().includes('/vendor-changed') && response.status() === 200);
|
await page.waitForResponse(response => response.url().includes('/form-changed') && response.status() === 200);
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Should pre-populate the vendor's default account (non-clientized) plus the "New account" row
|
// Should pre-populate the vendor's default account (non-clientized) plus the "New account" row
|
||||||
|
|||||||
4
resources/templates/components/select.html
Normal file
4
resources/templates/components/select.html
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{# Generic <select>. options = [{value, label, selected}]. Plain-HTML attributes -- the
|
||||||
|
Selmer migration target (no Hiccup keyword/string attribute ambiguity). Extra attrs
|
||||||
|
(hx-*, x-*) ride through {{ attrs|safe }}. #}
|
||||||
|
<select name="{{ name }}" class="{{ classes }}"{{ attrs|safe }}>{% for opt in options %}<option value="{{ opt.value }}"{% if opt.selected %} selected{% endif %}>{{ opt.label }}</option>{% endfor %}</select>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{# Bulk-code modal body: vendor field (a change repopulates the default account via a
|
||||||
|
whole-form swap), status select, and the expense-account grid. #}
|
||||||
|
<div class="space-y-4 p-4"><div class="grid grid-cols-2 gap-4"><div{{ vendor_changed_attrs|safe }}>{{ vendor_field|safe }}</div><div>{{ status_field|safe }}</div><div class="col-span-2 pt-4"><h3 class="text-lg font-medium mb-3">Expense Accounts</h3>{{ accounts_field|safe }}</div></div></div>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{# Top-level plain form for bulk-code (no wizard). The resolved (not-locked) transaction
|
||||||
|
id set rides in hidden ids[] fields -- the analog of the edit modal's single db/id
|
||||||
|
hidden -- so the selection survives form-changed / submit posts without an EDN snapshot
|
||||||
|
or a filter round-trip. #}
|
||||||
|
<form id="bulk-code-form"{{ form_attrs|safe }}>{{ ids_hidden|safe }}{{ modal|safe }}</form>
|
||||||
@@ -81,6 +81,24 @@
|
|||||||
(assoc :type "number" :step "0.01"))]
|
(assoc :type "number" :step "0.01"))]
|
||||||
(render "templates/components/money-input.html" {:attrs (attrs->str attrs)})))
|
(render "templates/components/money-input.html" {:attrs (attrs->str attrs)})))
|
||||||
|
|
||||||
|
(defn select
|
||||||
|
"Generic <select> rendered from a Selmer partial (the location-select.html shape,
|
||||||
|
generalized). options = [[value label] ...]; `value` (string or keyword) marks the
|
||||||
|
selected option. Class defaults to the standard input classes, like com/select. Extra
|
||||||
|
attrs (hx-*, x-*) ride through onto the element."
|
||||||
|
[{:keys [name value options class] :as params}]
|
||||||
|
(let [classes (-> ""
|
||||||
|
(hh/add-class inputs/default-input-classes)
|
||||||
|
(hh/add-class (or class "")))
|
||||||
|
sel (cond-> value (keyword? value) clojure.core/name)
|
||||||
|
attrs (dissoc params :name :value :options :class)]
|
||||||
|
(render "templates/components/select.html"
|
||||||
|
{:name name
|
||||||
|
:classes classes
|
||||||
|
:attrs (attrs->str attrs)
|
||||||
|
:options (for [[v label] options]
|
||||||
|
{:value v :label label :selected (= (str v) (str sel))})})))
|
||||||
|
|
||||||
;; --- field wrapper ---------------------------------------------------------------
|
;; --- field wrapper ---------------------------------------------------------------
|
||||||
|
|
||||||
(defn validated-field
|
(defn validated-field
|
||||||
|
|||||||
@@ -10,86 +10,74 @@
|
|||||||
[auto-ap.routes.utils :refer [wrap-client-redirect-unauthenticated]]
|
[auto-ap.routes.utils :refer [wrap-client-redirect-unauthenticated]]
|
||||||
[auto-ap.rule-matching :as rm]
|
[auto-ap.rule-matching :as rm]
|
||||||
[auto-ap.ssr-routes :as ssr-routes]
|
[auto-ap.ssr-routes :as ssr-routes]
|
||||||
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler]]
|
|
||||||
[auto-ap.ssr.components :as com]
|
[auto-ap.ssr.components :as com]
|
||||||
[auto-ap.ssr.components.multi-modal :as mm :refer [wrap-wizard]]
|
[auto-ap.ssr.components.selmer :as sc]
|
||||||
[auto-ap.ssr.form-cursor :as fc]
|
|
||||||
[auto-ap.ssr.grid-page-helper :refer [wrap-apply-sort]]
|
[auto-ap.ssr.grid-page-helper :refer [wrap-apply-sort]]
|
||||||
[auto-ap.ssr.hx :as hx]
|
[auto-ap.ssr.hx :as hx]
|
||||||
[auto-ap.ssr.ledger :refer [wrap-ensure-bank-account-belongs]]
|
[auto-ap.ssr.ledger :refer [wrap-ensure-bank-account-belongs]]
|
||||||
[auto-ap.ssr.svg :as svg]
|
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
|
||||||
|
[auto-ap.ssr.selmer :as sel]
|
||||||
[auto-ap.ssr.transaction.common :refer [grid-page query-schema
|
[auto-ap.ssr.transaction.common :refer [grid-page query-schema
|
||||||
selected->ids
|
selected->ids
|
||||||
wrap-status-from-source]]
|
wrap-status-from-source]]
|
||||||
[auto-ap.ssr.transaction.edit :as edit :refer [account-typeahead*
|
[auto-ap.ssr.transaction.edit :as edit :refer [account-typeahead*
|
||||||
location-select*]]
|
location-select*]]
|
||||||
[auto-ap.ssr.utils
|
[auto-ap.ssr.utils
|
||||||
:refer [apply-middleware-to-all-handlers entity-id
|
:refer [->db-id apply-middleware-to-all-handlers assert-schema entity-id
|
||||||
form-validation-error html-response percentage
|
form-validation-error html-response main-transformer modal-response
|
||||||
ref->enum-schema wrap-merge-prior-hx wrap-schema-enforce]]
|
path->name2 percentage ref->enum-schema wrap-form-4xx-2
|
||||||
|
wrap-merge-prior-hx wrap-schema-enforce]]
|
||||||
[bidi.bidi :as bidi]
|
[bidi.bidi :as bidi]
|
||||||
|
[clojure.string :as str]
|
||||||
[datomic.api :as dc]
|
[datomic.api :as dc]
|
||||||
[iol-ion.query :refer [dollars=]]
|
[iol-ion.query :refer [dollars=]]
|
||||||
[iol-ion.tx :refer [random-tempid]]
|
[iol-ion.tx :refer [random-tempid]]
|
||||||
[malli.core :as mc]))
|
[malli.core :as mc]))
|
||||||
|
|
||||||
(defn transaction-account-row* [{:keys [value client-id]}]
|
;; ---------------------------------------------------------------------------
|
||||||
(com/data-grid-row
|
;; Field-name / error helpers (no step-params[...] prefix -- posted fields
|
||||||
(-> {:x-data (hx/json {:show (boolean (not (fc/field-value (:new? value))))
|
;; decode straight into bulk-code-schema, mirroring transaction/edit.clj).
|
||||||
:accountId (fc/field-value (:account value))})
|
;; ---------------------------------------------------------------------------
|
||||||
:data-key "show"
|
|
||||||
:x-ref "p"}
|
|
||||||
hx/alpine-mount-then-appear)
|
|
||||||
(fc/with-field :db/id
|
|
||||||
(com/hidden {:name (fc/field-name)
|
|
||||||
:value (fc/field-value)}))
|
|
||||||
(fc/with-field :account
|
|
||||||
(com/data-grid-cell
|
|
||||||
{}
|
|
||||||
(com/validated-field
|
|
||||||
{:errors (fc/field-errors)}
|
|
||||||
(account-typeahead* {:value (fc/field-value)
|
|
||||||
:client-id client-id
|
|
||||||
:name (fc/field-name)
|
|
||||||
:x-model "accountId"}))))
|
|
||||||
(fc/with-field :location
|
|
||||||
(com/data-grid-cell
|
|
||||||
{}
|
|
||||||
(com/validated-field
|
|
||||||
{:errors (fc/field-errors)
|
|
||||||
:x-hx-val:account-id "accountId"
|
|
||||||
:hx-vals (hx/json (cond-> {:name (fc/field-name)}
|
|
||||||
client-id (assoc :client-id client-id)))
|
|
||||||
:x-dispatch:changed "accountId"
|
|
||||||
:hx-trigger "changed"
|
|
||||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select)
|
|
||||||
:hx-target "find *"
|
|
||||||
:hx-swap "outerHTML"}
|
|
||||||
(location-select* {:name (fc/field-name)
|
|
||||||
:account-location (let [account-id (:account @value)]
|
|
||||||
(when (nat-int? account-id)
|
|
||||||
(:account/location (dc/pull (dc/db conn) '[:account/location] account-id))))
|
|
||||||
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
|
|
||||||
:value (fc/field-value)}))))
|
|
||||||
(fc/with-field :percentage
|
|
||||||
(com/data-grid-cell
|
|
||||||
{}
|
|
||||||
(com/validated-field
|
|
||||||
{:errors (fc/field-errors)}
|
|
||||||
(com/money-input {:name (fc/field-name)
|
|
||||||
:class "w-16"
|
|
||||||
:value (some-> (fc/field-value)
|
|
||||||
(* 100)
|
|
||||||
(long))}))))
|
|
||||||
(com/data-grid-cell {:class "align-top"}
|
|
||||||
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x))))
|
|
||||||
|
|
||||||
(defn initial-bulk-edit-state [request]
|
(def ^:dynamic *errors*
|
||||||
(mm/->MultiStepFormState {:search-params (:query-params request)
|
"Humanized form errors for the current render, keyed by bulk-code-schema paths
|
||||||
:accounts []}
|
(e.g. {:accounts {0 {:location [\"required\"]}}}). Bound by render-form from the
|
||||||
[]
|
request's :form-errors. Plain map -- no wizard, no cursor."
|
||||||
{:search-params (:query-params request)
|
{})
|
||||||
:accounts []}))
|
|
||||||
|
(defn- ferr
|
||||||
|
"Field errors at a schema path, read from *errors* (no step-params prefix)."
|
||||||
|
[& path]
|
||||||
|
(get-in *errors* (vec path)))
|
||||||
|
|
||||||
|
(defn- account-field-name [index field]
|
||||||
|
(path->name2 :accounts index field))
|
||||||
|
|
||||||
|
(defn- account-field-errors [index field]
|
||||||
|
(ferr :accounts index field))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Schema + decode
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(def bulk-code-schema
|
||||||
|
(mc/schema [:map
|
||||||
|
[:vendor {:optional true} [:maybe entity-id]]
|
||||||
|
[:approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]]
|
||||||
|
[:ids {:optional true} [:maybe [:vector {:coerce? true} entity-id]]]
|
||||||
|
[:accounts {:optional true}
|
||||||
|
[:maybe
|
||||||
|
[:vector {:coerce? true}
|
||||||
|
[:map
|
||||||
|
[:db/id {:optional true} [:maybe :string]]
|
||||||
|
[:account entity-id]
|
||||||
|
[:location [:string {:min 1 :error/message "required"}]]
|
||||||
|
[:percentage percentage]]]]]]))
|
||||||
|
|
||||||
|
(def ^:private bulk-code-form-keys
|
||||||
|
"Editable top-level keys (vendor/status/accounts). The transaction selection (:ids)
|
||||||
|
is non-editable -- it is threaded separately by wrap-bulk-state."
|
||||||
|
[:vendor :approval-status :accounts])
|
||||||
|
|
||||||
(defn all-ids-not-locked
|
(defn all-ids-not-locked
|
||||||
"Filters transaction IDs to only those that aren't locked (client locked date earlier than transaction date)"
|
"Filters transaction IDs to only those that aren't locked (client locked date earlier than transaction date)"
|
||||||
@@ -105,16 +93,281 @@
|
|||||||
(dc/db conn))
|
(dc/db conn))
|
||||||
(map first)))
|
(map first)))
|
||||||
|
|
||||||
(def bulk-code-schema
|
(defn wrap-bulk-state
|
||||||
(mc/schema [:map
|
"Replaces the wizard's MultiStepFormState/snapshot round-trip. Parses the posted
|
||||||
[:vendor {:optional true} [:maybe entity-id]]
|
(nested) form params, decodes them straight into bulk-code-schema, and resolves the
|
||||||
[:approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]]
|
target transaction id set. On open (GET) the selection comes from the grid's
|
||||||
[:accounts {:optional true}
|
query-params (selected / all-selected + filters); on every post the concrete
|
||||||
[:maybe
|
(not-locked) id list rides back in hidden ids[] fields, so no EDN snapshot / filter
|
||||||
[:vector {:coerce? true}
|
round-trip is needed -- and we code exactly the transactions the user saw."
|
||||||
[:map [:account entity-id]
|
[handler]
|
||||||
[:location [:string {:min 1 :error/message "required"}]]
|
(-> (fn [request]
|
||||||
[:percentage percentage]]]]]]))
|
(let [parsed (:form-params request)
|
||||||
|
decoded (mc/decode bulk-code-schema parsed main-transformer)
|
||||||
|
decoded (if (map? decoded) decoded {})
|
||||||
|
posted-ids (some->> (:ids decoded) (keep ->db-id) vec)
|
||||||
|
ids (if (seq posted-ids)
|
||||||
|
posted-ids
|
||||||
|
(vec (all-ids-not-locked (selected->ids request (:query-params request)))))]
|
||||||
|
(handler (assoc request :bulk-state (assoc (select-keys decoded bulk-code-form-keys) :ids ids)))))
|
||||||
|
(wrap-nested-form-params)))
|
||||||
|
|
||||||
|
(defn- single-client-id
|
||||||
|
"Returns the client ID if the user has access to exactly one client, nil otherwise."
|
||||||
|
[request]
|
||||||
|
(when (= 1 (count (:clients request)))
|
||||||
|
(-> request :clients first :db/id)))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Render (100% Selmer -- reuses the transaction/edit.clj sc/* component library
|
||||||
|
;; and the shared edit-modal / transitioner chrome).
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defn transaction-account-row*
|
||||||
|
"One row of the bulk-code account grid, from a plain account map (no cursor). The
|
||||||
|
location cell swaps just itself (#account-location-<index>, Rule 2); remove swaps the
|
||||||
|
whole #bulk-code-form (Rule 3). Percentage rides along to submit (Rule 1, no request)."
|
||||||
|
[{:keys [value client-id index]}]
|
||||||
|
(let [account-val (let [av (:account value)]
|
||||||
|
(if (map? av) (:db/id av) av))
|
||||||
|
location-attrs {:x-hx-val:account-id "accountId"
|
||||||
|
:hx-vals (hx/json (cond-> {:name (account-field-name index :location)}
|
||||||
|
client-id (assoc :client-id client-id)))
|
||||||
|
:x-dispatch:changed "accountId"
|
||||||
|
:hx-trigger "changed"
|
||||||
|
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-code-form-changed)
|
||||||
|
:hx-target (str "#account-location-" index)
|
||||||
|
:hx-select (str "#account-location-" index)
|
||||||
|
:hx-swap "outerHTML"
|
||||||
|
:hx-include "closest form"}]
|
||||||
|
(sc/data-grid-row
|
||||||
|
(-> {:class "account-row"
|
||||||
|
:id (str "account-row-" index)
|
||||||
|
:x-data (hx/json {:show (boolean (not (:new? value)))
|
||||||
|
:accountId account-val})
|
||||||
|
:data-key "show"
|
||||||
|
:x-ref "p"}
|
||||||
|
hx/alpine-mount-then-appear)
|
||||||
|
(sc/hidden {:name (account-field-name index :db/id)
|
||||||
|
:value (:db/id value)})
|
||||||
|
(sc/data-grid-cell
|
||||||
|
{}
|
||||||
|
(sc/validated-field
|
||||||
|
{:errors (account-field-errors index :account)}
|
||||||
|
(account-typeahead* {:value account-val
|
||||||
|
:client-id client-id
|
||||||
|
:name (account-field-name index :account)
|
||||||
|
:x-model "accountId"})))
|
||||||
|
(sc/data-grid-cell
|
||||||
|
{:id (str "account-location-" index)}
|
||||||
|
(sc/validated-field
|
||||||
|
(merge {:errors (account-field-errors index :location)}
|
||||||
|
location-attrs)
|
||||||
|
(location-select* {:name (account-field-name index :location)
|
||||||
|
:account-location (:account/location (when (nat-int? account-val)
|
||||||
|
(dc/pull (dc/db conn) '[:account/location] account-val)))
|
||||||
|
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
|
||||||
|
:value (:location value)})))
|
||||||
|
(sc/data-grid-cell
|
||||||
|
{}
|
||||||
|
(sc/validated-field
|
||||||
|
{:errors (account-field-errors index :percentage)}
|
||||||
|
(sc/money-input {:name (account-field-name index :percentage)
|
||||||
|
:class "w-16"
|
||||||
|
:value (some-> (:percentage value) (* 100) long)})))
|
||||||
|
(sc/data-grid-cell
|
||||||
|
{:class "align-top"}
|
||||||
|
(sc/a-icon-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-code-form-changed)
|
||||||
|
:hx-vals (hx/json {:op "remove-account" :row-index (or index 0)})
|
||||||
|
:hx-target "#bulk-code-form"
|
||||||
|
:hx-select "#bulk-code-form"
|
||||||
|
:hx-swap "outerHTML"
|
||||||
|
:hx-include "closest form"
|
||||||
|
:class "account-remove-action"}
|
||||||
|
(sc/render "templates/components/svg-x.html" {}))))))
|
||||||
|
|
||||||
|
(defn- account-grid* [request]
|
||||||
|
(let [client-id (single-client-id request)
|
||||||
|
accounts (vec (:accounts (:bulk-state request)))]
|
||||||
|
(apply
|
||||||
|
sc/data-grid
|
||||||
|
{:headers [(sc/data-grid-header {} "Account")
|
||||||
|
(sc/data-grid-header {:class "w-32"} "Location")
|
||||||
|
(sc/data-grid-header {:class "w-16"} "%")
|
||||||
|
(sc/data-grid-header {:class "w-16"})]}
|
||||||
|
(concat
|
||||||
|
(map-indexed
|
||||||
|
(fn [index account]
|
||||||
|
(transaction-account-row* {:value account
|
||||||
|
:client-id client-id
|
||||||
|
:index index}))
|
||||||
|
accounts)
|
||||||
|
[(sc/data-grid-row
|
||||||
|
{:class "new-row"}
|
||||||
|
(sc/data-grid-cell {:colspan 4}
|
||||||
|
(sc/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-code-form-changed)
|
||||||
|
:hx-vals (hx/json {:op "new-account"})
|
||||||
|
:hx-target "#bulk-code-form"
|
||||||
|
:hx-select "#bulk-code-form"
|
||||||
|
:hx-swap "outerHTML"
|
||||||
|
:hx-include "closest form"
|
||||||
|
:color :secondary}
|
||||||
|
"New account")))]))))
|
||||||
|
|
||||||
|
(defn- bulk-code-body* [request]
|
||||||
|
(let [bulk-state (:bulk-state request)
|
||||||
|
vendor-val (:vendor bulk-state)
|
||||||
|
status-val (some-> (:approval-status bulk-state) name)]
|
||||||
|
(sel/render->hiccup
|
||||||
|
"templates/transaction-bulk-code/bulk-code-body.html"
|
||||||
|
{:vendor_changed_attrs (sc/attrs->str {:hx-trigger "change"
|
||||||
|
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-code-form-changed)
|
||||||
|
:hx-vals (hx/json {:op "vendor-changed"})
|
||||||
|
:hx-target "#bulk-code-form"
|
||||||
|
:hx-select "#bulk-code-form"
|
||||||
|
:hx-swap "outerHTML"
|
||||||
|
:hx-sync "this:replace"
|
||||||
|
:hx-include "closest form"})
|
||||||
|
:vendor_field (str (sc/validated-field
|
||||||
|
{:label "Vendor" :errors (ferr :vendor)}
|
||||||
|
(sc/typeahead {:name (path->name2 :vendor)
|
||||||
|
:id (path->name2 :vendor)
|
||||||
|
:error? (boolean (seq (ferr :vendor)))
|
||||||
|
:class "w-96"
|
||||||
|
:placeholder "Search for vendor..."
|
||||||
|
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
|
||||||
|
:value vendor-val
|
||||||
|
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})))
|
||||||
|
:status_field (str (sc/validated-field
|
||||||
|
{:label "Status" :errors (ferr :approval-status)}
|
||||||
|
(sc/select {:name (path->name2 :approval-status)
|
||||||
|
:value status-val
|
||||||
|
:options [["" "No Change"]
|
||||||
|
["approved" "Approved"]
|
||||||
|
["unapproved" "Unapproved"]
|
||||||
|
["suppressed" "Suppressed"]
|
||||||
|
["requires-feedback" "Client Review"]]})))
|
||||||
|
:accounts_field (str (sc/validated-field
|
||||||
|
{:errors (ferr :accounts)}
|
||||||
|
(sel/raw (str "<div id=\"account-entries\" class=\"space-y-3\">"
|
||||||
|
(str (account-grid* request))
|
||||||
|
"</div>"))))})))
|
||||||
|
|
||||||
|
(defn- form-errors-html [errors]
|
||||||
|
(str "<div id=\"form-errors\">"
|
||||||
|
(when (seq errors)
|
||||||
|
(str "<span class=\"error-content\"><p class=\"mt-2 text-xs text-red-600 dark:text-red-500 h-4\">"
|
||||||
|
(str/join ", " (filter string? errors))
|
||||||
|
"</p></span>"))
|
||||||
|
"</div>"))
|
||||||
|
|
||||||
|
(defn- footer* [request]
|
||||||
|
(sel/raw
|
||||||
|
(str "<div class=\"flex justify-end\"><div class=\"flex items-baseline gap-x-4\">"
|
||||||
|
(form-errors-html (:errors (:form-errors request)))
|
||||||
|
(str (sc/button {:color :primary :x-ref "next" :class "w-32 wizard-save-action"} "Save"))
|
||||||
|
"</div></div>")))
|
||||||
|
|
||||||
|
(defn render-form
|
||||||
|
"Renders the whole plain bulk-code form (no wizard). Binds *errors* from the request's
|
||||||
|
:form-errors so the field-level error lookups (ferr) resolve. Reuses the edit modal's
|
||||||
|
chrome (edit-modal.html), with no side panel."
|
||||||
|
[request]
|
||||||
|
(binding [*errors* (or (:form-errors request) {})]
|
||||||
|
(let [ids (:ids (:bulk-state request))
|
||||||
|
ids-hidden (apply str
|
||||||
|
(map-indexed (fn [i id]
|
||||||
|
(str (sc/hidden {:name (path->name2 :ids i) :value id})))
|
||||||
|
ids))
|
||||||
|
modal-card (sel/render "templates/transaction-edit/edit-modal.html"
|
||||||
|
{:head (str "<div class=\"p-2\">Bulk editing " (count ids) " transactions</div>")
|
||||||
|
:side_panel nil
|
||||||
|
:body (str (bulk-code-body* request))
|
||||||
|
:footer (str (footer* request))})]
|
||||||
|
(sel/render->hiccup
|
||||||
|
"templates/transaction-bulk-code/bulk-code-form.html"
|
||||||
|
{:ids_hidden ids-hidden
|
||||||
|
:form_attrs (sc/attrs->str {:hx-ext "response-targets"
|
||||||
|
:hx-swap "outerHTML"
|
||||||
|
:hx-target-400 "#form-errors .error-content"
|
||||||
|
:hx-trigger "submit"
|
||||||
|
:hx-target "this"
|
||||||
|
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-code-submit)})
|
||||||
|
:modal (str (sc/modal {:id "bulkcodemodal"} (sel/raw modal-card)))}))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; edit-form-changed ops (whole-form re-render). Replaces the per-operation
|
||||||
|
;; bulk-code-new-account / bulk-code-vendor-changed routes.
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defn- vendor-default-account
|
||||||
|
"Returns the vendor's standard default account. For single-client contexts,
|
||||||
|
the account name is clientized (tailored to the customer). For multi-client
|
||||||
|
contexts, the raw account name is used."
|
||||||
|
[vendor-id client-id]
|
||||||
|
(when vendor-id
|
||||||
|
(let [vendor (edit/get-vendor vendor-id)
|
||||||
|
account (:vendor/default-account vendor)]
|
||||||
|
(if client-id
|
||||||
|
(d-accounts/clientize account client-id)
|
||||||
|
account))))
|
||||||
|
|
||||||
|
(defn- build-default-account-row [account]
|
||||||
|
{:db/id (str (java.util.UUID/randomUUID))
|
||||||
|
:account (:db/id account)
|
||||||
|
:location (or (:account/location account) "Shared")
|
||||||
|
:percentage 1.0})
|
||||||
|
|
||||||
|
(defn apply-vendor-changed
|
||||||
|
"bulk-code-form-changed op: when the accounts are empty and a vendor with a default
|
||||||
|
account is chosen, pre-populate a single 100% default-account row."
|
||||||
|
[request]
|
||||||
|
(let [bulk-state (:bulk-state request)
|
||||||
|
client-id (single-client-id request)
|
||||||
|
vendor-id (->db-id (:vendor bulk-state))
|
||||||
|
accounts (:accounts bulk-state)]
|
||||||
|
(if (and (empty? accounts) vendor-id)
|
||||||
|
(if-let [default-account (vendor-default-account vendor-id client-id)]
|
||||||
|
(assoc-in request [:bulk-state :accounts] [(build-default-account-row default-account)])
|
||||||
|
request)
|
||||||
|
request)))
|
||||||
|
|
||||||
|
(defn apply-new-account
|
||||||
|
"bulk-code-form-changed op: append a fresh (blank, Shared) account row."
|
||||||
|
[request]
|
||||||
|
(let [accounts (vec (:accounts (:bulk-state request)))
|
||||||
|
new-account {:db/id (str (java.util.UUID/randomUUID))
|
||||||
|
:new? true
|
||||||
|
:location "Shared"}]
|
||||||
|
(assoc-in request [:bulk-state :accounts] (conj accounts new-account))))
|
||||||
|
|
||||||
|
(defn apply-remove-account
|
||||||
|
"bulk-code-form-changed op: remove the account row at form-param row-index."
|
||||||
|
[request]
|
||||||
|
(let [row-index (some-> request :form-params (get "row-index") Integer/parseInt)
|
||||||
|
accounts (vec (:accounts (:bulk-state request)))
|
||||||
|
updated-accounts (if (and row-index (< row-index (count accounts)))
|
||||||
|
(vec (concat (subvec accounts 0 row-index)
|
||||||
|
(subvec accounts (inc row-index))))
|
||||||
|
accounts)]
|
||||||
|
(assoc-in request [:bulk-state :accounts] updated-accounts)))
|
||||||
|
|
||||||
|
(defn bulk-code-form-changed-handler
|
||||||
|
"Single whole-form re-render endpoint. Dispatches on the `op` form-param (vendor
|
||||||
|
change, add/remove row), then re-renders the whole form. A missing/unknown op (e.g.
|
||||||
|
an account selection driving the location swap) just re-renders."
|
||||||
|
[request]
|
||||||
|
(let [op (get-in request [:form-params "op"])
|
||||||
|
request' (case op
|
||||||
|
"vendor-changed" (apply-vendor-changed request)
|
||||||
|
"new-account" (apply-new-account request)
|
||||||
|
"remove-account" (apply-remove-account request)
|
||||||
|
request)]
|
||||||
|
(html-response (render-form request'))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Submit
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
(defn maybe-code-accounts [transaction account-rules valid-locations]
|
(defn maybe-code-accounts [transaction account-rules valid-locations]
|
||||||
(with-precision 2
|
(with-precision 2
|
||||||
@@ -151,263 +404,95 @@
|
|||||||
[])]
|
[])]
|
||||||
accounts)))
|
accounts)))
|
||||||
|
|
||||||
(defrecord AccountsStep [linear-wizard]
|
|
||||||
mm/ModalWizardStep
|
|
||||||
(step-name [_]
|
|
||||||
"Bulk Code")
|
|
||||||
(step-key [_]
|
|
||||||
:accounts)
|
|
||||||
|
|
||||||
(edit-path [_ _]
|
|
||||||
[])
|
|
||||||
|
|
||||||
(step-schema [_]
|
|
||||||
(mm/form-schema linear-wizard))
|
|
||||||
|
|
||||||
(render-step [this {{:keys [snapshot] :as multi-form-state} :multi-form-state :as request}]
|
|
||||||
(let [_ (alog/peek ::SEARCH_PARAMS (:search-params snapshot))
|
|
||||||
selected-ids (selected->ids (assoc request :query-params (:search-params snapshot)) (:search-params snapshot))
|
|
||||||
all-ids (all-ids-not-locked selected-ids)]
|
|
||||||
(mm/default-render-step
|
|
||||||
linear-wizard this
|
|
||||||
:head [:div.p-2 "Bulk editing " (count all-ids) " transactions"]
|
|
||||||
:body (mm/default-step-body
|
|
||||||
{}
|
|
||||||
[:div
|
|
||||||
#_(com/hidden {:name "ids" :value (pr-str ids)})
|
|
||||||
|
|
||||||
[:div.space-y-4.p-4
|
|
||||||
[:div.grid.grid-cols-2.gap-4
|
|
||||||
|
|
||||||
;; Vendor field
|
|
||||||
[:div {:hx-trigger "change"
|
|
||||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-code-vendor-changed)
|
|
||||||
:hx-target "#account-entries"
|
|
||||||
:hx-swap "innerHTML"
|
|
||||||
:hx-include "closest form"}
|
|
||||||
(fc/with-field :vendor
|
|
||||||
(com/validated-field {:label "Vendor"
|
|
||||||
:errors (fc/field-errors)}
|
|
||||||
(com/typeahead {:name (fc/field-name)
|
|
||||||
:placeholder "Search for vendor..."
|
|
||||||
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
|
|
||||||
:value (fc/field-value)
|
|
||||||
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})))]
|
|
||||||
|
|
||||||
;; Status field
|
|
||||||
[:div
|
|
||||||
(fc/with-field :approval-status
|
|
||||||
(com/validated-field {:label "Status"
|
|
||||||
:errors (fc/field-errors)}
|
|
||||||
(com/select {:name (fc/field-name)
|
|
||||||
:value (some-> (fc/field-value)
|
|
||||||
name)
|
|
||||||
:options [["" "No Change"]
|
|
||||||
["approved" "Approved"]
|
|
||||||
["unapproved" "Unapproved"]
|
|
||||||
["suppressed" "Suppressed"]
|
|
||||||
["requires-feedback" "Client Review"]]})))]
|
|
||||||
|
|
||||||
;; Accounts section
|
|
||||||
[:div.col-span-2.pt-4
|
|
||||||
[:h3.text-lg.font-medium.mb-3 "Expense Accounts"]
|
|
||||||
|
|
||||||
[:div#account-entries.space-y-3
|
|
||||||
(fc/with-field :accounts
|
|
||||||
(com/validated-field
|
|
||||||
{:errors (fc/field-errors)}
|
|
||||||
(com/data-grid {:headers [(com/data-grid-header {} "Account")
|
|
||||||
(com/data-grid-header {:class "w-32"} "Location")
|
|
||||||
(com/data-grid-header {:class "w-16"} "%")
|
|
||||||
(com/data-grid-header {:class "w-16"})]}
|
|
||||||
(fc/cursor-map #(transaction-account-row* {:value %}))
|
|
||||||
|
|
||||||
(com/data-grid-new-row {:colspan 4
|
|
||||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
|
||||||
::route/bulk-code-new-account)
|
|
||||||
:row-offset 0
|
|
||||||
:index (count (fc/field-value))}
|
|
||||||
"New account"))))]]]]])
|
|
||||||
|
|
||||||
;; Button to add more accounts
|
|
||||||
|
|
||||||
:footer
|
|
||||||
(mm/default-step-footer linear-wizard this :validation-route ::route/new-wizard-navigate
|
|
||||||
:next-button (com/button {:color :primary :x-ref "next" :class "w-32"} "Save"))
|
|
||||||
:validation-route ::route/new-wizard-navigate))))
|
|
||||||
|
|
||||||
(defn assert-percentages-add-up [{:keys [accounts]}]
|
(defn assert-percentages-add-up [{:keys [accounts]}]
|
||||||
(let [account-total (reduce + 0 (map (fn [x] (:percentage x)) accounts))]
|
(let [account-total (reduce + 0 (map (fn [x] (:percentage x)) accounts))]
|
||||||
(when-not (dollars= 1.0 account-total)
|
(when-not (dollars= 1.0 account-total)
|
||||||
(form-validation-error (str "Expense account total (" account-total ") does not equal 100%")))))
|
(form-validation-error (str "Expense account total (" account-total ") does not equal 100%")))))
|
||||||
|
|
||||||
(defrecord BulkCodeWizard [_ current-step]
|
(defn submit
|
||||||
mm/LinearModalWizard
|
"Validates the posted bulk-code form (schema field errors via wrap-form-4xx-2, then the
|
||||||
(hydrate-from-request
|
percentage-sum and per-account location checks as form errors), then applies the chosen
|
||||||
[this request]
|
vendor / status / account-coding across every selected (not-locked) transaction."
|
||||||
this)
|
[request]
|
||||||
(navigate [this step-key]
|
(let [{:keys [ids vendor approval-status accounts]} (:bulk-state request)]
|
||||||
(assoc this :current-step step-key))
|
(assert-schema bulk-code-schema (select-keys (:bulk-state request) bulk-code-form-keys))
|
||||||
(get-current-step [this]
|
(when (seq accounts)
|
||||||
(if current-step
|
(assert-percentages-add-up {:accounts accounts}))
|
||||||
(mm/get-step this current-step)
|
(let [db (dc/db conn)
|
||||||
(mm/get-step this :accounts)))
|
transactions (pull-many db [:db/id :transaction/amount {:transaction/client [:db/id]}] (vec ids))
|
||||||
(render-wizard [this {:keys [multi-form-state] :as request}]
|
|
||||||
(mm/default-render-wizard
|
|
||||||
this request
|
|
||||||
:form-params
|
|
||||||
(-> mm/default-form-props
|
|
||||||
(assoc :hx-put
|
|
||||||
(str (bidi/path-for ssr-routes/only-routes ::route/bulk-code-submit))))
|
|
||||||
:render-timeline? false))
|
|
||||||
(steps [_]
|
|
||||||
[:accounts])
|
|
||||||
(get-step [this step-key]
|
|
||||||
(let [step-key-result (mc/parse mm/step-key-schema step-key)
|
|
||||||
[step-key-type step-key] step-key-result]
|
|
||||||
(get {:accounts (->AccountsStep this)}
|
|
||||||
step-key)))
|
|
||||||
(form-schema [_]
|
|
||||||
bulk-code-schema)
|
|
||||||
(submit [this {:keys [multi-form-state request-method identity] :as request}]
|
|
||||||
(let [ids (selected->ids (assoc request :query-params (:search-params (:snapshot multi-form-state))) (:search-params (:snapshot multi-form-state)))
|
|
||||||
all-ids (all-ids-not-locked ids)
|
|
||||||
vendor (-> request :multi-form-state :snapshot :vendor)
|
|
||||||
approval-status (-> request :multi-form-state :snapshot :approval-status)
|
|
||||||
accounts (-> request :multi-form-state :snapshot :accounts)]
|
|
||||||
(when (seq accounts)
|
|
||||||
(assert-percentages-add-up (:snapshot multi-form-state)))
|
|
||||||
(alog/peek ::ACCOUNTS (-> request :multi-form-state :snapshot))
|
|
||||||
|
|
||||||
;; Get transactions and filter for locked ones
|
;; Get client locations
|
||||||
(let [db (dc/db conn)
|
client->locations (->> (map (comp :db/id :transaction/client) transactions)
|
||||||
transactions (pull-many db [:db/id :transaction/amount {:transaction/client [:db/id]}] (vec all-ids))
|
(distinct)
|
||||||
|
(dc/q '[:find (pull ?e [:db/id :client/locations])
|
||||||
|
:in $ [?e ...]]
|
||||||
|
db)
|
||||||
|
(map (fn [[client]]
|
||||||
|
[(:db/id client) (:client/locations client)]))
|
||||||
|
(into {}))]
|
||||||
|
|
||||||
;; Get client locations
|
;; Validate account locations
|
||||||
client->locations (->> (map (comp :db/id :transaction/client) transactions)
|
(doseq [a accounts
|
||||||
(distinct)
|
:let [{:keys [:account/location :account/name]} (dc/pull db
|
||||||
(dc/q '[:find (pull ?e [:db/id :client/locations])
|
[:account/location :account/name]
|
||||||
:in $ [?e ...]]
|
(:account a))]]
|
||||||
db)
|
(when (and location (not= location (:location a)))
|
||||||
(map (fn [[client]]
|
(form-validation-error (str "Account " name " uses location " (:location a) ", but is supposed to be " location)))
|
||||||
[(:db/id client) (:client/locations client)]))
|
(doseq [[_ locations] client->locations]
|
||||||
(into {}))]
|
(when (and (not location)
|
||||||
|
(not (get (into #{"Shared"} locations)
|
||||||
|
(:location a))))
|
||||||
|
(form-validation-error (str "Account " name " uses location " (:location a) ", but doesn't belong to the client.")))))
|
||||||
|
|
||||||
;; Validate account locations
|
(audit-transact-batch
|
||||||
(doseq [a accounts
|
(map (fn [t]
|
||||||
:let [{:keys [:account/location :account/name]} (dc/pull db
|
(let [locations (client->locations (-> t :transaction/client :db/id))]
|
||||||
[:account/location :account/name]
|
[:upsert-transaction (cond-> t
|
||||||
(:account a))]]
|
approval-status
|
||||||
(when (and location (not= location (:location a)))
|
(assoc :transaction/approval-status approval-status)
|
||||||
(form-validation-error (str "Account " name " uses location " (:location a) ", but is supposed to be " location)))
|
|
||||||
(doseq [[_ locations] client->locations]
|
|
||||||
(when (and (not location)
|
|
||||||
(not (get (into #{"Shared"} locations)
|
|
||||||
(:location a))))
|
|
||||||
(form-validation-error (str "Account " name " uses location " (:location a) ", but doesn't belong to the client.")))))
|
|
||||||
|
|
||||||
(audit-transact-batch
|
vendor
|
||||||
(map (fn [t]
|
(assoc :transaction/vendor vendor)
|
||||||
(let [locations (client->locations (-> t :transaction/client :db/id))]
|
|
||||||
[:upsert-transaction (cond-> t
|
|
||||||
approval-status
|
|
||||||
(assoc :transaction/approval-status approval-status)
|
|
||||||
|
|
||||||
vendor
|
(seq accounts)
|
||||||
(assoc :transaction/vendor vendor)
|
(assoc :transaction/accounts
|
||||||
|
(maybe-code-accounts t accounts locations)))]))
|
||||||
|
transactions)
|
||||||
|
(:identity request))
|
||||||
|
|
||||||
(seq accounts)
|
;; Return success modal
|
||||||
(assoc :transaction/accounts
|
(html-response
|
||||||
(maybe-code-accounts t accounts locations)))]))
|
(com/success-modal {:title "Transactions Coded"}
|
||||||
transactions)
|
[:p (str "Successfully coded " (count ids) " transactions.")])
|
||||||
(:identity request))
|
:headers {"hx-trigger" "refreshTable, reset-selection"}))))
|
||||||
|
|
||||||
;; Return success modal
|
;; ---------------------------------------------------------------------------
|
||||||
(html-response
|
;; Handlers + routes
|
||||||
(com/success-modal {:title "Transactions Coded"}
|
;; ---------------------------------------------------------------------------
|
||||||
[:p (str "Successfully coded " (count all-ids) " transactions.")])
|
|
||||||
:headers {"hx-trigger" "refreshTable, reset-selection"})))))
|
|
||||||
|
|
||||||
(defn- vendor-default-account [vendor-id client-id]
|
(defn open-handler
|
||||||
"Returns the vendor's standard default account. For single-client contexts,
|
"Initial modal open (GET). Wraps the rendered form in the #transitioner shell expected
|
||||||
the account name is clientized (tailored to the customer). For multi-client
|
by the modal stack (reuses the edit modal's transitioner)."
|
||||||
contexts, the raw account name is used."
|
[request]
|
||||||
(when vendor-id
|
(modal-response
|
||||||
(let [vendor (edit/get-vendor vendor-id)
|
(sel/render->hiccup "templates/transaction-edit/transitioner.html"
|
||||||
account (:vendor/default-account vendor)]
|
{:body (str (render-form request))})))
|
||||||
(if client-id
|
|
||||||
(d-accounts/clientize account client-id)
|
|
||||||
account))))
|
|
||||||
|
|
||||||
(defn- build-default-account-row [account]
|
(defn- render-form-response
|
||||||
{:db/id (str (java.util.UUID/randomUUID))
|
"wrap-form-4xx-2 form-handler: re-render the whole form with field/form errors."
|
||||||
:account (:db/id account)
|
[request]
|
||||||
:location (or (:account/location account) "Shared")
|
(html-response (render-form request)
|
||||||
:percentage 1.0})
|
:headers {"HX-reswap" "outerHTML"}))
|
||||||
|
|
||||||
(defn- render-accounts-section [request]
|
|
||||||
(let [multi-form-state (:multi-form-state request)]
|
|
||||||
(html-response
|
|
||||||
[:div
|
|
||||||
(fc/start-form multi-form-state
|
|
||||||
(when (:form-errors request) {:step-params (:form-errors request)})
|
|
||||||
(fc/with-field :step-params
|
|
||||||
(fc/with-field :accounts
|
|
||||||
(com/validated-field
|
|
||||||
{:errors (fc/field-errors)}
|
|
||||||
(com/data-grid {:headers [(com/data-grid-header {} "Account")
|
|
||||||
(com/data-grid-header {:class "w-32"} "Location")
|
|
||||||
(com/data-grid-header {:class "w-16"} "%")
|
|
||||||
(com/data-grid-header {:class "w-16"})]}
|
|
||||||
(fc/cursor-map #(transaction-account-row* {:value %}))
|
|
||||||
(com/data-grid-new-row {:colspan 4
|
|
||||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
|
||||||
::route/bulk-code-new-account)
|
|
||||||
:row-offset 0
|
|
||||||
:index (count (fc/field-value))}
|
|
||||||
"New account"))))))])))
|
|
||||||
|
|
||||||
(defn- single-client-id [request]
|
|
||||||
"Returns the client ID if the user has access to exactly one client, nil otherwise."
|
|
||||||
(when (= 1 (count (:clients request)))
|
|
||||||
(-> request :clients first :db/id)))
|
|
||||||
|
|
||||||
(defn vendor-changed-handler [request]
|
|
||||||
(let [snapshot (:snapshot (:multi-form-state request))
|
|
||||||
step-params (:step-params (:multi-form-state request))
|
|
||||||
client-id (single-client-id request)
|
|
||||||
vendor-id (or (:vendor step-params) (:vendor snapshot))
|
|
||||||
updated-step-params (if (and (empty? (:accounts step-params))
|
|
||||||
vendor-id)
|
|
||||||
(if-let [default-account (vendor-default-account vendor-id client-id)]
|
|
||||||
(assoc step-params :accounts [(build-default-account-row default-account)])
|
|
||||||
step-params)
|
|
||||||
step-params)]
|
|
||||||
(render-accounts-section (assoc-in request [:multi-form-state :step-params] updated-step-params))))
|
|
||||||
|
|
||||||
(def bulk-code-wizard (->BulkCodeWizard nil nil))
|
|
||||||
|
|
||||||
(def key->handler
|
(def key->handler
|
||||||
(apply-middleware-to-all-handlers
|
(apply-middleware-to-all-handlers
|
||||||
{::route/bulk-code (-> mm/open-wizard-handler
|
{::route/bulk-code (-> open-handler
|
||||||
(mm/wrap-wizard bulk-code-wizard)
|
(wrap-bulk-state))
|
||||||
(mm/wrap-init-multi-form-state initial-bulk-edit-state))
|
::route/bulk-code-form-changed (-> bulk-code-form-changed-handler
|
||||||
::route/bulk-code-new-account (->
|
(wrap-bulk-state))
|
||||||
(add-new-entity-handler [:step-params :accounts]
|
::route/bulk-code-submit (-> submit
|
||||||
(fn render [cursor request]
|
(wrap-form-4xx-2 render-form-response)
|
||||||
(transaction-account-row*
|
(wrap-bulk-state))}
|
||||||
{:value cursor}))
|
|
||||||
(fn build-new-row [base _]
|
|
||||||
(assoc base :location "Shared")))
|
|
||||||
(wrap-schema-enforce :query-schema [:map
|
|
||||||
[:client-id {:optional true}
|
|
||||||
[:maybe entity-id]]]))
|
|
||||||
::route/bulk-code-vendor-changed (-> vendor-changed-handler
|
|
||||||
(mm/wrap-wizard bulk-code-wizard)
|
|
||||||
(mm/wrap-decode-multi-form-state))
|
|
||||||
::route/bulk-code-submit (-> mm/submit-handler
|
|
||||||
(wrap-wizard bulk-code-wizard)
|
|
||||||
(mm/wrap-decode-multi-form-state))}
|
|
||||||
(fn [h]
|
(fn [h]
|
||||||
(-> h
|
(-> h
|
||||||
(wrap-copy-qp-pqp)
|
(wrap-copy-qp-pqp)
|
||||||
|
|||||||
@@ -7,9 +7,8 @@
|
|||||||
"/bulk-delete" ::bulk-delete
|
"/bulk-delete" ::bulk-delete
|
||||||
"/bulk-suppress" ::bulk-suppress
|
"/bulk-suppress" ::bulk-suppress
|
||||||
"/bulk-code" {:get ::bulk-code
|
"/bulk-code" {:get ::bulk-code
|
||||||
:put ::bulk-code-submit
|
:post ::bulk-code-submit
|
||||||
"/new-account" ::bulk-code-new-account
|
"/form-changed" ::bulk-code-form-changed}}
|
||||||
"/vendor-changed" ::bulk-code-vendor-changed}}
|
|
||||||
"/new" {:get ::new
|
"/new" {:get ::new
|
||||||
:post ::new-submit
|
:post ::new-submit
|
||||||
"/location-select" ::location-select
|
"/location-select" ::location-select
|
||||||
|
|||||||
Reference in New Issue
Block a user