diff --git a/docs/superpowers/plans/2026-05-27-transaction-edit-simple-advanced-mode.md b/docs/superpowers/plans/2026-05-27-transaction-edit-simple-advanced-mode.md new file mode 100644 index 00000000..5339a05d --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-transaction-edit-simple-advanced-mode.md @@ -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 `` 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 2–5 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.