Compare commits
26 Commits
c892719bd1
...
integreat-
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bf87056d7 | |||
| 4139919036 | |||
| 599b849e6f | |||
| a289ff2557 | |||
| 03620e9d42 | |||
| 70c178de83 | |||
| e2ccfc8d2c | |||
| e8cbd2760c | |||
| e0da8e1866 | |||
| 2e3c1e3646 | |||
| a7e9fbaf6b | |||
| 8a676718a7 | |||
| 3ffb661da3 | |||
| f9438ba983 | |||
| 7d34b8a5f6 | |||
| c09d85ede6 | |||
| ec4f88b7fc | |||
| 8ca5e75c4d | |||
| 4aed27b204 | |||
| d0028f403c | |||
| 6b4392b74b | |||
| cdc87d3710 | |||
| 1e3952a7fb | |||
| e099714af1 | |||
| 11024b7b89 | |||
| de2a1ab850 |
@@ -149,3 +149,57 @@ divergence). Scorecard heuristic 1: faked roots → 0.
|
||||
```
|
||||
TODO Phase 2: the simple/advanced toggle becomes a `?mode=` re-render (plain form), not a
|
||||
dedicated route.
|
||||
|
||||
---
|
||||
|
||||
## The Selmer component library (`auto-ap.ssr.components.selmer` / `sc`) — Phase 2-final
|
||||
|
||||
Every shared component the modal renders through is now a thin Clojure wrapper over a
|
||||
partial under `resources/templates/components/`. **Reuse these before reaching for the
|
||||
Hiccup `com/*` versions in a migrated modal.** Each wrapper builds a context (reusing the
|
||||
real class helpers so output matches modulo Tailwind order) and renders its own partial via
|
||||
the interop bridge; dynamic HTMX/Alpine attrs go through `sc/attrs->str` →
|
||||
`{{ attrs|safe }}`. See `selmer-conventions.md` for the mechanics.
|
||||
|
||||
| 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/select` (Phase 3) | `select.html` | generic `<select>`; `options [[value label] …]`, `:value` (string/keyword) marks selected, extra hx-/x- attrs ride through. `location-select.html` generalized — reach for this before `com/select`. Added for the bulk-code status field. |
|
||||
|
||||
## inline click-to-edit cell (Phase 4) — targeted `.account-cell` swap, not a whole-form op
|
||||
|
||||
A "display value + pencil → edit-in-place → check/cancel" cell. Three tiny **stateless** routes,
|
||||
each swapping just the cell (`hx-target="closest .account-cell"`, `outerHTML`): a `display` cell
|
||||
(value + pencil `hx-get edit`), an `edit` cell (typeahead + check `hx-put save` / cancel
|
||||
`hx-get cancel`). State rides in the request (item index + current value via `hx-vals`), so no
|
||||
server-side "which cell is editing" flag is needed. Keep it as its own routes — it is a distinct
|
||||
feature, *not* folded into the whole-form `form-changed` dispatcher (that would lose the targeted
|
||||
swap and re-render the whole modal on every pencil click). The cells are assembled with `sc/*` +
|
||||
`sel/raw` strings (like `edit.clj`'s `footer*`); SVGs ride in as `svg/*` Hiccup via the
|
||||
`sc/a-icon-button` body (no `[:svg]` literal lands in the modal file).
|
||||
|
||||
## db/id-keyed item merge (Phase 4) — for rows the form posts only partially
|
||||
|
||||
When a row renders some fields read-only (so they aren't posted) but the entity holds them
|
||||
(sales-summary auto items post only db/id/category/account — not ledger-side/amount), the flat
|
||||
`wrap-derive-state` must **overlay posted items onto the persisted items by `:db/id`** so the
|
||||
unposted fields survive a re-render: `(merge (by-id (:db/id posted)) posted)`. New rows (temp
|
||||
`:db/id` not in the entity) ride through as-is. This is the row-level analog of edit's
|
||||
"entity-only fields always from the entity"; without it, a re-render drops ledger-side/amount and
|
||||
the debit/credit split + totals break.
|
||||
| `sc/validated-field` | `validated-field.html` | label + body + always-present error `<p>`; pass-through attrs land on the wrapping div (the per-row location cell hangs its swap wiring here) |
|
||||
| `sc/button` / `sc/a-button` / `sc/a-icon-button` | `button`/`a-button`/`a-icon-button`.html | spinner via `{% include "spinner.html" %}`; class via `btn/bg-colors` |
|
||||
| `sc/badge` / `sc/link` | `badge`/`link`.html | |
|
||||
| `sc/button-group` / `sc/button-group-button` | `button-group(+button).html` | the group does **not** mutate children's classes (the Hiccup `group-` added rounded-l/r) — add rounding in the caller/template (tabs do) |
|
||||
| `sc/radio-card` | `radio-card.html` | reproduces the `select-keys [:hx-post :hx-target :hx-swap :hx-include :hx-trigger]` filter (drops `:hx-vals`/`:hx-select`) **and** the dangling-`[:h3]` quirk: only the `<ul>` renders |
|
||||
| `sc/data-grid` (+ `-header`/`-row`/`-cell`) | `data-grid*.html` | table shell + optional `footer-tbody` (the swappable totals tbody) |
|
||||
| `sc/typeahead` | `typeahead.html` | full Alpine + tippy; resolves `{value,label}` server-side via `content-fn`; every `tippy?.` null-guard preserved; hidden posting `<input>` with `:value="value.value"` + the `x-init` watcher |
|
||||
| `sc/modal` | `modal.html` | the `@click.outside="open=false"` wrapper |
|
||||
| SVGs | `spinner`/`svg-x`/`svg-external-link`/`svg-drop-down`.html | static, `{% include %}`d so the markup isn't duplicated |
|
||||
|
||||
Modal-specific structure lives under `resources/templates/transaction-edit/`
|
||||
(`edit-form`, `edit-modal`, `links-body`, `manual-coding`, `simple-mode`, `account-totals`,
|
||||
`details-panel`, the four match panels, `transitioner`). The render fns in `edit.clj`
|
||||
gather data, call `sc/*`, and interpolate the fragments into these layout templates as
|
||||
`{{ frag|safe }}`. **Verify each wrapper by class-set equality + e2e, never byte-parity**
|
||||
(`hh/add-class` is set-based, so class order differs from the Hiccup output).
|
||||
|
||||
@@ -13,9 +13,13 @@ implement the multi-step protocol (`mm/ModalWizardStep` + friends), serialize an
|
||||
snapshot into hidden fields, and register 10–20 stacked-middleware routes — all for one
|
||||
step. That is pure overhead to delete.
|
||||
|
||||
> **Done — Transaction Edit is now a plain form.** `LinksStep`/`EditWizard` and all `mm/*`
|
||||
> usage were deleted from `transaction/edit.clj`; the worked example below is realized, not
|
||||
> aspirational. See "Single-step → plain form (realized)".
|
||||
|
||||
## The machinery being replaced
|
||||
|
||||
`transaction/edit.clj` today still carries the old shape, useful as the "before":
|
||||
The old shape (kept here as the "before"):
|
||||
|
||||
```clojure
|
||||
(defrecord LinksStep [linear-wizard]
|
||||
@@ -49,6 +53,33 @@ A `?mode=` toggle is just the `GET` re-rendering with a different query param
|
||||
plain form. An add-row interaction is one extra `POST` that appends a fresh row and
|
||||
re-renders (the `+1` route).
|
||||
|
||||
### Single-step → plain form (realized: Transaction Edit)
|
||||
|
||||
What replacing the wizard actually looked like, end to end:
|
||||
|
||||
1. **Delete the records + middleware.** `EditWizard`/`LinksStep`, `mm/open-wizard-handler`,
|
||||
`mm/next-handler`, `mm/submit-handler`, `mm/wrap-wizard`, `mm/wrap-decode-multi-form-state`,
|
||||
and the `edit-wizard-navigate` route all go. `render-step` becomes a plain `render-form`.
|
||||
2. **Rename the fields off `step-params[...]`.** Field names are now the schema path
|
||||
directly (`(path->name2 :transaction/accounts 0 :transaction-account/account)` →
|
||||
`transaction/accounts[0][transaction-account/account]`). They decode straight into the
|
||||
form schema via the unchanged `wrap-nested-form-params` + `mc/decode` — no two-key
|
||||
snapshot/step-params decode. **Strip stray keys after decode** (`select-keys` to the
|
||||
schema's keys) or a non-schema input like the tab group's `method` hidden 500s the save
|
||||
(see `gotchas.md`).
|
||||
3. **Flat state.** `wrap-derive-state` builds a plain `{:snapshot :edit-path :step-params}`
|
||||
map (not the `MultiStepFormState` record): `entity-only` fields from the entity, editable
|
||||
fields from the live posted form (absent = cleared). The ~34 `:snapshot` reads keep
|
||||
working.
|
||||
4. **Validation/error flow without `wrap-ensure-step`.** Reuse the generic
|
||||
`wrap-form-4xx-2` directly: `(-> submit-edit (wrap-form-4xx-2 render-form-response) …)`.
|
||||
`submit-edit` runs `assert-schema` then dispatches the save; on a throw, `wrap-form-4xx-2`
|
||||
re-renders the whole form with `:form-errors` keyed by schema paths. A `*errors*` dynamic
|
||||
var (bound by `render-form`) replaces the form-cursor's `*form-errors*` for field lookups.
|
||||
5. **Routes shrink to** `edit-wizard` (GET open), `edit-submit` (POST), `edit-form-changed`
|
||||
(POST whole-form re-render for dependent changes), `location-select` (GET),
|
||||
`unlink-payment` (POST).
|
||||
|
||||
---
|
||||
|
||||
## Genuinely multi-step → data-driven engine with session-stored step state
|
||||
|
||||
@@ -148,6 +148,138 @@ hides every test after the first failure, so fixing one unmasks the next):
|
||||
Actions")')`) hang once the modal is single-page. Drop the navigation; the action tabs
|
||||
are present immediately.
|
||||
|
||||
## Flat decode leaks stray form fields into the saved entity (the `method` 500)
|
||||
|
||||
Dropping the wizard's `step-params[...]` field-name prefix and decoding posted params
|
||||
**straight into the form schema** means the decode now captures **every** posted field, not
|
||||
just the namespaced ones. A single stray field breaks the save:
|
||||
|
||||
- The tab switcher is `(com/button-group {:name "method"} …)`, which emits
|
||||
`<input type="hidden" name="method">`. Under the wizard, `method` lived *outside*
|
||||
`step-params[...]` so it never entered the decoded map. After the rename it decodes to
|
||||
`:method ""` (malli `:map` is open and passes unknown keys), rides into `snapshot` →
|
||||
`tx-data`, and `:upsert-transaction` rejects it → **HTTP 500 on save**.
|
||||
- Symptom: the save POST fires (confirm with a `println` in the submit handler) but the
|
||||
modal never closes, because the 500 trips `htmx:response-error`. The server error may go
|
||||
to mulog, not stdout — an empty stdout log does **not** mean "no error." Reproduce the
|
||||
exact POST with `curl` (add/remove one field) to isolate the offender fast.
|
||||
|
||||
**Fix:** strip the decoded map to the schema's known top-level keys before threading on
|
||||
(`select-keys decoded edit-form-keys`); keep that allowlist next to the schema. Nested
|
||||
account sub-maps decode fine — only the top level needs the guard.
|
||||
|
||||
## REPL reload does not refresh a running jetty's routes — restart the JVM
|
||||
|
||||
`handler/match->handler-lookup` is a top-level `def` capturing `(merge ssr/key->handler …)`
|
||||
at load, through a chain of module-level `def`s (`edit` → `ssr.transaction` → `ssr.core` →
|
||||
`handler`). Reloading the leaf `edit.clj` updates it but **not** the captured merges, and a
|
||||
jetty started `(run-jetty app …)` holds a static `app` that doesn't re-deref the lookup per
|
||||
request. Net: after a handler/route/record change, an already-running dev server keeps
|
||||
serving the **old** code — `curl` shows the pre-change response (e.g. the old wizard
|
||||
transitioner) while your REPL renders the new one. **Restart in a fresh JVM** for
|
||||
route/record/middleware changes. For e2e, the Playwright test server
|
||||
(`lein run -m auto-ap.test-server`) is a fresh JVM compiling from disk — but kill any stale
|
||||
`:3333` first (`reuseExistingServer` reuses it), and kill **by port**
|
||||
(`ss -tlnp | grep :3333`), never `pkill -f test-server` (it matches its own command line).
|
||||
|
||||
## Full-suite e2e flakes are shared-seed interference
|
||||
|
||||
The test server seeds once at boot; edit tests **save** (mutate) those seed transactions.
|
||||
Run in parallel, workers race the same rows and earlier saves pollute later reads → phantom
|
||||
failures that pass in isolation.
|
||||
|
||||
**Proper fix (landed on `staging`, adopted at the rebase):** a `/test-reset` endpoint
|
||||
(`test_server.clj` → `reset-test-data!` recreates + re-seeds the in-memory db) called from a
|
||||
`test.beforeEach` in each spec, plus `fullyParallel: false` + `workers: 1` in
|
||||
`playwright.config.ts`. Every test starts from the same deterministic dataset regardless of
|
||||
run order. This **supersedes** the earlier `--workers=1`-only workaround (which kept order
|
||||
dependence; it merely serialized the races instead of eliminating cross-test state).
|
||||
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
|
||||
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.
|
||||
|
||||
## 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.)
|
||||
|
||||
## clojure-mcp structural edits reformat the whole file — use text Edit in big shared files
|
||||
|
||||
`clojure_edit` / `clojure_edit_replace_sexp` re-emit the **entire file** through the formatter.
|
||||
In a small single-modal file that's fine (cljfmt-clean output). In a **large multi-modal file**
|
||||
(Phase 5: `invoices.clj`, 1812 lines) a one-line require addition produced a **650-line spurious
|
||||
whitespace diff** that buries the real change and makes review impossible. For a surgical
|
||||
migration inside a big shared file, use the **text-based Edit tool** (exact-string match — no
|
||||
reformat); this is the AGENTS.md "edit Clojure with file tools only when absolutely necessary"
|
||||
carve-out. Verify with `load-file` (compile) + `lein cljfmt check`, not by eyeballing. Confirm the
|
||||
diff is contained with `git diff -U0 <file> | grep '^@@'` — the hunks should cluster only where you
|
||||
edited (requires + the modal region), nothing else.
|
||||
|
||||
## Scorecard exceptions (ratchet violations with a reason)
|
||||
|
||||
_None yet._ Append here if a migration must let a metric regress for a documented 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
|
||||
`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,
|
||||
and sit outside the form's interactive render path. Migrating them means porting the shared
|
||||
`success-modal` to Selmer — a Phase 11 cross-cutting task, not a single-modal one.
|
||||
|
||||
@@ -40,6 +40,20 @@ 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 |
|
||||
| 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** |
|
||||
| 3 | Transaction Bulk Code `transaction/bulk_code.clj` | 506 (was 420 — see exception) | **3** | 0 | 0 | **0** | 0 | 0† | reused **all** of Phase-2's `sc/*` lib + `account-typeahead*`/`location-select*` + `edit-modal`/`transitioner` chrome / added **`sc/select`** |
|
||||
|
||||
† The one `"hx-..."` string hit is a response-header map (`{"hx-trigger" "refreshTable, reset-selection"}`), not a mixed attribute encoding. mm coupling 19→**0**, wizard records 3→**0**, step-params 10→**0** (the 2 hits are comments), Hiccup-in-render → **0** except the shared `com/success-modal` (heuristic-9 exception, as in Phase 2).
|
||||
| 4 | POS Sales Summary `pos/sales_summaries.clj` | **732** (was 790) | **6** modal | 0 | 0 | **0** | 1✦ | 0✦ | reused `sc/*` lib + `edit-modal`/`transitioner` chrome / added the inline click-to-edit **account-cell** + **manual-items** patterns |
|
||||
|
||||
✦ The residual 1 `hx-swap-oob` and the `"hx-..."` string hits all live in the **grid page** code (the `grid-page` render lambdas + the `filters` form + the submit response-header map) — none are in the migrated **modal** render path, which is 100% Selmer. `defrecord` count **0** (all 4 wizard records gone), `fc/` cursor refs 51→**0**, mm coupling 20→**0**, step-params 27→**0** (2 comments). LOC dropped (this wizard held real custom code, unlike bulk-code's thin shell). **Two pre-existing bugs fixed** (per the user's call): the "New Summary Item" add button (was throwing `newRowIndex is not defined`) and the dead totals/balance display.
|
||||
|
||||
### New heuristics introduced at 2-final (full Selmer)
|
||||
|
||||
| # | Heuristic | Measure | Target |
|
||||
|---|-----------|---------|--------|
|
||||
| 9 | Hiccup HTML tags in the render path | `grep -cE '\[:(div\|span\|p\|a\|button\|input\|h[1-6]\|ul\|li\|label\|select\|option\|t(able\|head\|body\|r\|d\|h)\|form\|svg\|template)'` over the modal's render fns | → 0 (success-modal confirmation dialogs may keep the shared Hiccup component) |
|
||||
| 10 | mm wizard coupling | `grep -c 'mm/' the modal file` + `grep -c 'defrecord.*Wizard\|ModalWizardStep'` | → 0 for a single-step modal |
|
||||
|
||||
> **Phase 2 progress.** Achieved with parity held (swap spec **6/6**, transaction-edit
|
||||
> spec **8/8**, full suite **38 pass / 1 unrelated fail / 0 skip**, up from 30/2/7):
|
||||
@@ -59,3 +73,91 @@ Each migration appends one row (after-numbers), referencing the before in the di
|
||||
> Selmer sweep of the shared `com/typeahead`/`com/select`/`com/button-group-button` (those
|
||||
> shared call sites hold the last 8 mixed `@`/`:`-attr offenders; they clear when the
|
||||
> shared components move to Selmer — not a single-modal task, per Open decision 2).
|
||||
|
||||
> **Phase 2-final — full Selmer + wizard removed.** Every component the modal renders
|
||||
> through was ported to a Selmer partial under `resources/templates/components/` with a
|
||||
> thin Clojure wrapper in `auto-ap.ssr.components.selmer` (`sc/*`); the modal's own
|
||||
> structure lives under `resources/templates/transaction-edit/`. The `mm` wizard
|
||||
> abstraction (`EditWizard`/`LinksStep` records, `MultiStepFormState`, `step-params[...]`
|
||||
> field names, `wrap-wizard`/`wrap-decode-multi-form-state` middleware) was deleted — there
|
||||
> was only ever one step, so it was pure overhead. Result: heuristic 8 (mixed hx-) and 9
|
||||
> (Hiccup in render) and 10 (mm coupling) all → **0**; the `edit-wizard-navigate` route is
|
||||
> gone (routes 5). Parity held: swap spec **6/6**, transaction-edit spec **8/8**, full
|
||||
> suite **38 pass / 1 pre-existing unrelated fail** (serial, fresh seed). The only Hiccup
|
||||
> left in the file is the post-save `com/success-modal` confirmation dialogs (terminal,
|
||||
> 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`
|
||||
> (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).
|
||||
|
||||
> **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.
|
||||
|
||||
> **Phase 5 — Invoice Bulk Edit (cold apply in a 1812-line shared file).** Structurally
|
||||
> Phase 3's bulk-code applied to invoices (selected entities → expense-account rows:
|
||||
> account/location/percentage), so it was near-pure reuse: bulk-code's flat-state plumbing
|
||||
> (ids round-trip, `wrap-bulk-state`, schema/decode) + edit's `account-totals-tbody` for the
|
||||
> live totals. `BulkEditWizard`/`AccountsStep` + `MultiStepFormState` deleted, `step-params`
|
||||
> dropped, the rows de-cursored to Selmer with the explicit-id location swap, bulk-edit
|
||||
> routes 5→**3** (the `new-account` + `total` + `balance` routes folded into one
|
||||
> `form-changed` op dispatcher + the sibling-`<tbody>` totals swap). **Implemented the dead
|
||||
> TOTAL/BALANCE display** (the wizard had them commented out with a duplicate `id="total"`)
|
||||
> as a `#expense-totals` sibling-`<tbody>` refreshed by a Rule-4 percentage-keyup swap.
|
||||
> Parity held: invoice-bulk-edit spec 5/5, full suite 50/50.
|
||||
>
|
||||
> **Editing a wizard buried in a large shared file:** the clojure-mcp structural tools
|
||||
> (`clojure_edit` / `replace_sexp`) **reformat the whole file** — here that was a spurious
|
||||
> 650-line whitespace diff that would bury the real change. For a surgical migration inside a
|
||||
> big multi-modal file, use the **text-based Edit tool** instead (the AGENTS.md "absolutely
|
||||
> necessary" carve-out), then `load-file` + `cljfmt` to verify. The resulting diff was fully
|
||||
> contained to the requires + the bulk-edit region.
|
||||
>
|
||||
> **Repeated-row target-selector convention — settled (the Phase 5 exit criterion).** Across
|
||||
> edit / bulk-code / sales-summary / invoice-bulk-edit the convention converged on: **explicit
|
||||
> per-row ids** (`#account-location-<index>`, `#account-row-<index>`) for a cell-local swap
|
||||
> (Rule 2), and a **single stable-id sibling-`<tbody>`** (`#account-totals` / `#expense-totals`)
|
||||
> for running totals (Rule 4) — *not* data-attribute selectors or a `form-path→selector`
|
||||
> helper. Per-row ids are generated from the row index the form already uses for field names
|
||||
> (`path->name2`), so server and markup agree by construction. Whole-form swap (Rule 3) covers
|
||||
> structural changes (add/remove row). This is now the cookbook default; see `swap-doctrine.md`.
|
||||
|
||||
@@ -75,11 +75,60 @@ Lessons:
|
||||
- **Embed via the bridge.** `render->hiccup` lets the Selmer fragment sit inside the
|
||||
still-Hiccup row (`com/validated-field` wraps it) — strangler, one component at a time.
|
||||
|
||||
## Composition
|
||||
## Composition — verified mechanics (selmer 1.12.61)
|
||||
|
||||
Selmer composes via `{% include %}` and `{% block %}`. Prefer small per-component templates
|
||||
referenced by classpath path. Keep `|safe` to values the server fully controls (rendered
|
||||
Hiccup, JSON for `x-data`), never raw user input.
|
||||
Proven by REPL before the full migration (do the same before relying on any of these):
|
||||
|
||||
- **`{% include %}` works in FILE renders only.** `sel/render` = `selmer/render-file`, and
|
||||
include/extends/block are *parse-stage* tags. Rendering a template **string** that
|
||||
contains `{% include %}` throws `unrecognized tag: :include` (the runtime expr-tag has a
|
||||
nil handler). So includes only work from a `.html` file, never from `render-str`.
|
||||
- **`{% include %}` sees `{% for %}` loop bindings.** Inside `{% for row in rows %}…{% include "components/x.html" %}…{% endfor %}` the partial can read `row.*`. Good for repeated
|
||||
rows — though Clojure-composing the rows (below) is usually simpler.
|
||||
- **`{% include … with k=v %}` is IGNORED** in 1.12.61 (the `with` clause is dropped). To
|
||||
parametrize, either wrap with the block tag `{% with k=v %}{% include … %}{% endwith %}`
|
||||
(works), or — preferred — let a Clojure wrapper render the partial with an explicit ctx.
|
||||
|
||||
## The Clojure-wrapper composition pattern (`auto-ap.ssr.components.selmer` / `sc`)
|
||||
|
||||
Because `{% include with %}` can't pass args and the server computes most values anyway,
|
||||
each shared component is a **thin Clojure wrapper that renders its own partial** (the
|
||||
proven `location-select*` shape, generalised). The element *structure* lives 100% in the
|
||||
`.html`; the only Clojure is data assembly. The modal's render fns compose these wrappers
|
||||
and assemble a view-model, interpolating the pre-rendered fragments as `{{ frag|safe }}`.
|
||||
|
||||
```clojure
|
||||
(sc/hidden {:name … :value …}) ; -> render "components/hidden.html"
|
||||
(sc/validated-field {:label … :errors …} body…)
|
||||
(sc/typeahead {:name … :url … :value … :content-fn …}) ; resolves label server-side
|
||||
(sc/data-grid {:headers […] :footer-tbody …} rows…)
|
||||
```
|
||||
|
||||
### `attrs->str` — the dynamic-attribute bridge
|
||||
|
||||
HTMX/Alpine attributes vary per call site, so a fixed template can't enumerate them.
|
||||
`sc/attrs->str` serialises an attribute *map* to an HTML attribute *string* injected with
|
||||
`{{ attrs|safe }}`: `<input type="hidden"{{ attrs|safe }}>`. Rules mirror hiccup2 — nil/false
|
||||
dropped, `true` → bare boolean attr, else `name="escaped"` (via `hu/escape-html`, so JSON
|
||||
`x-data` and `x-init` quotes become `"`/`'` and the browser decodes them back).
|
||||
Keyword keys keep their full name incl. namespaced/colon forms (`:x-hx-val:account-id` →
|
||||
`x-hx-val:account-id`). This keeps templates free of per-attribute `{% if %}` ladders while
|
||||
still emitting 100% Selmer markup — `attrs->str` serialises data, it does not build Hiccup.
|
||||
|
||||
### Reuse the real class helpers
|
||||
|
||||
Wrappers reuse `inputs/default-input-classes`, `inputs/use-size`, `hh/add-class`,
|
||||
`btn/bg-colors` so output matches the Hiccup component **modulo Tailwind class order**.
|
||||
Verify by class-**set** equality + e2e, never byte-parity (see the cookbook entries).
|
||||
|
||||
### Trivial wrapper divs
|
||||
|
||||
A bare `<div class="w-72">…</div>` around a fragment is composed with a `wrap-div` string
|
||||
helper (or put the class in the parent template), not a Hiccup vector — string composition
|
||||
of a structural wrapper is not Hiccup and avoids a micro-template per div.
|
||||
|
||||
Keep `|safe` to values the server fully controls (rendered fragments, JSON for `x-data`),
|
||||
never raw user input.
|
||||
|
||||
## Scope (Open decision 2)
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Reset the shared test-server dataset before each test so tests are isolated
|
||||
// from one another (and from other spec files) regardless of run order.
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.post('/test-reset');
|
||||
});
|
||||
|
||||
let testInfoCache: any = null;
|
||||
|
||||
async function getTestInfo(page: any) {
|
||||
@@ -34,7 +40,7 @@ async function openBulkCodeModal(page: any) {
|
||||
const codeButton = page.locator('button:has-text("Code")').first();
|
||||
await codeButton.click();
|
||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
await page.waitForSelector('#bulkcodemodal');
|
||||
}
|
||||
|
||||
async function closeBulkCodeModal(page: any) {
|
||||
@@ -150,7 +156,7 @@ async function addNewAccount(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) => {
|
||||
el.dispatchEvent(new Event('submit', { bubbles: true }));
|
||||
});
|
||||
@@ -178,7 +184,7 @@ test.describe('Bulk Code Transactions - Happy Path', () => {
|
||||
await expect(page.locator('text=Bulk editing 1 transactions')).toBeVisible();
|
||||
|
||||
// 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);
|
||||
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
|
||||
const newInput = document.createElement('input');
|
||||
@@ -190,7 +196,7 @@ test.describe('Bulk Code Transactions - Happy Path', () => {
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// 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');
|
||||
|
||||
// Add account
|
||||
@@ -272,7 +278,7 @@ test.describe('Bulk Code Transactions - Validation', () => {
|
||||
const testInfo = await getTestInfo(page);
|
||||
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();
|
||||
|
||||
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
|
||||
@@ -287,11 +293,11 @@ test.describe('Bulk Code Transactions - Validation', () => {
|
||||
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);
|
||||
|
||||
// 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');
|
||||
|
||||
// Vendor selection pre-populated a default account row at 100%.
|
||||
@@ -304,10 +310,14 @@ test.describe('Bulk Code Transactions - Validation', () => {
|
||||
// Modal should still be open
|
||||
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
|
||||
|
||||
// Vendor should still be selected
|
||||
const vendorHiddenAfter = page.locator('input[type="hidden"][name="step-params[vendor]"]').first();
|
||||
const vendorValueAfter = await vendorHiddenAfter.inputValue();
|
||||
expect(vendorValueAfter).toBe(vendorId.toString());
|
||||
// Vendor should still be selected. The vendor typeahead is Alpine-managed and posts
|
||||
// its value via an x-bound hidden input, so the right correctness check is what the
|
||||
// form actually submits (the value that gets saved), not the lagging DOM .value of the
|
||||
// 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
|
||||
const statusValueAfter = await statusSelect.inputValue();
|
||||
@@ -458,7 +468,7 @@ test.describe('Bulk Code Transactions - Vendor Pre-population', () => {
|
||||
|
||||
// The vendor typeahead dispatches change from its parent div
|
||||
// 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();
|
||||
|
||||
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
|
||||
@@ -475,7 +485,7 @@ test.describe('Bulk Code Transactions - Vendor Pre-population', () => {
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
// Account should be pre-populated - check for account row
|
||||
@@ -515,7 +525,7 @@ test.describe('Bulk Code Transactions - Vendor Pre-population', () => {
|
||||
const testInfo = await getTestInfo(page);
|
||||
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();
|
||||
|
||||
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
|
||||
@@ -532,7 +542,7 @@ test.describe('Bulk Code Transactions - Vendor Pre-population', () => {
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
// Should pre-populate the vendor's default account (non-clientized) plus the "New account" row
|
||||
|
||||
145
e2e/invoice-bulk-edit.spec.ts
Normal file
145
e2e/invoice-bulk-edit.spec.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Characterization spec for the Invoice Bulk Edit modal. Captures CURRENT
|
||||
// (pre-migration) behavior so the wizard -> plain-Selmer migration can be proven
|
||||
// behavior-preserving. Reset the shared dataset before each test for isolation.
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.post('/test-reset');
|
||||
});
|
||||
|
||||
async function getTestInfo(page: any) {
|
||||
return (await page.request.get('/test-info')).json();
|
||||
}
|
||||
|
||||
async function navigateToInvoices(page: any) {
|
||||
await page.setExtraHTTPHeaders({ 'x-clients': '"mine"' });
|
||||
await page.goto('/invoice');
|
||||
await page.waitForSelector('#entity-table tbody tr');
|
||||
}
|
||||
|
||||
async function selectFirstInvoice(page: any) {
|
||||
await page.locator('#entity-table tbody input[type="checkbox"]').first().click();
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
async function openBulkEditModal(page: any) {
|
||||
await page.locator('button:has-text("Bulk Edit")').first().click();
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
}
|
||||
|
||||
async function addNewAccount(page: any) {
|
||||
await page.locator('a:has-text("New account")').first().click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Set an account on a row by replacing its Alpine-managed hidden input with a plain
|
||||
// one (Solr-backed typeahead is unavailable in tests), then dispatching the location
|
||||
// reload -- the same approach the bulk-code spec uses.
|
||||
async function setRowAccount(page: any, rowIndex: number, accountId: string) {
|
||||
const rows = page.locator('#bulk-edit-form tbody tr');
|
||||
const row = rows.nth(rowIndex);
|
||||
const hidden = row.locator('input[type="hidden"][name*="[account]"]').first();
|
||||
await hidden.evaluate((el: HTMLInputElement, value: string) => {
|
||||
const n = document.createElement('input');
|
||||
n.type = 'hidden'; n.name = el.name; n.value = value;
|
||||
el.parentNode!.replaceChild(n, el);
|
||||
}, accountId);
|
||||
await page.waitForTimeout(200);
|
||||
const loc = row.locator('[x-dispatch\\:changed]').first();
|
||||
if (await loc.count() > 0) {
|
||||
await loc.evaluate((el: HTMLElement) => el.dispatchEvent(new CustomEvent('changed', { bubbles: true })));
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
}
|
||||
|
||||
async function setRowPercentage(page: any, rowIndex: number, pct: string) {
|
||||
const row = page.locator('#bulk-edit-form tbody tr').nth(rowIndex);
|
||||
const input = row.locator('input.amount-field, input[name*="percentage"]').first();
|
||||
await input.fill(pct);
|
||||
await input.dispatchEvent('change');
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
async function submitForm(page: any) {
|
||||
await page.locator('#bulk-edit-form').evaluate((f: HTMLFormElement) =>
|
||||
f.dispatchEvent(new Event('submit', { bubbles: true })));
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Invoice Bulk Edit (characterization)', () => {
|
||||
test('opens the modal with the expense-account grid', async ({ page }) => {
|
||||
await navigateToInvoices(page);
|
||||
await selectFirstInvoice(page);
|
||||
await openBulkEditModal(page);
|
||||
|
||||
const modal = page.locator('#wizardmodal');
|
||||
await expect(modal).toContainText('Bulk editing 1 invoices');
|
||||
await expect(modal).toContainText('Account');
|
||||
await expect(modal).toContainText('Location');
|
||||
await expect(modal).toContainText('TOTAL');
|
||||
await expect(modal).toContainText('BALANCE');
|
||||
// a default expense-account row is present, plus the New account button
|
||||
expect(await modal.locator('input[name*="expense-accounts"][name*="[account]"]').count()).toBeGreaterThanOrEqual(1);
|
||||
await expect(modal.locator('a:has-text("New account")')).toBeVisible();
|
||||
});
|
||||
|
||||
test('New account adds an expense-account row', async ({ page }) => {
|
||||
await navigateToInvoices(page);
|
||||
await selectFirstInvoice(page);
|
||||
await openBulkEditModal(page);
|
||||
|
||||
const accountRows = () => page.locator('#bulk-edit-form input[name*="expense-accounts"][name*="[account]"]');
|
||||
const before = await accountRows().count();
|
||||
await addNewAccount(page);
|
||||
expect(await accountRows().count()).toBe(before + 1);
|
||||
});
|
||||
|
||||
test('saving a 100% account coding closes the modal', async ({ page }) => {
|
||||
const info = await getTestInfo(page);
|
||||
await navigateToInvoices(page);
|
||||
await selectFirstInvoice(page);
|
||||
await openBulkEditModal(page);
|
||||
|
||||
// the default row is at 100% already; set its account and save
|
||||
await setRowAccount(page, 0, info.accounts['test-account'].toString());
|
||||
await setRowPercentage(page, 0, '100');
|
||||
await submitForm(page);
|
||||
|
||||
// a successful save fires modalclose -> the modal closes
|
||||
await expect(page.locator('#wizardmodal')).toBeHidden({ timeout: 8000 });
|
||||
});
|
||||
|
||||
// The TOTAL/BALANCE percentage rows were dead code in the wizard (commented out, with a
|
||||
// duplicate id="total"); the migration implements them as a sibling-<tbody> Rule-4 swap.
|
||||
test('TOTAL/BALANCE percentages render and recompute on edit (implemented)', async ({ page }) => {
|
||||
await navigateToInvoices(page);
|
||||
await selectFirstInvoice(page);
|
||||
await openBulkEditModal(page);
|
||||
|
||||
// default row is 100% -> TOTAL 100.0%
|
||||
await expect(page.locator('#expense-totals')).toContainText('100.0%');
|
||||
// edit to 50% -> the totals tbody refreshes via the targeted swap
|
||||
const pct = page.locator('#bulk-edit-form input.amount-field').first();
|
||||
await pct.click();
|
||||
await pct.fill('');
|
||||
await pct.pressSequentially('50'); // keyup -> Rule-4 swap of #expense-totals
|
||||
await expect(page.locator('#expense-totals')).toContainText('50.0%', { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('rejects when account percentages do not total 100%', async ({ page }) => {
|
||||
const info = await getTestInfo(page);
|
||||
await navigateToInvoices(page);
|
||||
await selectFirstInvoice(page);
|
||||
await openBulkEditModal(page);
|
||||
|
||||
await setRowAccount(page, 0, info.accounts['test-account'].toString());
|
||||
await setRowPercentage(page, 0, '50');
|
||||
await submitForm(page);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// modal stays open on validation failure
|
||||
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
|
||||
await expect(page.locator('#wizardmodal')).toContainText('does not equal 100%');
|
||||
});
|
||||
});
|
||||
141
e2e/sales-summary-edit.spec.ts
Normal file
141
e2e/sales-summary-edit.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Characterization spec for the POS Sales Summary edit modal. Captures CURRENT
|
||||
// (pre-migration) behavior so the wizard -> plain-Selmer migration can be proven
|
||||
// behavior-preserving. Reset the shared dataset before each test for isolation.
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.post('/test-reset');
|
||||
});
|
||||
|
||||
async function getTestInfo(page: any) {
|
||||
return (await page.request.get('/test-info')).json();
|
||||
}
|
||||
|
||||
async function openEditModal(page: any) {
|
||||
await page.setExtraHTTPHeaders({ 'x-clients': '"mine"' });
|
||||
await page.goto('/pos/summaries');
|
||||
await page.waitForSelector('#entity-table tbody tr');
|
||||
// The row's edit button is an hx-get to /pos/summaries/<id> (the edit-wizard route).
|
||||
await page.locator('#entity-table tbody tr').first()
|
||||
.locator('a[hx-get], button[hx-get]').first().click();
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Sales Summary Edit (characterization)', () => {
|
||||
test('opens the edit modal with debit/credit columns, categories, accounts and amounts', async ({ page }) => {
|
||||
await openEditModal(page);
|
||||
|
||||
const modal = page.locator('#wizardmodal');
|
||||
await expect(modal).toContainText('Edit Summary');
|
||||
await expect(modal).toContainText('Debits');
|
||||
await expect(modal).toContainText('Credits');
|
||||
// seeded items
|
||||
await expect(modal).toContainText('Cash Deposit'); // debit item category
|
||||
await expect(modal).toContainText('Food Sales'); // credit item category
|
||||
// resolved account names (account-display-cell pulls the account name)
|
||||
await expect(modal).toContainText('Second Account'); // debit item account
|
||||
await expect(modal).toContainText('Test Account'); // credit item account
|
||||
// amounts render
|
||||
await expect(modal).toContainText('$500.00');
|
||||
|
||||
// two account cells, each with an inline-edit pencil
|
||||
expect(await modal.locator('.account-cell').count()).toBe(2);
|
||||
expect(await modal.locator('[hx-get*="edit/item-account"]').count()).toBe(2);
|
||||
});
|
||||
|
||||
test('seeded summary is balanced (shows Balanced totals, no out-of-balance)', async ({ page }) => {
|
||||
await openEditModal(page);
|
||||
const modal = page.locator('#wizardmodal');
|
||||
// balanced: $500 debit == $500 credit -> the (now-fixed) totals block shows Balanced
|
||||
await expect(modal.locator('#summary-totals')).toContainText('Balanced');
|
||||
await expect(modal.locator('#summary-totals')).toContainText('$500.00');
|
||||
await expect(modal).not.toContainText('Unbalanced');
|
||||
});
|
||||
|
||||
test('inline account edit: pencil opens the typeahead editor; cancel restores the display', async ({ page }) => {
|
||||
await openEditModal(page);
|
||||
const modal = page.locator('#wizardmodal');
|
||||
|
||||
// The debit row shows "Second Account" with a pencil. Click it -> account-edit-cell.
|
||||
const debitCell = modal.locator('.account-cell', { hasText: 'Second Account' }).first();
|
||||
await debitCell.locator('[hx-get*="edit/item-account"]').click();
|
||||
|
||||
// edit cell: a typeahead plus check (save) + cancel buttons
|
||||
const editCell = modal.locator('.account-cell').filter({ has: page.locator('[hx-put*="save-item-account"]') }).first();
|
||||
await expect(editCell.locator('[hx-put*="save-item-account"]')).toBeVisible();
|
||||
await expect(editCell.locator('[hx-get*="cancel-item-account"]')).toBeVisible();
|
||||
|
||||
// Cancel -> back to display mode showing the original account
|
||||
await editCell.locator('[hx-get*="cancel-item-account"]').click();
|
||||
await page.waitForTimeout(300);
|
||||
await expect(modal.locator('.account-cell', { hasText: 'Second Account' }).first()).toBeVisible();
|
||||
// back in display mode: the pencil (edit) is shown again
|
||||
await expect(modal.locator('.account-cell', { hasText: 'Second Account' })
|
||||
.first().locator('[hx-get*="edit/item-account"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('inline account edit: save (check) re-renders the account display cell', async ({ page }) => {
|
||||
await openEditModal(page);
|
||||
const modal = page.locator('#wizardmodal');
|
||||
|
||||
const creditCell = modal.locator('.account-cell', { hasText: 'Test Account' }).first();
|
||||
await creditCell.locator('[hx-get*="edit/item-account"]').click();
|
||||
|
||||
const editCell = modal.locator('.account-cell').filter({ has: page.locator('[hx-put*="save-item-account"]') }).first();
|
||||
await expect(editCell.locator('[hx-put*="save-item-account"]')).toBeVisible();
|
||||
// Save without changing -> display cell re-renders, account preserved, pencil back.
|
||||
await editCell.locator('[hx-put*="save-item-account"]').click();
|
||||
await page.waitForTimeout(300);
|
||||
const display = modal.locator('.account-cell', { hasText: 'Test Account' }).first();
|
||||
await expect(display).toBeVisible();
|
||||
await expect(display.locator('[hx-get*="edit/item-account"]')).toBeVisible();
|
||||
});
|
||||
|
||||
// The "New Summary Item" button was broken in the pre-migration wizard (its Alpine
|
||||
// handler threw "newRowIndex is not defined"); the migration fixes it as a whole-form
|
||||
// swap (op=new-item). This asserts the FIXED behavior: clicking adds an editable manual
|
||||
// row (category + account typeahead + debit/credit money inputs).
|
||||
test('New Summary Item adds an editable manual row (fixed)', async ({ page }) => {
|
||||
await openEditModal(page);
|
||||
const modal = page.locator('#wizardmodal');
|
||||
expect(await modal.locator('.manual-item-row').count()).toBe(0);
|
||||
|
||||
await modal.locator('a[hx-vals*="new-item"]').click();
|
||||
await expect(modal.locator('.manual-item-row').first()).toBeVisible();
|
||||
expect(await modal.locator('.manual-item-row').count()).toBe(1);
|
||||
|
||||
const row = modal.locator('.manual-item-row').first();
|
||||
await expect(row.locator('input[placeholder="Category/Explanation"]')).toBeVisible();
|
||||
expect(await row.locator('input[name*="[debit]"]').count()).toBe(1);
|
||||
expect(await row.locator('input[name*="[credit]"]').count()).toBe(1);
|
||||
});
|
||||
|
||||
test('a manual debit amount recomputes the totals to Unbalanced (fixed)', async ({ page }) => {
|
||||
await openEditModal(page);
|
||||
const modal = page.locator('#wizardmodal');
|
||||
await expect(modal.locator('#summary-totals')).toContainText('Balanced');
|
||||
await modal.locator('a[hx-vals*="new-item"]').click();
|
||||
await expect(modal.locator('.manual-item-row').first()).toBeVisible();
|
||||
// adding a $500 debit -> $1000 debit vs $500 credit -> the totals block recomputes
|
||||
const debit = modal.locator('.manual-item-row input[name*="[debit]"]').first();
|
||||
await debit.click();
|
||||
await debit.pressSequentially('500'); // fires keyup -> hx-trigger "keyup changed delay:300ms"
|
||||
await expect(modal.locator('#summary-totals')).toContainText('Unbalanced', { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('Save closes the modal and the summary stays in the grid', async ({ page }) => {
|
||||
await openEditModal(page);
|
||||
const modal = page.locator('#wizardmodal');
|
||||
// submit the form via the Save button; the PUT swaps the grid row + fires modalclose
|
||||
const putResp = page.waitForResponse(r =>
|
||||
r.url().includes('/pos/summaries') && r.request().method() === 'PUT');
|
||||
await modal.locator('button[type="submit"]').click();
|
||||
expect((await putResp).status()).toBe(200);
|
||||
// modalclose hides the modal (it is hidden, not removed from the DOM)
|
||||
await expect(modal).toBeHidden({ timeout: 5000 });
|
||||
// the grid still shows the summary row
|
||||
await expect(page.locator('#entity-table tbody tr')).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { test, expect } from '@playwright/test';
|
||||
// re-renders the entire form, and the client selects what to swap back -- with
|
||||
// no out-of-band swaps and no morph extension:
|
||||
// - discrete changes (vendor, account, location, mode, add/remove row) swap
|
||||
// all of #wizard-form (the active action/tab round-trips through the form,
|
||||
// all of #edit-form (the active action/tab round-trips through the form,
|
||||
// so it survives the swap);
|
||||
// - typed fields never swap the input the user is in -- the amount field swaps
|
||||
// only the #account-totals tbody (a sibling of the input rows), and the memo
|
||||
@@ -32,7 +32,7 @@ async function openManualAdvanced(page: any, transactionIndex = 0) {
|
||||
.nth(transactionIndex)
|
||||
.click();
|
||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
await page.waitForSelector('#editmodal');
|
||||
await page.click('button:has-text("Manual")');
|
||||
|
||||
// First transaction has no accounts so it opens in "simple" mode. Switch to
|
||||
@@ -107,7 +107,7 @@ test.describe('Transaction Edit whole-form swap', () => {
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// The form must survive the swap intact.
|
||||
await expect(page.locator('#wizard-form')).toHaveCount(1);
|
||||
await expect(page.locator('#edit-form')).toHaveCount(1);
|
||||
expect(errors, errors.join('\n')).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -192,7 +192,7 @@ test.describe('Transaction Edit whole-form swap', () => {
|
||||
await page.goto('/transaction2');
|
||||
await page.waitForSelector('table tbody tr');
|
||||
await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click();
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
await page.waitForSelector('#editmodal');
|
||||
|
||||
const memo = page.locator('#edit-memo');
|
||||
await memo.waitFor();
|
||||
@@ -301,7 +301,7 @@ test.describe('Transaction Edit whole-form swap', () => {
|
||||
await page.goto('/transaction2');
|
||||
await page.waitForSelector('table tbody tr');
|
||||
await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click();
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
await page.waitForSelector('#editmodal');
|
||||
await page.click('button:has-text("Manual")');
|
||||
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
|
||||
|
||||
@@ -350,7 +350,7 @@ test.describe('Transaction Edit whole-form swap', () => {
|
||||
await page.goto('/transaction2');
|
||||
await page.waitForSelector('table tbody tr');
|
||||
await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click();
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
await page.waitForSelector('#editmodal');
|
||||
await page.click('button:has-text("Manual")');
|
||||
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Reset the shared test-server dataset before each test so tests are isolated
|
||||
// from one another (and from other spec files) regardless of run order.
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.post('/test-reset');
|
||||
});
|
||||
|
||||
async function openEditModal(page: any, transactionIndex: number = 0) {
|
||||
// Navigate to transactions page
|
||||
await page.goto('/transaction2');
|
||||
@@ -13,21 +19,21 @@ async function openEditModal(page: any, transactionIndex: number = 0) {
|
||||
|
||||
// Wait for the modal to open
|
||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
await page.waitForSelector('#editmodal');
|
||||
|
||||
// The modal is now single-page (Edit Transaction). Click "Manual" tab to ensure
|
||||
// the manual account coding form is active.
|
||||
await page.click('button:has-text("Manual")');
|
||||
|
||||
// Transactions with 0-1 accounts open in "simple" mode, which has no account
|
||||
// grid. Switch to "advanced" mode (a whole-form morph swap) so the grid the
|
||||
// rest of these helpers manipulate is present.
|
||||
// grid. Switch to "advanced" mode (a whole-form swap) so the grid the rest of
|
||||
// these helpers manipulate is present.
|
||||
const advancedLink = page.locator('a:has-text("Switch to advanced mode")');
|
||||
if (await advancedLink.count()) {
|
||||
await advancedLink.first().click();
|
||||
}
|
||||
|
||||
// Wait for the manual form to appear
|
||||
// Wait for the manual form (account grid) to appear
|
||||
await page.waitForSelector('#account-grid-body');
|
||||
}
|
||||
|
||||
@@ -144,7 +150,7 @@ async function saveTransaction(page: any) {
|
||||
}
|
||||
|
||||
async function toggleToPercentMode(page: any) {
|
||||
const percentRadio = page.locator('input[name="step-params[amount-mode]"][value="%"]');
|
||||
const percentRadio = page.locator('input[name="amount-mode"][value="%"]');
|
||||
await percentRadio.click();
|
||||
|
||||
// Wait for HTMX to swap the grid body
|
||||
@@ -155,7 +161,7 @@ async function toggleToPercentMode(page: any) {
|
||||
}
|
||||
|
||||
async function toggleToDollarMode(page: any) {
|
||||
const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]');
|
||||
const dollarRadio = page.locator('input[name="amount-mode"][value="$"]');
|
||||
await dollarRadio.click();
|
||||
|
||||
// Wait for HTMX to swap the grid body
|
||||
@@ -235,7 +241,7 @@ test.describe('Transaction Edit Full Workflow', () => {
|
||||
await openEditModal(page, 0);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]');
|
||||
const dollarRadio = page.locator('input[name="amount-mode"][value="$"]');
|
||||
await expect(dollarRadio).toBeChecked();
|
||||
|
||||
const val0 = await (await findAccountRow(page, 0)).locator('.account-amount-field').inputValue();
|
||||
@@ -272,13 +278,13 @@ test.describe('Transaction Edit Validation', () => {
|
||||
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
|
||||
|
||||
// The form should still be present
|
||||
const form = page.locator('#wizard-form');
|
||||
const form = page.locator('#edit-form');
|
||||
await expect(form).toBeVisible();
|
||||
|
||||
// Verify the account row is still there with our $50 value
|
||||
const amountInput = page.locator('.account-amount-field').first();
|
||||
const value = await amountInput.inputValue();
|
||||
expect(parseFloat(value)).toBeCloseTo(50.0, 1);
|
||||
// Note: the validation-error response re-renders the manual section, and with
|
||||
// a single account that renders in "simple" mode (no advanced grid), so we
|
||||
// don't assert on the advanced-grid amount field here. The error message
|
||||
// below confirms the $50 value was received and validated.
|
||||
|
||||
// Verify the user-friendly error message is displayed
|
||||
const errorElement = page.locator('#form-errors .error-content');
|
||||
@@ -304,7 +310,12 @@ async function openEditModalForTransaction(page: any, description: string) {
|
||||
// navigation), so the action tabs -- including "Link to payment" -- are available
|
||||
// immediately; callers click the tab they need.
|
||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
await page.waitForSelector('#editmodal');
|
||||
|
||||
// The modal is single-page: the link tabs ("Link to payment", "Link to unpaid
|
||||
// invoices", ...) and "Manual" are all present, so there is no separate
|
||||
// "Transaction Actions" step to navigate to. Just wait for the tabs to render.
|
||||
await page.waitForSelector('button:has-text("Link to payment")');
|
||||
}
|
||||
|
||||
async function selectVendorFromTypeahead(page: any, vendorName: string) {
|
||||
@@ -447,7 +458,7 @@ async function openManualVendorSection(page: any, transactionIndex: number) {
|
||||
await editButton.click();
|
||||
|
||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
await page.waitForSelector('#editmodal');
|
||||
await page.click('button:has-text("Manual")');
|
||||
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
|
||||
}
|
||||
@@ -472,7 +483,7 @@ test.describe('Transaction Edit Vendor Selection', () => {
|
||||
// The server-rendered hidden input must carry the newly selected vendor id.
|
||||
const hidden = page
|
||||
.locator(
|
||||
'div[hx-vals*="vendor-changed"] input[type="hidden"][name="step-params[transaction/vendor]"]'
|
||||
'div[hx-vals*="vendor-changed"] input[type="hidden"][name="transaction/vendor"]'
|
||||
)
|
||||
.first();
|
||||
await expect(hidden).toHaveValue(vendorId.toString());
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Reset the shared test-server dataset before each test so tests are isolated
|
||||
// from one another (and from other spec files) regardless of run order.
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.post('/test-reset');
|
||||
});
|
||||
|
||||
// The SSR manual transaction import accepts the exact Yodlee positional-column
|
||||
// TSV format from the master branch. Column order (14 columns), per
|
||||
// auto-ap.import.manual/columns:
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Reset the shared test-server dataset before each test so tests are isolated
|
||||
// from one another (and from other spec files) regardless of run order.
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.post('/test-reset');
|
||||
});
|
||||
|
||||
async function navigateToTransactions(page: any, path: string = '/transaction2') {
|
||||
await page.setExtraHTTPHeaders({
|
||||
'x-clients': '"mine"'
|
||||
@@ -90,15 +96,24 @@ test.describe('Transaction Navigation - Amount Filter Persistence', () => {
|
||||
|
||||
test.describe('Transaction Navigation - Date Filter Persistence', () => {
|
||||
test('should persist date-range preset when navigating between pages', async ({ page }) => {
|
||||
// Step 1: Navigate with date-range=all (includes 2022 test data)
|
||||
// Step 1: Navigate with date-range=all (includes 2022 test data).
|
||||
// The server expands the "all" preset into a concrete start-date (~6 years
|
||||
// back) and drops the date-range key, so persistence happens via start-date.
|
||||
await navigateToTransactions(page, '/transaction2?date-range=all');
|
||||
|
||||
// Step 2: Click Unapproved nav link
|
||||
await clickTransactionNavLink(page, 'Unapproved');
|
||||
|
||||
// Step 3: Verify date-range persisted
|
||||
const unapprovedUrl = page.url();
|
||||
expect(unapprovedUrl).toContain('date-range=all');
|
||||
// Step 3: Verify the expanded date range persisted as a start-date.
|
||||
// "all" resolves to roughly 6 years before today (MM/DD/YYYY).
|
||||
const sixYearsAgo = new Date();
|
||||
sixYearsAgo.setFullYear(sixYearsAgo.getFullYear() - 6);
|
||||
const mm = String(sixYearsAgo.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(sixYearsAgo.getDate()).padStart(2, '0');
|
||||
const expectedStart = `${mm}/${dd}/${sixYearsAgo.getFullYear()}`;
|
||||
|
||||
const startDate = new URL(page.url()).searchParams.get('start-date');
|
||||
expect(startDate).toBe(expectedStart);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,10 +8,13 @@ const useExternalServer = !!process.env.BASE_URL;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
// These tests share a single stateful test server with one fixed dataset and
|
||||
// mutate the same transactions (coding, bulk coding, etc.), so they must run
|
||||
// serially. Running them in parallel causes cross-test races and flakes.
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
workers: 1,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL,
|
||||
|
||||
File diff suppressed because one or more lines are too long
1
resources/templates/components/a-button.html
Normal file
1
resources/templates/components/a-button.html
Normal file
@@ -0,0 +1 @@
|
||||
<a class="{{ classes }}"{{ attrs|safe }}>{% if indicator %}<div class="htmx-indicator flex items-center">{% include "templates/components/spinner.html" %}<div class="ml-3">Loading...</div></div>{% endif %}<div class="inline-flex gap-2 items-center justify-center{% if indicator %} htmx-indicator-hidden{% endif %}">{{ body|safe }}</div></a>
|
||||
1
resources/templates/components/a-icon-button.html
Normal file
1
resources/templates/components/a-icon-button.html
Normal file
@@ -0,0 +1 @@
|
||||
<a class="{{ classes }}"{{ attrs|safe }}><div class="h-4 w-4">{{ body|safe }}</div></a>
|
||||
1
resources/templates/components/badge.html
Normal file
1
resources/templates/components/badge.html
Normal file
@@ -0,0 +1 @@
|
||||
<div class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}</div>
|
||||
1
resources/templates/components/button-group-button.html
Normal file
1
resources/templates/components/button-group-button.html
Normal file
@@ -0,0 +1 @@
|
||||
<button class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}</button>
|
||||
1
resources/templates/components/button-group.html
Normal file
1
resources/templates/components/button-group.html
Normal file
@@ -0,0 +1 @@
|
||||
<div class="inline-flex rounded-md shadow-sm" role="group" hx-on:click="this.querySelector("input").value = event.target.value; this.querySelector("input").dispatchEvent(new Event('change', {bubbles: true}));"><input type="hidden" name="{{ name }}">{{ body|safe }}</div>
|
||||
1
resources/templates/components/button.html
Normal file
1
resources/templates/components/button.html
Normal file
@@ -0,0 +1 @@
|
||||
<button class="{{ classes }}"{{ attrs|safe }}><div class="htmx-indicator flex items-center absolute inset-0 justify-center">{% include "templates/components/spinner.html" %}{% if loading_label %}<div class="ml-3">Loading...</div>{% endif %}</div><div class="htmx-indicator-invisible inline-flex gap-2 items-center justify-center">{{ body|safe }}</div></button>
|
||||
1
resources/templates/components/data-grid-cell.html
Normal file
1
resources/templates/components/data-grid-cell.html
Normal file
@@ -0,0 +1 @@
|
||||
<td class="px-4 py-2{% if klass %} {{ klass }}{% endif %}"{{ attrs|safe }}>{{ body|safe }}</td>
|
||||
1
resources/templates/components/data-grid-header.html
Normal file
1
resources/templates/components/data-grid-header.html
Normal file
@@ -0,0 +1 @@
|
||||
<th class="px-4 py-3{% if klass %} {{ klass }}{% endif %}" scope="col" @click="{{ click|safe }}"{{ attrs|safe }}>{% if sort_key %}<a href="#">{{ body|safe }}</a>{% else %}{{ body|safe }}{% endif %}</th>
|
||||
1
resources/templates/components/data-grid-row.html
Normal file
1
resources/templates/components/data-grid-row.html
Normal file
@@ -0,0 +1 @@
|
||||
<tr class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}</tr>
|
||||
1
resources/templates/components/data-grid.html
Normal file
1
resources/templates/components/data-grid.html
Normal file
@@ -0,0 +1 @@
|
||||
<div class="shrink overflow-y-scroll"><table class="{{ table_class }}"{{ table_attrs|safe }}><thead class="{{ thead_class }}"><tr>{{ headers|safe }}</tr></thead><tbody>{{ rows|safe }}</tbody>{{ footer_tbody|safe }}</table></div>
|
||||
3
resources/templates/components/hidden.html
Normal file
3
resources/templates/components/hidden.html
Normal file
@@ -0,0 +1,3 @@
|
||||
{# Hidden input. The Clojure wrapper (sc/hidden) serializes the full attribute map
|
||||
(name, value, optional id/form/class/Alpine :value bind) into `attrs`. #}
|
||||
<input type="hidden"{{ attrs|safe }}>
|
||||
1
resources/templates/components/link.html
Normal file
1
resources/templates/components/link.html
Normal file
@@ -0,0 +1 @@
|
||||
<a class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}</a>
|
||||
1
resources/templates/components/modal.html
Normal file
1
resources/templates/components/modal.html
Normal file
@@ -0,0 +1 @@
|
||||
<div class="{{ classes }}" @click.outside="open=false"{{ attrs|safe }}>{{ body|safe }}</div>
|
||||
2
resources/templates/components/money-input.html
Normal file
2
resources/templates/components/money-input.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{# Money input (number, step=0.01, right-aligned). sc/money-input builds `attrs`. #}
|
||||
<input{{ attrs|safe }}>
|
||||
1
resources/templates/components/radio-card.html
Normal file
1
resources/templates/components/radio-card.html
Normal file
@@ -0,0 +1 @@
|
||||
<ul class="{{ ul_class }}">{% for opt in options %}<li class="{{ li_class }}"><div class="{{ div_class }}"><input id="{{ opt.id }}" type="radio" value="{{ opt.value }}" name="{{ name }}" class="{{ input_class }}"{{ input_attrs|safe }}{% if opt.checked %} checked{% endif %}><label for="{{ opt.id }}" class="{{ label_class }}">{{ opt.content|safe }}</label></div></li>{% endfor %}</ul>
|
||||
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>
|
||||
1
resources/templates/components/spinner.html
Normal file
1
resources/templates/components/spinner.html
Normal file
@@ -0,0 +1 @@
|
||||
<svg aria-hidden="true" class="animate-spin inline w-4 h-4 text-white" fill="none" role="status" viewbox="0 0 100 101" xmlns="http://www.w3.org/2000/svg"><path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="#E5E7EB"></path><path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentColor"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
resources/templates/components/svg-drop-down.html
Normal file
1
resources/templates/components/svg-drop-down.html
Normal file
@@ -0,0 +1 @@
|
||||
<svg aria-hidden="true" fill="none" stroke="currentColor" viewbox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19 9l-7 7-7-7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path></svg>
|
||||
|
After Width: | Height: | Size: 216 B |
1
resources/templates/components/svg-external-link.html
Normal file
1
resources/templates/components/svg-external-link.html
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><defs></defs><title>navigation-next</title><path d="M23,9.5H12.387a4,4,0,0,0-4,4v2" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor"></path><polyline fill="none" points="19 13.498 23 9.498 19 5.498" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor"></polyline><path d="M14.387,13v5.5a1,1,0,0,1-1,1h-12a1,1,0,0,1-1-1V6.5a1,1,0,0,1,1-1h12a1,1,0,0,1,1,1V7" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor"></path></svg>
|
||||
|
After Width: | Height: | Size: 560 B |
1
resources/templates/components/svg-x.html
Normal file
1
resources/templates/components/svg-x.html
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewbox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><defs></defs><title>delete-2</title><circle cx="12" cy="12" fill="none" r="11.5" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor"></circle><line fill="none" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor" x1="7" x2="17" y1="7" y2="17"></line><line fill="none" stroke-linecap="round" stroke-linejoin="round" stroke="currentColor" x1="17" x2="7" y1="7" y2="17"></line></svg>
|
||||
|
After Width: | Height: | Size: 474 B |
3
resources/templates/components/text-input.html
Normal file
3
resources/templates/components/text-input.html
Normal file
@@ -0,0 +1,3 @@
|
||||
{# Text input. sc/text-input builds the full attr map (type/autocomplete/class+size
|
||||
already merged, reusing inputs/default-input-classes + hh/add-class) into `attrs`. #}
|
||||
<input{{ attrs|safe }}>
|
||||
4
resources/templates/components/typeahead.html
Normal file
4
resources/templates/components/typeahead.html
Normal file
@@ -0,0 +1,4 @@
|
||||
{# Click-to-select typeahead (Alpine + tippy). Survives whole-form swaps; null-guarded
|
||||
tippy?. / $refs.input? throughout. The Clojure wrapper (sc/typeahead) resolves the
|
||||
initial {value,label} server-side and builds x_data + the hidden-input attrs. #}
|
||||
<div class="relative" x-data="{{ x_data }}" x-modelable="value.value"{% if x_model %} x-model="{{ x_model }}"{% endif %}{% if key %} key="{{ key }}"{% endif %}>{% if disabled %}<span x-text="value.label"></span>{% else %}<a class="{{ a_class }}" x-tooltip.on.click="{content: ()=>($refs.dropdown?.innerHTML ?? ''), placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}" @keydown.down.prevent.stop="tippy?.show();" @keydown.backspace="tippy?.hide(); value = {value: '', label: '' }" tabindex="0" x-init="{{ a_xinit }}" x-ref="input"><input{{ hidden_attrs|safe }}><div class="flex w-full justify-items-stretch"><span class="flex-grow text-left" x-text="value.label"></span><div class="w-3 h-3 m-1 inline ml-1 justify-self-end text-gray-500 self-center">{% include "templates/components/svg-drop-down.html" %}</div><div x-show="value.warning"><div class="peer absolute inline-flex items-center z-10 justify-center w-6 h-6 text-xs font-black text-white border-3 border-white rounded-full -top-2 -right-2 dark:border-gray-900 bg-red-300" x-tooltip="value.warning">!</div></div></div></a>{% endif %}<template x-ref="dropdown"><ul class="dropdown-contents bg-gray-100 dark:bg-gray-600 ring-1" @keydown.escape="$refs.input?.__x_tippy?.hide(); value = {value: '', label: '' }; " x-destroy="if ($refs.input) {$refs.input.focus();}"><input type="text" autofocus class="{{ search_class }}" x-model="search" placeholder="{{ placeholder }}" @change.stop="" @keydown.down.prevent="active ++; active = active >= elements.length - 1 ? elements.length - 1 : active" @keydown.up.prevent="active --; active = active < 0 ? 0 : active" @keydown.enter.prevent.stop="$refs.input?.__x_tippy?.hide(); value = elements.length > 0 ? $data.elements[active >= 0 ? active : 0] : {'value': '', label: ''}; $refs.input?.focus()" x-init="$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; $refs.input?.__x_tippy?.popperInstance?.update()}) }})"><div class="dropdown-options rounded-b-lg overflow-hidden"><template x-for="(element, index) in elements"><li><a class="px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 [&.active]:dark:bg-primary-700 text-gray-800 dark:text-gray-100" href="#" :class="active == index ? 'active' : ''" @mouseover="active = index" @mouseout="active = -1" @click.prevent="value = element; $refs.input?.__x_tippy?.hide(); setTimeout(() => $refs.input?.focus(), 10)" x-html="element.label"></a></li></template><template x-if="elements.length == 0"><li class="px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs ">No results found</li></template></div></ul></template></div>
|
||||
6
resources/templates/components/validated-field.html
Normal file
6
resources/templates/components/validated-field.html
Normal file
@@ -0,0 +1,6 @@
|
||||
{# Field wrapper with label + always-present error <p> (the errors- variant of field-).
|
||||
`classes` already folds group / has-error / caller class via hh/add-class; `attrs`
|
||||
carries any pass-through div attributes (the per-row location cell hangs its hx-* /
|
||||
x-dispatch swap wiring here); `body` is the pre-rendered inner control HTML;
|
||||
`errors_str` is the comma-joined string errors (empty when none). #}
|
||||
<div class="{{ classes }}"{{ attrs|safe }}>{% if label %}<label class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">{{ label }}</label>{% endif %}{{ body|safe }}<p class="mt-2 text-xs text-red-600 dark:text-red-500 h-4">{{ errors_str }}</p></div>
|
||||
4
resources/templates/invoice-bulk-edit/edit-form.html
Normal file
4
resources/templates/invoice-bulk-edit/edit-form.html
Normal file
@@ -0,0 +1,4 @@
|
||||
{# Top-level plain bulk-edit form (no wizard). The resolved (not-locked) invoice id set
|
||||
rides in hidden ids[] fields so the selection survives form-changed / submit posts
|
||||
without an EDN snapshot or a filter round-trip. #}
|
||||
<form id="bulk-edit-form"{{ form_attrs|safe }}>{{ ids_hidden|safe }}{{ modal|safe }}</form>
|
||||
@@ -0,0 +1,5 @@
|
||||
{# Running TOTAL / BALANCE percentage rows in their own swappable <tbody>, a sibling of
|
||||
the input rows, so a percentage edit refreshes them with a targeted swap (Rule 4) and
|
||||
never replaces the input-bearing rows above. Replaces the old per-cell bulk-edit-total
|
||||
/ bulk-edit-balance routes. #}
|
||||
<tbody id="expense-totals">{{ rows|safe }}</tbody>
|
||||
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>
|
||||
@@ -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>
|
||||
3
resources/templates/transaction-edit/account-totals.html
Normal file
3
resources/templates/transaction-edit/account-totals.html
Normal file
@@ -0,0 +1,3 @@
|
||||
{# Totals live in their own swappable <tbody> so an amount edit refreshes them with a
|
||||
targeted swap, never replacing the input-bearing rows above (caret survives). #}
|
||||
<tbody id="account-totals">{{ rows|safe }}</tbody>
|
||||
@@ -0,0 +1 @@
|
||||
<div x-data="{{ x_data }}">{{ status_hidden|safe }}<div class="inline-flex rounded-md shadow-sm" role="group">{{ buttons|safe }}</div></div>
|
||||
2
resources/templates/transaction-edit/details-panel.html
Normal file
2
resources/templates/transaction-edit/details-panel.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{# Read-only transaction summary shown in the modal's left side panel. #}
|
||||
<div class="p-4 space-y-4"><h3 class="text-sm font-semibold text-gray-900 uppercase tracking-wider">Details</h3><div class="space-y-3"><div><div class="text-xs font-medium text-gray-500">Amount</div><div class="text-sm font-medium text-gray-900">{{ amount }}</div></div><div><div class="text-xs font-medium text-gray-500">Date</div><div class="text-sm text-gray-900">{{ date }}</div></div><div><div class="text-xs font-medium text-gray-500">Bank Account</div><div class="text-sm text-gray-900">{{ bank_account }}</div></div><div><div class="text-xs font-medium text-gray-500">Post Date</div><div class="text-sm text-gray-900">{{ post_date }}</div></div><div><div class="text-xs font-medium text-gray-500">Description</div><div class="text-sm text-gray-900 truncate cursor-help" title="{{ description_original }}">{{ description_simple }}</div></div><div><div class="text-xs font-medium text-gray-500">Check Number</div><div class="text-sm text-gray-900">{{ check_number }}</div></div><div><div class="text-xs font-medium text-gray-500">Status</div><div class="text-sm text-gray-900">{{ status }}</div></div><div><div class="text-xs font-medium text-gray-500">Transaction Type</div><div class="text-sm text-gray-900">{{ type }}</div></div></div></div>
|
||||
4
resources/templates/transaction-edit/edit-form.html
Normal file
4
resources/templates/transaction-edit/edit-form.html
Normal file
@@ -0,0 +1,4 @@
|
||||
{# Top-level plain form. The entity id rides in a hidden field; all other state is the
|
||||
live form, re-derived against the entity each request (no serialized snapshot, no
|
||||
wizard step-params). #}
|
||||
<form id="edit-form"{{ form_attrs|safe }}><input type="hidden" name="db/id" value="{{ db_id }}">{{ modal|safe }}</form>
|
||||
4
resources/templates/transaction-edit/edit-modal.html
Normal file
4
resources/templates/transaction-edit/edit-modal.html
Normal file
@@ -0,0 +1,4 @@
|
||||
{# Modal card chrome (header / optional side panel / body / footer). Single-step, so
|
||||
no timeline, no back/next nav -- just the Done button in the footer. Enter triggers
|
||||
the save button via $refs.next. #}
|
||||
<div class="modal-card bg-white rounded-lg shadow dark:bg-gray-700 dark:text-white modal-content flex flex-col max-h-screen max-w-screen md:w-[950px] md:h-[650px] w-full h-full last-modal-step transition duration-150" @keydown.enter.prevent.stop="if ($refs.next) {$refs.next.click()}" x-data=""><div class="flex items-start justify-between p-4 border-b rounded-t dark:border-gray-600 shrink-0">{{ head|safe }}</div><div class="flex shrink overflow-auto grow">{% if side_panel %}<div class="grow-0 w-64 bg-gray-50 border-r hidden md:block overflow-y-auto max-h-full">{{ side_panel|safe }}</div>{% endif %}<div class="px-6 py-2 space-y-6 overflow-y-scroll w-full shrink grow">{{ body|safe }}</div></div><div class="p-4 border-t">{{ footer|safe }}</div></div>
|
||||
1
resources/templates/transaction-edit/invoice-option.html
Normal file
1
resources/templates/transaction-edit/invoice-option.html
Normal file
@@ -0,0 +1 @@
|
||||
<div class="ml-3"><span class="block text-sm font-medium">{{ number }}</span><span class="block text-sm text-gray-500">{{ vendor }}</span><span class="block text-sm text-gray-500">{{ date }}</span><span class="block text-sm font-medium">{{ amount }}</span></div>
|
||||
1
resources/templates/transaction-edit/linked-payment.html
Normal file
1
resources/templates/transaction-edit/linked-payment.html
Normal file
@@ -0,0 +1 @@
|
||||
<div class="my-4 p-4 bg-blue-50 rounded"><h3 class="text-lg font-bold mb-2">Linked Payment{{ external_link|safe }}</h3><div class="space-y-2"><div class="flex justify-between"><div class="font-medium">Payment #</div><div>{{ number }}</div></div><div class="flex justify-between"><div class="font-medium">Vendor</div><div>{{ vendor }}</div></div><div class="flex justify-between"><div class="font-medium">Amount</div><div>{{ amount }}</div></div><div class="flex justify-between"><div class="font-medium">Status</div><div>{{ status }}</div></div><div class="flex justify-between"><div class="font-medium">Date</div><div>{{ date }}</div></div>{{ payment_id_hidden|safe }}<div class="mt-4"{{ unlink_attrs|safe }}>{{ unlink_button|safe }}</div></div></div>
|
||||
3
resources/templates/transaction-edit/links-body.html
Normal file
3
resources/templates/transaction-edit/links-body.html
Normal file
@@ -0,0 +1,3 @@
|
||||
{# The single step's body: memo + the activeForm tab switcher (link payment / unpaid /
|
||||
autopay / rule / manual) + the five x-show panels. Fragments are pre-rendered. #}
|
||||
<div class="space-y-1"><div>{{ memo_field|safe }}<div x-data="{{ x_data }}" @unlinked="canChange=true"><div class="flex space-x-2 mb-4">{{ action_hidden|safe }}{{ tabs|safe }}</div><div x-show="activeForm === 'link-payment'" x-transition:enter="transition ease-out duration-500" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100">{{ panel_payment|safe }}</div><div x-show="activeForm === 'link-unpaid-invoices'" x-transition:enter="transition ease-out duration-500" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100">{{ panel_unpaid|safe }}</div><div x-show="activeForm === 'link-autopay-invoices'" x-transition:enter="transition ease-out duration-500" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100">{{ panel_autopay|safe }}</div><div x-show="activeForm === 'apply-rule'" x-transition:enter="transition ease-out duration-500" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100">{{ panel_rule|safe }}</div><div x-show="activeForm === 'manual'" x-transition:enter="transition ease-out duration-500" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100"><div>{{ panel_manual|safe }}</div></div></div></div></div>
|
||||
3
resources/templates/transaction-edit/manual-coding.html
Normal file
3
resources/templates/transaction-edit/manual-coding.html
Normal file
@@ -0,0 +1,3 @@
|
||||
{# Vendor field (a change repopulates the default account via a whole-form swap) + either
|
||||
the simple single-row coding or the advanced account grid. #}
|
||||
<div id="manual-coding-section">{{ mode_hidden|safe }}<div{{ vendor_changed_attrs|safe }}>{{ vendor_field|safe }}</div>{% if is_simple %}<div x-data="{{ simple_xdata }}">{{ simple_mode|safe }}</div>{% else %}<div>{{ toggle_link|safe }}{{ accounts_field|safe }}</div>{% endif %}</div>
|
||||
1
resources/templates/transaction-edit/panel-empty.html
Normal file
1
resources/templates/transaction-edit/panel-empty.html
Normal file
@@ -0,0 +1 @@
|
||||
<div class="text-center py-4 text-gray-500">{{ message }}</div>
|
||||
3
resources/templates/transaction-edit/panel-list.html
Normal file
3
resources/templates/transaction-edit/panel-list.html
Normal file
@@ -0,0 +1,3 @@
|
||||
{# Shared shell for the autopay/unpaid/rule match panels: heading + action hidden +
|
||||
prompt label + a radio-card of options. #}
|
||||
<div><h3 class="text-lg font-bold mb-4">{{ heading }}</h3>{{ action_hidden|safe }}<div class="space-y-2"><label class="block text-sm font-medium mb-1">{{ prompt }}</label>{{ radio|safe }}</div></div>
|
||||
@@ -0,0 +1,2 @@
|
||||
{# Outer wrapper for the payment tab; the unlink-payment route swaps #payment-matches. #}
|
||||
<div id="payment-matches">{{ inner|safe }}</div>
|
||||
1
resources/templates/transaction-edit/rule-option.html
Normal file
1
resources/templates/transaction-edit/rule-option.html
Normal file
@@ -0,0 +1 @@
|
||||
<div class="ml-3"><span class="block text-sm font-medium">{{ note }}</span><span class="block text-sm text-gray-500">{{ description }}</span></div>
|
||||
4
resources/templates/transaction-edit/simple-mode.html
Normal file
4
resources/templates/transaction-edit/simple-mode.html
Normal file
@@ -0,0 +1,4 @@
|
||||
{# Simple mode: a single account row (account typeahead + location select) rendered at a
|
||||
fixed index 0, plus the link to switch to the advanced grid. Selecting the account
|
||||
swaps just the location cell (#simple-account-location). #}
|
||||
<div><span>{{ row_id_hidden|safe }}<div class="flex gap-2 mt-2">{{ account_field|safe }}<div id="simple-account-location">{{ location_field|safe }}</div>{{ amount_hidden|safe }}</div></span><div class="mt-1"><a class="text-sm text-blue-600 hover:underline cursor-pointer"{{ toggle_attrs|safe }}>Switch to advanced mode</a></div></div>
|
||||
3
resources/templates/transaction-edit/transitioner.html
Normal file
3
resources/templates/transaction-edit/transitioner.html
Normal file
@@ -0,0 +1,3 @@
|
||||
{# Wrapper the modal stack expects around the opened form (the wizard transition hooks
|
||||
are gone -- there is only one step). #}
|
||||
<div id="transitioner" class="flex-1">{{ body|safe }}</div>
|
||||
@@ -656,13 +656,7 @@
|
||||
linear-wizard this
|
||||
:head "Transaction rule"
|
||||
:body (mm/default-step-body {}
|
||||
[:form#my-form {:hx-ext "response-targets"
|
||||
:hx-target-400 "#form-errors .error-content"
|
||||
:hx-indicator "#submit"
|
||||
:x-trap "true"
|
||||
(if (:db/id (fc/field-value))
|
||||
:hx-put
|
||||
:hx-post) (str (bidi/path-for ssr-routes/only-routes ::route/save))}
|
||||
[:div#my-form {:x-trap "true"}
|
||||
[:fieldset {:class "hx-disable"
|
||||
:x-data (hx/json {:clientId (or (:db/id (:transaction-rule/client (fc/field-value)))
|
||||
(:transaction-rule/client (fc/field-value)))})}
|
||||
@@ -728,25 +722,26 @@
|
||||
:class "w-24"
|
||||
:placeholder "NTG"
|
||||
:value (fc/field-value)})]))
|
||||
(fc/with-field :transaction-rule/bank-account
|
||||
(com/validated-field
|
||||
(-> {:label "Bank Account"
|
||||
:errors (fc/field-errors)
|
||||
:x-show "bankAccountFilter"}
|
||||
hx/alpine-appear)
|
||||
[:div.w-96
|
||||
[:div#bank-account-changer {:hx-get (bidi/path-for ssr-routes/only-routes :bank-account-typeahead)
|
||||
:hx-trigger "changed"
|
||||
:hx-target "next *"
|
||||
:hx-include "#bank-account-changer"
|
||||
:hx-swap "innerHTML"
|
||||
(let [rule-client (fc/field-value (:transaction-rule/client fc/*current*))]
|
||||
(fc/with-field :transaction-rule/bank-account
|
||||
(com/validated-field
|
||||
(-> {:label "Bank Account"
|
||||
:errors (fc/field-errors)
|
||||
:x-show "bankAccountFilter"}
|
||||
hx/alpine-appear)
|
||||
[:div.w-96
|
||||
[:div#bank-account-changer {:hx-get (bidi/path-for ssr-routes/only-routes :bank-account-typeahead)
|
||||
:hx-trigger "changed"
|
||||
:hx-target "next *"
|
||||
:hx-include "#bank-account-changer"
|
||||
:hx-swap "outerHTML"
|
||||
|
||||
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId}" (fc/field-name))
|
||||
:x-init "$watch('clientId', cid => $dispatch('changed', $data))"}]
|
||||
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId}" (fc/field-name))
|
||||
:x-init "$watch('clientId', cid => $dispatch('changed', $data))"}]
|
||||
|
||||
(bank-account-typeahead* {:client-id (:transaction-rule/client (fc/field-value))
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})]))
|
||||
(bank-account-typeahead* {:client-id (or (:db/id rule-client) rule-client)
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})])))
|
||||
|
||||
(com/field (-> {:label "Amount"
|
||||
:x-show "amountFilter"}
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
[:link {:rel "stylesheet" :href "/output.css"}]
|
||||
[:script {:defer true :src "https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"}]
|
||||
[:style
|
||||
"body{background:linear-gradient(160deg,#79b52e 0%,#009cea 100%);min-height:100vh}"]]
|
||||
"body{background:linear-gradient(160deg,#79b52e 0%,#009cea 100%);min-height:100vh}@keyframes slideUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}"]]
|
||||
[:body contents]]))})
|
||||
|
||||
(defn- page-contents [request]
|
||||
@@ -72,7 +72,7 @@
|
||||
[:div.flex-shrink-0.w-5.h-5.text-red-500 svg/alert]
|
||||
[:div.flex-1.min-w-0
|
||||
[:p.text-sm.font-medium.text-gray-900 "Something went wrong"]
|
||||
[:p.text-xs.text-gray-500.mt-0.5
|
||||
[:div.text-xs.text-gray-500.mt-0.5
|
||||
"Our team has been notified. Please try again."
|
||||
[:span {:x-data (hx/json {"e" false})}
|
||||
" "
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
(com/data-grid-header {} "Synced count")
|
||||
(com/data-grid-header {} "Approved transactions")
|
||||
(com/data-grid-header {} "Unapproved transactions")
|
||||
(com/data-grid-header {} "Requires feedback transactions")
|
||||
(com/data-grid-header {} "Client Review transactions")
|
||||
(com/data-grid-header {} "Missing transactions")])
|
||||
#_#_:thead-params {:class "sticky top-0 z-50"}}
|
||||
(for [row report]
|
||||
@@ -84,18 +84,18 @@
|
||||
(com/validated-field {:label "Start"
|
||||
:errors (fc/field-errors)}
|
||||
[:div {:class "w-64"}
|
||||
(com/date-input {:name (fc/field-name)
|
||||
(com/date-input {:name (fc/field-name)
|
||||
:class "w-64"
|
||||
:value (some-> (fc/field-value)
|
||||
(atime/unparse-local atime/normal-date))})]))
|
||||
:value (some-> (fc/field-value)
|
||||
(atime/unparse-local atime/normal-date))})]))
|
||||
(fc/with-field :end-date
|
||||
(com/validated-field {:label "End"
|
||||
:errors (fc/field-errors)}
|
||||
[:div {:class "w-64"}
|
||||
(com/date-input {:name (fc/field-name)
|
||||
(com/date-input {:name (fc/field-name)
|
||||
:class "w-64"
|
||||
:value (some-> (fc/field-value)
|
||||
(atime/unparse-local atime/normal-date))})]))
|
||||
:value (some-> (fc/field-value)
|
||||
(atime/unparse-local atime/normal-date))})]))
|
||||
(com/button {:color :primary :class "self-center w-24"} "Run")])]
|
||||
(if report
|
||||
(report* {:request request :report report})
|
||||
@@ -104,15 +104,15 @@
|
||||
(defn page [request]
|
||||
(base-page
|
||||
request
|
||||
(com/page {:nav com/company-aside-nav
|
||||
(com/page {:nav com/company-aside-nav
|
||||
:client-selection (:client-selection request)
|
||||
:client (:client request)
|
||||
:clients (:clients request)
|
||||
:identity (:identity request)
|
||||
:app-params {:hx-get (bidi/path-for ssr-routes/only-routes :company-reconciliation-report)
|
||||
:identity (:identity request)
|
||||
:app-params {:hx-get (bidi/path-for ssr-routes/only-routes :company-reconciliation-report)
|
||||
:hx-trigger "clientSelected from:body"
|
||||
:hx-select "#app-contents"
|
||||
:hx-swap "outerHTML swap:300ms"}}
|
||||
:hx-swap "outerHTML swap:300ms"}}
|
||||
(com/breadcrumbs {}
|
||||
[:a {:href (bidi/path-for ssr-routes/only-routes :company)}
|
||||
"My Company"]
|
||||
@@ -133,7 +133,7 @@
|
||||
|
||||
(defn get-report-data [start-date end-date client-ids]
|
||||
(let [client-codes (map first (dc/q '[:find ?cc :in $ [?c ...] :where [?c :client/code ?cc]] (dc/db conn) client-ids))]
|
||||
(for [[ib ba c] (seq (apply get-intuit-bank-accounts (dc/db conn) client-codes))
|
||||
(for [[ib ba c] (seq (apply get-intuit-bank-accounts (dc/db conn) client-codes))
|
||||
:let [raw-transactions (get-transactions (atime/unparse-local start-date atime/iso-date)
|
||||
(atime/unparse-local end-date atime/iso-date)
|
||||
ib)
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
(dropdown-search-results* {:options (get-clients identity (get (:query-params request) "search-text"))})))
|
||||
|
||||
(defn dropdown [{:keys [client-selection client identity clients]}]
|
||||
[:div#company-dropdown {:x-data (hx/json {})}
|
||||
[:div#company-dropdown {:x-data (hx/json {}) :class "shrink-0"}
|
||||
[:script
|
||||
(hiccup/raw
|
||||
"localStorage.setItem(\"last-client-id\", \"" (:db/id client) "\")" "\n"
|
||||
@@ -93,22 +93,23 @@
|
||||
:else
|
||||
client-selection) ")")]
|
||||
[:div
|
||||
[:button#company-dropdown-button {:class "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2.5 text-center inline-flex items-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
[:button#company-dropdown-button {:class "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2.5 text-center inline-flex items-center whitespace-nowrap dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
|
||||
"x-tooltip.on.click" "{content: ()=>$refs.tooltip.innerHTML, theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}"
|
||||
:type "button"}
|
||||
(cond
|
||||
(= :mine client-selection)
|
||||
"My Companies"
|
||||
(= :all client-selection)
|
||||
"All Companies"
|
||||
[:span {:class "truncate max-w-[10rem] sm:max-w-[14rem]"}
|
||||
(cond
|
||||
(= :mine client-selection)
|
||||
"My Companies"
|
||||
(= :all client-selection)
|
||||
"All Companies"
|
||||
|
||||
(and client
|
||||
(= 1 (count clients)))
|
||||
(:client/name client)
|
||||
(and client
|
||||
(= 1 (count clients)))
|
||||
(:client/name client)
|
||||
|
||||
:else
|
||||
(str (count clients) " Companies"))
|
||||
[:div.w-4.h-4.ml-2
|
||||
:else
|
||||
(str (count clients) " Companies"))]
|
||||
[:div.w-4.h-4.ml-2.shrink-0
|
||||
svg/drop-down]]
|
||||
[:template#company-dropdown-list {:x-ref "tooltip"}
|
||||
[:div {:class "w-[300px]"
|
||||
|
||||
@@ -138,6 +138,33 @@
|
||||
[:div.space-y-1 {}
|
||||
children])
|
||||
|
||||
(defn flatten-form-errors
|
||||
"Walks a malli-humanized error structure and returns a flat sequence of
|
||||
human-readable strings, prefixing each leaf message with the nearest
|
||||
field name for context. Lets the footer's error bar surface every
|
||||
validation error for the whole form, even ones whose field lives on a
|
||||
hidden step/tab and so would otherwise be invisible."
|
||||
([errors] (flatten-form-errors nil errors))
|
||||
([field errors]
|
||||
(let [label (cond (keyword? field) (name field)
|
||||
(string? field) field
|
||||
:else nil)
|
||||
decorate (fn [msg] (if label (str label ": " msg) msg))]
|
||||
(cond
|
||||
(map? errors)
|
||||
(mapcat (fn [[k v]] (flatten-form-errors k v)) errors)
|
||||
|
||||
(and (sequential? errors) (every? string? errors))
|
||||
(map decorate errors)
|
||||
|
||||
(sequential? errors)
|
||||
(mapcat #(flatten-form-errors field %) errors)
|
||||
|
||||
(string? errors)
|
||||
[(decorate errors)]
|
||||
|
||||
:else nil))))
|
||||
|
||||
(defn default-step-footer [linear-wizard step & {:keys [validation-route
|
||||
discard-button
|
||||
next-button
|
||||
@@ -146,7 +173,8 @@
|
||||
[:div.flex.items-baseline.gap-x-4
|
||||
(let [step-errors (:step-params fc/*form-errors*)]
|
||||
(com/form-errors {:errors (or (:errors step-errors)
|
||||
(when (sequential? step-errors) step-errors))}))
|
||||
(when (sequential? step-errors) step-errors)
|
||||
(seq (distinct (flatten-form-errors step-errors))))}))
|
||||
(when (not= (first (steps linear-wizard))
|
||||
(step-key step))
|
||||
(when validation-route
|
||||
|
||||
@@ -7,32 +7,42 @@
|
||||
[auto-ap.ssr.components.buttons :refer [icon-button-]]
|
||||
[auto-ap.ssr.components.user-dropdown :as user-dropdown]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[bidi.bidi :as bidi]))
|
||||
[bidi.bidi :as bidi]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(defn navbar- [{:keys [client-selection client identity clients dd-env]}]
|
||||
[:nav {:class "fixed z-30 w-full bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700"}
|
||||
[:div {:class "px-3 py-3 lg:px-5 lg:pl-3"}
|
||||
[:div {:class "flex items-center justify-between"}
|
||||
[:div {:class "flex items-center justify-start"}
|
||||
[:button {:aria-controls "left-nav", :id "left-nav-toggle" :type "button", :class "inline-flex items-center p-2 mt-2 ml-2 mr-2 text-sm text-gray-500 rounded-lg hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
|
||||
[:div {:class "px-3 lg:px-5 lg:pl-3 h-16 flex items-center"}
|
||||
[:div {:class "flex items-center w-full"}
|
||||
;; Left cluster: sidebar toggle, logo, environment badge. Holds its size.
|
||||
[:div {:class "flex items-center shrink-0"}
|
||||
[:button {:aria-controls "left-nav", :id "left-nav-toggle" :type "button", :class "inline-flex items-center p-2 mr-2 text-sm text-gray-500 rounded-lg hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
|
||||
"@click" "leftNavShow = !leftNavShow"}
|
||||
[:span {:class "sr-only"} "Open sidebar"]
|
||||
[:svg {:class "w-6 h-6", :aria-hidden "true", :fill "currentColor", :viewbox "0 0 20 20", :xmlns "http://www.w3.org/2000/svg"}
|
||||
[:path {:clip-rule "evenodd", :fill-rule "evenodd", :d "M2 4.75A.75.75 0 012.75 4h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 4.75zm0 10.5a.75.75 0 01.75-.75h7.5a.75.75 0 010 1.5h-7.5a.75.75 0 01-.75-.75zM2 10a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 10z"}]]]
|
||||
[:a {:href (bidi/path-for ssr-routes/only-routes ::dashboard/page) :class "flex ml-2 hidden md:mr-24 sm:inline"}
|
||||
[:img {:src "/img/logo-big2.png", :class "h-10", :alt "Integreat logo"}]]
|
||||
(when-not (= "prod" dd-env) [:div.rounded-full.bg-yellow-200.text-lg.text-yellow-800.px-4.hidden.md:block.mr-8 "environment: " dd-env])]
|
||||
[:a {:href (bidi/path-for ssr-routes/only-routes ::dashboard/page) :class "hidden sm:flex items-center shrink-0"}
|
||||
[:img {:src "/img/logo-big2.png", :class "h-10 max-w-none", :alt "Integreat logo"}]]
|
||||
(when (and dd-env (not= "prod" dd-env))
|
||||
(let [env-label (str "environment: " dd-env)]
|
||||
[:div {:class "shrink-0"}
|
||||
;; Full pill when there is room (md-lg and xl+); compact letter badge in the tight lg range.
|
||||
[:span {:class "hidden md:inline-flex lg:hidden xl:inline-flex items-center ml-4 h-8 px-3 rounded-full bg-yellow-200 text-yellow-800 text-sm font-medium whitespace-nowrap"}
|
||||
env-label]
|
||||
[:span {:class "hidden lg:flex xl:hidden items-center justify-center ml-3 w-8 h-8 rounded-full bg-yellow-200 text-yellow-800 text-sm font-bold"
|
||||
:title env-label}
|
||||
(str/upper-case (subs dd-env 0 1))]]))]
|
||||
|
||||
[:div {:class "flex items-center gap-4"}
|
||||
;; Search: fills the middle, grows to a comfortable max and shrinks first when space is tight.
|
||||
(when (is-admin? identity)
|
||||
[:button.relative.hidden.lg:block.flex-1.min-w-0.max-w-md.mx-4 {:class "bg-gray-50 hover:bg-gray-200 dark:hover:bg-gray-700 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 pl-10 h-10 pr-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes :search)}
|
||||
[:div {:class "absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-gray-500"}
|
||||
[:div.w-4.h-4 svg/search]
|
||||
[:span.ml-2 "Search"]]])
|
||||
|
||||
(when (is-admin? identity)
|
||||
[:button.mt-1.lg:w-96.relative.hidden.lg:block {:class "bg-gray-50 hover:bg-gray-200 dark:hover:bg-gray-700 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 w-full pl-10 py-4 pr-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500 gap-4 "
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes :search)}
|
||||
[:div {:class "absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-gray-500"}
|
||||
[:div.w-4.h-4 svg/search]
|
||||
[:span.ml-2 "Search"]]])
|
||||
[:div {:class "hidden mr-3 -mb-1 sm:block"}
|
||||
[:span]]
|
||||
;; Right cluster: mobile search, company selector, user menu. Stays pinned right and keeps its size.
|
||||
[:div {:class "flex items-center gap-2 sm:gap-4 ml-auto shrink-0"}
|
||||
(icon-button-
|
||||
{:id "toggleSidebarMobileSearch", :type "button", :class "p-2 text-gray-500 rounded-lg lg:hidden hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
|
||||
310
src/clj/auto_ap/ssr/components/selmer.clj
Normal file
310
src/clj/auto_ap/ssr/components/selmer.clj
Normal file
@@ -0,0 +1,310 @@
|
||||
(ns auto-ap.ssr.components.selmer
|
||||
"Selmer-rendered versions of the shared SSR components used by the Transaction Edit
|
||||
modal (see .claude/skills/ssr-form-migration). Each wrapper assembles a plain-data
|
||||
context and renders its own template under resources/templates/components/ via the
|
||||
interop bridge -- the element structure lives entirely in the .html templates; the
|
||||
only Clojure is data assembly. Dynamic HTMX/Alpine attributes (which vary per call
|
||||
site) are serialized to an attribute string by `attrs->str` and injected with
|
||||
{{ attrs|safe }}, so the templates stay free of per-attribute {% if %} ladders.
|
||||
|
||||
Reuses class logic from auto-ap.ssr.components.inputs so output matches the Hiccup
|
||||
components byte-for-byte modulo Tailwind class ordering (verify by string-match +
|
||||
e2e, never byte-parity -- see selmer-conventions.md)."
|
||||
(:require
|
||||
[auto-ap.ssr.components.buttons :as btn]
|
||||
[auto-ap.ssr.components.inputs :as inputs]
|
||||
[auto-ap.ssr.hiccup-helper :as hh]
|
||||
[auto-ap.ssr.hx :as hx]
|
||||
[auto-ap.ssr.selmer :as sel]
|
||||
[clojure.string :as str]
|
||||
[hiccup.util :as hu]))
|
||||
|
||||
(defn- attr-name [k]
|
||||
(if (keyword? k) (subs (str k) 1) (str k)))
|
||||
|
||||
(defn attrs->str
|
||||
"Serialize an attribute map to an HTML attribute string with a leading space, so it
|
||||
concatenates after fixed template attributes: <input type=\"text\"{{ attrs|safe }}>.
|
||||
nil/false values are dropped, true renders a bare boolean attribute, everything else
|
||||
renders name=\"escaped-value\". Mirrors how hiccup2 emits attributes."
|
||||
[m]
|
||||
(->> m
|
||||
(keep (fn [[k v]]
|
||||
(cond
|
||||
(nil? v) nil
|
||||
(false? v) nil
|
||||
(true? v) (str " " (attr-name k))
|
||||
:else (str " " (attr-name k) "=\""
|
||||
(hu/escape-html (if (keyword? v) (name v) (str v)))
|
||||
"\""))))
|
||||
(apply str)))
|
||||
|
||||
(defn render
|
||||
"Render a component partial and trim outer whitespace (so {# comments #} and the
|
||||
file's trailing newline don't leak into the embedding tree). Returns a raw-wrapped
|
||||
string ready to drop into Hiccup or another Selmer context value."
|
||||
[template ctx]
|
||||
(sel/raw (str/trim (sel/render template ctx))))
|
||||
|
||||
(defn- body->html
|
||||
"Render child content (Hiccup vectors and/or raw Selmer fragments) to an HTML string."
|
||||
[body]
|
||||
(->> (if (sequential? body) body [body])
|
||||
(remove nil?)
|
||||
(map sel/hiccup->html)
|
||||
(apply str)))
|
||||
|
||||
;; --- leaf inputs -----------------------------------------------------------------
|
||||
|
||||
(defn hidden [{:keys [name value] :as params}]
|
||||
(render "templates/components/hidden.html"
|
||||
{:attrs (attrs->str (merge {:name name}
|
||||
(when (some? value) {:value value})
|
||||
(dissoc params :name :value)))}))
|
||||
|
||||
(defn text-input [{:keys [size] :as params}]
|
||||
(let [attrs (-> params
|
||||
(dissoc :error? :size)
|
||||
(assoc :type "text" :autocomplete "off")
|
||||
(update :class #(-> ""
|
||||
(hh/add-class inputs/default-input-classes)
|
||||
(hh/add-class %)))
|
||||
(update :class #(str % (inputs/use-size size))))]
|
||||
(render "templates/components/text-input.html" {:attrs (attrs->str attrs)})))
|
||||
|
||||
(defn money-input [{:keys [size] :as params}]
|
||||
(let [attrs (-> params
|
||||
(dissoc :size)
|
||||
(update :class (fnil hh/add-class "") inputs/default-input-classes)
|
||||
(update :class hh/add-class "appearance-none text-right")
|
||||
(update :class #(str % (inputs/use-size size)))
|
||||
(assoc :type "number" :step "0.01"))]
|
||||
(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 ---------------------------------------------------------------
|
||||
|
||||
(defn validated-field
|
||||
"Selmer port of com/validated-field (the errors- variant of field-): label + body +
|
||||
an always-present error <p>. Pass-through attrs land on the wrapping div (the account
|
||||
row's location cell hangs its swap wiring here)."
|
||||
[{:keys [label errors] :as params} & body]
|
||||
(let [classes (cond-> (or (:class params) "")
|
||||
(sequential? errors) (hh/add-class "has-error")
|
||||
:always (hh/add-class "group"))
|
||||
attrs (dissoc params :label :errors :error-source :error-key :class)
|
||||
errors-str (when (sequential? errors)
|
||||
(str/join ", " (filter string? errors)))]
|
||||
(render "templates/components/validated-field.html"
|
||||
{:label label
|
||||
:classes classes
|
||||
:attrs (attrs->str attrs)
|
||||
:body (body->html body)
|
||||
:errors_str (or errors-str "")})))
|
||||
|
||||
;; --- buttons / badges / links ----------------------------------------------------
|
||||
|
||||
(defn badge [{:keys [color] :as params} & children]
|
||||
(let [classes (-> (hh/add-class
|
||||
"absolute inline-flex items-center z-10 justify-center w-6 h-6 text-xs font-black text-white \n border-3 border-white rounded-full -top-2 -right-2 dark:border-gray-900"
|
||||
(:class params))
|
||||
(hh/add-class (or (some-> color (#(str "bg-" % "-300"))) "bg-red-300")))]
|
||||
(render "templates/components/badge.html"
|
||||
{:classes classes
|
||||
:attrs (attrs->str (dissoc params :class))
|
||||
:body (body->html children)})))
|
||||
|
||||
(defn link [{:keys [class] :as params} & children]
|
||||
(render "templates/components/link.html"
|
||||
{:classes (str class " font-medium text-blue-600 dark:text-blue-500 hover:underline cursor-pointer")
|
||||
:attrs (attrs->str (dissoc params :class))
|
||||
:body (body->html children)}))
|
||||
|
||||
(defn button [{:keys [color disabled minimal-loading?] :as params} & children]
|
||||
(let [classes (cond-> (:class params)
|
||||
true (str " focus:ring-4 font-bold rounded-lg text-xs p-3 text-center mr-2 inline-flex items-center relative justify-center disabled:opacity-50"
|
||||
(btn/bg-colors color disabled))
|
||||
(not disabled) (str " hover:scale-105 transition duration-100")
|
||||
disabled (str " cursor-not-allowed")
|
||||
(some? color) (str " text-white ")
|
||||
(nil? color) (str " bg-white dark:bg-gray-600 border-gray-300 dark:border-gray-700 text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-100 font-medium border border-gray-300 dark:border-gray-700"))]
|
||||
(render "templates/components/button.html"
|
||||
{:classes classes
|
||||
:attrs (attrs->str (dissoc params :class))
|
||||
:loading_label (not minimal-loading?)
|
||||
:body (body->html children)})))
|
||||
|
||||
(defn a-button [{:keys [color disabled] :as params} & children]
|
||||
(let [indicator? (:indicator? params true)
|
||||
classes (cond-> (:class params)
|
||||
true (str " focus:ring-4 font-bold rounded-lg text-xs p-3 text-center mr-2 inline-flex items-center hover:scale-105 transition duration-100 justify-center")
|
||||
(= :secondary color) (str " text-white bg-blue-500 hover:bg-blue-600 focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700")
|
||||
(= :primary color) (str " text-white bg-green-500 hover:bg-green-600 focus:ring-green-300 dark:bg-green-600 dark:hover:bg-green-700 ")
|
||||
(= :secondary-light color) (str " text-blue-800 bg-white-200 border-gray-100 border hover:bg-blue-100 focus:ring-blue-100 dark:bg-blue-400 dark:hover:bg-blue-800 ")
|
||||
(some? color) (str " text-white " (btn/bg-colors color disabled))
|
||||
(nil? color) (str " bg-white dark:bg-gray-600 border-gray-300 dark:border-gray-700 text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-100 font-medium border border-gray-300 dark:border-gray-700"))]
|
||||
(render "templates/components/a-button.html"
|
||||
{:classes classes
|
||||
:attrs (attrs->str (-> (dissoc params :class)
|
||||
(assoc :tabindex 0 :href (:href params "#"))))
|
||||
:indicator indicator?
|
||||
:body (body->html children)})))
|
||||
|
||||
(defn a-icon-button [{:keys [class] :as params} & children]
|
||||
(let [class-str (or class "")
|
||||
has-padding? (re-find #"\bp[xy]?-\d+(\.\d+)?\b" class-str)
|
||||
classes (str class-str (if has-padding? "" " p-3")
|
||||
" inline-flex items-center justify-center bg-white dark:bg-gray-600 items-center text-sm font-medium border border-gray-300 dark:border-gray-700 text-center text-gray-500 hover:text-gray-800 rounded-lg dark:text-gray-400 dark:hover:text-gray-100")]
|
||||
(render "templates/components/a-icon-button.html"
|
||||
{:classes classes
|
||||
:attrs (attrs->str (-> (dissoc params :class)
|
||||
(assoc :href (or (:href params) ""))))
|
||||
:body (body->html children)})))
|
||||
|
||||
(defn button-group-button [{:keys [size] :or {size :normal} :as params} & children]
|
||||
(let [classes (cond-> (:class params)
|
||||
true (str " font-medium text-gray-900 bg-white border border-gray-200 hover:bg-gray-100 hover:text-primary-700 focus:z-10 focus:ring-2 focus:ring-green-700 focus:text-green-700 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-green-500 dark:focus:text-white disabled:opacity-50")
|
||||
(= :small size) (str " text-xs px-3 py-2")
|
||||
(= :normal size) (str " text-sm px-4 py-2"))]
|
||||
(render "templates/components/button-group-button.html"
|
||||
{:classes classes
|
||||
:attrs (attrs->str (-> (dissoc params :class :size)
|
||||
(assoc :type (or (:type params) "button"))))
|
||||
:body (body->html children)})))
|
||||
|
||||
(defn button-group [{:keys [name]} & children]
|
||||
(render "templates/components/button-group.html"
|
||||
{:name name
|
||||
:body (body->html children)}))
|
||||
|
||||
;; --- radio-card ------------------------------------------------------------------
|
||||
|
||||
(defn radio-card
|
||||
"Selmer port of com/radio-card. NB: the Hiccup radio-card- has a dangling [:h3 title]
|
||||
the let discards, so only the <ul> renders -- reproduced here. Only the documented
|
||||
htmx keys ride onto each <input> (the same select-keys filter; :hx-vals / :hx-select
|
||||
are intentionally dropped, matching existing behavior)."
|
||||
[{:keys [options name title size orientation width] :or {size :medium width "w-48"}
|
||||
selected-value :value :as params}]
|
||||
(let [htmx-attrs (select-keys params [:hx-post :hx-target :hx-swap :hx-include :hx-trigger])
|
||||
sel (cond-> selected-value (keyword? selected-value) clojure.core/name)
|
||||
ul-class (cond-> " text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
(= orientation :horizontal) (-> (hh/add-class "flex gap-2 flex-wrap")
|
||||
(hh/remove-wildcard ["w-" "rounded-lg" "border" "bg-"]))
|
||||
:always (str " " width " "))
|
||||
li-class (cond-> "w-full border-b border-gray-200 rounded-t-lg dark:border-gray-600"
|
||||
(= orientation :horizontal) (-> (hh/remove-wildcard ["w-full" "rounded-"])
|
||||
(hh/add-class "w-auto shrink-0 block rounded-lg border border-gray-200 dark:border-gray-600 px-3")))
|
||||
div-class (cond-> "flex items-center"
|
||||
(not= orientation :horizontal) (hh/add-class "pl-3"))
|
||||
input-class (cond-> "w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-700 dark:focus:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500"
|
||||
(= size :small) (str " text-xs")
|
||||
(= size :medium) (str " text-sm"))
|
||||
label-class (cond-> "w-full ml-2 font-medium text-gray-900 dark:text-gray-300"
|
||||
(= size :small) (str " text-xs py-2")
|
||||
(= size :medium) (str " text-sm py-3")
|
||||
(= orientation :horizontal) (hh/remove-class "w-full"))]
|
||||
(render "templates/components/radio-card.html"
|
||||
{:ul_class ul-class :li_class li-class :div_class div-class
|
||||
:input_class input-class :label_class label-class
|
||||
:name name
|
||||
:input_attrs (attrs->str htmx-attrs)
|
||||
:options (for [{:keys [value content]} options]
|
||||
{:id (str "list-" name "-" value)
|
||||
:value value
|
||||
:checked (= sel value)
|
||||
:content (body->html content)})})))
|
||||
|
||||
;; --- data grid -------------------------------------------------------------------
|
||||
|
||||
(defn data-grid-header [params & body]
|
||||
(render "templates/components/data-grid-header.html"
|
||||
{:klass (:class params)
|
||||
:click (format "$dispatch('sorted', {key: '%s'})" (:sort-key params))
|
||||
:sort_key (:sort-key params)
|
||||
:attrs (attrs->str (cond-> {} (:style params) (assoc :style (:style params))))
|
||||
:body (body->html body)}))
|
||||
|
||||
(defn data-grid-row [params & body]
|
||||
(render "templates/components/data-grid-row.html"
|
||||
{:classes (str (:class params) " border-b dark:border-gray-600 group hover:bg-gray-100 dark:hover:bg-gray-700")
|
||||
:attrs (attrs->str (dissoc params :class))
|
||||
:body (body->html body)}))
|
||||
|
||||
(defn data-grid-cell [params & body]
|
||||
(render "templates/components/data-grid-cell.html"
|
||||
{:klass (:class params)
|
||||
:attrs (attrs->str (dissoc params :class))
|
||||
:body (body->html body)}))
|
||||
|
||||
(defn data-grid
|
||||
"Table shell: outer scroll div > table > thead(headers) > tbody(rows) + optional
|
||||
footer-tbody. `headers`, `rows`, and `footer-tbody` are pre-rendered fragments."
|
||||
[{:keys [headers footer-tbody] :as params} & rows]
|
||||
(render "templates/components/data-grid.html"
|
||||
{:table_class "w-full text-sm text-left text-gray-500 dark:text-gray-400 shrink"
|
||||
:table_attrs (attrs->str (dissoc params :headers :thead-params :footer-tbody))
|
||||
:thead_class "text-xs text-gray-800 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 group-[.raw]:sticky group-[.raw]:z-10 group-[.raw]:top-0"
|
||||
:headers (body->html headers)
|
||||
:rows (body->html rows)
|
||||
:footer_tbody (when footer-tbody (body->html footer-tbody))}))
|
||||
|
||||
;; --- modal + typeahead -----------------------------------------------------------
|
||||
|
||||
(defn modal [{:as params} & children]
|
||||
(render "templates/components/modal.html"
|
||||
{:classes (hh/add-class "" (:class params ""))
|
||||
:attrs (attrs->str (dissoc params :handle-unexpected-error? :class))
|
||||
:body (body->html children)}))
|
||||
|
||||
(defn typeahead
|
||||
"Selmer port of com/typeahead. Resolves the initial {value,label} server-side via
|
||||
value-fn/content-fn (DB lookups), builds the Alpine x-data, and serializes the
|
||||
hidden posting-input attributes. Preserves every tippy?. null-guard."
|
||||
[{:keys [value value-fn content-fn x-model x-init id class placeholder disabled url]
|
||||
:as params}]
|
||||
(let [vf (or value-fn identity)
|
||||
cf (or content-fn identity)
|
||||
vval (vf value)
|
||||
vlabel (cf value)
|
||||
x-data (hx/json {:baseUrl (str url)
|
||||
:value {:value vval :label vlabel}
|
||||
:tippy nil :search "" :active -1
|
||||
:elements (if vval [{:value vval :label vlabel}] [])})
|
||||
a-class (-> (hh/add-class (or class "") inputs/default-input-classes)
|
||||
(hh/add-class "cursor-pointer"))
|
||||
a-xinit (str "$nextTick(() => tippy = $el.__x_tippy); " x-init)
|
||||
search-class (-> (or class "")
|
||||
(hh/add-class inputs/default-input-classes)
|
||||
(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))
|
||||
hidden-attrs (-> params
|
||||
(dissoc :class :value-fn :content-fn :placeholder :x-model)
|
||||
(assoc "x-ref" "hidden" :type "hidden" ":value" "value.value"
|
||||
:x-init "$watch('value', v => { $el.value = (v && v.value != null) ? v.value : ''; $nextTick(() => $dispatch('change')); }); "))]
|
||||
(render "templates/components/typeahead.html"
|
||||
{:x_data x-data
|
||||
:x_model x-model
|
||||
:key (when id (str id "--" vval))
|
||||
:disabled disabled
|
||||
:a_class a-class
|
||||
:a_xinit a-xinit
|
||||
:search_class search-class
|
||||
:placeholder placeholder
|
||||
:hidden_attrs (attrs->str hidden-attrs)})))
|
||||
@@ -282,6 +282,7 @@
|
||||
[:div {:x-data (hx/json {:selected [] :all_selected false :type (:entity-name grid-spec)})
|
||||
"x-on:copy" "if (selected.length > 0) {$clipboard(JSON.stringify({'type': type, 'selected': selected}))}"
|
||||
"x-on:client-selected.document" "selected=[]; all_selected=false"
|
||||
"x-on:reset-selection.document" "selected=[]; all_selected=false"
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
:x-init "$watch('selected', s=> $dispatch('selectedChanged', {selected: s, all_selected: all_selected}) );
|
||||
$watch('all_selected', a=>$dispatch('selectedChanged', {selected: selected, all_selected: a}))"}
|
||||
|
||||
@@ -31,8 +31,12 @@
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.components.link-dropdown :refer [link-dropdown]]
|
||||
[auto-ap.ssr.components.multi-modal :as mm]
|
||||
[auto-ap.ssr.components.selmer :as sc]
|
||||
[auto-ap.ssr.form-cursor :as fc]
|
||||
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
|
||||
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
|
||||
[auto-ap.ssr.selmer :as sel]
|
||||
[auto-ap.ssr.transaction.edit :as tx-edit]
|
||||
[auto-ap.ssr.hiccup-helper :as hh]
|
||||
[auto-ap.ssr.hx :as hx]
|
||||
[auto-ap.ssr.invoice.common :refer [default-read]]
|
||||
@@ -41,11 +45,11 @@
|
||||
[auto-ap.ssr.components.date-range :as dr]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [apply-middleware-to-all-handlers assert-schema
|
||||
:refer [->db-id apply-middleware-to-all-handlers assert-schema
|
||||
clj-date-schema dissoc-nil-transformer entity-id
|
||||
form-validation-error html-response main-transformer
|
||||
many-entity modal-response money percentage
|
||||
ref->enum-schema round-money strip wrap-entity
|
||||
many-entity modal-response money path->name2 percentage
|
||||
ref->enum-schema round-money strip wrap-entity wrap-form-4xx-2
|
||||
wrap-implied-route-param wrap-merge-prior-hx
|
||||
wrap-schema-enforce]]
|
||||
[auto-ap.time :as atime]
|
||||
@@ -1433,32 +1437,83 @@
|
||||
target-route)
|
||||
(:query-params request)))}}))
|
||||
|
||||
(defn initial-bulk-edit-state [request]
|
||||
(mm/->MultiStepFormState {:search-params (:query-params request)
|
||||
:expense-accounts [{:db/id "123"
|
||||
:location "Shared"
|
||||
:account nil
|
||||
:percentage 1.0}]}
|
||||
[]
|
||||
{:search-params (:query-params request)
|
||||
:expense-accounts [{:db/id "123"
|
||||
:location "Shared"
|
||||
:account nil
|
||||
:percentage 1.0}]}))
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Flat state plumbing for the bulk-edit modal (replaces the wizard +
|
||||
;; MultiStepFormState + the EDN snapshot). Mirrors transaction/bulk_code.clj.
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(declare all-ids-not-locked)
|
||||
|
||||
(def ^:dynamic *errors*
|
||||
"Humanized form errors for the current bulk-edit render, keyed by schema paths
|
||||
(e.g. {:expense-accounts {0 {:location [\"required\"]}}}). Bound by render-form."
|
||||
{})
|
||||
|
||||
(defn- ferr [& path]
|
||||
(get-in *errors* (vec path)))
|
||||
|
||||
(defn- account-field-name [index field]
|
||||
(path->name2 :expense-accounts index field))
|
||||
|
||||
(defn- account-field-errors [index field]
|
||||
(ferr :expense-accounts index field))
|
||||
|
||||
(def bulk-edit-schema
|
||||
(mc/schema [:map
|
||||
[:ids {:optional true} [:maybe [:vector {:coerce? true} entity-id]]]
|
||||
[:expense-accounts {:optional true}
|
||||
[:maybe
|
||||
[:vector {:coerce? true}
|
||||
[:map
|
||||
[:db/id {:optional true} [:maybe :string]]
|
||||
[:account entity-id]
|
||||
[:location [:string {:min 1 :error/message "required"}]]
|
||||
[:percentage percentage]]]]]]))
|
||||
|
||||
(def ^:private bulk-edit-form-keys [:expense-accounts])
|
||||
|
||||
(defn- default-expense-row []
|
||||
{:db/id (str (java.util.UUID/randomUUID))
|
||||
:location "Shared"
|
||||
:account nil
|
||||
:percentage 1.0})
|
||||
|
||||
(defn wrap-bulk-state
|
||||
"Decodes the posted form into the flat bulk-edit state and resolves the target invoice
|
||||
id set. On open (GET) the selection comes from the grid query-params (selected /
|
||||
all-selected + filters); on every post the concrete (not-locked) id list rides back in
|
||||
hidden ids[] fields, so no EDN snapshot / filter round-trip is needed."
|
||||
[handler]
|
||||
(-> (fn [request]
|
||||
(let [decoded (mc/decode bulk-edit-schema (:form-params request) main-transformer)
|
||||
decoded (if (map? decoded) decoded {})
|
||||
posted-ids (some->> (:ids decoded) (keep ->db-id) vec)
|
||||
ids (if (seq posted-ids)
|
||||
posted-ids
|
||||
(vec (all-ids-not-locked (selected->ids request (:query-params request)))))
|
||||
accounts (or (seq (:expense-accounts decoded)) [(default-expense-row)])]
|
||||
(handler (assoc request :bulk-state {:ids ids :expense-accounts (vec accounts)}))))
|
||||
(wrap-nested-form-params)))
|
||||
|
||||
(defn- single-client-id
|
||||
"The client id if the user has access to exactly one client, nil otherwise (the bulk
|
||||
set may span clients)."
|
||||
[request]
|
||||
(when (= 1 (count (:clients request)))
|
||||
(-> request :clients first :db/id)))
|
||||
|
||||
(defn- account-typeahead*
|
||||
[{:keys [name value client-id x-model]}]
|
||||
[:div.flex.flex-col
|
||||
(com/typeahead {:name name
|
||||
:placeholder "Search..."
|
||||
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
|
||||
{:purpose "invoice"})
|
||||
:id name
|
||||
:x-model x-model
|
||||
:value value
|
||||
:content-fn (fn [value]
|
||||
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
|
||||
client-id)))})])
|
||||
(sc/typeahead {:name name
|
||||
:placeholder "Search..."
|
||||
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
|
||||
{:purpose "invoice"})
|
||||
:id name
|
||||
:x-model x-model
|
||||
:value value
|
||||
:content-fn (fn [value]
|
||||
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
|
||||
client-id)))}))
|
||||
|
||||
;; TODO clientize
|
||||
(defn all-ids-not-locked [all-ids]
|
||||
@@ -1472,121 +1527,135 @@
|
||||
[(>= ?d ?lu)]]
|
||||
(dc/db conn))
|
||||
(map first)))
|
||||
(defn- bulk-edit-account-row* [{:keys [value client-id]}]
|
||||
|
||||
(com/data-grid-row
|
||||
(-> {:x-data (hx/json {:show (boolean (not (fc/field-value (:new? value))))
|
||||
:accountId (fc/field-value (:account value))})
|
||||
:data-key "show"
|
||||
:x-ref "p"}
|
||||
hx/alpine-mount-then-appear)
|
||||
(fc/with-field :db/id
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (fc/field-value)}))
|
||||
(fc/with-field :account
|
||||
(com/data-grid-cell
|
||||
(defn- bulk-edit-account-row*
|
||||
"One expense-account row (no cursor). The location cell swaps just itself
|
||||
(#account-location-<index>, Rule 2); the percentage swaps only #expense-totals
|
||||
(Rule 4); remove swaps the whole #bulk-edit-form (Rule 3)."
|
||||
[{:keys [value client-id index]}]
|
||||
(let [account-val (let [av (:account value)]
|
||||
(if (map? av) (:db/id av) av))
|
||||
location-attrs {:x-hx-val:account-id "accountId"
|
||||
:hx-vals (hx/json (cond-> {:name (account-field-name index :location)}
|
||||
client-id (assoc :client-id client-id)))
|
||||
:x-dispatch:changed "accountId"
|
||||
:hx-trigger "changed"
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-form-changed)
|
||||
:hx-target (str "#account-location-" index)
|
||||
:hx-select (str "#account-location-" index)
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"}]
|
||||
(sc/data-grid-row
|
||||
(-> {:class "account-row"
|
||||
:id (str "account-row-" index)
|
||||
:x-data (hx/json {:show (boolean (not (:new? value)))
|
||||
:accountId account-val})
|
||||
:data-key "show"
|
||||
:x-ref "p"}
|
||||
hx/alpine-mount-then-appear)
|
||||
(sc/hidden {:name (account-field-name index :db/id)
|
||||
:value (:db/id value)})
|
||||
(sc/data-grid-cell
|
||||
{}
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(account-typeahead* {:value (fc/field-value)
|
||||
(sc/validated-field
|
||||
{:errors (account-field-errors index :account)}
|
||||
(account-typeahead* {:value account-val
|
||||
:client-id client-id
|
||||
:name (fc/field-name)
|
||||
:x-model "accountId"}))))
|
||||
(fc/with-field :location
|
||||
(com/data-grid-cell
|
||||
:name (account-field-name index :account)
|
||||
:x-model "accountId"})))
|
||||
(sc/data-grid-cell
|
||||
{:id (str "account-location-" index)}
|
||||
(sc/validated-field
|
||||
(merge {:errors (account-field-errors index :location)} location-attrs)
|
||||
(tx-edit/location-select* {:name (account-field-name index :location)
|
||||
:account-location (:account/location (when (nat-int? account-val)
|
||||
(dc/pull (dc/db conn) '[:account/location] account-val)))
|
||||
:value (:location value)})))
|
||||
(sc/data-grid-cell
|
||||
{}
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)
|
||||
:x-hx-val:account-id "accountId"
|
||||
:hx-vals (hx/json {:name (fc/field-name)})
|
||||
:x-dispatch:changed "accountId"
|
||||
:hx-trigger "changed"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select)
|
||||
:hx-target "find *"
|
||||
:hx-swap "outerHTML"}
|
||||
(location-select* {:name (fc/field-name)
|
||||
:account-location (:account/location (cond->> (:account @value)
|
||||
(nat-int? (:account @value)) (dc/pull (dc/db conn)
|
||||
'[:account/location])))
|
||||
:value (fc/field-value)}))))
|
||||
(fc/with-field :percentage
|
||||
(com/data-grid-cell
|
||||
{}
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/money-input {:name (fc/field-name)
|
||||
:class "w-16 amount-field"
|
||||
:value (some-> (fc/field-value)
|
||||
(* 100)
|
||||
(long))}))))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x))))
|
||||
(sc/validated-field
|
||||
{:errors (account-field-errors index :percentage)}
|
||||
(sc/money-input {:name (account-field-name index :percentage)
|
||||
:class "w-16 amount-field"
|
||||
:value (some-> (:percentage value) (* 100) long)
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-form-changed)
|
||||
:hx-target "#expense-totals"
|
||||
:hx-select "#expense-totals"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-trigger "keyup changed delay:300ms"
|
||||
:hx-include "closest form"})))
|
||||
(sc/data-grid-cell
|
||||
{:class "align-top"}
|
||||
(sc/a-icon-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-form-changed)
|
||||
:hx-vals (hx/json {:op "remove-account" :row-index (or index 0)})
|
||||
:hx-target "#bulk-edit-form"
|
||||
:hx-select "#bulk-edit-form"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"
|
||||
:class "account-remove-action"}
|
||||
svg/x)))))
|
||||
|
||||
(defrecord AccountsStep [linear-wizard]
|
||||
mm/ModalWizardStep
|
||||
(step-name [_]
|
||||
"Expense Accounts")
|
||||
(step-key [_]
|
||||
:accounts)
|
||||
(defn- expense-total* [request]
|
||||
(let [total (->> (-> request :bulk-state :expense-accounts)
|
||||
(map (fnil :percentage 0.0))
|
||||
(filter number?)
|
||||
(reduce + 0.0))]
|
||||
(format "%.1f%%" (* 100.0 total))))
|
||||
|
||||
(edit-path [_ _]
|
||||
[])
|
||||
(defn- expense-balance* [request]
|
||||
(let [total (->> (-> request :bulk-state :expense-accounts)
|
||||
(map (fnil :percentage 0.0))
|
||||
(filter number?)
|
||||
(reduce + 0.0))
|
||||
balance (- 100.0 (* 100.0 total))]
|
||||
(sel/raw (str "<span"
|
||||
(when-not (dollars= 0.0 balance) " class=\"text-red-300\"")
|
||||
">" (format "%.1f%%" balance) "</span>"))))
|
||||
|
||||
(step-schema [_]
|
||||
(mut/select-keys (mm/form-schema linear-wizard) #{:expense-accounts}))
|
||||
(defn- expense-totals-tbody*
|
||||
"The separately-swappable TOTAL/BALANCE <tbody> (#expense-totals, Rule 4 target)."
|
||||
[request]
|
||||
(sel/render->hiccup
|
||||
"templates/invoice-bulk-edit/expense-totals.html"
|
||||
{:rows (str
|
||||
(sc/data-grid-row {}
|
||||
(sc/data-grid-cell {})
|
||||
(sc/data-grid-cell {:class "text-right"} (sel/raw "<span class=\"font-bold text-right\">TOTAL</span>"))
|
||||
(sc/data-grid-cell {:id "expense-total" :class "text-right"} (expense-total* request))
|
||||
(sc/data-grid-cell {}))
|
||||
(sc/data-grid-row {}
|
||||
(sc/data-grid-cell {})
|
||||
(sc/data-grid-cell {:class "text-right"} (sel/raw "<span class=\"font-bold text-right\">BALANCE</span>"))
|
||||
(sc/data-grid-cell {:id "expense-balance" :class "text-right"} (expense-balance* request))
|
||||
(sc/data-grid-cell {})))}))
|
||||
|
||||
(render-step [this {{:keys [snapshot] :as multi-form-state} :multi-form-state :as request}]
|
||||
(let [selected-ids (selected->ids (assoc request :query-params (:search-params snapshot)) (:search-params snapshot))
|
||||
all-ids (all-ids-not-locked selected-ids)]
|
||||
(mm/default-render-step
|
||||
linear-wizard this
|
||||
:head [:div.p-2 "Bulk editing " (count all-ids) " invoices"]
|
||||
:body (mm/default-step-body
|
||||
{}
|
||||
[:div {}
|
||||
(fc/with-field :expense-accounts
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/data-grid {:headers [(com/data-grid-header {} "Account")
|
||||
(com/data-grid-header {:class "w-32"} "Location")
|
||||
(com/data-grid-header {:class "w-16"} "%")
|
||||
(com/data-grid-header {:class "w-16"})]}
|
||||
(fc/cursor-map #(bulk-edit-account-row* {:value %
|
||||
:client-id (:invoice/client snapshot)}))
|
||||
|
||||
(com/data-grid-new-row {:colspan 4
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
::route/bulk-edit-new-account)
|
||||
:row-offset 0
|
||||
:index (count (fc/field-value))}
|
||||
"New account")
|
||||
(com/data-grid-row {}
|
||||
(com/data-grid-cell {})
|
||||
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"])
|
||||
(com/data-grid-cell {:id "total"
|
||||
:class "text-right"
|
||||
:hx-trigger "change from:closest form target:.amount-field"
|
||||
:hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/bulk-edit-total)
|
||||
:hx-target "this"
|
||||
:hx-swap "innerHTML"}
|
||||
#_(invoice-expense-account-total* request))
|
||||
(com/data-grid-cell {}))
|
||||
|
||||
(com/data-grid-row {}
|
||||
(com/data-grid-cell {})
|
||||
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"])
|
||||
(com/data-grid-cell {:id "total"
|
||||
:class "text-right"
|
||||
:hx-trigger "change from:closest form target:.amount-field"
|
||||
:hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/bulk-edit-balance)
|
||||
:hx-target "this"
|
||||
:hx-swap "innerHTML"}
|
||||
#_(invoice-expense-account-balance* request))
|
||||
(com/data-grid-cell {})))))])
|
||||
:footer
|
||||
(mm/default-step-footer linear-wizard this :validation-route ::route/new-wizard-navigate
|
||||
:next-button (com/button {:color :primary :x-ref "next" :class "w-32"} "Save"))
|
||||
:validation-route ::route/new-wizard-navigate))))
|
||||
(defn- account-grid* [request]
|
||||
(let [client-id (single-client-id request)
|
||||
accounts (vec (:expense-accounts (:bulk-state request)))]
|
||||
(apply
|
||||
sc/data-grid
|
||||
{:headers [(sc/data-grid-header {} "Account")
|
||||
(sc/data-grid-header {:class "w-32"} "Location")
|
||||
(sc/data-grid-header {:class "w-16"} "%")
|
||||
(sc/data-grid-header {:class "w-16"})]
|
||||
:footer-tbody (expense-totals-tbody* request)}
|
||||
(concat
|
||||
(map-indexed
|
||||
(fn [index account]
|
||||
(bulk-edit-account-row* {:value account
|
||||
:client-id client-id
|
||||
:index index}))
|
||||
accounts)
|
||||
[(sc/data-grid-row
|
||||
{:class "new-row"}
|
||||
(sc/data-grid-cell {:colspan 4}
|
||||
(sc/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-form-changed)
|
||||
:hx-vals (hx/json {:op "new-account"})
|
||||
:hx-target "#bulk-edit-form"
|
||||
:hx-select "#bulk-edit-form"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"
|
||||
:color :secondary}
|
||||
"New account")))]))))
|
||||
|
||||
(defn maybe-code-accounts [invoice account-rules valid-locations]
|
||||
(with-precision 2
|
||||
@@ -1629,96 +1698,121 @@
|
||||
(when-not (dollars= 1.0 expense-account-total)
|
||||
(form-validation-error (str "Expense account total (" expense-account-total ") does not equal 100%")))))
|
||||
|
||||
(defrecord BulkEditWizard [_ current-step]
|
||||
mm/LinearModalWizard
|
||||
(hydrate-from-request
|
||||
[this request]
|
||||
this)
|
||||
(navigate [this step-key]
|
||||
(assoc this :current-step step-key))
|
||||
(get-current-step [this]
|
||||
(if current-step
|
||||
(mm/get-step this current-step)
|
||||
(mm/get-step this :accounts)))
|
||||
(render-wizard [this {:keys [multi-form-state] :as request}]
|
||||
(mm/default-render-wizard
|
||||
this request
|
||||
:form-params
|
||||
(-> mm/default-form-props
|
||||
(assoc :hx-put
|
||||
(str (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-submit))))
|
||||
:render-timeline? false))
|
||||
(steps [_]
|
||||
[:accounts])
|
||||
(get-step [this step-key]
|
||||
(let [step-key-result (mc/parse mm/step-key-schema step-key)
|
||||
[step-key-type step-key] step-key-result]
|
||||
(get {:accounts (->AccountsStep this)}
|
||||
step-key)))
|
||||
(form-schema [_]
|
||||
(mc/schema [:map
|
||||
[:expense-accounts
|
||||
(many-entity {:min 1}
|
||||
[:account entity-id]
|
||||
[:location [:string {:min 1 :error/message "required"}]]
|
||||
[:percentage percentage])]]))
|
||||
(submit [this {:keys [multi-form-state request-method identity] :as request}]
|
||||
(let [selected-ids (selected->ids (assoc request :query-params (:search-params (:snapshot multi-form-state))) (:search-params (:snapshot multi-form-state)))
|
||||
all-ids (all-ids-not-locked selected-ids)
|
||||
invoices (pull-many (dc/db conn) '[:db/id :invoice/total {:invoice/client [:client/locations]}] (vec all-ids))]
|
||||
(assert-percentages-add-up (:snapshot multi-form-state))
|
||||
(defn- form-errors-html [errors]
|
||||
(str "<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>"))
|
||||
|
||||
(doseq [a (-> multi-form-state :snapshot :expense-accounts)
|
||||
:let [{:keys [:account/location :account/name]} (dc/pull (dc/db conn) [:account/location :account/name] (:account a))]]
|
||||
(when (and location (not= location (:location a)))
|
||||
(let [err (str "Account " name " uses location " (:location a) ", but is supposed to be " location)]
|
||||
(throw (ex-info err {:validation-error err})))))
|
||||
(alog/info ::bulk-code :count (count all-ids))
|
||||
(audit-transact-batch
|
||||
(map (fn [i]
|
||||
[:upsert-invoice {:db/id (:db/id i)
|
||||
:invoice/expense-accounts (maybe-code-accounts i (-> multi-form-state :snapshot :expense-accounts) (-> i :invoice/client :client/locations))}])
|
||||
invoices)
|
||||
(:identity request))
|
||||
(defn- footer* [request]
|
||||
(sel/raw
|
||||
(str "<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>")))
|
||||
|
||||
(html-response
|
||||
[:div]
|
||||
:headers (cond-> {"hx-trigger" (hx/json {"modalclose" ""
|
||||
"invalidated" ""
|
||||
"notification" (str "Successfully coded " (count all-ids) " invoices.")})
|
||||
"hx-reswap" "outerHTML"})))))
|
||||
(defn render-form
|
||||
"Renders the whole plain bulk-edit form (no wizard). Binds *errors* so the field-level
|
||||
lookups resolve. Reuses the edit modal chrome."
|
||||
[request]
|
||||
(binding [*errors* (or (:form-errors request) {})]
|
||||
(let [ids (:ids (:bulk-state request))
|
||||
ids-hidden (apply str
|
||||
(map-indexed (fn [i id]
|
||||
(str (sc/hidden {:name (path->name2 :ids i) :value id})))
|
||||
ids))
|
||||
body (str "<div class=\"space-y-4 p-4\">"
|
||||
(str (sc/validated-field
|
||||
{:errors (ferr :expense-accounts)}
|
||||
(sel/raw (str (account-grid* request)))))
|
||||
"</div>")
|
||||
modal-card (sel/render "templates/transaction-edit/edit-modal.html"
|
||||
{:head (str "<div class=\"p-2\">Bulk editing " (count ids) " invoices</div>")
|
||||
:side_panel nil
|
||||
:body body
|
||||
:footer (str (footer* request))})]
|
||||
(sel/render->hiccup
|
||||
"templates/invoice-bulk-edit/edit-form.html"
|
||||
{:ids_hidden ids-hidden
|
||||
:form_attrs (sc/attrs->str {:hx-ext "response-targets"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-target-400 "#form-errors .error-content"
|
||||
:hx-trigger "submit"
|
||||
:hx-target "this"
|
||||
:hx-put (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-submit)})
|
||||
:modal (str (sc/modal {:id "wizardmodal"} (sel/raw modal-card)))}))))
|
||||
|
||||
(def bulk-edit-wizard (->BulkEditWizard nil nil))
|
||||
(defn apply-new-account
|
||||
"bulk-edit-form-changed op: append a fresh (blank, Shared) expense-account row."
|
||||
[request]
|
||||
(let [accounts (vec (:expense-accounts (:bulk-state request)))
|
||||
new-account {:db/id (str (java.util.UUID/randomUUID))
|
||||
:new? true
|
||||
:location "Shared"
|
||||
:percentage nil}]
|
||||
(assoc-in request [:bulk-state :expense-accounts] (conj accounts new-account))))
|
||||
|
||||
(defn bulk-edit-total* [request]
|
||||
(let [total (->> (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:expense-accounts)
|
||||
(map (fnil :percentage 0.0))
|
||||
(filter number?)
|
||||
(reduce + 0.0))]
|
||||
(format "%.1f%%" (* 100.0 total))))
|
||||
(defn apply-remove-account
|
||||
"bulk-edit-form-changed op: remove the expense-account row at form-param row-index."
|
||||
[request]
|
||||
(let [row-index (some-> request :form-params (get "row-index") Integer/parseInt)
|
||||
accounts (vec (:expense-accounts (:bulk-state request)))
|
||||
updated-accounts (if (and row-index (< row-index (count accounts)))
|
||||
(vec (concat (subvec accounts 0 row-index)
|
||||
(subvec accounts (inc row-index))))
|
||||
accounts)]
|
||||
(assoc-in request [:bulk-state :expense-accounts] updated-accounts)))
|
||||
|
||||
(defn bulk-edit-balance* [request]
|
||||
(let [total (->> (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:expense-accounts)
|
||||
(map (fnil :percentage 0.0))
|
||||
(filter number?)
|
||||
(reduce + 0.0))
|
||||
balance (- 100.0
|
||||
(* 100.0 total))]
|
||||
[:span {:class (when-not (dollars= 0.0 balance)
|
||||
"text-red-300")}
|
||||
(format "%.1f%%" balance)]))
|
||||
(defn bulk-edit-form-changed-handler
|
||||
"Single whole-form re-render endpoint. Dispatches on `op` (add/remove a row); a missing
|
||||
op (an account-selection location swap or a percentage keyup) just re-renders, and the
|
||||
caller's hx-select picks the cell / #expense-totals it needs."
|
||||
[request]
|
||||
(let [op (get-in request [:form-params "op"])
|
||||
request' (case op
|
||||
"new-account" (apply-new-account request)
|
||||
"remove-account" (apply-remove-account request)
|
||||
request)]
|
||||
(html-response (render-form request'))))
|
||||
|
||||
(defn bulk-edit-total [request]
|
||||
(html-response (bulk-edit-total* request)))
|
||||
(defn open-handler [request]
|
||||
(modal-response
|
||||
(sel/render->hiccup "templates/transaction-edit/transitioner.html"
|
||||
{:body (str (render-form request))})))
|
||||
|
||||
(defn bulk-edit-balance [request]
|
||||
(html-response (bulk-edit-balance* request)))
|
||||
(defn- render-form-response [request]
|
||||
(html-response (render-form request)
|
||||
:headers {"HX-reswap" "outerHTML"}))
|
||||
|
||||
(defn submit
|
||||
"Validates the posted expense-account coding (schema field errors + the percentage-sum
|
||||
and per-account location checks), then applies it across every selected (not-locked)
|
||||
invoice."
|
||||
[request]
|
||||
(let [{:keys [ids expense-accounts]} (:bulk-state request)
|
||||
invoices (pull-many (dc/db conn) '[:db/id :invoice/total {:invoice/client [:client/locations]}] (vec ids))]
|
||||
(assert-schema bulk-edit-schema (select-keys (:bulk-state request) bulk-edit-form-keys))
|
||||
(assert-percentages-add-up {:expense-accounts expense-accounts})
|
||||
(doseq [a expense-accounts
|
||||
:let [{:keys [:account/location :account/name]} (dc/pull (dc/db conn) [:account/location :account/name] (:account a))]]
|
||||
(when (and location (not= location (:location a)))
|
||||
(let [err (str "Account " name " uses location " (:location a) ", but is supposed to be " location)]
|
||||
(throw (ex-info err {:validation-error err})))))
|
||||
(alog/info ::bulk-code :count (count ids))
|
||||
(audit-transact-batch
|
||||
(map (fn [i]
|
||||
[:upsert-invoice {:db/id (:db/id i)
|
||||
:invoice/expense-accounts (maybe-code-accounts i expense-accounts (-> i :invoice/client :client/locations))}])
|
||||
invoices)
|
||||
(:identity request))
|
||||
(html-response
|
||||
[:div]
|
||||
:headers {"hx-trigger" (hx/json {"modalclose" ""
|
||||
"invalidated" ""
|
||||
"notification" (str "Successfully coded " (count ids) " invoices.")})
|
||||
"hx-reswap" "outerHTML"})))
|
||||
|
||||
(def key->handler
|
||||
(apply-middleware-to-all-handlers
|
||||
@@ -1737,32 +1831,14 @@
|
||||
::route/legacy-paid-invoices (redirect-handler ::route/paid-page)
|
||||
::route/legacy-voided-invoices (redirect-handler ::route/voided-page)
|
||||
::route/legacy-new-invoice (redirect-handler ::route/new-wizard)
|
||||
::route/bulk-edit (-> mm/open-wizard-handler
|
||||
(mm/wrap-wizard bulk-edit-wizard)
|
||||
(mm/wrap-init-multi-form-state initial-bulk-edit-state))
|
||||
::route/bulk-edit-submit (-> mm/submit-handler
|
||||
(mm/wrap-wizard bulk-edit-wizard)
|
||||
(mm/wrap-decode-multi-form-state)
|
||||
::route/bulk-edit (-> open-handler
|
||||
(wrap-bulk-state))
|
||||
::route/bulk-edit-submit (-> submit
|
||||
(wrap-form-4xx-2 render-form-response)
|
||||
(wrap-bulk-state)
|
||||
(wrap-must {:subject :invoice :activity :bulk-edit}))
|
||||
::route/bulk-edit-total (-> bulk-edit-total
|
||||
(mm/wrap-wizard bulk-edit-wizard)
|
||||
(mm/wrap-decode-multi-form-state)
|
||||
(wrap-must {:subject :invoice :activity :bulk-edit}))
|
||||
::route/bulk-edit-balance (-> bulk-edit-balance
|
||||
|
||||
(mm/wrap-wizard bulk-edit-wizard)
|
||||
(mm/wrap-decode-multi-form-state)
|
||||
(wrap-must {:subject :invoice :activity :bulk-edit}))
|
||||
::route/bulk-edit-new-account (->
|
||||
(add-new-entity-handler [:step-params :expense-accounts]
|
||||
(fn render [cursor request]
|
||||
(bulk-edit-account-row*
|
||||
{:value cursor}))
|
||||
(fn build-new-row [base _]
|
||||
(assoc base :invoice-expense-account/location "Shared")))
|
||||
(wrap-schema-enforce :query-schema [:map
|
||||
[:client-id {:optional true}
|
||||
[:maybe entity-id]]]))
|
||||
::route/bulk-edit-form-changed (-> bulk-edit-form-changed-handler
|
||||
(wrap-bulk-state))
|
||||
|
||||
::route/undo-autopay (-> undo-autopay
|
||||
(wrap-entity [:route-params :db/id] default-read)
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
[auto-ap.ssr.ui :refer [base-page]]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [apply-middleware-to-all-handlers clj-date-schema
|
||||
html-response main-transformer money strip
|
||||
html-response modal-response main-transformer money strip
|
||||
wrap-form-4xx-2 wrap-implied-route-param
|
||||
wrap-merge-prior-hx wrap-schema-decode
|
||||
wrap-schema-enforce]]
|
||||
@@ -69,6 +69,40 @@
|
||||
selected)]
|
||||
ids))
|
||||
|
||||
(defn all-ids-not-locked
|
||||
"Filters journal-entry ids to only those whose date is on/after the client's
|
||||
locked-until date (i.e. not in a reconciled/locked period)."
|
||||
[all-ids]
|
||||
(->> all-ids
|
||||
(dc/q '[:find ?t
|
||||
:in $ [?t ...]
|
||||
:where
|
||||
[?t :journal-entry/client ?c]
|
||||
[(get-else $ ?c :client/locked-until #inst "2000-01-01") ?lu]
|
||||
[?t :journal-entry/date ?d]
|
||||
[(>= ?d ?lu)]]
|
||||
(dc/db conn))
|
||||
(map first)))
|
||||
|
||||
(defn bulk-delete [request]
|
||||
(assert-admin (:identity request))
|
||||
(let [params (:form-params request)
|
||||
ids (selected->ids (assoc-in request [:route-params :external?] true) params)
|
||||
all-ids (all-ids-not-locked ids)]
|
||||
(if (> (count all-ids) 1000)
|
||||
(modal-response
|
||||
(com/success-modal {:title "Too many ledger entries"}
|
||||
[:p "You can only delete 1000 ledger entries at a time."]))
|
||||
(do
|
||||
(alog/info ::bulk-delete-ledger :count (count all-ids) :sample (take 3 all-ids))
|
||||
(audit-transact-batch
|
||||
(map (fn [i] [:db/retractEntity i]) all-ids)
|
||||
(:identity request))
|
||||
(modal-response
|
||||
(com/success-modal {:title "Ledger Entries Deleted"}
|
||||
[:p (str "Successfully deleted " (count all-ids) " ledger entries.")])
|
||||
:headers {"hx-trigger" "invalidated, reset-selection"})))))
|
||||
|
||||
(defn delete [{invoice :entity :as request identity :identity}]
|
||||
(exception->notification
|
||||
#(when-not (= :invoice-status/unpaid (:invoice/status invoice))
|
||||
@@ -696,6 +730,8 @@
|
||||
::route/csv (helper/csv-route grid-page)
|
||||
::route/external-import-page external-import-page
|
||||
::route/bank-account-filter bank-account-filter
|
||||
::route/bulk-delete (-> bulk-delete
|
||||
(wrap-schema-enforce :form-schema query-schema))
|
||||
::route/external-import-parse (-> external-import-parse
|
||||
(wrap-schema-enforce :form-schema parse-form-schema)
|
||||
(wrap-form-4xx-2 external-import-parse)
|
||||
|
||||
@@ -482,10 +482,26 @@
|
||||
(assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)])
|
||||
:query-schema query-schema
|
||||
:action-buttons (fn [request]
|
||||
[(when-not (:external? (:route-params request)) (com/button {:color :primary
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
::route/new)}
|
||||
"Add journal entry"))])
|
||||
[(when-not (:external? (:route-params request))
|
||||
(com/button {:color :primary
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
::route/new)}
|
||||
"Add journal entry"))
|
||||
(when (and (:external? (:route-params request))
|
||||
(= "admin" (:user/role (:identity request))))
|
||||
(com/button {:color :red
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)
|
||||
;; target the persistent modal shell content slot directly so the
|
||||
;; request never relies on the outerHTML swap inherited from the
|
||||
;; data-grid card (which would replace #modal-holder and break the
|
||||
;; next click). modal-response also retargets here.
|
||||
:hx-target "#modal-content"
|
||||
:hx-swap "innerHTML"
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"x-bind:disabled" "selected.length === 0 && !all_selected"
|
||||
"hx-include" "#ledger-filters"
|
||||
:hx-confirm "Are you sure you want to delete these ledger entries?"}
|
||||
"Delete selected"))])
|
||||
:row-buttons (fn [request entity]
|
||||
[(when (and (= :invoice-status/unpaid (:invoice/status entity))
|
||||
(can? (:identity request) {:subject :invoice :activity :delete}))
|
||||
|
||||
@@ -102,16 +102,16 @@
|
||||
:numeric-code (:numeric_code account)
|
||||
:name (:name account)
|
||||
:sample sample
|
||||
:period {:start (coerce/to-date (:start p)) :end (coerce/to-date (:end p))}}))
|
||||
:period {:start (coerce/to-date (:start p)) :end (coerce/to-date (:end p))}}))
|
||||
|
||||
args (assoc (:form-params request)
|
||||
:periods (map (fn [d]
|
||||
{:start (coerce/to-date (:start d)) :end (coerce/to-date (:end d))}) periods))
|
||||
clients (pull-many (dc/db conn) [:client/code :client/name :db/id :client/feature-flags] client-ids)
|
||||
args (assoc (:form-params request)
|
||||
:periods (map (fn [d]
|
||||
{:start (coerce/to-date (:start d)) :end (coerce/to-date (:end d))}) periods))
|
||||
clients (pull-many (dc/db conn) [:client/code :client/name :db/id :client/feature-flags] client-ids)
|
||||
|
||||
pnl-data (l-reports/->PNLData args data (by :db/id :client/code clients))
|
||||
pnl-data (l-reports/->PNLData args data (by :db/id :client/code clients))
|
||||
#_#__ (clojure.pprint/pprint pnl-data)
|
||||
report (l-reports/summarize-pnl pnl-data)]
|
||||
report (l-reports/summarize-pnl pnl-data)]
|
||||
(alog/info ::profit-and-loss :params args)
|
||||
{:data report
|
||||
:report report})))
|
||||
@@ -129,7 +129,17 @@
|
||||
(let [{:keys [client warning]} (maybe-trim-clients request client)
|
||||
{:keys [data report]} (get-report (assoc-in request [:form-params :client] client))
|
||||
client-count (count (set (map :client-id (:data data))))
|
||||
table-contents (concat-tables (concat (:summaries report) (:details report)))]
|
||||
table-contents (concat-tables (concat (:summaries report) (:details report)))
|
||||
warning-text (not-empty (str/join "\n " (filter not-empty [warning (:warning report)])))
|
||||
sample-links (when (can? (:identity request)
|
||||
{:subject :history
|
||||
:activity :view})
|
||||
(seq (for [n (:invalid-ids report)]
|
||||
[:div
|
||||
(com/link {:href (str (bidi/path-for ssr-routes/only-routes
|
||||
:admin-history)
|
||||
"/" n)}
|
||||
"Sample")])))]
|
||||
(list
|
||||
[:div.text-2xl.font-bold.text-gray-600 (str "Profit and loss - " (str/join ", " (map :client/name client)))]
|
||||
(table {:widths (into [20] (take (dec (cell-count table-contents))
|
||||
@@ -139,19 +149,9 @@
|
||||
[13 6 13]
|
||||
[13 6])))))
|
||||
:investigate-url (bidi.bidi/path-for ssr-routes/only-routes ::route/investigate)
|
||||
:table table-contents
|
||||
:warning [:div
|
||||
(not-empty (str (str/join "\n " (filter not-empty [warning (:warning report)]))))
|
||||
|
||||
(when (can? (:identity request)
|
||||
{:subject :history
|
||||
:activity :view})
|
||||
(for [n (:invalid-ids report)]
|
||||
[:div
|
||||
(com/link {:href (str (bidi/path-for ssr-routes/only-routes
|
||||
:admin-history)
|
||||
"/" n)}
|
||||
"Sample")]))]}))))])
|
||||
:table table-contents
|
||||
:warning (when (or warning-text sample-links)
|
||||
[:div warning-text sample-links])}))))])
|
||||
|
||||
(defn form* [request & children]
|
||||
(let [params (or (:query-params request) {})]
|
||||
@@ -169,12 +169,12 @@
|
||||
(fc/with-field :client
|
||||
(com/validated-inline-field
|
||||
{:label "Customers" :errors (fc/field-errors)}
|
||||
(com/multi-typeahead {:name (fc/field-name)
|
||||
(com/multi-typeahead {:name (fc/field-name)
|
||||
:placeholder "Search for companies..."
|
||||
:class "w-64"
|
||||
:id "client"
|
||||
:id "client"
|
||||
:url (bidi/path-for ssr-routes/only-routes :company-search)
|
||||
:value (fc/field-value)
|
||||
:value (fc/field-value)
|
||||
:value-fn :db/id
|
||||
:content-fn :client/name})))
|
||||
(fc/with-field :periods
|
||||
@@ -204,12 +204,12 @@
|
||||
(defn profit-and-loss [request]
|
||||
(base-page
|
||||
request
|
||||
(com/page {:nav com/main-aside-nav
|
||||
(com/page {:nav com/main-aside-nav
|
||||
|
||||
:client-selection (:client-selection request)
|
||||
:clients (:clients request)
|
||||
:client (:client request)
|
||||
:identity (:identity request)
|
||||
:clients (:clients request)
|
||||
:client (:client request)
|
||||
:identity (:identity request)
|
||||
:request request}
|
||||
(apply com/breadcrumbs {} [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)}
|
||||
"Ledger"]])
|
||||
@@ -222,9 +222,9 @@
|
||||
table (concat-tables (:details report))]
|
||||
(pdf/pdf
|
||||
(-> [{:left-margin 10 :right-margin 10 :top-margin 15 :bottom-margin 15
|
||||
:size :letter
|
||||
:font {:size 6
|
||||
:ttf-name "fonts/calibri-light.ttf"}}
|
||||
:size :letter
|
||||
:font {:size 6
|
||||
:ttf-name "fonts/calibri-light.ttf"}}
|
||||
[:heading (str "Profit and Loss - " (str/join ", " (map :client/name (seq (:client (:form-params request))))))]]
|
||||
|
||||
(conj [:paragraph {:color [128 0 0] :size 9} (:warning report)])
|
||||
@@ -254,35 +254,35 @@
|
||||
(str/replace (->> client-ids (pull-many (dc/db conn) [:client/name]) (map :client/name) (str/join "-")) #"[^\w]" "_"))
|
||||
|
||||
(defn profit-and-loss-args->name [request]
|
||||
(let [date (atime/unparse-local
|
||||
(:date (:query-params request))
|
||||
atime/iso-date)
|
||||
name (->> request :query-params :client (map :db/id) join-names)]
|
||||
(let [{:keys [client periods]} (:form-params request)
|
||||
client (if (= :all client) (:clients request) client)
|
||||
date (some-> periods last :end (atime/unparse-local atime/iso-date))
|
||||
name (->> client (map :db/id) join-names)]
|
||||
(format "Profit-and-loss-%s-for-%s" date name)))
|
||||
|
||||
(defn print-profit-and-loss [request]
|
||||
(let [uuid (str (UUID/randomUUID))
|
||||
(let [uuid (str (UUID/randomUUID))
|
||||
{:keys [client warning]} (maybe-trim-clients request (:client (:form-params request)))
|
||||
request (assoc-in request [:form-params :client] client)
|
||||
request (assoc-in request [:form-params :client] client)
|
||||
pdf-data (binding [*report-pedantic* (boolean ((set (:client/feature-flags (first client)))
|
||||
"report-pedantic"))] (make-profit-and-loss-pdf request (:report (get-report request))))
|
||||
name (profit-and-loss-args->name request)
|
||||
key (str "reports/profit-and-loss/" uuid "/" name ".pdf")
|
||||
url (str "https://" (:data-bucket env) "/" key)]
|
||||
name (profit-and-loss-args->name request)
|
||||
key (str "reports/profit-and-loss/" uuid "/" name ".pdf")
|
||||
url (str "https://" (:data-bucket env) "/" key)]
|
||||
(s3/put-object :bucket-name (:data-bucket env/env)
|
||||
:key key
|
||||
:input-stream (io/make-input-stream pdf-data {})
|
||||
:metadata {:content-length (count pdf-data)
|
||||
:content-type "application/pdf"})
|
||||
:content-type "application/pdf"})
|
||||
@(dc/transact conn
|
||||
[{:report/name name
|
||||
:report/client (map :db/id client)
|
||||
:report/key key
|
||||
:report/url url
|
||||
[{:report/name name
|
||||
:report/client (map :db/id client)
|
||||
:report/key key
|
||||
:report/url url
|
||||
:report/creator (:user (:identity request))
|
||||
:report/created (java.util.Date.)}])
|
||||
{:report/name name
|
||||
:report/url url}))
|
||||
:report/url url}))
|
||||
|
||||
;; TODO PRINT WARNING
|
||||
(defn export [request]
|
||||
|
||||
@@ -9,29 +9,29 @@
|
||||
[auto-ap.client-routes :as client-routes]
|
||||
[auto-ap.routes.pos.sales-summaries :as route]
|
||||
[auto-ap.ssr-routes :as ssr-routes]
|
||||
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler]]
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.components.link-dropdown :refer [link-dropdown]]
|
||||
[auto-ap.ssr.components.multi-modal :as mm]
|
||||
[auto-ap.ssr.form-cursor :as fc]
|
||||
[auto-ap.ssr.components.selmer :as sc]
|
||||
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
|
||||
[auto-ap.ssr.hx :as hx]
|
||||
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
|
||||
[auto-ap.ssr.pos.common
|
||||
:refer [date-range-field*]]
|
||||
[auto-ap.ssr.selmer :as sel]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [apply-middleware-to-all-handlers clj-date-schema
|
||||
default-grid-fields-schema entity-id html-response money
|
||||
strip temp-id wrap-merge-prior-hx wrap-schema-enforce]]
|
||||
:refer [->db-id apply-middleware-to-all-handlers assert-schema
|
||||
clj-date-schema default-grid-fields-schema entity-id html-response
|
||||
main-transformer modal-response money path->name2 strip temp-id
|
||||
wrap-form-4xx-2 wrap-merge-prior-hx wrap-schema-enforce]]
|
||||
[auto-ap.time :as atime]
|
||||
[bidi.bidi :as bidi]
|
||||
[clj-time.coerce :as c]
|
||||
[clojure.string :as str]
|
||||
[datomic.api :as dc]
|
||||
[hiccup.util :as hu]
|
||||
[iol-ion.query :refer [dollars= dollars-0?]]
|
||||
[malli.core :as mc]
|
||||
[malli.util :as mut]))
|
||||
[iol-ion.query :refer [dollars=]]
|
||||
[malli.core :as mc]))
|
||||
|
||||
(def query-schema (mc/schema
|
||||
[:maybe
|
||||
@@ -133,63 +133,6 @@
|
||||
(str (subs s 0 (- max-len 3)) "...")
|
||||
s))
|
||||
|
||||
(defn account-typeahead*
|
||||
[{:keys [name value client-id]}]
|
||||
[:div.flex.flex-col
|
||||
(com/typeahead {:name name
|
||||
:placeholder "Search..."
|
||||
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
|
||||
{:client-id client-id
|
||||
:purpose "invoice"})
|
||||
:value value
|
||||
:content-fn (fn [value]
|
||||
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
|
||||
client-id)))})])
|
||||
|
||||
(defn account-display-cell [{:keys [item field-name-prefix client-id]}]
|
||||
(let [account-id (:ledger-mapped/account item)
|
||||
account-name (when account-id
|
||||
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read account-id)
|
||||
client-id)))]
|
||||
[:div.account-cell.flex.items-center.gap-2
|
||||
(com/hidden {:name (str field-name-prefix "[ledger-mapped/account]")
|
||||
:value (or account-id "")})
|
||||
(if account-id
|
||||
[:span.text-sm account-name]
|
||||
(com/pill {:color :red} "Missing acct"))
|
||||
(com/a-icon-button {:class "p-1"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-item-account)
|
||||
:hx-target "closest .account-cell"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-vals (hx/json {:item-index (or (:item-index item) 0)
|
||||
:client-id client-id
|
||||
:current-account-id (or account-id "")})}
|
||||
svg/pencil)]))
|
||||
|
||||
(defn account-edit-cell [{:keys [field-name-prefix client-id current-account-id]}]
|
||||
(let [account-input-name (str field-name-prefix "[ledger-mapped/account]")]
|
||||
[:div.account-cell.flex.flex-col.gap-2
|
||||
(account-typeahead* {:name account-input-name
|
||||
:value current-account-id
|
||||
:client-id client-id})
|
||||
[:div.flex.gap-1
|
||||
(com/a-icon-button {:class "p-1"
|
||||
:hx-put (bidi/path-for ssr-routes/only-routes ::route/save-item-account)
|
||||
:hx-target "closest .account-cell"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest .account-cell"
|
||||
:hx-vals (hx/json {:field-name-prefix field-name-prefix
|
||||
:client-id client-id})}
|
||||
svg/check)
|
||||
(com/a-icon-button {:class "p-1"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/cancel-item-account)
|
||||
:hx-target "closest .account-cell"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-vals (hx/json {:field-name-prefix field-name-prefix
|
||||
:client-id client-id
|
||||
:current-account-id (or current-account-id "")})}
|
||||
svg/x)]]))
|
||||
|
||||
(def grid-page
|
||||
(helper/build {:id "entity-table"
|
||||
:id-fn :db/id
|
||||
@@ -247,7 +190,7 @@
|
||||
[:span.shrink-0.font-mono.tabular-nums.text-right.text-gray-900.whitespace-nowrap
|
||||
(format "$%,.2f" (:ledger-mapped/amount si))]])
|
||||
(for [_ (range (max 0 (- credit-count (count debit-items))))]
|
||||
[:li.py-0.5.text-sm " "])]
|
||||
[:li.py-0.5.text-sm " "])]
|
||||
[:div.border-t-2.border-gray-300.mt-1.pt-1.flex.justify-between.items-baseline
|
||||
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-gray-500 "Total"]
|
||||
[:span.font-mono.tabular-nums.font-bold.text-gray-900
|
||||
@@ -273,7 +216,7 @@
|
||||
[:span.shrink-0.font-mono.tabular-nums.text-right.text-gray-900.whitespace-nowrap
|
||||
(format "$%,.2f" (:ledger-mapped/amount si))]])
|
||||
(for [_ (range (max 0 (- debit-count (count credit-items))))]
|
||||
[:li.py-0.5.text-sm " "])]
|
||||
[:li.py-0.5.text-sm " "])]
|
||||
[:div.border-t-2.border-gray-300.mt-1.pt-1.flex.justify-between.items-baseline
|
||||
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-gray-500 "Total"]
|
||||
[:span.font-mono.tabular-nums.font-bold.text-gray-900
|
||||
@@ -348,296 +291,308 @@
|
||||
(not (and (:credit x)
|
||||
(:debit x))))]]]]])
|
||||
|
||||
(defn summary-total-row* [request]
|
||||
(let [total-credits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-credits))
|
||||
total-debits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-debits))]
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Field-name / error helpers (no step-params[...] prefix) + item helpers.
|
||||
;; Mirrors transaction/edit.clj and transaction/bulk_code.clj.
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(com/data-grid-row {:id "total-row"
|
||||
:class "bg-slate-50 border-t-2 border-slate-300"}
|
||||
(com/data-grid-cell {})
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-slate-600
|
||||
"Total"])
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
[:span.font-mono.tabular-nums.font-bold.text-slate-900
|
||||
(format "$%,.2f" total-debits)])
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
[:span.font-mono.tabular-nums.font-bold.text-slate-900
|
||||
(format "$%,.2f" total-credits)])
|
||||
(com/data-grid-cell {}))))
|
||||
(def ^:dynamic *errors*
|
||||
"Humanized form errors for the current render, keyed by edit-schema paths. Bound by
|
||||
render-form from the request's :form-errors. Plain map -- no wizard, no cursor."
|
||||
{})
|
||||
|
||||
(defn unbalanced-row* [request]
|
||||
(let [total-credits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-credits))
|
||||
total-debits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-debits))
|
||||
unbalanced? (not (dollars= total-credits total-debits))
|
||||
debit-over? (and unbalanced? (> total-debits total-credits))
|
||||
credit-over? (and unbalanced? (> total-credits total-debits))]
|
||||
(defn- ferr [& path]
|
||||
(get-in *errors* (vec path)))
|
||||
|
||||
(com/data-grid-row {:id "unbalanced-row"
|
||||
:class (when unbalanced? "bg-red-50 border-t border-red-200")}
|
||||
(com/data-grid-cell {})
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
(when unbalanced?
|
||||
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-red-700
|
||||
"Out of balance"]))
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
(when debit-over?
|
||||
[:span.font-mono.tabular-nums.font-bold.text-red-700
|
||||
(format "$%,.2f" (- total-debits total-credits))]))
|
||||
(com/data-grid-cell {:class "text-right"}
|
||||
(when credit-over?
|
||||
[:span.font-mono.tabular-nums.font-bold.text-red-700
|
||||
(format "$%,.2f" (- total-credits total-debits))]))
|
||||
(com/data-grid-cell {}))))
|
||||
(defn- item-field-name [index field]
|
||||
(path->name2 :sales-summary/items index field))
|
||||
|
||||
(defn summary-total-display [request]
|
||||
(let [total-credits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-credits))
|
||||
total-debits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-debits))]
|
||||
[:div.flex.justify-between.text-sm.py-1.border-t.mt-1
|
||||
{:id "total-display"}]
|
||||
[:span.font-semibold "Total"]
|
||||
[:div.flex.gap-8
|
||||
[:span.font-mono (format "$%,.2f" total-debits)]
|
||||
[:span.font-mono (format "$%,.2f" total-credits)]]))
|
||||
(defn- item-field-errors [index field]
|
||||
(ferr :sales-summary/items index field))
|
||||
|
||||
(defn unbalanced-display [request]
|
||||
(let [total-credits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-credits))
|
||||
total-debits (-> request
|
||||
:multi-form-state
|
||||
:step-params
|
||||
:sales-summary/items
|
||||
(total-debits))
|
||||
delta (- total-debits total-credits)]
|
||||
(when-not (dollars-0? delta)
|
||||
[:div.flex.justify-between.text-sm.py-1
|
||||
{:id "unbalanced-display"}
|
||||
[:span.font-semibold.text-red-600 "Unbalanced"]
|
||||
[:div.flex.gap-8
|
||||
[:span.font-mono (when (pos? delta) (format "$%,.2f" delta))
|
||||
[:span.font-mono (when (neg? delta) (format "$%,.2f" (Math/abs delta)))]]]])))
|
||||
(defn- item-side
|
||||
"Which column an item belongs to: its persisted ledger-side for auto items, else the
|
||||
filled-in credit/debit for a manual row (nil for a brand-new blank manual row)."
|
||||
[item]
|
||||
(cond
|
||||
(= :ledger-side/debit (:ledger-mapped/ledger-side item)) :debit
|
||||
(= :ledger-side/credit (:ledger-mapped/ledger-side item)) :credit
|
||||
(:debit item) :debit
|
||||
(:credit item) :credit
|
||||
:else nil))
|
||||
|
||||
(defn sales-summary-item-row* [{:keys [value client-id]}]
|
||||
(let [manual? (fc/field-value (:sales-summary-item/manual? value))]
|
||||
(com/data-grid-row (cond-> {:x-ref "p"
|
||||
:x-data (hx/json {})
|
||||
:class (when manual?
|
||||
"bg-indigo-50/40 border-l-2 border-indigo-300")}
|
||||
(fc/field-value (:new? value)) (hx/htmx-transition-appear))
|
||||
(fc/with-field :db/id
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (fc/field-value)}))
|
||||
(when manual?
|
||||
(fc/with-field :sales-summary-item/manual?
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value true})))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
(fc/with-field :sales-summary-item/category
|
||||
(if manual?
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/text-input {:placeholder "Category/Explanation"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)}))
|
||||
(defn- sum-debits [items]
|
||||
(->> items (keep :debit) (filter number?) (reduce + 0.0)))
|
||||
|
||||
(list
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (fc/field-value)})
|
||||
[:span.text-sm.text-gray-700
|
||||
(fc/field-value (:sales-summary-item/category value))]))))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
(fc/with-field :ledger-mapped/account
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(account-typeahead* {:value (fc/field-value)
|
||||
:client-id client-id
|
||||
:name (fc/field-name)}))))
|
||||
(com/data-grid-cell {:class "text-right align-top"}
|
||||
(defn- sum-credits [items]
|
||||
(->> items (keep :credit) (filter number?) (reduce + 0.0)))
|
||||
|
||||
(if manual?
|
||||
(fc/with-field :debit
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})))
|
||||
(when (= (fc/field-value (:ledger-mapped/ledger-side value))
|
||||
:ledger-side/debit)
|
||||
[:span.font-mono.tabular-nums.text-gray-900.text-sm.whitespace-nowrap
|
||||
(format "$%,.2f" (fc/field-value (:ledger-mapped/amount value)))])))
|
||||
(com/data-grid-cell {:class "text-right align-top"}
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Render (Selmer): account typeahead, inline account cell (display/edit),
|
||||
;; the read-only auto rows, the editable manual rows, totals/balance.
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(if manual?
|
||||
(fc/with-field :credit
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})))
|
||||
(when (= (fc/field-value (:ledger-mapped/ledger-side value))
|
||||
:ledger-side/credit)
|
||||
[:span.font-mono.tabular-nums.text-gray-900.text-sm.whitespace-nowrap
|
||||
(format "$%,.2f" (fc/field-value (:ledger-mapped/amount value)))])))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
(when manual?
|
||||
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x))))))
|
||||
(defn account-typeahead* [{:keys [name value client-id]}]
|
||||
(sc/typeahead {:name name
|
||||
:id name
|
||||
:placeholder "Search..."
|
||||
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
|
||||
{:client-id client-id
|
||||
:purpose "invoice"})
|
||||
:value value
|
||||
:content-fn (fn [value]
|
||||
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
|
||||
client-id)))}))
|
||||
|
||||
(defrecord MainStep [linear-wizard]
|
||||
mm/ModalWizardStep
|
||||
(step-name [_]
|
||||
"Main")
|
||||
(step-key [_]
|
||||
:main)
|
||||
(defn account-display-cell*
|
||||
"Account name + inline-edit pencil. The pencil swaps just this `.account-cell`
|
||||
(#account-cell, Rule 2) into the edit cell."
|
||||
[{:keys [index account-id client-id]}]
|
||||
(let [account-id (when (and account-id (not= account-id "")) (->db-id account-id))
|
||||
account-name (when account-id
|
||||
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read account-id)
|
||||
client-id)))]
|
||||
(str "<div class=\"account-cell flex items-center gap-2\">"
|
||||
(str (sc/hidden {:name (item-field-name index :ledger-mapped/account)
|
||||
:value (or account-id "")}))
|
||||
(if account-name
|
||||
(str "<span class=\"text-sm\">" (hu/escape-html account-name) "</span>")
|
||||
(str (sel/hiccup->html (com/pill {:color :red} "Missing acct"))))
|
||||
(str (sc/a-icon-button {:class "p-1"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-item-account)
|
||||
:hx-target "closest .account-cell"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-vals (hx/json {:item-index index
|
||||
:client-id client-id
|
||||
:current-account-id (or account-id "")})}
|
||||
svg/pencil))
|
||||
"</div>")))
|
||||
|
||||
(edit-path [_ _]
|
||||
[])
|
||||
(defn account-edit-cell*
|
||||
"The account typeahead + check (save) / cancel buttons. Each swaps just the
|
||||
`.account-cell` back to the display cell."
|
||||
[{:keys [index account-id client-id]}]
|
||||
(str "<div class=\"account-cell flex flex-col gap-2\">"
|
||||
(str (account-typeahead* {:name (item-field-name index :ledger-mapped/account)
|
||||
:value account-id
|
||||
:client-id client-id}))
|
||||
"<div class=\"flex gap-1\">"
|
||||
(str (sc/a-icon-button {:class "p-1"
|
||||
:hx-put (bidi/path-for ssr-routes/only-routes ::route/save-item-account)
|
||||
:hx-target "closest .account-cell"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest .account-cell"
|
||||
:hx-vals (hx/json {:item-index index :client-id client-id})}
|
||||
svg/check))
|
||||
(str (sc/a-icon-button {:class "p-1"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/cancel-item-account)
|
||||
:hx-target "closest .account-cell"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-vals (hx/json {:item-index index
|
||||
:client-id client-id
|
||||
:current-account-id (or account-id "")})}
|
||||
svg/x))
|
||||
"</div></div>"))
|
||||
|
||||
(step-schema [_]
|
||||
(mut/select-keys (mm/form-schema linear-wizard) #{:db/id :sales-summary/items}))
|
||||
(defn- auto-item-row*
|
||||
"A read-only auto item in its Debits/Credits column: category + inline-editable account
|
||||
cell + the (read-only) amount. Posts db/id, category, and account."
|
||||
[index item client-id]
|
||||
(let [side (item-side item)
|
||||
amount (if (= side :debit) (:debit item) (:credit item))]
|
||||
(str "<div class=\"flex items-center gap-2 text-sm\">"
|
||||
(str (sc/hidden {:name (item-field-name index :db/id) :value (:db/id item)}))
|
||||
(str (sc/hidden {:name (item-field-name index :sales-summary-item/category)
|
||||
:value (:sales-summary-item/category item)}))
|
||||
"<span class=\"text-gray-500 flex-1\">" (hu/escape-html (str (:sales-summary-item/category item))) "</span>"
|
||||
(str (account-display-cell* {:index index
|
||||
:account-id (:ledger-mapped/account item)
|
||||
:client-id client-id}))
|
||||
"<span class=\"ml-auto font-mono tabular-nums text-gray-900\">" (format "$%,.2f" (or amount 0.0)) "</span>"
|
||||
"</div>")))
|
||||
|
||||
(render-step
|
||||
[this {:keys [multi-form-state] :as request}]
|
||||
(let [client-id (:db/id (:sales-summary/client (:snapshot multi-form-state)))
|
||||
items (:sales-summary/items (:step-params multi-form-state))
|
||||
sorted-items (sort-items items)
|
||||
indexed-items (map-indexed vector sorted-items)
|
||||
debit-items (filter #(= :ledger-side/debit (:ledger-mapped/ledger-side (second %))) indexed-items)
|
||||
credit-items (filter #(= :ledger-side/credit (:ledger-mapped/ledger-side (second %))) indexed-items)
|
||||
max-rows (max (count debit-items) (count credit-items))
|
||||
padded-debits (concat debit-items (repeat (- max-rows (count debit-items)) nil))
|
||||
padded-credits (concat credit-items (repeat (- max-rows (count credit-items)) nil))]
|
||||
(mm/default-render-step
|
||||
linear-wizard this
|
||||
:head [:div.p-2 "Edit Summary"]
|
||||
:body (mm/default-step-body
|
||||
{}
|
||||
[:div
|
||||
(fc/with-field :db/id
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (fc/field-value)}))
|
||||
[:div.grid.grid-cols-2.gap-6
|
||||
[:div
|
||||
[:div.font-semibold.text-sm.mb-2 "Debits"]
|
||||
[:div.space-y-1
|
||||
(for [[actual-idx item] padded-debits]
|
||||
(if item
|
||||
(let [manual? (:sales-summary-item/manual? item)]
|
||||
(if manual?
|
||||
[:div.flex.items-center.gap-2.text-sm {:x-ref "p" :x-data (hx/json {})}
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
|
||||
:value (:db/id item)})
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/manual?]")
|
||||
:value "true"})
|
||||
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
|
||||
item []
|
||||
(fc/with-field :sales-summary-item/category
|
||||
(com/text-input {:placeholder "Category"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)
|
||||
:class "w-32 text-sm"})))
|
||||
(account-typeahead* {:name (str "step-params[sales-summary/items][" actual-idx "][ledger-mapped/account]")
|
||||
:value (:ledger-mapped/account item)
|
||||
:client-id client-id})
|
||||
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
|
||||
item []
|
||||
(fc/with-field :debit
|
||||
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})))
|
||||
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x)]
|
||||
[:div.flex.items-center.gap-2.text-sm
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
|
||||
:value (:db/id item)})
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/category]")
|
||||
:value (:sales-summary-item/category item)})
|
||||
[:span.text-gray-500 (truncate (:sales-summary-item/category item) 30)]
|
||||
(account-display-cell {:item (assoc item :item-index actual-idx)
|
||||
:field-name-prefix (str "step-params[sales-summary/items][" actual-idx "]")
|
||||
:client-id client-id})
|
||||
[:span.ml-auto.font-mono.tabular-nums.text-gray-900 (format "$%,.2f" (:ledger-mapped/amount item))]]))
|
||||
[:div.h-6]))]
|
||||
[:div.mt-2.border-t.pt-1
|
||||
(summary-total-display request)
|
||||
(unbalanced-display request)]]
|
||||
[:div
|
||||
[:div.font-semibold.text-sm.mb-2 "Credits"]
|
||||
[:div.space-y-1
|
||||
(for [[actual-idx item] padded-credits]
|
||||
(if item
|
||||
(let [manual? (:sales-summary-item/manual? item)]
|
||||
(if manual?
|
||||
[:div.flex.items-center.gap-2.text-sm {:x-ref "p" :x-data (hx/json {})}
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
|
||||
:value (:db/id item)})
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/manual?]")
|
||||
:value "true"})
|
||||
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
|
||||
item []
|
||||
(fc/with-field :sales-summary-item/category
|
||||
(com/text-input {:placeholder "Category"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)
|
||||
:class "w-32 text-sm"})))
|
||||
(account-typeahead* {:name (str "step-params[sales-summary/items][" actual-idx "][ledger-mapped/account]")
|
||||
:value (:ledger-mapped/account item)
|
||||
:client-id client-id})
|
||||
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
|
||||
item []
|
||||
(fc/with-field :credit
|
||||
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
|
||||
:name (fc/field-name)
|
||||
:value (fc/field-value)})))
|
||||
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x)]
|
||||
[:div.flex.items-center.gap-2.text-sm
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
|
||||
:value (:db/id item)})
|
||||
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/category]")
|
||||
:value (:sales-summary-item/category item)})
|
||||
[:span.text-gray-500 (truncate (:sales-summary-item/category item) 30)]
|
||||
(account-display-cell {:item (assoc item :item-index actual-idx)
|
||||
:field-name-prefix (str "step-params[sales-summary/items][" actual-idx "]")
|
||||
:client-id client-id})
|
||||
[:span.ml-auto.font-mono.tabular-nums.text-gray-900 (format "$%,.2f" (:ledger-mapped/amount item))]]))
|
||||
[:div.h-6]))]
|
||||
[:div.mt-2.border-t.pt-1
|
||||
(summary-total-display request)
|
||||
(unbalanced-display request)]]]
|
||||
[:div.mt-4.border-t.pt-2
|
||||
(fc/with-field :sales-summary/items
|
||||
(com/data-grid-new-row {:colspan 2
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-summary-item)
|
||||
:row-offset 0
|
||||
:index (count (fc/field-value))
|
||||
:tr-params {:hx-vals (hx/json {:client-id client-id})}}
|
||||
"New Summary Item"))]])
|
||||
(defn- manual-amount-input* [index field item]
|
||||
(sc/money-input {:name (item-field-name index field)
|
||||
:value (get item field)
|
||||
:class "w-24 text-right font-mono tabular-nums"
|
||||
:placeholder (str/capitalize (clojure.core/name field))
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/form-changed)
|
||||
:hx-target "#summary-totals"
|
||||
:hx-select "#summary-totals"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-trigger "keyup changed delay:300ms"
|
||||
:hx-include "closest form"}))
|
||||
|
||||
:footer
|
||||
(mm/default-step-footer linear-wizard this :validation-route ::route/edit-wizard-navigate)
|
||||
:validation-route ::route/edit-wizard-navigate
|
||||
:width-height-class "lg:w-[900px] lg:h-[600px]"))))
|
||||
(defn- manual-item-row*
|
||||
"An editable manual item: category + account typeahead + debit + credit money inputs +
|
||||
remove. The amount inputs swap the totals block (Rule 4); remove swaps the whole form."
|
||||
[index item client-id]
|
||||
(str "<div class=\"manual-item-row flex items-center gap-2\">"
|
||||
(str (sc/hidden {:name (item-field-name index :db/id) :value (:db/id item)}))
|
||||
(str (sc/hidden {:name (item-field-name index :sales-summary-item/manual?) :value "true"}))
|
||||
(str (sc/validated-field
|
||||
{:errors (item-field-errors index :sales-summary-item/category) :class "flex-1"}
|
||||
(sc/text-input {:name (item-field-name index :sales-summary-item/category)
|
||||
:value (:sales-summary-item/category item)
|
||||
:placeholder "Category/Explanation"})))
|
||||
(str (sc/validated-field
|
||||
{:errors (item-field-errors index :ledger-mapped/account)}
|
||||
(account-typeahead* {:name (item-field-name index :ledger-mapped/account)
|
||||
:value (:ledger-mapped/account item)
|
||||
:client-id client-id})))
|
||||
(str (manual-amount-input* index :debit item))
|
||||
(str (manual-amount-input* index :credit item))
|
||||
(str (sc/a-icon-button {:class "p-1 account-remove-action"
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/form-changed)
|
||||
:hx-vals (hx/json {:op "remove-item" :row-index index})
|
||||
:hx-target "#summary-edit-form"
|
||||
:hx-select "#summary-edit-form"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"}
|
||||
svg/x))
|
||||
"</div>"))
|
||||
|
||||
(defn- totals*
|
||||
"Swappable totals + balance block (#summary-totals). Fixes the previously-dead total /
|
||||
balance display: shows the running debit/credit totals and a Balanced / Unbalanced
|
||||
indicator."
|
||||
[items]
|
||||
(let [td (sum-debits items)
|
||||
tc (sum-credits items)
|
||||
balanced? (dollars= td tc)
|
||||
delta (- td tc)]
|
||||
(str "<div class=\"border-t pt-2 mt-2 space-y-1\">"
|
||||
"<div class=\"flex justify-between text-sm font-semibold\"><span>Total</span>"
|
||||
"<div class=\"flex gap-8\"><span class=\"font-mono\">" (format "$%,.2f" td) "</span>"
|
||||
"<span class=\"font-mono\">" (format "$%,.2f" tc) "</span></div></div>"
|
||||
(if balanced?
|
||||
"<div class=\"text-sm text-emerald-700 font-semibold\">Balanced</div>"
|
||||
(str "<div class=\"text-sm text-red-600 font-semibold flex justify-between\"><span>Unbalanced</span>"
|
||||
"<span class=\"font-mono\">" (format "$%,.2f" (Math/abs delta)) " "
|
||||
(if (pos? delta) "Debit over" "Credit over") "</span></div>"))
|
||||
"</div>")))
|
||||
|
||||
(defn- new-item-button* []
|
||||
(sc/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/form-changed)
|
||||
:hx-vals (hx/json {:op "new-item"})
|
||||
:hx-target "#summary-edit-form"
|
||||
:hx-select "#summary-edit-form"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"
|
||||
:color :secondary}
|
||||
"New Summary Item"))
|
||||
|
||||
(defn- form-errors-html [errors]
|
||||
(str "<div id=\"form-errors\">"
|
||||
(when (seq errors)
|
||||
(str "<span class=\"error-content\"><p class=\"mt-2 text-xs text-red-600 dark:text-red-500 h-4\">"
|
||||
(str/join ", " (filter string? errors))
|
||||
"</p></span>"))
|
||||
"</div>"))
|
||||
|
||||
(defn- footer* [request]
|
||||
(sel/raw
|
||||
(str "<div class=\"flex justify-end\"><div class=\"flex items-baseline gap-x-4\">"
|
||||
(form-errors-html (:errors (:form-errors request)))
|
||||
(str (sc/button {:color :primary :x-ref "next" :class "w-32" :type "submit"} "Save"))
|
||||
"</div></div>")))
|
||||
|
||||
(defn render-form
|
||||
"Renders the whole plain sales-summary edit form (no wizard). Binds *errors* so the
|
||||
field-level lookups (item-field-errors) resolve. Reuses the edit modal chrome."
|
||||
[request]
|
||||
(binding [*errors* (or (:form-errors request) {})]
|
||||
(let [{tx-id :db/id client :sales-summary/client items :sales-summary/items} (:edit-state request)
|
||||
client-id (:db/id client)
|
||||
indexed (map-indexed vector items)
|
||||
auto (remove (fn [[_ it]] (:sales-summary-item/manual? it)) indexed)
|
||||
manual (filter (fn [[_ it]] (:sales-summary-item/manual? it)) indexed)
|
||||
debit-rows (->> auto
|
||||
(filter (fn [[_ it]] (= :debit (item-side it))))
|
||||
(map (fn [[i it]] (auto-item-row* i it client-id)))
|
||||
(apply str))
|
||||
credit-rows (->> auto
|
||||
(filter (fn [[_ it]] (= :credit (item-side it))))
|
||||
(map (fn [[i it]] (auto-item-row* i it client-id)))
|
||||
(apply str))
|
||||
manual-rows (->> manual
|
||||
(map (fn [[i it]] (manual-item-row* i it client-id)))
|
||||
(apply str))
|
||||
body (sel/render "templates/sales-summary/summary-body.html"
|
||||
{:debit_rows debit-rows
|
||||
:credit_rows credit-rows
|
||||
:totals (totals* items)
|
||||
:manual_rows manual-rows
|
||||
:new_item_button (str (new-item-button*))})
|
||||
modal-card (sel/render "templates/transaction-edit/edit-modal.html"
|
||||
{:head "<div class=\"p-2\">Edit Summary</div>"
|
||||
:side_panel nil
|
||||
:body body
|
||||
:footer (str (footer* request))})]
|
||||
(sel/render->hiccup
|
||||
"templates/sales-summary/edit-form.html"
|
||||
{:db_id tx-id
|
||||
:form_attrs (sc/attrs->str {:hx-ext "response-targets"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-target-400 "#form-errors .error-content"
|
||||
:hx-trigger "submit"
|
||||
:hx-target "this"
|
||||
:hx-put (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-submit)})
|
||||
:modal (str (sc/modal {:id "wizardmodal"} (sel/raw modal-card)))}))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; State: derive the flat edit-state from the entity overlaid with the posted
|
||||
;; form (replaces MultiStepFormState + the EDN snapshot round-trip).
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn entity->edit-state
|
||||
"The persisted sales summary, shaped like the form's state: each item gets a :credit or
|
||||
:debit field derived from its ledger-side/amount (what initial-edit-wizard-state did)."
|
||||
[tx-id]
|
||||
(let [e (dc/pull (dc/db conn) default-read tx-id)
|
||||
items (->> (:sales-summary/items e)
|
||||
sort-items
|
||||
(mapv (fn [x]
|
||||
(if (= :ledger-side/debit (:ledger-mapped/ledger-side x))
|
||||
(assoc x :debit (:ledger-mapped/amount x))
|
||||
(assoc x :credit (:ledger-mapped/amount x))))))]
|
||||
{:db/id (:db/id e)
|
||||
:sales-summary/client (:sales-summary/client e)
|
||||
:sales-summary/items items}))
|
||||
|
||||
(defn- merge-items
|
||||
"Overlay the posted items onto the persisted items by :db/id, so read-only fields the
|
||||
form doesn't post (ledger-side, amount, the credit/debit shaping for auto items)
|
||||
survive while edited fields (category, account, manual credit/debit) win. New manual
|
||||
rows (temp db/id) have no persisted match and ride through as-is."
|
||||
[entity-items posted-items]
|
||||
(let [by-id (into {} (map (juxt :db/id identity)) entity-items)]
|
||||
(mapv (fn [pi] (merge (get by-id (:db/id pi)) pi)) posted-items)))
|
||||
|
||||
(defn wrap-decode
|
||||
"Parses the posted (nested) form params and decodes them straight into edit-schema --
|
||||
no step-params[...] prefix. Strips to the editable top-level keys."
|
||||
[handler]
|
||||
(-> (fn [request]
|
||||
(let [decoded (mc/decode edit-schema (:form-params request) main-transformer)
|
||||
decoded (if (map? decoded) (select-keys decoded [:db/id :sales-summary/items]) {})]
|
||||
(handler (assoc request :posted decoded))))
|
||||
(wrap-nested-form-params)))
|
||||
|
||||
(defn wrap-derive-state
|
||||
"Builds :edit-state from the entity (db/id hidden, or the route on initial open) overlaid
|
||||
with the live posted items -- no serialized snapshot. db/id + client always come from
|
||||
the entity; items are the merged posted items when present, else the entity's."
|
||||
[handler]
|
||||
(fn [request]
|
||||
(let [tx-id (->db-id (or (some-> request :form-params (get "db/id"))
|
||||
(-> request :route-params :db/id)))
|
||||
base (entity->edit-state tx-id)
|
||||
posted (:posted request)
|
||||
items (if (contains? posted :sales-summary/items)
|
||||
(merge-items (:sales-summary/items base) (:sales-summary/items posted))
|
||||
(:sales-summary/items base))]
|
||||
(handler (assoc request :edit-state (assoc base :sales-summary/items items))))))
|
||||
|
||||
(defn attach-ledger [i]
|
||||
(cond-> i
|
||||
@@ -645,142 +600,129 @@
|
||||
:ledger-mapped/amount (:credit i))
|
||||
(:debit i) (assoc :ledger-mapped/ledger-side :ledger-side/debit
|
||||
:ledger-mapped/amount (:debit i))
|
||||
true (dissoc :credit :debit)
|
||||
true (dissoc :credit :debit :new? :item-index)
|
||||
true (assoc :sales-summary-item/manual? true)))
|
||||
|
||||
(defrecord EditWizard [_ current-step]
|
||||
mm/LinearModalWizard
|
||||
(hydrate-from-request
|
||||
[this request]
|
||||
this)
|
||||
(navigate [this step-key]
|
||||
(assoc this :current-step step-key))
|
||||
(get-current-step
|
||||
[this]
|
||||
(mm/get-step this :main))
|
||||
(render-wizard [this {:keys [multi-form-state] :as request}]
|
||||
(mm/default-render-wizard
|
||||
this request
|
||||
:form-params
|
||||
(-> mm/default-form-props
|
||||
(assoc :hx-put
|
||||
(str (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-submit))))
|
||||
:render-timeline? false))
|
||||
(steps [_]
|
||||
[:main])
|
||||
(get-step [this step-key]
|
||||
(let [step-key-result (mc/parse mm/step-key-schema step-key)
|
||||
[step-key-type step-key] step-key-result]
|
||||
(->MainStep this)))
|
||||
(form-schema [_]
|
||||
edit-schema)
|
||||
(submit [this {:keys [multi-form-state request-method identity] :as request}]
|
||||
(let [result (:snapshot multi-form-state)
|
||||
transaction [:upsert-sales-summary {:db/id (:db/id result)
|
||||
:sales-summary/items (map
|
||||
(fn [i]
|
||||
(if (:sales-summary-item/manual? i)
|
||||
(attach-ledger i)
|
||||
{:db/id (:db/id i)
|
||||
:ledger-mapped/account (:ledger-mapped/account i)}))
|
||||
(:sales-summary/items result))}]]
|
||||
@(dc/transact conn [transaction])
|
||||
(html-response
|
||||
(row* identity (dc/pull (dc/db conn) default-read (:db/id result))
|
||||
{:flash? true
|
||||
:request request})
|
||||
:headers (cond-> {"hx-trigger" "modalclose"
|
||||
"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id result))
|
||||
"hx-reswap" "outerHTML"})))))
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; form-changed ops (whole-form re-render): add / remove a manual item. A bare
|
||||
;; re-render (no op) refreshes the totals block (manual amount edits).
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(def edit-wizard (->EditWizard nil nil))
|
||||
(defn apply-new-item [request]
|
||||
(let [items (vec (:sales-summary/items (:edit-state request)))
|
||||
new-item {:db/id (str (java.util.UUID/randomUUID))
|
||||
:new? true
|
||||
:sales-summary-item/manual? true
|
||||
:sales-summary-item/category ""}]
|
||||
(assoc-in request [:edit-state :sales-summary/items] (conj items new-item))))
|
||||
|
||||
(defn initial-edit-wizard-state [request]
|
||||
(let [entity (dc/pull (dc/db conn) default-read (:db/id (:route-params request)))
|
||||
entity (select-keys entity (mut/keys edit-schema))
|
||||
entity (update entity :sales-summary/items (comp #(map (fn [x]
|
||||
(if (= :ledger-side/debit (:ledger-mapped/ledger-side x))
|
||||
(assoc x :debit (:ledger-mapped/amount x))
|
||||
(assoc x :credit (:ledger-mapped/amount x))))
|
||||
%) sort-items))]
|
||||
(defn apply-remove-item [request]
|
||||
(let [row-index (some-> request :form-params (get "row-index") Integer/parseInt)
|
||||
items (vec (:sales-summary/items (:edit-state request)))
|
||||
updated (if (and row-index (< row-index (count items)))
|
||||
(vec (concat (subvec items 0 row-index)
|
||||
(subvec items (inc row-index))))
|
||||
items)]
|
||||
(assoc-in request [:edit-state :sales-summary/items] updated)))
|
||||
|
||||
(mm/->MultiStepFormState entity [] entity)))
|
||||
(defn form-changed-handler
|
||||
"Single whole-form re-render endpoint. Dispatches on `op` (add/remove a manual item);
|
||||
a missing op (a manual amount keyup) just re-renders (hx-select picks #summary-totals)."
|
||||
[request]
|
||||
(let [op (get-in request [:form-params "op"])
|
||||
request' (case op
|
||||
"new-item" (apply-new-item request)
|
||||
"remove-item" (apply-remove-item request)
|
||||
request)]
|
||||
(html-response (render-form request'))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Inline account editor (targeted .account-cell swaps -- a distinct click-to-edit
|
||||
;; feature, kept as its own three small stateless routes).
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn edit-item-account [request]
|
||||
(let [{:keys [item-index client-id current-account-id]} (:query-params request)
|
||||
item-index (if (string? item-index) (Integer/parseInt item-index) item-index)
|
||||
field-name-prefix (str "step-params[sales-summary/items][" item-index "]")
|
||||
current-account-id (when (and current-account-id (not= current-account-id ""))
|
||||
(if (string? current-account-id)
|
||||
(Long/parseLong current-account-id)
|
||||
current-account-id))
|
||||
client-id (if (string? client-id) (Long/parseLong client-id) client-id)]
|
||||
idx (if (string? item-index) (Integer/parseInt item-index) item-index)
|
||||
account-id (when (and current-account-id (not= current-account-id "")) (->db-id current-account-id))]
|
||||
(html-response
|
||||
(account-edit-cell {:field-name-prefix field-name-prefix
|
||||
:client-id client-id
|
||||
:current-account-id current-account-id}))))
|
||||
(sel/raw (account-edit-cell* {:index idx :account-id account-id :client-id (->db-id client-id)})))))
|
||||
|
||||
(defn save-item-account [request]
|
||||
(let [field-name-prefix (get-in request [:params "field-name-prefix"])
|
||||
client-id (get-in request [:params "client-id"])
|
||||
account-input-name (str field-name-prefix "[ledger-mapped/account]")
|
||||
account-id-str (get-in request [:form-params account-input-name])
|
||||
account-id (when (and account-id-str (not= account-id-str ""))
|
||||
(Long/parseLong account-id-str))
|
||||
item {:ledger-mapped/account account-id
|
||||
:item-index (second (re-find #"\[(\d+)\]" (or field-name-prefix "")))}
|
||||
client-id (if (string? client-id) (Long/parseLong client-id) client-id)]
|
||||
(let [item-index (get-in request [:params "item-index"])
|
||||
idx (if (string? item-index) (Integer/parseInt item-index) item-index)
|
||||
client-id (->db-id (get-in request [:params "client-id"]))
|
||||
account-id-str (get-in request [:form-params (item-field-name idx :ledger-mapped/account)])
|
||||
account-id (when (and account-id-str (not= account-id-str "")) (->db-id account-id-str))]
|
||||
(html-response
|
||||
(account-display-cell {:item item
|
||||
:field-name-prefix field-name-prefix
|
||||
:client-id client-id}))))
|
||||
(sel/raw (account-display-cell* {:index idx :account-id account-id :client-id client-id})))))
|
||||
|
||||
(defn cancel-item-account [request]
|
||||
(let [{:keys [field-name-prefix client-id current-account-id]} (:query-params request)
|
||||
account-id (when (and current-account-id (not= current-account-id ""))
|
||||
(if (string? current-account-id)
|
||||
(Long/parseLong current-account-id)
|
||||
current-account-id))
|
||||
item {:ledger-mapped/account account-id
|
||||
:item-index (second (re-find #"\[(\d+)\]" (or field-name-prefix "")))}
|
||||
client-id (if (string? client-id) (Long/parseLong client-id) client-id)]
|
||||
(let [{:keys [item-index client-id current-account-id]} (:query-params request)
|
||||
idx (if (string? item-index) (Integer/parseInt item-index) item-index)
|
||||
account-id (when (and current-account-id (not= current-account-id "")) (->db-id current-account-id))]
|
||||
(html-response
|
||||
(account-display-cell {:item item
|
||||
:field-name-prefix field-name-prefix
|
||||
:client-id client-id}))))
|
||||
(sel/raw (account-display-cell* {:index idx :account-id account-id :client-id (->db-id client-id)})))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Open + submit
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn open-handler [request]
|
||||
(modal-response
|
||||
(sel/render->hiccup "templates/transaction-edit/transitioner.html"
|
||||
{:body (str (render-form request))})))
|
||||
|
||||
(defn- render-form-response [request]
|
||||
(html-response (render-form request)
|
||||
:headers {"HX-reswap" "outerHTML"}))
|
||||
|
||||
(defn submit
|
||||
"Validates the posted edit-state against edit-schema (field errors via wrap-form-4xx-2),
|
||||
then upserts the sales summary: manual items attach-ledger (credit/debit -> ledger
|
||||
side+amount), auto items update only their account."
|
||||
[request]
|
||||
(let [{tx-id :db/id items :sales-summary/items :as edit-state} (:edit-state request)]
|
||||
(assert-schema edit-schema edit-state)
|
||||
(let [transaction [:upsert-sales-summary
|
||||
{:db/id tx-id
|
||||
:sales-summary/items (map (fn [i]
|
||||
(if (:sales-summary-item/manual? i)
|
||||
(attach-ledger i)
|
||||
{:db/id (:db/id i)
|
||||
:ledger-mapped/account (:ledger-mapped/account i)}))
|
||||
items)}]]
|
||||
@(dc/transact conn [transaction])
|
||||
(html-response
|
||||
(row* (:identity request) (dc/pull (dc/db conn) default-read tx-id)
|
||||
{:flash? true
|
||||
:request request})
|
||||
:headers {"hx-trigger" "modalclose"
|
||||
"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" tx-id)
|
||||
"hx-reswap" "outerHTML"}))))
|
||||
|
||||
(def key->handler
|
||||
(apply-middleware-to-all-handlers
|
||||
(->>
|
||||
{::route/page (helper/page-route grid-page)
|
||||
::route/table (helper/table-route grid-page)
|
||||
::route/edit-wizard (-> mm/open-wizard-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(mm/wrap-init-multi-form-state initial-edit-wizard-state)
|
||||
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
|
||||
::route/edit-wizard-navigate (-> mm/next-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
::route/new-summary-item (-> (add-new-entity-handler [:step-params :sales-summary/items]
|
||||
(fn render [cursor request]
|
||||
(sales-summary-item-row*
|
||||
{:value cursor
|
||||
:client-id (:client-id (:query-params request))}))
|
||||
(fn build-new-row [base _]
|
||||
(assoc base :sales-summary-item/manual? true)))
|
||||
{::route/page (helper/page-route grid-page)
|
||||
::route/table (helper/table-route grid-page)
|
||||
::route/edit-wizard (-> open-handler
|
||||
(wrap-derive-state)
|
||||
(wrap-decode)
|
||||
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
|
||||
::route/form-changed (-> form-changed-handler
|
||||
(wrap-derive-state)
|
||||
(wrap-decode))
|
||||
::route/edit-item-account (-> edit-item-account
|
||||
(wrap-schema-enforce :query-schema [:map
|
||||
[:client-id {:optional true}
|
||||
[:maybe entity-id]]]))
|
||||
::route/edit-item-account (-> edit-item-account
|
||||
(wrap-schema-enforce :query-schema [:map
|
||||
[:item-index nat-int?]
|
||||
[:client-id {:optional true} [:maybe entity-id]]
|
||||
[:current-account-id {:optional true} [:maybe :string]]]))
|
||||
::route/save-item-account save-item-account
|
||||
::route/cancel-item-account cancel-item-account
|
||||
::route/edit-wizard-submit (-> mm/submit-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(mm/wrap-decode-multi-form-state))})
|
||||
[:item-index nat-int?]
|
||||
[:client-id {:optional true} [:maybe entity-id]]
|
||||
[:current-account-id {:optional true} [:maybe :string]]]))
|
||||
::route/save-item-account save-item-account
|
||||
::route/cancel-item-account cancel-item-account
|
||||
::route/edit-wizard-submit (-> submit
|
||||
(wrap-form-4xx-2 render-form-response)
|
||||
(wrap-derive-state)
|
||||
(wrap-decode))}
|
||||
(fn [h]
|
||||
(-> h
|
||||
(wrap-copy-qp-pqp)
|
||||
|
||||
@@ -10,86 +10,74 @@
|
||||
[auto-ap.routes.utils :refer [wrap-client-redirect-unauthenticated]]
|
||||
[auto-ap.rule-matching :as rm]
|
||||
[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.multi-modal :as mm :refer [wrap-wizard]]
|
||||
[auto-ap.ssr.form-cursor :as fc]
|
||||
[auto-ap.ssr.components.selmer :as sc]
|
||||
[auto-ap.ssr.grid-page-helper :refer [wrap-apply-sort]]
|
||||
[auto-ap.ssr.hx :as hx]
|
||||
[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
|
||||
selected->ids
|
||||
wrap-status-from-source]]
|
||||
[auto-ap.ssr.transaction.edit :as edit :refer [account-typeahead*
|
||||
location-select*]]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [apply-middleware-to-all-handlers entity-id
|
||||
form-validation-error html-response percentage
|
||||
ref->enum-schema wrap-merge-prior-hx wrap-schema-enforce]]
|
||||
:refer [->db-id apply-middleware-to-all-handlers assert-schema entity-id
|
||||
form-validation-error html-response main-transformer modal-response
|
||||
path->name2 percentage ref->enum-schema wrap-form-4xx-2
|
||||
wrap-merge-prior-hx wrap-schema-enforce]]
|
||||
[bidi.bidi :as bidi]
|
||||
[clojure.string :as str]
|
||||
[datomic.api :as dc]
|
||||
[iol-ion.query :refer [dollars=]]
|
||||
[iol-ion.tx :refer [random-tempid]]
|
||||
[malli.core :as mc]))
|
||||
|
||||
(defn transaction-account-row* [{:keys [value client-id]}]
|
||||
(com/data-grid-row
|
||||
(-> {:x-data (hx/json {:show (boolean (not (fc/field-value (:new? value))))
|
||||
:accountId (fc/field-value (:account value))})
|
||||
:data-key "show"
|
||||
:x-ref "p"}
|
||||
hx/alpine-mount-then-appear)
|
||||
(fc/with-field :db/id
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value (fc/field-value)}))
|
||||
(fc/with-field :account
|
||||
(com/data-grid-cell
|
||||
{}
|
||||
(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))))
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Field-name / error helpers (no step-params[...] prefix -- posted fields
|
||||
;; decode straight into bulk-code-schema, mirroring transaction/edit.clj).
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn initial-bulk-edit-state [request]
|
||||
(mm/->MultiStepFormState {:search-params (:query-params request)
|
||||
:accounts []}
|
||||
[]
|
||||
{:search-params (:query-params request)
|
||||
:accounts []}))
|
||||
(def ^:dynamic *errors*
|
||||
"Humanized form errors for the current render, keyed by bulk-code-schema paths
|
||||
(e.g. {:accounts {0 {:location [\"required\"]}}}). Bound by render-form from the
|
||||
request's :form-errors. Plain map -- no wizard, no cursor."
|
||||
{})
|
||||
|
||||
(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
|
||||
"Filters transaction IDs to only those that aren't locked (client locked date earlier than transaction date)"
|
||||
@@ -105,16 +93,281 @@
|
||||
(dc/db conn))
|
||||
(map first)))
|
||||
|
||||
(def bulk-code-schema
|
||||
(mc/schema [:map
|
||||
[:vendor {:optional true} [:maybe entity-id]]
|
||||
[:approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]]
|
||||
[:accounts {:optional true}
|
||||
[:maybe
|
||||
[:vector {:coerce? true}
|
||||
[:map [:account entity-id]
|
||||
[:location [:string {:min 1 :error/message "required"}]]
|
||||
[:percentage percentage]]]]]]))
|
||||
(defn wrap-bulk-state
|
||||
"Replaces the wizard's MultiStepFormState/snapshot round-trip. Parses the posted
|
||||
(nested) form params, decodes them straight into bulk-code-schema, and resolves the
|
||||
target transaction id set. On open (GET) the selection comes from the grid's
|
||||
query-params (selected / all-selected + filters); on every post the concrete
|
||||
(not-locked) id list rides back in hidden ids[] fields, so no EDN snapshot / filter
|
||||
round-trip is needed -- and we code exactly the transactions the user saw."
|
||||
[handler]
|
||||
(-> (fn [request]
|
||||
(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]
|
||||
(with-precision 2
|
||||
@@ -151,263 +404,95 @@
|
||||
[])]
|
||||
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" "Requires Feedback"]]})))]
|
||||
|
||||
;; 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]}]
|
||||
(let [account-total (reduce + 0 (map (fn [x] (:percentage x)) accounts))]
|
||||
(when-not (dollars= 1.0 account-total)
|
||||
(form-validation-error (str "Expense account total (" account-total ") does not equal 100%")))))
|
||||
|
||||
(defrecord BulkCodeWizard [_ current-step]
|
||||
mm/LinearModalWizard
|
||||
(hydrate-from-request
|
||||
[this request]
|
||||
this)
|
||||
(navigate [this step-key]
|
||||
(assoc this :current-step step-key))
|
||||
(get-current-step [this]
|
||||
(if current-step
|
||||
(mm/get-step this current-step)
|
||||
(mm/get-step this :accounts)))
|
||||
(render-wizard [this {:keys [multi-form-state] :as request}]
|
||||
(mm/default-render-wizard
|
||||
this request
|
||||
:form-params
|
||||
(-> mm/default-form-props
|
||||
(assoc :hx-put
|
||||
(str (bidi/path-for ssr-routes/only-routes ::route/bulk-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))
|
||||
(defn submit
|
||||
"Validates the posted bulk-code form (schema field errors via wrap-form-4xx-2, then the
|
||||
percentage-sum and per-account location checks as form errors), then applies the chosen
|
||||
vendor / status / account-coding across every selected (not-locked) transaction."
|
||||
[request]
|
||||
(let [{:keys [ids vendor approval-status accounts]} (:bulk-state request)]
|
||||
(assert-schema bulk-code-schema (select-keys (:bulk-state request) bulk-code-form-keys))
|
||||
(when (seq accounts)
|
||||
(assert-percentages-add-up {:accounts accounts}))
|
||||
(let [db (dc/db conn)
|
||||
transactions (pull-many db [:db/id :transaction/amount {:transaction/client [:db/id]}] (vec ids))
|
||||
|
||||
;; Get transactions and filter for locked ones
|
||||
(let [db (dc/db conn)
|
||||
transactions (pull-many db [:db/id :transaction/amount {:transaction/client [:db/id]}] (vec all-ids))
|
||||
;; Get client locations
|
||||
client->locations (->> (map (comp :db/id :transaction/client) transactions)
|
||||
(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
|
||||
client->locations (->> (map (comp :db/id :transaction/client) transactions)
|
||||
(distinct)
|
||||
(dc/q '[:find (pull ?e [:db/id :client/locations])
|
||||
:in $ [?e ...]]
|
||||
db)
|
||||
(map (fn [[client]]
|
||||
[(:db/id client) (:client/locations client)]))
|
||||
(into {}))]
|
||||
;; Validate account locations
|
||||
(doseq [a accounts
|
||||
:let [{:keys [:account/location :account/name]} (dc/pull db
|
||||
[:account/location :account/name]
|
||||
(:account a))]]
|
||||
(when (and location (not= location (:location a)))
|
||||
(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.")))))
|
||||
|
||||
;; Validate account locations
|
||||
(doseq [a accounts
|
||||
:let [{:keys [:account/location :account/name]} (dc/pull db
|
||||
[:account/location :account/name]
|
||||
(:account a))]]
|
||||
(when (and location (not= location (:location a)))
|
||||
(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
|
||||
(map (fn [t]
|
||||
(let [locations (client->locations (-> t :transaction/client :db/id))]
|
||||
[:upsert-transaction (cond-> t
|
||||
approval-status
|
||||
(assoc :transaction/approval-status approval-status)
|
||||
|
||||
(audit-transact-batch
|
||||
(map (fn [t]
|
||||
(let [locations (client->locations (-> t :transaction/client :db/id))]
|
||||
[:upsert-transaction (cond-> t
|
||||
approval-status
|
||||
(assoc :transaction/approval-status approval-status)
|
||||
vendor
|
||||
(assoc :transaction/vendor vendor)
|
||||
|
||||
vendor
|
||||
(assoc :transaction/vendor vendor)
|
||||
(seq accounts)
|
||||
(assoc :transaction/accounts
|
||||
(maybe-code-accounts t accounts locations)))]))
|
||||
transactions)
|
||||
(:identity request))
|
||||
|
||||
(seq accounts)
|
||||
(assoc :transaction/accounts
|
||||
(maybe-code-accounts t accounts locations)))]))
|
||||
transactions)
|
||||
(:identity request))
|
||||
;; Return success modal
|
||||
(html-response
|
||||
(com/success-modal {:title "Transactions Coded"}
|
||||
[:p (str "Successfully coded " (count ids) " transactions.")])
|
||||
:headers {"hx-trigger" "refreshTable, reset-selection"}))))
|
||||
|
||||
;; Return success modal
|
||||
(html-response
|
||||
(com/success-modal {:title "Transactions Coded"}
|
||||
[:p (str "Successfully coded " (count all-ids) " transactions.")])
|
||||
:headers {"hx-trigger" "refreshTable"})))))
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Handlers + routes
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- vendor-default-account [vendor-id client-id]
|
||||
"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."
|
||||
(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 open-handler
|
||||
"Initial modal open (GET). Wraps the rendered form in the #transitioner shell expected
|
||||
by the modal stack (reuses the edit modal's transitioner)."
|
||||
[request]
|
||||
(modal-response
|
||||
(sel/render->hiccup "templates/transaction-edit/transitioner.html"
|
||||
{:body (str (render-form request))})))
|
||||
|
||||
(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- 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))
|
||||
(defn- render-form-response
|
||||
"wrap-form-4xx-2 form-handler: re-render the whole form with field/form errors."
|
||||
[request]
|
||||
(html-response (render-form request)
|
||||
:headers {"HX-reswap" "outerHTML"}))
|
||||
|
||||
(def key->handler
|
||||
(apply-middleware-to-all-handlers
|
||||
{::route/bulk-code (-> mm/open-wizard-handler
|
||||
(mm/wrap-wizard bulk-code-wizard)
|
||||
(mm/wrap-init-multi-form-state initial-bulk-edit-state))
|
||||
::route/bulk-code-new-account (->
|
||||
(add-new-entity-handler [:step-params :accounts]
|
||||
(fn render [cursor request]
|
||||
(transaction-account-row*
|
||||
{: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))}
|
||||
{::route/bulk-code (-> open-handler
|
||||
(wrap-bulk-state))
|
||||
::route/bulk-code-form-changed (-> bulk-code-form-changed-handler
|
||||
(wrap-bulk-state))
|
||||
::route/bulk-code-submit (-> submit
|
||||
(wrap-form-4xx-2 render-form-response)
|
||||
(wrap-bulk-state))}
|
||||
(fn [h]
|
||||
(-> h
|
||||
(wrap-copy-qp-pqp)
|
||||
|
||||
@@ -36,9 +36,9 @@
|
||||
[:import-batch-id {:optional true} [:maybe entity-id]]
|
||||
[:unresolved {:optional true}
|
||||
[:maybe [:boolean {:decode/string {:enter #(cond (= % "on") true
|
||||
(= % "") false
|
||||
:else
|
||||
(boolean %))}}]]]
|
||||
(= % "true") true
|
||||
(boolean? %) %
|
||||
:else false)}}]]]
|
||||
[:description {:optional true} [:maybe [:string {:decode/string strip}]]]
|
||||
[:memo {:optional true} [:maybe [:string {:decode/string strip}]]]
|
||||
[:vendor {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :vendor/name]}]]]
|
||||
@@ -50,9 +50,9 @@
|
||||
[:location {:optional true} [:maybe [:string {:decode/string strip}]]]
|
||||
[:potential-duplicates {:optional true}
|
||||
[:maybe [:boolean {:decode/string {:enter #(cond (= % "on") true
|
||||
(= % "") false
|
||||
:else
|
||||
(boolean %))}}]]]
|
||||
(= % "true") true
|
||||
(boolean? %) %
|
||||
:else false)}}]]]
|
||||
#_[:status {:optional true} [:maybe (ref->enum-schema "transaction-status")]]
|
||||
[:exact-match-id {:optional true} [:maybe entity-id]]
|
||||
[:all-selected {:optional true :default nil} [:maybe :boolean]]
|
||||
@@ -421,6 +421,35 @@
|
||||
(import-batch-id* request)
|
||||
(exact-match-id* request)]])
|
||||
|
||||
(def non-date-filter-params
|
||||
"Query-param keys that represent transaction filters other than the date range."
|
||||
[:vendor :account :bank-account :description :memo :location
|
||||
:amount-gte :amount-lte :linked-to :unresolved :potential-duplicates
|
||||
:import-batch-id :exact-match-id])
|
||||
|
||||
(defn- filter-value-active? [v]
|
||||
(cond
|
||||
(nil? v) false
|
||||
(false? v) false
|
||||
(string? v) (not (str/blank? v))
|
||||
:else true))
|
||||
|
||||
(defn non-date-filters-active? [request]
|
||||
(boolean (some (comp filter-value-active? #(get (:query-params request) %))
|
||||
non-date-filter-params)))
|
||||
|
||||
(defn clear-filters-href
|
||||
"URL for the transactions page with every non-date filter cleared, preserving
|
||||
the active date range (and an implied status, if any)."
|
||||
[request]
|
||||
(let [qp (:query-params request)
|
||||
status (:status qp)]
|
||||
(str (hu/url (bidi/path-for ssr-routes/only-routes ::route/page)
|
||||
(cond-> {}
|
||||
(:start-date qp) (assoc "start-date" (atime/unparse (:start-date qp) atime/normal-date))
|
||||
(:end-date qp) (assoc "end-date" (atime/unparse (:end-date qp) atime/normal-date))
|
||||
(keyword? status) (assoc "status" (name status)))))))
|
||||
|
||||
(def grid-page
|
||||
(helper/build {:id "entity-table"
|
||||
:nav com/main-aside-nav
|
||||
@@ -434,26 +463,34 @@
|
||||
(assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)
|
||||
(some-> (import-batch-id* request) (assoc-in [1 :hx-swap-oob] true))])
|
||||
:action-buttons (fn [request]
|
||||
[(com/button {:color :primary
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/bulk-code)
|
||||
:hx-target "#modal-holder"
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"hx-include" "#transaction-filters"}
|
||||
"Code")
|
||||
(com/button {:color :primary
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)
|
||||
:hx-target "#modal-holder"
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"hx-include" "#transaction-filters"
|
||||
:hx-confirm "Are you sure you want to delete these transactions?"}
|
||||
"Delete")
|
||||
(com/button {:color :primary
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)
|
||||
:hx-target "#modal-holder"
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"hx-include" "#transaction-filters"
|
||||
:hx-confirm "Are you sure you want to suppress these transactions?"}
|
||||
"Suppress")])
|
||||
(cond-> [(com/button {:color :primary
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/bulk-code)
|
||||
:hx-target "#modal-holder"
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"x-bind:disabled" "selected.length === 0 && !all_selected"
|
||||
"hx-include" "#transaction-filters"}
|
||||
"Code")
|
||||
(com/button {:color :primary
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)
|
||||
:hx-target "#modal-holder"
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"x-bind:disabled" "selected.length === 0 && !all_selected"
|
||||
"hx-include" "#transaction-filters"
|
||||
:hx-confirm "Are you sure you want to delete these transactions?"}
|
||||
"Delete")
|
||||
(com/button {:color :primary
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)
|
||||
:hx-target "#modal-holder"
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"x-bind:disabled" "selected.length === 0 && !all_selected"
|
||||
"hx-include" "#transaction-filters"
|
||||
:hx-confirm "Are you sure you want to suppress these transactions?"}
|
||||
"Suppress")]
|
||||
(non-date-filters-active? request)
|
||||
(conj (com/a-button {:color :secondary
|
||||
:hx-boost "true"
|
||||
:href (clear-filters-href request)}
|
||||
"Clear filters"))))
|
||||
:row-buttons (fn [request entity]
|
||||
(let [client (:transaction/client entity)
|
||||
locked-until (:client/locked-until client)
|
||||
@@ -499,6 +536,17 @@
|
||||
(= 1 (count (:client/locations (:client args))))))
|
||||
:render (fn [x] [:div.flex.items-center.gap-2 (-> x :transaction/client :client/name)])
|
||||
:render-csv (fn [x] (-> x :transaction/client :client/name))}
|
||||
{:key "bank-account"
|
||||
:name "Bank Account"
|
||||
:show-starting "lg"
|
||||
:render (fn [x]
|
||||
(let [ba (:transaction/bank-account x)]
|
||||
(or (:bank-account/name ba)
|
||||
(:bank-account/numeric-code ba))))
|
||||
:render-csv (fn [x]
|
||||
(let [ba (:transaction/bank-account x)]
|
||||
(or (:bank-account/name ba)
|
||||
(:bank-account/numeric-code ba))))}
|
||||
{:key "vendor"
|
||||
:name "Vendor"
|
||||
:sort-key "vendor"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -211,7 +211,7 @@
|
||||
(com/data-grid-cell {} (fc/with-field :description-original
|
||||
(com/text-input {:value (fc/field-value) :name (fc/field-name)})))
|
||||
(com/data-grid-cell {} (fc/with-field :amount
|
||||
(com/money-input {:value (fc/field-value) :name (fc/field-name) :class "w-28"})))
|
||||
(com/text-input {:value (fc/field-value) :name (fc/field-name) :class "w-28 text-right" :inputmode "decimal"})))
|
||||
(com/data-grid-cell {} (fc/with-field :bank-account-code
|
||||
(com/text-input {:value (fc/field-value) :name (fc/field-name) :class "w-28"})))
|
||||
(com/data-grid-cell {} (fc/with-field :client-code
|
||||
|
||||
@@ -33,9 +33,7 @@
|
||||
:delete ::bulk-delete-confirm}
|
||||
"/bulk-edit" {:get ::bulk-edit
|
||||
:put ::bulk-edit-submit
|
||||
"/account" ::bulk-edit-new-account
|
||||
"/total" ::bulk-edit-total
|
||||
"/balance" ::bulk-edit-balance}
|
||||
"/form-changed" ::bulk-edit-form-changed}
|
||||
["/" [#"\d+" :db/id]] {:delete ::delete
|
||||
"/undo-autopay" ::undo-autopay
|
||||
"/unvoid" ::unvoid
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"/line-item" {:get ::new-line-item}}
|
||||
|
||||
"/external-new" ::external-page
|
||||
"/bulk-delete" ::bulk-delete
|
||||
"/external-import-new" {"" ::external-import-page
|
||||
"/parse" ::external-import-parse
|
||||
"/import" ::external-import-import}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
(ns auto-ap.routes.pos.sales-summaries)
|
||||
(def routes {"" {:get ::page
|
||||
:put ::edit-wizard-submit}
|
||||
(def routes {"" {:get ::page
|
||||
:put ::edit-wizard-submit}
|
||||
"/table" ::table
|
||||
["/" [#"\d+" :db/id]] {:get ::edit-wizard}
|
||||
"/edit/navigate" ::edit-wizard-navigate
|
||||
"/edit/sales-summary-item" ::new-summary-item
|
||||
"/edit/item-account" ::edit-item-account
|
||||
"/edit/save-item-account" ::save-item-account
|
||||
"/edit/form-changed" ::form-changed
|
||||
"/edit/item-account" ::edit-item-account
|
||||
"/edit/save-item-account" ::save-item-account
|
||||
"/edit/cancel-item-account" ::cancel-item-account})
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
(ns auto-ap.routes.transactions)
|
||||
|
||||
(def routes {"" {:get ::page
|
||||
:put ::edit-wizard-navigate
|
||||
"/unapproved" ::unapproved-page
|
||||
"/requires-feedback" ::requires-feedback-page
|
||||
"/approved" ::approved-page
|
||||
"/bulk-delete" ::bulk-delete
|
||||
"/bulk-suppress" ::bulk-suppress
|
||||
"/bulk-code" {:get ::bulk-code
|
||||
:put ::bulk-code-submit
|
||||
"/new-account" ::bulk-code-new-account
|
||||
"/vendor-changed" ::bulk-code-vendor-changed}}
|
||||
:post ::bulk-code-submit
|
||||
"/form-changed" ::bulk-code-form-changed}}
|
||||
"/new" {:get ::new
|
||||
:post ::new-submit
|
||||
"/location-select" ::location-select
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
[auto-ap.datomic :refer [conn audit-transact transact-schema install-functions]]
|
||||
[auto-ap.datomic.accounts :as a]
|
||||
[auto-ap.integration.util :refer [wrap-setup test-client test-vendor test-bank-account test-account
|
||||
setup-test-data admin-token]]
|
||||
setup-test-data admin-token user-token]]
|
||||
[auto-ap.ssr.ledger :as sut]
|
||||
[auto-ap.ssr.utils :refer [main-transformer]]
|
||||
[auto-ap.ssr.ledger.common :as common]
|
||||
@@ -557,3 +557,65 @@
|
||||
:identity (admin-token)})]
|
||||
(is (= (format "#entity-table tr[data-id=\"%d\"]" invoice-id)
|
||||
(get-in response [:headers "hx-retarget"])))))))))
|
||||
|
||||
;; =============================================================================
|
||||
;; Bulk Delete - all-ids-not-locked, bulk-delete
|
||||
;; =============================================================================
|
||||
|
||||
(defn- create-journal-entry [client-id date external-id]
|
||||
(let [temp (str (java.util.UUID/randomUUID))
|
||||
tx @(dc/transact conn [{:db/id temp
|
||||
:journal-entry/client client-id
|
||||
:journal-entry/date date
|
||||
:journal-entry/external-id external-id
|
||||
:journal-entry/source "manual"
|
||||
:journal-entry/amount 100.0}])]
|
||||
(get-in tx [:tempids temp])))
|
||||
|
||||
(deftest all-ids-not-locked-test
|
||||
(testing "Should exclude entries dated before the client's locked-until date"
|
||||
(let [tempids (setup-test-data [(test-client :db/id "lock-client"
|
||||
:client/code "LOCKTEST"
|
||||
:client/locked-until #inst "2099-01-01")])
|
||||
client-id (get tempids "lock-client")
|
||||
locked-id (create-journal-entry client-id #inst "2020-01-01" "ext-locked")
|
||||
open-id (create-journal-entry client-id #inst "2099-06-01" "ext-open")
|
||||
result (set (sut/all-ids-not-locked [locked-id open-id]))]
|
||||
(is (contains? result open-id))
|
||||
(is (not (contains? result locked-id))))))
|
||||
|
||||
(deftest bulk-delete-test
|
||||
(testing "Admin can delete selected ledger entries"
|
||||
(let [tempids (setup-test-data [(test-client :db/id "bd-client"
|
||||
:client/code "BDTEST")])
|
||||
client-id (get tempids "bd-client")
|
||||
id1 (create-journal-entry client-id #inst "2021-01-01" "ext-bd-1")
|
||||
id2 (create-journal-entry client-id #inst "2021-02-01" "ext-bd-2")
|
||||
response (sut/bulk-delete {:identity (admin-token)
|
||||
:form-params {:selected [id1 id2]}})
|
||||
db-after (dc/db conn)]
|
||||
(is (= 200 (:status response)))
|
||||
;; modal-response retargets to the persistent #modal-content shell (innerHTML)
|
||||
;; so the modal-holder survives repeated deletes; it also appends modalopen.
|
||||
(is (= "invalidated, reset-selection, modalopen" (get-in response [:headers "hx-trigger"])))
|
||||
(is (= "#modal-content" (get-in response [:headers "hx-retarget"])))
|
||||
(is (= "innerHTML" (get-in response [:headers "hx-reswap"])))
|
||||
(is (nil? (:journal-entry/external-id (dc/pull db-after [:journal-entry/external-id] id1))))
|
||||
(is (nil? (:journal-entry/external-id (dc/pull db-after [:journal-entry/external-id] id2))))))
|
||||
|
||||
(testing "Should preserve entries in a locked period even when selected"
|
||||
(let [tempids (setup-test-data [(test-client :db/id "bd-lock-client"
|
||||
:client/code "BDLOCK"
|
||||
:client/locked-until #inst "2099-01-01")])
|
||||
client-id (get tempids "bd-lock-client")
|
||||
locked-id (create-journal-entry client-id #inst "2020-01-01" "ext-bd-locked")
|
||||
open-id (create-journal-entry client-id #inst "2099-06-01" "ext-bd-open")
|
||||
_ (sut/bulk-delete {:identity (admin-token)
|
||||
:form-params {:selected [locked-id open-id]}})
|
||||
db-after (dc/db conn)]
|
||||
(is (some? (:journal-entry/external-id (dc/pull db-after [:journal-entry/external-id] locked-id))))
|
||||
(is (nil? (:journal-entry/external-id (dc/pull db-after [:journal-entry/external-id] open-id))))))
|
||||
|
||||
(testing "Non-admin cannot bulk-delete"
|
||||
(is (thrown? Exception (sut/bulk-delete {:identity (user-token)
|
||||
:form-params {:selected [1]}})))))
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
(def test-transaction-id (atom nil))
|
||||
(def test-account-ids (atom {}))
|
||||
(def test-client-ids (atom {}))
|
||||
(def test-sales-summary-id (atom nil))
|
||||
|
||||
(defn admin-identity []
|
||||
(case @test-identity-mode
|
||||
@@ -160,7 +161,26 @@
|
||||
:invoice/invoice-number "UNPAID-001"
|
||||
:invoice/expense-accounts [{:invoice-expense-account/account "account-id"
|
||||
:invoice-expense-account/amount 150.0
|
||||
:invoice-expense-account/location "DT"}])])
|
||||
:invoice-expense-account/location "DT"}])
|
||||
;; Sales summary for the POS sales-summary edit modal e2e
|
||||
;; (balanced: $500 credit = $500 debit).
|
||||
{:db/id "sales-summary-id"
|
||||
:sales-summary/client "client-id"
|
||||
:sales-summary/date #inst "2026-06-20T00:00:00Z"
|
||||
:sales-summary/items [{:db/id "ss-item-credit"
|
||||
:sales-summary-item/category "Food Sales"
|
||||
:sales-summary-item/sort-order 0
|
||||
:sales-summary-item/manual? false
|
||||
:ledger-mapped/ledger-side :ledger-side/credit
|
||||
:ledger-mapped/amount 500.0
|
||||
:ledger-mapped/account "account-id"}
|
||||
{:db/id "ss-item-debit"
|
||||
:sales-summary-item/category "Cash Deposit"
|
||||
:sales-summary-item/sort-order 1
|
||||
:sales-summary-item/manual? false
|
||||
:ledger-mapped/ledger-side :ledger-side/debit
|
||||
:ledger-mapped/amount 500.0
|
||||
:ledger-mapped/account "account-id-2"}]}])
|
||||
tempids (:tempids tx-result)
|
||||
tx-entity-id (get tempids "transaction-id")]
|
||||
(println "Test transaction entity ID:" tx-entity-id)
|
||||
@@ -174,6 +194,7 @@
|
||||
(reset! test-client-ids
|
||||
{:test (get tempids "client-id")
|
||||
:test2 (get tempids "client-id-2")})
|
||||
(reset! test-sales-summary-id (get tempids "sales-summary-id"))
|
||||
tx-entity-id))
|
||||
|
||||
(defn test-info-handler [request]
|
||||
@@ -183,6 +204,7 @@
|
||||
{:transactionId @test-transaction-id
|
||||
:accounts @test-account-ids
|
||||
:clientMode @test-identity-mode
|
||||
:salesSummaryId @test-sales-summary-id
|
||||
:clients (mapv :client/code (:clients request))})})
|
||||
|
||||
(defn test-set-client-mode-handler [request]
|
||||
@@ -198,6 +220,22 @@
|
||||
:body (cheshire.core/generate-string
|
||||
{:mode mode})}))
|
||||
|
||||
(defn reset-test-data! []
|
||||
"Recreate and re-seed the in-memory test database, returning to the same
|
||||
baseline the server starts with. Used by the /test-reset endpoint so each
|
||||
browser test can start from a clean, deterministic dataset."
|
||||
(reset! test-identity-mode :single-client)
|
||||
(let [conn (create-test-db)
|
||||
tx-id (seed-test-data conn)]
|
||||
(reset! test-transaction-id tx-id)
|
||||
tx-id))
|
||||
|
||||
(defn test-reset-handler [_request]
|
||||
{:status 200
|
||||
:headers {"Content-Type" "application/json"}
|
||||
:body (cheshire.core/generate-string {:ok true
|
||||
:transactionId (reset-test-data!)})})
|
||||
|
||||
(defn wrap-test-info [handler]
|
||||
(fn [request]
|
||||
(cond
|
||||
@@ -205,6 +243,8 @@
|
||||
(test-info-handler request)
|
||||
(= "/test-set-client-mode" (:uri request))
|
||||
(test-set-client-mode-handler request)
|
||||
(= "/test-reset" (:uri request))
|
||||
(test-reset-handler request)
|
||||
:else
|
||||
(handler request))))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user