26 Commits

Author SHA1 Message Date
2bf87056d7 refactor(ssr): Phase 5 — full Selmer migration of Invoice Bulk Edit; remove the wizard; implement live totals
Migrates the Invoice Bulk Edit modal off the wizard to a plain Selmer form,
building on the parity gate. Structurally Phase 3's bulk-code applied to invoices
(selected entities -> expense-account rows), so near-pure reuse of bulk-code's
flat-state plumbing + edit's account-totals-tbody.

What changed
- Wizard removed: deleted BulkEditWizard/AccountsStep records, MultiStepFormState,
  the step-params[...] prefix, the EDN snapshot, and all mm/* for this modal.
  Replaced with a plain handler + flat wrap-bulk-state (decode straight into
  bulk-edit-schema, no snapshot).
- Selection-as-ids round-trip: the non-editable invoice selection is resolved to a
  concrete not-locked id vector at open and ridden back in hidden ids[] fields (the
  bulk analog of edit's single db/id) -- no filter re-query.
- De-cursored bulk-edit-account-row* to Selmer (sc/*), explicit-id location swap
  (#account-location-<index>, replacing the old find * swap), reusing
  tx-edit/location-select*.
- 100% Selmer modal render path; the surgical edit was done with the text-based
  Edit tool (the clojure-mcp structural tools reformat the whole 1812-line file),
  so the diff is contained to the requires + the bulk-edit region.
- Routes 5 -> 3: GET bulk-edit, PUT bulk-edit-submit, POST bulk-edit-form-changed
  (one whole-form op dispatcher folding the old new-account route).

Implemented the dead totals
- The wizard's TOTAL/BALANCE percentage rows were commented out (#_(...)) with a
  duplicate id="total". Implemented as a #expense-totals sibling-<tbody> refreshed by
  a Rule-4 percentage-keyup swap (the new-account + total + balance routes all fold
  into form-changed / the sibling-tbody).

Scorecard delta (bulk-edit modal): wizard records 2->0, bulk-edit routes 5->3,
step-params/fc-cursor (modal) ->0, location swap find *-> explicit-id, totals
dead->implemented.

Verification: invoice-bulk-edit spec 5/5 (incl. add-row, save, validation, the
implemented totals); full Playwright suite 50/50; cljfmt clean; diff confined to
the modal region. Skill fed: scorecard row + settled repeated-row target-selector
convention; gotcha (structural tools reformat large files -> use text Edit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 23:09:37 -07:00
4139919036 test(ssr): Phase 5 parity gate — characterization spec for Invoice Bulk Edit
Behavior-parity safety net before migrating the Invoice Bulk Edit modal off the
wizard. The modal had no e2e coverage; the existing seeded invoice is bulk-editable
as-is, so no seed change was needed (avoids interfering with the transaction-link
spec).

e2e/invoice-bulk-edit.spec.ts (4 tests): open the modal (expense-account grid with
Account/Location/%/TOTAL/BALANCE + a default row + New account), add an account row,
save a 100% coding (modalclose), and the percentage-validation rejection. Models the
bulk-code-transactions spec.

Full Playwright suite 50/50 (46 prior + 4 new).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:50:44 -07:00
599b849e6f refactor(ssr): Phase 4 — full Selmer migration of POS Sales Summary; remove the wizard; fix add-item + totals
Migrates the POS Sales Summary edit modal off the wizard to a plain Selmer form,
building on the parity gate committed earlier. Largest migration so far and the
first with no prior test coverage.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:13:19 -07:00
a289ff2557 test(ssr): Phase 4 parity gate — seed + characterization spec for Sales Summary edit
Establishes the behavior-parity safety net required before migrating the POS
Sales Summary edit modal off the wizard (the modal had zero test coverage and the
test server seeded no POS data).

- test_server.clj: seed a balanced sales summary ($500 credit = $500 debit) with
  two auto items referencing the existing test client + accounts; surface its id
  via /test-info (`salesSummaryId`).
- e2e/sales-summary-edit.spec.ts: characterization spec (6 tests) capturing current
  behavior — open modal (debit/credit columns, categories, resolved account names,
  amounts), balanced state, inline account editor (pencil -> typeahead editor ->
  cancel restores / save re-renders the cell), and Save (PUT round-trip closes the
  modal + keeps the grid row). Exercises the edit-wizard, edit/save/cancel-item-account,
  and edit-wizard-submit routes.

Notable finding: the "New Summary Item" button is currently BROKEN (its Alpine
handler throws "newRowIndex is not defined" and hx-target="closest .new-row"
matches no ancestor, so the new-summary-item route never fires). The spec documents
this as inert rather than asserting it works; the migration will decide fix-vs-preserve.

Full Playwright suite 45/45 (39 prior + 6 new).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:15:28 -07:00
03620e9d42 refactor(ssr): Phase 3 — full Selmer migration of Transaction Bulk Code; remove the wizard
Migrates the Transaction Bulk Code modal (a single-step form wearing a full
wizard costume) to a plain Selmer form, cold-applying the ssr-form-migration
skill. Almost entirely reuse of the Phase-2 work: the whole `sc/*` Selmer
component library, `account-typeahead*` / `location-select*`, and the
`edit-modal` / `transitioner` chrome are imported wholesale.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:38:09 -07:00
70c178de83 refactor(ssr): full Selmer migration of Transaction Edit; remove the wizard
Squashed Phase-2 SSR work: migrate the Transaction Edit modal's render path
entirely to Selmer templates (zero Hiccup in the render path), rip out the
multi-step wizard abstraction (EditWizard/LinksStep records, MultiStepFormState,
step-params[...] field names, mm/* middleware) in favor of a plain form with
flat derived state, and promote shared UI components to reusable Selmer partials
under resources/templates/components/. Adds the Selmer interop bridge, the
auto-ap.ssr.components.selmer (sc) wrapper library, and the ssr-form-migration
skill capturing the learnings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 08:36:29 -07:00
e2ccfc8d2c Merge branch 'ledger-bulk' into staging 2026-06-23 22:03:29 -07:00
e8cbd2760c fixes 2026-06-23 22:03:26 -07:00
e0da8e1866 feat(ssr): add delete selected to external ledger
Replicate the master CLJS "delete external ledger" feature on the SSR
external ledger page: an admin-only bulk delete that retracts the
selected journal entries, skipping any in a client's locked period and
capping at 1000 per request.

Return the result via modal-response (retargets the persistent
#modal-content shell) and target #modal-content from the button so the
request never relies on the outerHTML swap inherited from the data-grid
card, which previously replaced #modal-holder and broke the next click.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:48:39 -07:00
2e3c1e3646 feat(ssr): add clear filters button to transactions
Shows a "Clear filters" button in the transactions action bar whenever a
non-date filter is active. It's a boosted link back to the transactions
page that preserves the date range (and any implied status), so the
sidebar filters and table both reset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:43:43 -07:00
a7e9fbaf6b feat(ssr): disable bulk action buttons until a transaction is selected
Disable Code, Delete, and Suppress until at least one row is checked or
"select all" is active, matching the existing selection-aware UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:19:39 -07:00
8a676718a7 feat(ssr): reset transaction selection after bulk code
Bulk coding left the checked items selected after the table refreshed.
Add a dedicated reset-selection event that the grid's Alpine state
listens for, and fire it alongside refreshTable on bulk-code submit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:06:37 -07:00
3ffb661da3 fix(ssr): stop unresolved filter flipping to true on transactions navigation
The unresolved/potential-duplicates query-param decoders fell through to
(boolean %) for unrecognized strings. A round-tripped "false" (pushed into the
URL, re-read via HX-Current-URL) decoded to true since any non-nil string is
truthy, so navigating pages silently turned on the "Unresolved only" filter.

Handle "false" and already-boolean values symmetrically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 21:40:31 -07:00
f9438ba983 fix(ssr): only require account coding for manual transaction edits
Account coding lived in the always-applied base map of edit-form-schema, so
every action (including the link/apply-rule/unlink actions) required a valid
transaction-account/account. The edit modal always submits the Manual tab's
(usually blank) account row, so link submits failed validation before reaching
their save-handler and silently no-op'd. Move account validation into the
:manual branch of the action :multi so link actions validate without it.

Also surface whole-form validation errors in the wizard footer error bar:
default-step-footer only handled top-level/sequential error shapes, so nested
field-error maps (e.g. a hidden tab's account error) produced an empty bar and
a silent failure. Add flatten-form-errors to flatten the humanized error tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 21:30:29 -07:00
7d34b8a5f6 money 2026-06-18 20:26:07 -07:00
c09d85ede6 fix(ssr): fix Client Review (requires-feedback) status in bulk-code dialog
The bulk-code "Requires Feedback" option submitted "requires_feedback"
(underscore), which decoded to an enum keyword not present in the
schema (idents use a hyphen), so selecting it failed validation. Use
the hyphenated value and relabel the option, the reconciliation report
header to "Client Review" to unify with the sidebar terminology.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:38:00 -07:00
ec4f88b7fc fix(ssr): hide P&L warning box when there is no warning
The profit-and-loss report always passed :warning as a [:div ...] hiccup
vector, which is truthy even when empty. The shared report table renders
its red warning box with (when warning ...), so a clean report with no
warning and no unresolved entries still showed an empty red error box.

Only build the warning div when there is actual warning text or sample
links, matching how the balance-sheet and cash-flows reports pass nil.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:56:43 -07:00
8ca5e75c4d fix(ssr): hide client column in edited transaction row for single client
The save-handler re-rendered the edited row via row* without passing
:request, so the Client column's :hide? predicate received a nil request
and never hid the column. Pass :request request like table* does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:40:50 -07:00
4aed27b204 feat(ssr): add bank account column to transactions table
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:36:08 -07:00
d0028f403c fix(ssr): allow picking bank account when editing a transaction rule
The bank-account filter rendered "Please select a client" even when a
client was set on the rule. Two causes:

- Inside (fc/with-field :transaction-rule/bank-account ...) the cursor is
  rebound to the bank-account field, so (:transaction-rule/client
  (fc/field-value)) read the nil bank-account value and the server
  rendered the placeholder. The clientId watcher only fires on change, so
  when editing (client preset, unchanged) the htmx swap never corrected
  it. Read the client from the form root before entering the field.
- The clientId-change swap used innerHTML, nesting a fresh typeahead
  inside the stale one and breaking its Alpine refs. Use outerHTML so the
  typeahead is replaced in place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:32:30 -07:00
6b4392b74b fix(ssr): keep top bar to a fixed-height single row
The top bar grew vertically on narrower viewports when the environment
badge and company-selector labels wrapped, pushing content under the
fixed navbar (which the layout offsets with a fixed pt-16).

Rework the navbar into a fixed h-16 row with a priority-based responsive
layout:
- search fills the middle (flex-1) and shrinks first when space is tight
- company selector holds its size and truncates long names
- environment badge degrades full pill -> compact letter badge -> hidden
- harmonize control heights (40px controls, 32px badge/avatar accents) so
  the search no longer renders as a cramped thin strip

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 09:01:32 -07:00
cdc87d3710 fix 2026-06-16 20:36:00 -07:00
1e3952a7fb fix(auth): login error-details pre escaping Alpine scope
The error-details <pre> lived inside a <span x-data="{e:false}"> that was
itself inside a <p>. Since <pre> is block content, the HTML parser closed
the <p> and reparented the <pre> out of the span, so Alpine evaluated
x-show="e" with e no longer in scope ("e is not defined"). Use a <div>
wrapper instead of <p> so the pre stays within the e scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 20:05:43 -07:00
e099714af1 fix(ssr): transaction edit dropdown duplication and advanced->simple toggle
- Location field hx-target "find *" resolved to the <label> (first child),
  so changing an account swapped the reloaded <select> over the label and
  left a duplicate dropdown. Target "find select" instead (simple + advanced).
- edit-wizard-toggle-mode-handler read mode only from step-params, but the
  hidden "mode" field is a top-level form param, so current-mode always
  defaulted to "simple" and the toggle could never return from advanced.
  Read it from form-params too, matching edit-vendor-changed-handler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 20:02:41 -07:00
11024b7b89 fix(ssr): transaction rule wizard drops fields on Next
The EditModal step body wrapped all rule fields in a nested
<form id="my-form"> inside the wizard's own #wizard-form. By HTML
form-ownership rules those fields belonged to the inner form, so when
htmx serialized #wizard-form on Next, none of the step-params fields
were sent. The server saw an empty rule, reported "required" for
description/accounts, and re-rendered a blank wizard (losing input).

Replace the nested <form> with a plain <div>; the wizard form already
owns submission, so the inner form and its htmx attributes were
redundant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:58:36 -07:00
de2a1ab850 fixes pnl file name 2026-06-16 14:37:13 -07:00
81 changed files with 3304 additions and 1891 deletions

View File

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

View File

@@ -13,9 +13,13 @@ implement the multi-step protocol (`mm/ModalWizardStep` + friends), serialize an
snapshot into hidden fields, and register 1020 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

View File

@@ -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.

View File

@@ -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 3839). mm coupling 19→0, snapshot merges 4→0,
> wizard records 3→0, routes 4→3 (open / submit / `form-changed` — the per-op `new-account` +
> `vendor-changed` routes folded into one `form-changed` op dispatcher), the location swap moved
> off `find *` onto explicit `#account-location-<index>` + `hx-select`.
>
> **The one regression — LOC 420→506 (documented exception, see `gotchas.md`).** Unlike edit
> (whose wizard held real custom code), bulk-code's wizard was a *thin* shell that delegated
> almost everything to `mm/*` defaults (`default-render-step`, `default-render-wizard`,
> `submit-handler`, `open-wizard-handler`). Ripping the wizard out moves that
> previously-shared plumbing **into the file** as explicit render/decode/submit/handler code.
> The trade is intended: every other heuristic improved and the modal is now self-contained
> and wizard-free. New patterns added to the cookbook: the **selection-as-`ids[]` round-trip**
> (resolve the non-editable selection to a concrete id vector at open, ride it in hidden
> fields — the bulk analog of edit's single `db/id`), and the **`:id`-keyed vendor typeahead**
> (a value-bound hidden must be keyed or its posted value goes stale across a whole-form swap).
> **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`.

View File

@@ -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 `&quot;`/`&apos;` 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)

View File

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

View 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%');
});
});

View 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);
});
});

View File

@@ -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"]');

View File

@@ -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());

View File

@@ -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:

View File

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

View File

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

View 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>

View File

@@ -0,0 +1 @@
<a class="{{ classes }}"{{ attrs|safe }}><div class="h-4 w-4">{{ body|safe }}</div></a>

View File

@@ -0,0 +1 @@
<div class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}</div>

View File

@@ -0,0 +1 @@
<button class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}</button>

View File

@@ -0,0 +1 @@
<div class="inline-flex rounded-md shadow-sm" role="group" hx-on:click="this.querySelector(&quot;input&quot;).value = event.target.value; this.querySelector(&quot;input&quot;).dispatchEvent(new Event('change', {bubbles: true}));"><input type="hidden" name="{{ name }}">{{ body|safe }}</div>

View 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>

View File

@@ -0,0 +1 @@
<td class="px-4 py-2{% if klass %} {{ klass }}{% endif %}"{{ attrs|safe }}>{{ body|safe }}</td>

View 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>

View File

@@ -0,0 +1 @@
<tr class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}</tr>

View 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>

View 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 }}>

View File

@@ -0,0 +1 @@
<a class="{{ classes }}"{{ attrs|safe }}>{{ body|safe }}</a>

View File

@@ -0,0 +1 @@
<div class="{{ classes }}" @click.outside="open=false"{{ attrs|safe }}>{{ body|safe }}</div>

View File

@@ -0,0 +1,2 @@
{# Money input (number, step=0.01, right-aligned). sc/money-input builds `attrs`. #}
<input{{ attrs|safe }}>

View 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>

View File

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

View File

@@ -0,0 +1 @@
<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

View 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

View 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

View 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

View 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 }}>

View 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>

View 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>

View 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>

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
{# Sales-summary modal body: a read-only Debits | Credits two-column view of the auto
items (each account is inline-editable), a swappable totals/balance block, and an
editable Manual Items section with a working "New Summary Item" add. #}
<div class="space-y-4 p-2"><div class="grid grid-cols-2 gap-6"><div><div class="font-semibold text-sm mb-2">Debits</div><div class="space-y-1">{{ debit_rows|safe }}</div></div><div><div class="font-semibold text-sm mb-2">Credits</div><div class="space-y-1">{{ credit_rows|safe }}</div></div></div><div id="summary-totals">{{ totals|safe }}</div><div class="mt-4 border-t pt-3"><div class="font-semibold text-sm mb-2">Manual Items</div><div class="space-y-2" id="manual-items">{{ manual_rows|safe }}</div><div class="mt-2 flex justify-center">{{ new_item_button|safe }}</div></div></div>

View File

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

View File

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

View File

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

View File

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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1 @@
<div class="text-center py-4 text-gray-500">{{ message }}</div>

View 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>

View File

@@ -0,0 +1,2 @@
{# Outer wrapper for the payment tab; the unlink-payment route swaps #payment-matches. #}
<div id="payment-matches">{{ inner|safe }}</div>

View 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>

View 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>

View 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>

View File

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

View File

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

View File

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

View File

@@ -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]"

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]

View File

@@ -9,29 +9,29 @@
[auto-ap.client-routes :as client-routes]
[auto-ap.routes.pos.sales-summaries :as route]
[auto-ap.ssr-routes :as ssr-routes]
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler]]
[auto-ap.ssr.components :as com]
[auto-ap.ssr.components.link-dropdown :refer [link-dropdown]]
[auto-ap.ssr.components.multi-modal :as mm]
[auto-ap.ssr.form-cursor :as fc]
[auto-ap.ssr.components.selmer :as sc]
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
[auto-ap.ssr.hx :as hx]
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
[auto-ap.ssr.pos.common
:refer [date-range-field*]]
[auto-ap.ssr.selmer :as sel]
[auto-ap.ssr.svg :as svg]
[auto-ap.ssr.utils
:refer [apply-middleware-to-all-handlers clj-date-schema
default-grid-fields-schema entity-id html-response money
strip temp-id wrap-merge-prior-hx wrap-schema-enforce]]
:refer [->db-id apply-middleware-to-all-handlers assert-schema
clj-date-schema default-grid-fields-schema entity-id html-response
main-transformer modal-response money path->name2 strip temp-id
wrap-form-4xx-2 wrap-merge-prior-hx wrap-schema-enforce]]
[auto-ap.time :as atime]
[bidi.bidi :as bidi]
[clj-time.coerce :as c]
[clojure.string :as str]
[datomic.api :as dc]
[hiccup.util :as hu]
[iol-ion.query :refer [dollars= dollars-0?]]
[malli.core :as mc]
[malli.util :as mut]))
[iol-ion.query :refer [dollars=]]
[malli.core :as mc]))
(def query-schema (mc/schema
[:maybe
@@ -133,63 +133,6 @@
(str (subs s 0 (- max-len 3)) "...")
s))
(defn account-typeahead*
[{:keys [name value client-id]}]
[:div.flex.flex-col
(com/typeahead {:name name
:placeholder "Search..."
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
{:client-id client-id
:purpose "invoice"})
:value value
:content-fn (fn [value]
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
client-id)))})])
(defn account-display-cell [{:keys [item field-name-prefix client-id]}]
(let [account-id (:ledger-mapped/account item)
account-name (when account-id
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read account-id)
client-id)))]
[:div.account-cell.flex.items-center.gap-2
(com/hidden {:name (str field-name-prefix "[ledger-mapped/account]")
:value (or account-id "")})
(if account-id
[:span.text-sm account-name]
(com/pill {:color :red} "Missing acct"))
(com/a-icon-button {:class "p-1"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-item-account)
:hx-target "closest .account-cell"
:hx-swap "outerHTML"
:hx-vals (hx/json {:item-index (or (:item-index item) 0)
:client-id client-id
:current-account-id (or account-id "")})}
svg/pencil)]))
(defn account-edit-cell [{:keys [field-name-prefix client-id current-account-id]}]
(let [account-input-name (str field-name-prefix "[ledger-mapped/account]")]
[:div.account-cell.flex.flex-col.gap-2
(account-typeahead* {:name account-input-name
:value current-account-id
:client-id client-id})
[:div.flex.gap-1
(com/a-icon-button {:class "p-1"
:hx-put (bidi/path-for ssr-routes/only-routes ::route/save-item-account)
:hx-target "closest .account-cell"
:hx-swap "outerHTML"
:hx-include "closest .account-cell"
:hx-vals (hx/json {:field-name-prefix field-name-prefix
:client-id client-id})}
svg/check)
(com/a-icon-button {:class "p-1"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/cancel-item-account)
:hx-target "closest .account-cell"
:hx-swap "outerHTML"
:hx-vals (hx/json {:field-name-prefix field-name-prefix
:client-id client-id
:current-account-id (or current-account-id "")})}
svg/x)]]))
(def grid-page
(helper/build {:id "entity-table"
:id-fn :db/id
@@ -247,7 +190,7 @@
[:span.shrink-0.font-mono.tabular-nums.text-right.text-gray-900.whitespace-nowrap
(format "$%,.2f" (:ledger-mapped/amount si))]])
(for [_ (range (max 0 (- credit-count (count debit-items))))]
[:li.py-0.5.text-sm " "])]
[:li.py-0.5.text-sm " "])]
[:div.border-t-2.border-gray-300.mt-1.pt-1.flex.justify-between.items-baseline
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-gray-500 "Total"]
[:span.font-mono.tabular-nums.font-bold.text-gray-900
@@ -273,7 +216,7 @@
[:span.shrink-0.font-mono.tabular-nums.text-right.text-gray-900.whitespace-nowrap
(format "$%,.2f" (:ledger-mapped/amount si))]])
(for [_ (range (max 0 (- debit-count (count credit-items))))]
[:li.py-0.5.text-sm " "])]
[:li.py-0.5.text-sm " "])]
[:div.border-t-2.border-gray-300.mt-1.pt-1.flex.justify-between.items-baseline
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-gray-500 "Total"]
[:span.font-mono.tabular-nums.font-bold.text-gray-900
@@ -348,296 +291,308 @@
(not (and (:credit x)
(:debit x))))]]]]])
(defn summary-total-row* [request]
(let [total-credits (-> request
:multi-form-state
:step-params
:sales-summary/items
(total-credits))
total-debits (-> request
:multi-form-state
:step-params
:sales-summary/items
(total-debits))]
;; ---------------------------------------------------------------------------
;; Field-name / error helpers (no step-params[...] prefix) + item helpers.
;; Mirrors transaction/edit.clj and transaction/bulk_code.clj.
;; ---------------------------------------------------------------------------
(com/data-grid-row {:id "total-row"
:class "bg-slate-50 border-t-2 border-slate-300"}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"}
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-slate-600
"Total"])
(com/data-grid-cell {:class "text-right"}
[:span.font-mono.tabular-nums.font-bold.text-slate-900
(format "$%,.2f" total-debits)])
(com/data-grid-cell {:class "text-right"}
[:span.font-mono.tabular-nums.font-bold.text-slate-900
(format "$%,.2f" total-credits)])
(com/data-grid-cell {}))))
(def ^:dynamic *errors*
"Humanized form errors for the current render, keyed by edit-schema paths. Bound by
render-form from the request's :form-errors. Plain map -- no wizard, no cursor."
{})
(defn unbalanced-row* [request]
(let [total-credits (-> request
:multi-form-state
:step-params
:sales-summary/items
(total-credits))
total-debits (-> request
:multi-form-state
:step-params
:sales-summary/items
(total-debits))
unbalanced? (not (dollars= total-credits total-debits))
debit-over? (and unbalanced? (> total-debits total-credits))
credit-over? (and unbalanced? (> total-credits total-debits))]
(defn- ferr [& path]
(get-in *errors* (vec path)))
(com/data-grid-row {:id "unbalanced-row"
:class (when unbalanced? "bg-red-50 border-t border-red-200")}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"}
(when unbalanced?
[:span.text-xs.uppercase.tracking-wider.font-semibold.text-red-700
"Out of balance"]))
(com/data-grid-cell {:class "text-right"}
(when debit-over?
[:span.font-mono.tabular-nums.font-bold.text-red-700
(format "$%,.2f" (- total-debits total-credits))]))
(com/data-grid-cell {:class "text-right"}
(when credit-over?
[:span.font-mono.tabular-nums.font-bold.text-red-700
(format "$%,.2f" (- total-credits total-debits))]))
(com/data-grid-cell {}))))
(defn- item-field-name [index field]
(path->name2 :sales-summary/items index field))
(defn summary-total-display [request]
(let [total-credits (-> request
:multi-form-state
:step-params
:sales-summary/items
(total-credits))
total-debits (-> request
:multi-form-state
:step-params
:sales-summary/items
(total-debits))]
[:div.flex.justify-between.text-sm.py-1.border-t.mt-1
{:id "total-display"}]
[:span.font-semibold "Total"]
[:div.flex.gap-8
[:span.font-mono (format "$%,.2f" total-debits)]
[:span.font-mono (format "$%,.2f" total-credits)]]))
(defn- item-field-errors [index field]
(ferr :sales-summary/items index field))
(defn unbalanced-display [request]
(let [total-credits (-> request
:multi-form-state
:step-params
:sales-summary/items
(total-credits))
total-debits (-> request
:multi-form-state
:step-params
:sales-summary/items
(total-debits))
delta (- total-debits total-credits)]
(when-not (dollars-0? delta)
[:div.flex.justify-between.text-sm.py-1
{:id "unbalanced-display"}
[:span.font-semibold.text-red-600 "Unbalanced"]
[:div.flex.gap-8
[:span.font-mono (when (pos? delta) (format "$%,.2f" delta))
[:span.font-mono (when (neg? delta) (format "$%,.2f" (Math/abs delta)))]]]])))
(defn- item-side
"Which column an item belongs to: its persisted ledger-side for auto items, else the
filled-in credit/debit for a manual row (nil for a brand-new blank manual row)."
[item]
(cond
(= :ledger-side/debit (:ledger-mapped/ledger-side item)) :debit
(= :ledger-side/credit (:ledger-mapped/ledger-side item)) :credit
(:debit item) :debit
(:credit item) :credit
:else nil))
(defn sales-summary-item-row* [{:keys [value client-id]}]
(let [manual? (fc/field-value (:sales-summary-item/manual? value))]
(com/data-grid-row (cond-> {:x-ref "p"
:x-data (hx/json {})
:class (when manual?
"bg-indigo-50/40 border-l-2 border-indigo-300")}
(fc/field-value (:new? value)) (hx/htmx-transition-appear))
(fc/with-field :db/id
(com/hidden {:name (fc/field-name)
:value (fc/field-value)}))
(when manual?
(fc/with-field :sales-summary-item/manual?
(com/hidden {:name (fc/field-name)
:value true})))
(com/data-grid-cell {:class "align-top"}
(fc/with-field :sales-summary-item/category
(if manual?
(com/validated-field {:errors (fc/field-errors)}
(com/text-input {:placeholder "Category/Explanation"
:name (fc/field-name)
:value (fc/field-value)}))
(defn- sum-debits [items]
(->> items (keep :debit) (filter number?) (reduce + 0.0)))
(list
(com/hidden {:name (fc/field-name)
:value (fc/field-value)})
[:span.text-sm.text-gray-700
(fc/field-value (:sales-summary-item/category value))]))))
(com/data-grid-cell {:class "align-top"}
(fc/with-field :ledger-mapped/account
(com/validated-field {:errors (fc/field-errors)}
(account-typeahead* {:value (fc/field-value)
:client-id client-id
:name (fc/field-name)}))))
(com/data-grid-cell {:class "text-right align-top"}
(defn- sum-credits [items]
(->> items (keep :credit) (filter number?) (reduce + 0.0)))
(if manual?
(fc/with-field :debit
(com/validated-field {:errors (fc/field-errors)}
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
:name (fc/field-name)
:value (fc/field-value)})))
(when (= (fc/field-value (:ledger-mapped/ledger-side value))
:ledger-side/debit)
[:span.font-mono.tabular-nums.text-gray-900.text-sm.whitespace-nowrap
(format "$%,.2f" (fc/field-value (:ledger-mapped/amount value)))])))
(com/data-grid-cell {:class "text-right align-top"}
;; ---------------------------------------------------------------------------
;; Render (Selmer): account typeahead, inline account cell (display/edit),
;; the read-only auto rows, the editable manual rows, totals/balance.
;; ---------------------------------------------------------------------------
(if manual?
(fc/with-field :credit
(com/validated-field {:errors (fc/field-errors)}
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
:name (fc/field-name)
:value (fc/field-value)})))
(when (= (fc/field-value (:ledger-mapped/ledger-side value))
:ledger-side/credit)
[:span.font-mono.tabular-nums.text-gray-900.text-sm.whitespace-nowrap
(format "$%,.2f" (fc/field-value (:ledger-mapped/amount value)))])))
(com/data-grid-cell {:class "align-top"}
(when manual?
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x))))))
(defn account-typeahead* [{:keys [name value client-id]}]
(sc/typeahead {:name name
:id name
:placeholder "Search..."
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
{:client-id client-id
:purpose "invoice"})
:value value
:content-fn (fn [value]
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
client-id)))}))
(defrecord MainStep [linear-wizard]
mm/ModalWizardStep
(step-name [_]
"Main")
(step-key [_]
:main)
(defn account-display-cell*
"Account name + inline-edit pencil. The pencil swaps just this `.account-cell`
(#account-cell, Rule 2) into the edit cell."
[{:keys [index account-id client-id]}]
(let [account-id (when (and account-id (not= account-id "")) (->db-id account-id))
account-name (when account-id
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read account-id)
client-id)))]
(str "<div class=\"account-cell flex items-center gap-2\">"
(str (sc/hidden {:name (item-field-name index :ledger-mapped/account)
:value (or account-id "")}))
(if account-name
(str "<span class=\"text-sm\">" (hu/escape-html account-name) "</span>")
(str (sel/hiccup->html (com/pill {:color :red} "Missing acct"))))
(str (sc/a-icon-button {:class "p-1"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-item-account)
:hx-target "closest .account-cell"
:hx-swap "outerHTML"
:hx-vals (hx/json {:item-index index
:client-id client-id
:current-account-id (or account-id "")})}
svg/pencil))
"</div>")))
(edit-path [_ _]
[])
(defn account-edit-cell*
"The account typeahead + check (save) / cancel buttons. Each swaps just the
`.account-cell` back to the display cell."
[{:keys [index account-id client-id]}]
(str "<div class=\"account-cell flex flex-col gap-2\">"
(str (account-typeahead* {:name (item-field-name index :ledger-mapped/account)
:value account-id
:client-id client-id}))
"<div class=\"flex gap-1\">"
(str (sc/a-icon-button {:class "p-1"
:hx-put (bidi/path-for ssr-routes/only-routes ::route/save-item-account)
:hx-target "closest .account-cell"
:hx-swap "outerHTML"
:hx-include "closest .account-cell"
:hx-vals (hx/json {:item-index index :client-id client-id})}
svg/check))
(str (sc/a-icon-button {:class "p-1"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/cancel-item-account)
:hx-target "closest .account-cell"
:hx-swap "outerHTML"
:hx-vals (hx/json {:item-index index
:client-id client-id
:current-account-id (or account-id "")})}
svg/x))
"</div></div>"))
(step-schema [_]
(mut/select-keys (mm/form-schema linear-wizard) #{:db/id :sales-summary/items}))
(defn- auto-item-row*
"A read-only auto item in its Debits/Credits column: category + inline-editable account
cell + the (read-only) amount. Posts db/id, category, and account."
[index item client-id]
(let [side (item-side item)
amount (if (= side :debit) (:debit item) (:credit item))]
(str "<div class=\"flex items-center gap-2 text-sm\">"
(str (sc/hidden {:name (item-field-name index :db/id) :value (:db/id item)}))
(str (sc/hidden {:name (item-field-name index :sales-summary-item/category)
:value (:sales-summary-item/category item)}))
"<span class=\"text-gray-500 flex-1\">" (hu/escape-html (str (:sales-summary-item/category item))) "</span>"
(str (account-display-cell* {:index index
:account-id (:ledger-mapped/account item)
:client-id client-id}))
"<span class=\"ml-auto font-mono tabular-nums text-gray-900\">" (format "$%,.2f" (or amount 0.0)) "</span>"
"</div>")))
(render-step
[this {:keys [multi-form-state] :as request}]
(let [client-id (:db/id (:sales-summary/client (:snapshot multi-form-state)))
items (:sales-summary/items (:step-params multi-form-state))
sorted-items (sort-items items)
indexed-items (map-indexed vector sorted-items)
debit-items (filter #(= :ledger-side/debit (:ledger-mapped/ledger-side (second %))) indexed-items)
credit-items (filter #(= :ledger-side/credit (:ledger-mapped/ledger-side (second %))) indexed-items)
max-rows (max (count debit-items) (count credit-items))
padded-debits (concat debit-items (repeat (- max-rows (count debit-items)) nil))
padded-credits (concat credit-items (repeat (- max-rows (count credit-items)) nil))]
(mm/default-render-step
linear-wizard this
:head [:div.p-2 "Edit Summary"]
:body (mm/default-step-body
{}
[:div
(fc/with-field :db/id
(com/hidden {:name (fc/field-name)
:value (fc/field-value)}))
[:div.grid.grid-cols-2.gap-6
[:div
[:div.font-semibold.text-sm.mb-2 "Debits"]
[:div.space-y-1
(for [[actual-idx item] padded-debits]
(if item
(let [manual? (:sales-summary-item/manual? item)]
(if manual?
[:div.flex.items-center.gap-2.text-sm {:x-ref "p" :x-data (hx/json {})}
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
:value (:db/id item)})
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/manual?]")
:value "true"})
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
item []
(fc/with-field :sales-summary-item/category
(com/text-input {:placeholder "Category"
:name (fc/field-name)
:value (fc/field-value)
:class "w-32 text-sm"})))
(account-typeahead* {:name (str "step-params[sales-summary/items][" actual-idx "][ledger-mapped/account]")
:value (:ledger-mapped/account item)
:client-id client-id})
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
item []
(fc/with-field :debit
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
:name (fc/field-name)
:value (fc/field-value)})))
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x)]
[:div.flex.items-center.gap-2.text-sm
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
:value (:db/id item)})
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/category]")
:value (:sales-summary-item/category item)})
[:span.text-gray-500 (truncate (:sales-summary-item/category item) 30)]
(account-display-cell {:item (assoc item :item-index actual-idx)
:field-name-prefix (str "step-params[sales-summary/items][" actual-idx "]")
:client-id client-id})
[:span.ml-auto.font-mono.tabular-nums.text-gray-900 (format "$%,.2f" (:ledger-mapped/amount item))]]))
[:div.h-6]))]
[:div.mt-2.border-t.pt-1
(summary-total-display request)
(unbalanced-display request)]]
[:div
[:div.font-semibold.text-sm.mb-2 "Credits"]
[:div.space-y-1
(for [[actual-idx item] padded-credits]
(if item
(let [manual? (:sales-summary-item/manual? item)]
(if manual?
[:div.flex.items-center.gap-2.text-sm {:x-ref "p" :x-data (hx/json {})}
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
:value (:db/id item)})
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/manual?]")
:value "true"})
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
item []
(fc/with-field :sales-summary-item/category
(com/text-input {:placeholder "Category"
:name (fc/field-name)
:value (fc/field-value)
:class "w-32 text-sm"})))
(account-typeahead* {:name (str "step-params[sales-summary/items][" actual-idx "][ledger-mapped/account]")
:value (:ledger-mapped/account item)
:client-id client-id})
(fc/start-form-with-prefix [(str "step-params[sales-summary/items][" actual-idx "]")]
item []
(fc/with-field :credit
(com/money-input {:class "w-24 text-right font-mono tabular-nums"
:name (fc/field-name)
:value (fc/field-value)})))
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x)]
[:div.flex.items-center.gap-2.text-sm
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][db/id]")
:value (:db/id item)})
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/category]")
:value (:sales-summary-item/category item)})
[:span.text-gray-500 (truncate (:sales-summary-item/category item) 30)]
(account-display-cell {:item (assoc item :item-index actual-idx)
:field-name-prefix (str "step-params[sales-summary/items][" actual-idx "]")
:client-id client-id})
[:span.ml-auto.font-mono.tabular-nums.text-gray-900 (format "$%,.2f" (:ledger-mapped/amount item))]]))
[:div.h-6]))]
[:div.mt-2.border-t.pt-1
(summary-total-display request)
(unbalanced-display request)]]]
[:div.mt-4.border-t.pt-2
(fc/with-field :sales-summary/items
(com/data-grid-new-row {:colspan 2
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-summary-item)
:row-offset 0
:index (count (fc/field-value))
:tr-params {:hx-vals (hx/json {:client-id client-id})}}
"New Summary Item"))]])
(defn- manual-amount-input* [index field item]
(sc/money-input {:name (item-field-name index field)
:value (get item field)
:class "w-24 text-right font-mono tabular-nums"
:placeholder (str/capitalize (clojure.core/name field))
:hx-post (bidi/path-for ssr-routes/only-routes ::route/form-changed)
:hx-target "#summary-totals"
:hx-select "#summary-totals"
:hx-swap "outerHTML"
:hx-trigger "keyup changed delay:300ms"
:hx-include "closest form"}))
:footer
(mm/default-step-footer linear-wizard this :validation-route ::route/edit-wizard-navigate)
:validation-route ::route/edit-wizard-navigate
:width-height-class "lg:w-[900px] lg:h-[600px]"))))
(defn- manual-item-row*
"An editable manual item: category + account typeahead + debit + credit money inputs +
remove. The amount inputs swap the totals block (Rule 4); remove swaps the whole form."
[index item client-id]
(str "<div class=\"manual-item-row flex items-center gap-2\">"
(str (sc/hidden {:name (item-field-name index :db/id) :value (:db/id item)}))
(str (sc/hidden {:name (item-field-name index :sales-summary-item/manual?) :value "true"}))
(str (sc/validated-field
{:errors (item-field-errors index :sales-summary-item/category) :class "flex-1"}
(sc/text-input {:name (item-field-name index :sales-summary-item/category)
:value (:sales-summary-item/category item)
:placeholder "Category/Explanation"})))
(str (sc/validated-field
{:errors (item-field-errors index :ledger-mapped/account)}
(account-typeahead* {:name (item-field-name index :ledger-mapped/account)
:value (:ledger-mapped/account item)
:client-id client-id})))
(str (manual-amount-input* index :debit item))
(str (manual-amount-input* index :credit item))
(str (sc/a-icon-button {:class "p-1 account-remove-action"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/form-changed)
:hx-vals (hx/json {:op "remove-item" :row-index index})
:hx-target "#summary-edit-form"
:hx-select "#summary-edit-form"
:hx-swap "outerHTML"
:hx-include "closest form"}
svg/x))
"</div>"))
(defn- totals*
"Swappable totals + balance block (#summary-totals). Fixes the previously-dead total /
balance display: shows the running debit/credit totals and a Balanced / Unbalanced
indicator."
[items]
(let [td (sum-debits items)
tc (sum-credits items)
balanced? (dollars= td tc)
delta (- td tc)]
(str "<div class=\"border-t pt-2 mt-2 space-y-1\">"
"<div class=\"flex justify-between text-sm font-semibold\"><span>Total</span>"
"<div class=\"flex gap-8\"><span class=\"font-mono\">" (format "$%,.2f" td) "</span>"
"<span class=\"font-mono\">" (format "$%,.2f" tc) "</span></div></div>"
(if balanced?
"<div class=\"text-sm text-emerald-700 font-semibold\">Balanced</div>"
(str "<div class=\"text-sm text-red-600 font-semibold flex justify-between\"><span>Unbalanced</span>"
"<span class=\"font-mono\">" (format "$%,.2f" (Math/abs delta)) " "
(if (pos? delta) "Debit over" "Credit over") "</span></div>"))
"</div>")))
(defn- new-item-button* []
(sc/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/form-changed)
:hx-vals (hx/json {:op "new-item"})
:hx-target "#summary-edit-form"
:hx-select "#summary-edit-form"
:hx-swap "outerHTML"
:hx-include "closest form"
:color :secondary}
"New Summary Item"))
(defn- form-errors-html [errors]
(str "<div id=\"form-errors\">"
(when (seq errors)
(str "<span class=\"error-content\"><p class=\"mt-2 text-xs text-red-600 dark:text-red-500 h-4\">"
(str/join ", " (filter string? errors))
"</p></span>"))
"</div>"))
(defn- footer* [request]
(sel/raw
(str "<div class=\"flex justify-end\"><div class=\"flex items-baseline gap-x-4\">"
(form-errors-html (:errors (:form-errors request)))
(str (sc/button {:color :primary :x-ref "next" :class "w-32" :type "submit"} "Save"))
"</div></div>")))
(defn render-form
"Renders the whole plain sales-summary edit form (no wizard). Binds *errors* so the
field-level lookups (item-field-errors) resolve. Reuses the edit modal chrome."
[request]
(binding [*errors* (or (:form-errors request) {})]
(let [{tx-id :db/id client :sales-summary/client items :sales-summary/items} (:edit-state request)
client-id (:db/id client)
indexed (map-indexed vector items)
auto (remove (fn [[_ it]] (:sales-summary-item/manual? it)) indexed)
manual (filter (fn [[_ it]] (:sales-summary-item/manual? it)) indexed)
debit-rows (->> auto
(filter (fn [[_ it]] (= :debit (item-side it))))
(map (fn [[i it]] (auto-item-row* i it client-id)))
(apply str))
credit-rows (->> auto
(filter (fn [[_ it]] (= :credit (item-side it))))
(map (fn [[i it]] (auto-item-row* i it client-id)))
(apply str))
manual-rows (->> manual
(map (fn [[i it]] (manual-item-row* i it client-id)))
(apply str))
body (sel/render "templates/sales-summary/summary-body.html"
{:debit_rows debit-rows
:credit_rows credit-rows
:totals (totals* items)
:manual_rows manual-rows
:new_item_button (str (new-item-button*))})
modal-card (sel/render "templates/transaction-edit/edit-modal.html"
{:head "<div class=\"p-2\">Edit Summary</div>"
:side_panel nil
:body body
:footer (str (footer* request))})]
(sel/render->hiccup
"templates/sales-summary/edit-form.html"
{:db_id tx-id
:form_attrs (sc/attrs->str {:hx-ext "response-targets"
:hx-swap "outerHTML"
:hx-target-400 "#form-errors .error-content"
:hx-trigger "submit"
:hx-target "this"
:hx-put (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-submit)})
:modal (str (sc/modal {:id "wizardmodal"} (sel/raw modal-card)))}))))
;; ---------------------------------------------------------------------------
;; State: derive the flat edit-state from the entity overlaid with the posted
;; form (replaces MultiStepFormState + the EDN snapshot round-trip).
;; ---------------------------------------------------------------------------
(defn entity->edit-state
"The persisted sales summary, shaped like the form's state: each item gets a :credit or
:debit field derived from its ledger-side/amount (what initial-edit-wizard-state did)."
[tx-id]
(let [e (dc/pull (dc/db conn) default-read tx-id)
items (->> (:sales-summary/items e)
sort-items
(mapv (fn [x]
(if (= :ledger-side/debit (:ledger-mapped/ledger-side x))
(assoc x :debit (:ledger-mapped/amount x))
(assoc x :credit (:ledger-mapped/amount x))))))]
{:db/id (:db/id e)
:sales-summary/client (:sales-summary/client e)
:sales-summary/items items}))
(defn- merge-items
"Overlay the posted items onto the persisted items by :db/id, so read-only fields the
form doesn't post (ledger-side, amount, the credit/debit shaping for auto items)
survive while edited fields (category, account, manual credit/debit) win. New manual
rows (temp db/id) have no persisted match and ride through as-is."
[entity-items posted-items]
(let [by-id (into {} (map (juxt :db/id identity)) entity-items)]
(mapv (fn [pi] (merge (get by-id (:db/id pi)) pi)) posted-items)))
(defn wrap-decode
"Parses the posted (nested) form params and decodes them straight into edit-schema --
no step-params[...] prefix. Strips to the editable top-level keys."
[handler]
(-> (fn [request]
(let [decoded (mc/decode edit-schema (:form-params request) main-transformer)
decoded (if (map? decoded) (select-keys decoded [:db/id :sales-summary/items]) {})]
(handler (assoc request :posted decoded))))
(wrap-nested-form-params)))
(defn wrap-derive-state
"Builds :edit-state from the entity (db/id hidden, or the route on initial open) overlaid
with the live posted items -- no serialized snapshot. db/id + client always come from
the entity; items are the merged posted items when present, else the entity's."
[handler]
(fn [request]
(let [tx-id (->db-id (or (some-> request :form-params (get "db/id"))
(-> request :route-params :db/id)))
base (entity->edit-state tx-id)
posted (:posted request)
items (if (contains? posted :sales-summary/items)
(merge-items (:sales-summary/items base) (:sales-summary/items posted))
(:sales-summary/items base))]
(handler (assoc request :edit-state (assoc base :sales-summary/items items))))))
(defn attach-ledger [i]
(cond-> i
@@ -645,142 +600,129 @@
:ledger-mapped/amount (:credit i))
(:debit i) (assoc :ledger-mapped/ledger-side :ledger-side/debit
:ledger-mapped/amount (:debit i))
true (dissoc :credit :debit)
true (dissoc :credit :debit :new? :item-index)
true (assoc :sales-summary-item/manual? true)))
(defrecord EditWizard [_ current-step]
mm/LinearModalWizard
(hydrate-from-request
[this request]
this)
(navigate [this step-key]
(assoc this :current-step step-key))
(get-current-step
[this]
(mm/get-step this :main))
(render-wizard [this {:keys [multi-form-state] :as request}]
(mm/default-render-wizard
this request
:form-params
(-> mm/default-form-props
(assoc :hx-put
(str (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-submit))))
:render-timeline? false))
(steps [_]
[:main])
(get-step [this step-key]
(let [step-key-result (mc/parse mm/step-key-schema step-key)
[step-key-type step-key] step-key-result]
(->MainStep this)))
(form-schema [_]
edit-schema)
(submit [this {:keys [multi-form-state request-method identity] :as request}]
(let [result (:snapshot multi-form-state)
transaction [:upsert-sales-summary {:db/id (:db/id result)
:sales-summary/items (map
(fn [i]
(if (:sales-summary-item/manual? i)
(attach-ledger i)
{:db/id (:db/id i)
:ledger-mapped/account (:ledger-mapped/account i)}))
(:sales-summary/items result))}]]
@(dc/transact conn [transaction])
(html-response
(row* identity (dc/pull (dc/db conn) default-read (:db/id result))
{:flash? true
:request request})
:headers (cond-> {"hx-trigger" "modalclose"
"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id result))
"hx-reswap" "outerHTML"})))))
;; ---------------------------------------------------------------------------
;; form-changed ops (whole-form re-render): add / remove a manual item. A bare
;; re-render (no op) refreshes the totals block (manual amount edits).
;; ---------------------------------------------------------------------------
(def edit-wizard (->EditWizard nil nil))
(defn apply-new-item [request]
(let [items (vec (:sales-summary/items (:edit-state request)))
new-item {:db/id (str (java.util.UUID/randomUUID))
:new? true
:sales-summary-item/manual? true
:sales-summary-item/category ""}]
(assoc-in request [:edit-state :sales-summary/items] (conj items new-item))))
(defn initial-edit-wizard-state [request]
(let [entity (dc/pull (dc/db conn) default-read (:db/id (:route-params request)))
entity (select-keys entity (mut/keys edit-schema))
entity (update entity :sales-summary/items (comp #(map (fn [x]
(if (= :ledger-side/debit (:ledger-mapped/ledger-side x))
(assoc x :debit (:ledger-mapped/amount x))
(assoc x :credit (:ledger-mapped/amount x))))
%) sort-items))]
(defn apply-remove-item [request]
(let [row-index (some-> request :form-params (get "row-index") Integer/parseInt)
items (vec (:sales-summary/items (:edit-state request)))
updated (if (and row-index (< row-index (count items)))
(vec (concat (subvec items 0 row-index)
(subvec items (inc row-index))))
items)]
(assoc-in request [:edit-state :sales-summary/items] updated)))
(mm/->MultiStepFormState entity [] entity)))
(defn form-changed-handler
"Single whole-form re-render endpoint. Dispatches on `op` (add/remove a manual item);
a missing op (a manual amount keyup) just re-renders (hx-select picks #summary-totals)."
[request]
(let [op (get-in request [:form-params "op"])
request' (case op
"new-item" (apply-new-item request)
"remove-item" (apply-remove-item request)
request)]
(html-response (render-form request'))))
;; ---------------------------------------------------------------------------
;; Inline account editor (targeted .account-cell swaps -- a distinct click-to-edit
;; feature, kept as its own three small stateless routes).
;; ---------------------------------------------------------------------------
(defn edit-item-account [request]
(let [{:keys [item-index client-id current-account-id]} (:query-params request)
item-index (if (string? item-index) (Integer/parseInt item-index) item-index)
field-name-prefix (str "step-params[sales-summary/items][" item-index "]")
current-account-id (when (and current-account-id (not= current-account-id ""))
(if (string? current-account-id)
(Long/parseLong current-account-id)
current-account-id))
client-id (if (string? client-id) (Long/parseLong client-id) client-id)]
idx (if (string? item-index) (Integer/parseInt item-index) item-index)
account-id (when (and current-account-id (not= current-account-id "")) (->db-id current-account-id))]
(html-response
(account-edit-cell {:field-name-prefix field-name-prefix
:client-id client-id
:current-account-id current-account-id}))))
(sel/raw (account-edit-cell* {:index idx :account-id account-id :client-id (->db-id client-id)})))))
(defn save-item-account [request]
(let [field-name-prefix (get-in request [:params "field-name-prefix"])
client-id (get-in request [:params "client-id"])
account-input-name (str field-name-prefix "[ledger-mapped/account]")
account-id-str (get-in request [:form-params account-input-name])
account-id (when (and account-id-str (not= account-id-str ""))
(Long/parseLong account-id-str))
item {:ledger-mapped/account account-id
:item-index (second (re-find #"\[(\d+)\]" (or field-name-prefix "")))}
client-id (if (string? client-id) (Long/parseLong client-id) client-id)]
(let [item-index (get-in request [:params "item-index"])
idx (if (string? item-index) (Integer/parseInt item-index) item-index)
client-id (->db-id (get-in request [:params "client-id"]))
account-id-str (get-in request [:form-params (item-field-name idx :ledger-mapped/account)])
account-id (when (and account-id-str (not= account-id-str "")) (->db-id account-id-str))]
(html-response
(account-display-cell {:item item
:field-name-prefix field-name-prefix
:client-id client-id}))))
(sel/raw (account-display-cell* {:index idx :account-id account-id :client-id client-id})))))
(defn cancel-item-account [request]
(let [{:keys [field-name-prefix client-id current-account-id]} (:query-params request)
account-id (when (and current-account-id (not= current-account-id ""))
(if (string? current-account-id)
(Long/parseLong current-account-id)
current-account-id))
item {:ledger-mapped/account account-id
:item-index (second (re-find #"\[(\d+)\]" (or field-name-prefix "")))}
client-id (if (string? client-id) (Long/parseLong client-id) client-id)]
(let [{:keys [item-index client-id current-account-id]} (:query-params request)
idx (if (string? item-index) (Integer/parseInt item-index) item-index)
account-id (when (and current-account-id (not= current-account-id "")) (->db-id current-account-id))]
(html-response
(account-display-cell {:item item
:field-name-prefix field-name-prefix
:client-id client-id}))))
(sel/raw (account-display-cell* {:index idx :account-id account-id :client-id (->db-id client-id)})))))
;; ---------------------------------------------------------------------------
;; Open + submit
;; ---------------------------------------------------------------------------
(defn open-handler [request]
(modal-response
(sel/render->hiccup "templates/transaction-edit/transitioner.html"
{:body (str (render-form request))})))
(defn- render-form-response [request]
(html-response (render-form request)
:headers {"HX-reswap" "outerHTML"}))
(defn submit
"Validates the posted edit-state against edit-schema (field errors via wrap-form-4xx-2),
then upserts the sales summary: manual items attach-ledger (credit/debit -> ledger
side+amount), auto items update only their account."
[request]
(let [{tx-id :db/id items :sales-summary/items :as edit-state} (:edit-state request)]
(assert-schema edit-schema edit-state)
(let [transaction [:upsert-sales-summary
{:db/id tx-id
:sales-summary/items (map (fn [i]
(if (:sales-summary-item/manual? i)
(attach-ledger i)
{:db/id (:db/id i)
:ledger-mapped/account (:ledger-mapped/account i)}))
items)}]]
@(dc/transact conn [transaction])
(html-response
(row* (:identity request) (dc/pull (dc/db conn) default-read tx-id)
{:flash? true
:request request})
:headers {"hx-trigger" "modalclose"
"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" tx-id)
"hx-reswap" "outerHTML"}))))
(def key->handler
(apply-middleware-to-all-handlers
(->>
{::route/page (helper/page-route grid-page)
::route/table (helper/table-route grid-page)
::route/edit-wizard (-> mm/open-wizard-handler
(mm/wrap-wizard edit-wizard)
(mm/wrap-init-multi-form-state initial-edit-wizard-state)
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
::route/edit-wizard-navigate (-> mm/next-handler
(mm/wrap-wizard edit-wizard)
(mm/wrap-decode-multi-form-state))
::route/new-summary-item (-> (add-new-entity-handler [:step-params :sales-summary/items]
(fn render [cursor request]
(sales-summary-item-row*
{:value cursor
:client-id (:client-id (:query-params request))}))
(fn build-new-row [base _]
(assoc base :sales-summary-item/manual? true)))
{::route/page (helper/page-route grid-page)
::route/table (helper/table-route grid-page)
::route/edit-wizard (-> open-handler
(wrap-derive-state)
(wrap-decode)
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
::route/form-changed (-> form-changed-handler
(wrap-derive-state)
(wrap-decode))
::route/edit-item-account (-> edit-item-account
(wrap-schema-enforce :query-schema [:map
[:client-id {:optional true}
[:maybe entity-id]]]))
::route/edit-item-account (-> edit-item-account
(wrap-schema-enforce :query-schema [:map
[:item-index nat-int?]
[:client-id {:optional true} [:maybe entity-id]]
[:current-account-id {:optional true} [:maybe :string]]]))
::route/save-item-account save-item-account
::route/cancel-item-account cancel-item-account
::route/edit-wizard-submit (-> mm/submit-handler
(mm/wrap-wizard edit-wizard)
(mm/wrap-decode-multi-form-state))})
[:item-index nat-int?]
[:client-id {:optional true} [:maybe entity-id]]
[:current-account-id {:optional true} [:maybe :string]]]))
::route/save-item-account save-item-account
::route/cancel-item-account cancel-item-account
::route/edit-wizard-submit (-> submit
(wrap-form-4xx-2 render-form-response)
(wrap-derive-state)
(wrap-decode))}
(fn [h]
(-> h
(wrap-copy-qp-pqp)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,9 @@
(ns auto-ap.routes.pos.sales-summaries)
(def routes {"" {:get ::page
:put ::edit-wizard-submit}
(def routes {"" {:get ::page
:put ::edit-wizard-submit}
"/table" ::table
["/" [#"\d+" :db/id]] {:get ::edit-wizard}
"/edit/navigate" ::edit-wizard-navigate
"/edit/sales-summary-item" ::new-summary-item
"/edit/item-account" ::edit-item-account
"/edit/save-item-account" ::save-item-account
"/edit/form-changed" ::form-changed
"/edit/item-account" ::edit-item-account
"/edit/save-item-account" ::save-item-account
"/edit/cancel-item-account" ::cancel-item-account})

View File

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

View File

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

View File

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