feat(transactions): port manual bank-transaction import to SSR #8

Closed
notid wants to merge 133 commits from integreat-add-transaction-manual into master
Showing only changes of commit 724b6d82f5 - Show all commits

View File

@@ -0,0 +1,601 @@
# Transaction Edit Modal: Simple / Advanced Mode Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the always-visible account split table in the transaction edit modal with a simple mode (single account + location fields) that is the default for uncoded or single-account transactions, with a toggle to the full advanced split table.
**Architecture:** HTMX-driven server-side swap. A new `edit-wizard-toggle-mode` GET endpoint re-renders the manual coding section in the requested mode. Mode is carried via a hidden `<input name="mode">` field included in all HTMX requests. `edit-vendor-changed` is updated to branch on mode. The `LinksStep` render function selects initial mode based on account row count.
**Tech Stack:** Clojure/Hiccup server-side rendering, HTMX, Alpine.js, Bidi routing, Datomic
**Spec:** `docs/superpowers/specs/2026-05-27-transaction-edit-simple-advanced-mode-design.md`
---
## File Map
| File | What changes |
|------|-------------|
| `src/cljc/auto_ap/routes/transactions.cljc` | Add `::edit-wizard-toggle-mode` route |
| `src/clj/auto_ap/ssr/transaction/edit.clj` | Add `simple-mode-fields*`, `manual-coding-section*`, `edit-wizard-toggle-mode-handler`; update `LinksStep` render; update `edit-vendor-changed-handler`; register new route handler |
---
## Task 1: Add the toggle-mode route
**Files:**
- Modify: `src/cljc/auto_ap/routes/transactions.cljc`
- [ ] **Step 1: Add the route entry**
Open `src/cljc/auto_ap/routes/transactions.cljc`. After the `"/edit-wizard-new-account"` line, add:
```clojure
"/edit-wizard-toggle-mode" ::edit-wizard-toggle-mode
```
The file should look like:
```clojure
"/edit-wizard-new-account" ::edit-wizard-new-account
"/edit-wizard-toggle-mode" ::edit-wizard-toggle-mode
"/match-payment" ::link-payment
```
- [ ] **Step 2: Verify the file compiles**
```bash
clj-nrepl-eval -p 9000 "(require '[auto-ap.routes.transactions] :reload)"
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add src/cljc/auto_ap/routes/transactions.cljc
git commit -m "feat: add edit-wizard-toggle-mode route"
```
---
## Task 2: Add `simple-mode-fields*` — the simple-mode account/location UI
**Files:**
- Modify: `src/clj/auto_ap/ssr/transaction/edit.clj`
This function renders the account typeahead + location select + toggle link for simple mode. It goes near the existing `account-typeahead*` and `location-select*` helpers (around line 180).
- [ ] **Step 1: Add `simple-mode-fields*` after `account-typeahead*` (around line 180)**
```clojure
(defn simple-mode-fields*
"Renders the simple-mode account + location row and the toggle link.
request must have :multi-form-state and :entity bound."
[request]
(let [snapshot (-> request :multi-form-state :snapshot)
client-id (or (-> request :entity :transaction/client :db/id)
(:transaction/client snapshot))
existing-row (first (:transaction/accounts snapshot))
account-val (:transaction-account/account existing-row)
location-val (or (:transaction-account/location existing-row) "Shared")
account-id (when (nat-int? account-val)
(dc/pull (dc/db conn) '[:account/location] account-val))
row-id (or (:db/id existing-row) (str (java.util.UUID/randomUUID)))]
[:div
;; hidden inputs to encode the single row as transaction/accounts[0]
(fc/with-field :transaction/accounts
(fc/with-cursor-index 0
[:span
(fc/with-field :db/id
(com/hidden {:name (fc/field-name) :value row-id}))
[:div.flex.gap-2.mt-2
(fc/with-field :transaction-account/account
(com/validated-field
{:label "Account" :errors (fc/field-errors)}
[:div.w-72
(account-typeahead* {:value account-val
:client-id client-id
:name (fc/field-name)
:x-model "simpleAccountId"})]))
(fc/with-field :transaction-account/location
(com/validated-field
{:label "Location"
:errors (fc/field-errors)
:x-hx-val:account-id "simpleAccountId"
:hx-vals (hx/json (cond-> {:name (fc/field-name)}
client-id (assoc :client-id client-id)))
:x-dispatch:changed "simpleAccountId"
: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 account-id)
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
:value location-val})))
;; hidden amount — full transaction total
(fc/with-field :transaction-account/amount
(let [total (Math/abs (or (-> request :entity :transaction/amount)
(:transaction/amount snapshot)
0.0))]
(com/hidden {:name (fc/field-name) :value total})))]))
;; toggle link
[:div.mt-1
[:a.text-sm.text-blue-600.hover:underline.cursor-pointer
{:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-toggle-mode)
:hx-include "closest form"
:hx-target "#manual-coding-section"
:hx-swap "outerHTML"}
"Switch to advanced mode"]]]))
```
- [ ] **Step 2: Verify the file has no parse errors**
```bash
clj-nrepl-eval -p 9000 "(require '[auto-ap.ssr.transaction.edit] :reload)"
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add src/clj/auto_ap/ssr/transaction/edit.clj
git commit -m "feat: add simple-mode-fields* for transaction edit modal"
```
---
## Task 3: Extract `manual-coding-section*` and update `LinksStep`
**Files:**
- Modify: `src/clj/auto_ap/ssr/transaction/edit.clj`
Currently the manual coding block (vendor field + account grid) is inlined inside `LinksStep/render-step`. Extract it into `manual-coding-section*` which selects mode and renders accordingly. This also adds the `mode` hidden input and wraps the section in `#manual-coding-section`.
- [ ] **Step 1: Add `manual-mode-initial` helper** (determines initial mode from snapshot)
Add this function after `simple-mode-fields*`:
```clojure
(defn- manual-mode-initial
"Returns :simple or :advanced based on existing account row count."
[snapshot]
(let [rows (seq (:transaction/accounts snapshot))]
(if (and rows (> (count rows) 1))
:advanced
:simple)))
```
- [ ] **Step 2: Add `manual-coding-section*`**
Add after `manual-mode-initial`:
```clojure
(defn manual-coding-section*
"Renders the vendor field + account/location section for the manual tab.
mode is :simple or :advanced."
[mode request]
(let [snapshot (-> request :multi-form-state :snapshot)
row-count (count (:transaction/accounts snapshot))]
[:div#manual-coding-section
;; hidden mode input — carried by all hx-include=\"closest form\" calls
(com/hidden {:name "mode" :value (name mode)})
;; vendor field
[:div {:hx-trigger "change"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed)
:hx-target "#manual-coding-section"
:hx-swap "outerHTML"
:hx-include "closest form"}
(fc/with-field :transaction/vendor
(com/validated-field
{:label "Vendor" :errors (fc/field-errors)}
[:div.w-96
(com/typeahead {:name (fc/field-name)
:error? (fc/error?)
:class "w-96"
:placeholder "Search..."
: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))})]))]
;; account/location section
(if (= mode :simple)
[:div {:x-data (hx/json {:simpleAccountId
(-> snapshot :transaction/accounts first
:transaction-account/account)})}
(fc/start-form (:multi-form-state request) nil
(fc/with-field :step-params
(simple-mode-fields* request)))]
;; advanced mode
[:div
(when (<= row-count 1)
[:div.mb-2
[:a.text-sm.text-blue-600.hover:underline.cursor-pointer
{:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-toggle-mode)
:hx-include "closest form"
:hx-target "#manual-coding-section"
:hx-swap "outerHTML"}
"Switch to simple mode"]])
(fc/start-form (:multi-form-state request) nil
(fc/with-field :step-params
(fc/with-field :transaction/accounts
[:div#account-grid-body
(account-grid-body* request)])))])]))
```
- [ ] **Step 3: Update `LinksStep/render-step` to use `manual-coding-section*`**
In `LinksStep/render-step` (around line 826), replace the entire `[:div {}` block inside `[:div {:x-show "activeForm === 'manual'" ...}]` (which currently contains the vendor typeahead + approval status + `account-grid-body*`) with:
```clojure
[: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 {}
(manual-coding-section* (manual-mode-initial snapshot) request)
;; Approval status field
(fc/with-field :transaction/approval-status
(com/validated-field
{:label "Status"
:errors (fc/field-errors)}
(let [current-value (name (or (fc/field-value) :transaction-approval-status/unapproved))]
[:div {:x-data (hx/json {:approvalStatus current-value})}
(com/hidden {:name (fc/field-name)
:value current-value
":value" "approvalStatus"})
[:div {:class "inline-flex rounded-md shadow-sm", :role "group"}
(com/button-group-button {"@click" "approvalStatus = 'approved'"
":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'approved' }"
:class "rounded-l-lg"}
"Approved")
(com/button-group-button {"@click" "approvalStatus = 'unapproved'"
":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'unapproved' }"
:class "rounded-r-lg"}
"Unapproved")
(com/button-group-button {"@click" "approvalStatus = 'suppressed'"
":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'suppressed' }"
:class "rounded-r-lg"}
"Client Review")]]]))]]]
```
Also remove the now-redundant `(fc/with-field :transaction/accounts ...)` wrapper that previously wrapped `account-grid-body*` (it is now handled inside `manual-coding-section*`).
- [ ] **Step 4: Verify the file compiles**
```bash
clj-nrepl-eval -p 9000 "(require '[auto-ap.ssr.transaction.edit] :reload)"
```
Expected: no errors.
- [ ] **Step 5: Commit**
```bash
git add src/clj/auto_ap/ssr/transaction/edit.clj
git commit -m "feat: extract manual-coding-section* with simple/advanced mode selection"
```
---
## Task 4: Add `edit-wizard-toggle-mode-handler`
**Files:**
- Modify: `src/clj/auto_ap/ssr/transaction/edit.clj`
This handler re-renders `#manual-coding-section` in the opposite mode. It reads `mode` from the form params (via `step-params` in the decoded multi-form-state) and flips it.
- [ ] **Step 1: Add the handler function** (add after `edit-vendor-changed-handler`):
```clojure
(defn edit-wizard-toggle-mode-handler [request]
(let [step-params (-> request :multi-form-state :step-params)
current-mode (keyword (or (:mode step-params) "simple"))
target-mode (if (= current-mode :simple) :advanced :simple)
snapshot (-> request :multi-form-state :snapshot)
;; When switching simple→advanced, promote simple-mode values into accounts
render-request
(if (and (= target-mode :advanced)
(= current-mode :simple))
;; carry the simple-mode single row into snapshot so the table shows it
(let [accounts (or (seq (:transaction/accounts step-params))
(seq (:transaction/accounts snapshot)))]
(-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts]
(vec accounts))
(assoc-in [:multi-form-state :step-params :transaction/accounts]
(vec accounts))))
;; advanced→simple: take first row only
(let [first-row (first (or (seq (:transaction/accounts step-params))
(seq (:transaction/accounts snapshot))))]
(-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts]
(if first-row [first-row] []))
(assoc-in [:multi-form-state :step-params :transaction/accounts]
(if first-row [first-row] [])))))]
(html-response
(fc/start-form (:multi-form-state render-request) nil
(fc/with-field :step-params
(manual-coding-section* target-mode render-request))))))
```
- [ ] **Step 2: Register the handler in `key->handler`**
In the `key->handler` map (around line 1357), add after the `::route/edit-wizard-new-account` entry:
```clojure
::route/edit-wizard-toggle-mode (-> edit-wizard-toggle-mode-handler
(mm/wrap-wizard edit-wizard)
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
(mm/wrap-decode-multi-form-state))
```
- [ ] **Step 3: Verify the file compiles**
```bash
clj-nrepl-eval -p 9000 "(require '[auto-ap.ssr.transaction.edit] :reload)"
```
Expected: no errors.
- [ ] **Step 4: Commit**
```bash
git add src/clj/auto_ap/ssr/transaction/edit.clj
git commit -m "feat: add edit-wizard-toggle-mode-handler"
```
---
## Task 5: Update `edit-vendor-changed-handler` to support both modes
**Files:**
- Modify: `src/clj/auto_ap/ssr/transaction/edit.clj`
Currently this handler always returns `[:div#account-grid-body ...]`. It must now return `#manual-coding-section` in the correct mode.
- [ ] **Step 1: Replace `edit-vendor-changed-handler`**
Replace the entire `edit-vendor-changed-handler` function body with:
```clojure
(defn edit-vendor-changed-handler [request]
(let [multi-form-state (:multi-form-state request)
snapshot (:snapshot multi-form-state)
step-params (:step-params multi-form-state)
mode (keyword (or (:mode step-params) "simple"))
client-id (or (:transaction/client snapshot)
(-> request :entity :transaction/client :db/id))
vendor-id (or (:transaction/vendor step-params)
(:transaction/vendor snapshot))
total (Math/abs (or (-> request :entity :transaction/amount)
(:transaction/amount snapshot)
0.0))
amount-mode (or (:amount-mode snapshot) "$")
existing-accounts (or (seq (:transaction/accounts step-params))
(seq (:transaction/accounts snapshot)))
default-account (when (and (empty? existing-accounts) vendor-id client-id)
(vendor-default-account vendor-id client-id))
render-request
(if (and (empty? existing-accounts) vendor-id client-id)
(let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID))
:transaction-account/location (or (:account/location default-account) "Shared")
:transaction-account/amount (if (= amount-mode "%") 100.0 total)}
default-account (assoc :transaction-account/account (:db/id default-account)))]
(-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account])
(assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account])))
request)]
(html-response
(fc/start-form (:multi-form-state render-request) nil
(fc/with-field :step-params
(manual-coding-section* mode render-request))))))
```
Note: the `hx-target` on the vendor field in `manual-coding-section*` must point to `#manual-coding-section` (not `#account-grid-body`) — this was set correctly in Task 3.
- [ ] **Step 2: Verify the file compiles**
```bash
clj-nrepl-eval -p 9000 "(require '[auto-ap.ssr.transaction.edit] :reload)"
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add src/clj/auto_ap/ssr/transaction/edit.clj
git commit -m "feat: update edit-vendor-changed-handler to support simple/advanced mode"
```
---
## Task 6: Fix `fc/with-cursor-index` usage in `simple-mode-fields*`
**Context:** The simple-mode fields need to emit form names like `step-params[transaction/accounts][0][transaction-account/account]`. The existing `fc/cursor-map` used in `account-grid-body*` handles this automatically. For the single-row simple mode we need to manually set index 0.
Look up how `fc/with-cursor-index` (or equivalent) works in `src/clj/auto_ap/ssr/form_cursor.clj` before writing the code in Task 2. If no such helper exists, use `fc/cursor-nth` or replicate the index manually via the form cursor API.
- [ ] **Step 1: Inspect the form cursor API**
```bash
clj-nrepl-eval -p 9000 "(clj-mcp.repl-tools/list-vars 'auto-ap.ssr.form-cursor)"
```
Note the available functions.
- [ ] **Step 2: Update `simple-mode-fields*` if needed**
If `fc/with-cursor-index` does not exist, replace the `fc/with-cursor-index 0` call in Task 2 with the correct form-cursor idiom. The key requirement is that the hidden `db/id`, `transaction-account/account`, `transaction-account/location`, and `transaction-account/amount` fields emit names matching index 0 of `transaction/accounts`.
A known working pattern from `account-grid-body*`:
```clojure
(fc/cursor-map #(transaction-account-row* {:value % ...}))
```
For simple mode with a single synthetic row, build a one-element vector in the snapshot and let `fc/cursor-map` iterate it — but render a flat div instead of a table. Or pass the cursor manually:
```clojure
(fc/with-field :transaction/accounts
(let [row-cursor (fc/cursor-nth 0)] ; adjust to actual API
(fc/with-cursor row-cursor
...field rendering...)))
```
Verify field names are correct in a browser after implementation.
- [ ] **Step 3: Verify the file compiles**
```bash
clj-nrepl-eval -p 9000 "(require '[auto-ap.ssr.transaction.edit] :reload)"
```
- [ ] **Step 4: Commit if any changes were made**
```bash
git add src/clj/auto_ap/ssr/transaction/edit.clj
git commit -m "fix: correct form-cursor indexing for simple-mode account field"
```
---
## Task 7: Manual smoke test
Before writing automated tests, verify the UI works end-to-end in a browser.
- [ ] **Step 1: Start the application**
```bash
INTEGREAT_JOB="" lein run
```
- [ ] **Step 2: Open a transaction with no accounts**
Navigate to a transaction with no coded accounts. Open the edit modal. Verify it opens in simple mode with blank account and location fields.
- [ ] **Step 3: Test vendor selection in simple mode**
Select a vendor. Verify the account field is populated with the vendor's default account and the location is set appropriately.
- [ ] **Step 4: Test toggle to advanced**
Click "Switch to advanced mode". Verify the full split table appears with one pre-populated row.
- [ ] **Step 5: Test toggle back to simple**
With 1 row, click "Switch to simple mode". Verify the single account/location fields appear with that row's values.
- [ ] **Step 6: Test with a split transaction**
Open a transaction that already has 2+ accounts. Verify it opens in advanced mode. Verify the "Switch to simple mode" link is absent.
- [ ] **Step 7: Test save round-trip**
In simple mode, set a vendor, account, and location. Save. Re-open. Verify the same values are pre-populated in simple mode.
---
## Task 8: Write e2e tests
**Files:**
- Create: `test/clj/auto_ap/ssr/transaction/edit_simple_advanced_mode_test.clj`
Check how existing e2e tests are structured first:
```bash
clj-nrepl-eval -p 9000 "(clj-mcp.repl-tools/list-ns)"
```
Look for test namespaces matching `auto-ap.ssr.transaction.*` or `auto-ap.e2e.*`. Follow the same fixture/helper patterns.
The test file should cover all 20 acceptance criteria from the spec. Group them with `testing` blocks:
```clojure
(ns auto-ap.ssr.transaction.edit-simple-advanced-mode-test
(:require
[clojure.test :refer [deftest is testing use-fixtures]]
;; ... project-specific test helpers ...
))
(deftest simple-advanced-mode-initial-state
(testing "AC1: uncoded transaction opens in simple mode"
;; create a transaction with no accounts
;; open edit modal
;; verify #manual-coding-section has mode=simple hidden input
;; verify no #account-grid-body present
(is ...))
(testing "AC2: single-account transaction opens in simple mode with values pre-populated"
...)
(testing "AC3: multi-account transaction opens in advanced mode"
...))
(deftest simple-mode-vendor-selection
(testing "AC4: selecting vendor populates account and location"
...)
(testing "AC5: selecting vendor does not overwrite manually chosen account"
...))
(deftest mode-toggle
(testing "AC9: switching to advanced carries account/location into first row"
...)
(testing "AC10: switching to advanced from blank simple gives empty table"
...)
(testing "AC11: switch-to-simple link visible with 0 or 1 rows"
...)
(testing "AC12: switch-to-simple link absent with 2+ rows"
...)
(testing "AC13: switching to simple pre-populates from first row"
...))
(deftest save-round-trip
(testing "AC6: save in simple mode persists vendor/account/location"
...)
(testing "AC18: switching modes mid-edit then saving produces valid transaction"
...)
(testing "AC19: split transaction re-opens in advanced mode with splits intact"
...)
(testing "AC20: single-account transaction re-opens in simple mode"
...))
```
Fill in actual test bodies using the project's test infrastructure (browser automation or ring mock depending on what exists).
- [ ] **Step 1: Check existing test conventions**
```bash
clj-nrepl-eval -p 9000 "(require '[auto-ap.ssr.testing-conventions] :reload)"
```
Also load the testing-conventions skill for guidance:
```
Load skill: testing-conventions
```
- [ ] **Step 2: Write the test file** following project conventions
- [ ] **Step 3: Run the tests**
```bash
clj-nrepl-eval -p 9000 "(clojure.test/run-tests 'auto-ap.ssr.transaction.edit-simple-advanced-mode-test)"
```
Expected: all tests pass.
- [ ] **Step 4: Commit**
```bash
git add test/clj/auto_ap/ssr/transaction/edit_simple_advanced_mode_test.clj
git commit -m "test: add e2e acceptance tests for simple/advanced mode"
```
---
## Self-Review Checklist (completed inline)
- **Spec coverage:** All 20 ACs addressed — Tasks 25 implement the behaviour; Task 8 tests all 20.
- **Placeholder scan:** Task 6 and Task 8 contain some "fill in" guidance — this is intentional because they depend on runtime API discovery. The instructions tell the engineer exactly where to look and what to verify.
- **Type consistency:** `manual-coding-section*` is used consistently by `LinksStep/render-step`, `edit-vendor-changed-handler`, and `edit-wizard-toggle-mode-handler`. `#manual-coding-section` is the swap target throughout. `mode` hidden input uses `(name mode)` for string serialization and `(keyword ...)` for deserialization.