tries sales changes

This commit is contained in:
2026-05-18 15:38:07 -07:00
parent de1c154706
commit a156ac99fe
5 changed files with 1016 additions and 50 deletions

View File

@@ -0,0 +1,613 @@
# Inline Account Editing 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 sales summary wizard's flat data-grid with a two-column debit/credit layout matching the embedded grid, and add HTMX-based inline account editing (click pencil → typeahead → confirm → swap back to display mode).
**Architecture:** Each item's account cell renders in "display mode" (account name + hidden input + pencil icon). Clicking the pencil fires an HTMX GET that swaps in a typeahead + confirm/cancel buttons. Confirm fires an HTMX PUT that swaps back to display mode with an updated hidden input. No DB writes until the wizard form is submitted.
**Tech Stack:** Clojure, Hiccup, HTMX, Alpine.js (for typeahead), form-cursor, multi-modal wizard middleware, Datomic.
---
### Task 1: Add route keys to route definitions
**Files:**
- Modify: `src/cljc/auto_ap/routes/pos/sales_summaries.cljc`
- [ ] **Step 1: Add three new route keys**
Add these routes inside the existing `routes` map, alongside `"/edit/sales-summary-item"`:
```clojure
"/edit/item-account" ::edit-item-account
"/edit/save-item-account" ::save-item-account
"/edit/cancel-item-account" ::cancel-item-account
```
The full routes map should become:
```clojure
(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/cancel-item-account" ::cancel-item-account})
```
- [ ] **Step 2: Verify the route file parses**
Run: `clj -M:check` or similar. If no checker is available, move on — the Clojure compiler will catch errors at load time.
---
### Task 2: Add account display cell helper and account edit cell helper
**Files:**
- Modify: `src/clj/auto_ap/ssr/pos/sales_summaries.clj`
These are pure rendering functions — no routes, no handlers, just hiccup.
- [ ] **Step 1: Make `account-typeahead*` public**
Change `defn-` to `defn` for `account-typeahead*` so it can be used from the new handlers:
```clojure
(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)))})])
```
- [ ] **Step 2: Add `account-display-cell` function**
This renders the display-mode account cell: account name (or "Missing acct" pill), hidden input, and pencil icon. Insert after the `truncate` defn:
```clojure
(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.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 {:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-item-account)
:hx-target "closest td"
:hx-swap "innerHTML"
:hx-vals (hx/json {:item-index (or (:item-index item) 0)
:client-id client-id
:current-account-id (or account-id "")})}
svg/pencil)]))
```
- [ ] **Step 3: Add `account-edit-cell` function**
This renders the edit-mode account cell: typeahead + confirm/cancel buttons. This is what `::route/edit-item-account` returns:
```clojure
(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.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 {:hx-put (bidi/path-for ssr-routes/only-routes ::route/save-item-account)
:hx-target "closest td"
:hx-swap "innerHTML"
:hx-include "closest td"
:hx-vals (hx/json {:field-name-prefix field-name-prefix
:client-id client-id})}
svg/check)
(com/a-icon-button {:hx-get (bidi/path-for ssr-routes/only-routes ::route/cancel-item-account)
:hx-target "closest td"
:hx-swap "innerHTML"
:hx-vals (hx/json {:field-name-prefix field-name-prefix
:client-id client-id
:current-account-id (or current-account-id "")})}
svg/x)]]))
```
**Note:** We construct the input name directly from `field-name-prefix` + `[ledger-mapped/account]` instead of using form-cursor, because the HTMX handler doesn't have access to the wizard's form state. The typeahead component accepts a `:name` string directly.
---
### Task 3: Verify svg/check exists
**Files:**
- Check: `src/clj/auto_ap/ssr/svg.clj`
- [ ] **Step 1: Search for check icon**
Run: `rg "def.*check" src/clj/auto_ap/ssr/svg.clj`
If `svg/check` does not exist, look for alternatives like `svg/tick`, `svg/confirm`, or `svg/save`. If none exist, use `svg/pencil` with a different label, or use a simple `[:span "✓"]` instead.
---
### Task 4: Rewrite MainStep render-step to use two-column layout
**Files:**
- Modify: `src/clj/auto_ap/ssr/pos/sales_summaries.clj`
This is the core UI change. Replace the flat data-grid in `render-step` with a two-column layout matching the embedded grid.
- [ ] **Step 1: Replace the MainStep record's render-step body**
Replace the existing `render-step` implementation in the `defrecord MainStep` with:
```clojure
(render-step
[this {:keys [multi-form-state] :as request}]
(let [client-id (:db/id (:sales-summary/client (:snapshot multi-form-state)))
items (sort-items (:sales-summary/items (:step-params multi-form-state)))
debit-items (filter #(= :ledger-side/debit (:ledger-mapped/ledger-side %)) items)
credit-items (filter #(= :ledger-side/credit (:ledger-mapped/ledger-side %)) 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-4
[:div
[:div.font-semibold.text-sm.mb-2 "Debits"]
[:div.space-y-1
(for [[idx item] (map-indexed vector padded-debits)]
(if item
(let [manual? (:sales-summary-item/manual? item)]
[:div.flex.items-center.gap-2.text-sm
(com/hidden {:name (str "step-params[sales-summary/items][" idx "][db/id]")
:value (:db/id item)})
(com/hidden {:name (str "step-params[sales-summary/items][" idx "][sales-summary-item/category]")
:value (:sales-summary-item/category item)})
(when manual?
(com/hidden {:name (str "step-params[sales-summary/items][" idx "][sales-summary-item/manual?]")
:value "true"}))
[:span.text-gray-500 (truncate (:sales-summary-item/category item) 30)]
(account-display-cell {:item (assoc item :item-index idx)
:field-name-prefix (str "step-params[sales-summary/items][" idx "]")
:client-id client-id})
[:span.font-mono (format "$%,.2f" (:ledger-mapped/amount item))]])
[:div.h-6]))]
(summary-total-row* request)
(unbalanced-row* request)]
[:div
[:div.font-semibold.text-sm.mb-2 "Credits"]
[:div.space-y-1
(for [[idx item] (map-indexed vector padded-credits)]
(if item
(let [actual-idx (+ (count debit-items) idx)
manual? (:sales-summary-item/manual? item)]
[: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)})
(when manual?
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/manual?]")
:value "true"}))
[: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.font-mono (format "$%,.2f" (:ledger-mapped/amount item))]])
[:div.h-6]))]
(summary-total-row* request)
(unbalanced-row* request)]]
[:div.mt-4
(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"))]])
: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]")))
```
**Important note on item indexing:** The padded lists are for display alignment only. The hidden inputs must use the *actual* index in the `:sales-summary/items` vector, not the display index. Debit items keep their original indices; credit items' indices start after all debit items. This is a simplification — if items are interspersed (debit, credit, debit), this approach breaks. We need to compute actual indices from the sorted list, not from the filtered sublists. See Step 2.
- [ ] **Step 2: Fix index calculation to use actual sorted position**
The approach in Step 1 has an indexing bug. Items in the form are stored as a vector and submitted by index. We must preserve the actual vector index for each item. Replace the layout logic with:
```clojure
(render-step
[this {:keys [multi-form-state] :as request}]
(let [client-id (:db/id (:sales-summary/client (:snapshot multi-form-state)))
items (sort-items (:sales-summary/items (:step-params multi-form-state)))
indexed-items (map-indexed vector 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-4
[: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)]
[: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)})
(when manual?
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/manual?]")
:value "true"}))
[: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.font-mono (format "$%,.2f" (:ledger-mapped/amount item))]])
[:div.h-6]))]
[: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)]
[: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)})
(when manual?
(com/hidden {:name (str "step-params[sales-summary/items][" actual-idx "][sales-summary-item/manual?]")
:value "true"}))
[: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.font-mono (format "$%,.2f" (:ledger-mapped/amount item))]])
[:div.h-6]))]]]
[:div.mt-4
(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"))]])
: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]")))
```
**Key design decisions:**
- `map-indexed vector items` preserves actual vector position for hidden input names
- `padded-debits` / `padded-credits` are sequences of `[actual-idx item]` or `nil` for padding rows
- Padding rows render as empty `[:div.h-6]` to maintain alignment
- Total/unbalanced rows are not repeated per column — they go below the two-column grid, shared
- [ ] **Step 3: Move total/unbalanced rows outside the two-column grid**
The `summary-total-row*` and `unbalanced-row*` functions currently render as `<tr>` elements inside a data-grid. In the new layout, these should be simple flex rows below the grid, not table rows. For now, keep them as-is but render them in a single section below both columns (remove the duplicate from the credit column). Adjust the `:body` content:
After the `[:div.grid.grid-cols-2.gap-4 ...]` block, add:
```clojure
[:div.mt-2.border-t.pt-2
(summary-total-row* request)
(unbalanced-row* request)]
```
But since `summary-total-row*` and `unbalanced-row*` currently return `<tr>` elements, they won't render correctly outside a table. For the initial implementation, replace them with inline hiccup that renders the same info in a flex layout. See Task 5.
---
### Task 5: Rewrite total and unbalanced display as non-table hiccup
**Files:**
- Modify: `src/clj/auto_ap/ssr/pos/sales_summaries.clj`
The existing `summary-total-row*` and `unbalanced-row*` return `<tr>` / `<td>` elements for the data-grid. The new layout is not a table, so these need to be simple div-based layouts.
- [ ] **Step 1: Add `summary-total-display` function**
Insert after `unbalanced-row*`:
```clojure
(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
[:span.font-semibold "Total"]
[:div.flex.gap-8
[:span.font-mono (format "$%,.2f" total-debits)]
[:span.font-mono (format "$%,.2f" total-credits)]]]))
```
- [ ] **Step 2: Add `unbalanced-display` function**
```clojure
(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
[:span.font-semibold {:class (if (pos? delta) "text-red-600" "text-green-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)))]]]])))
```
- [ ] **Step 3: Use these in the render-step body**
In Task 4's render-step, replace the total/unbalanced section at the bottom with:
```clojure
[:div.mt-2.border-t.pt-2
(summary-total-display request)
(unbalanced-display request)]
```
---
### Task 6: Add `edit-item-account` handler
**Files:**
- Modify: `src/clj/auto_ap/ssr/pos/sales_summaries.clj`
- [ ] **Step 1: Write the handler**
Insert before `key->handler`:
```clojure
(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 "]")]
(html-response
(account-edit-cell {:field-name-prefix field-name-prefix
:client-id (if (string? client-id) (Long/parseLong client-id) client-id)
: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))}))))
```
- [ ] **Step 2: Add it to key->handler**
Add to the handler map inside `key->handler`:
```clojure
::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]]]))
```
---
### Task 7: Add `save-item-account` handler
**Files:**
- Modify: `src/clj/auto_ap/ssr/pos/sales_summaries.clj`
- [ ] **Step 1: Write the handler**
This handler receives the typeahead's selected value via `hx-include "closest td"`, which includes the hidden input from the typeahead. The typeahead's hidden input name will be something like `step-params[sales-summary/items][2][ledger-mapped/account]`. We need to extract the selected account ID from the form params and return a display cell with the updated value.
```clojure
(defn save-item-account [request]
(let [{:keys [field-name-prefix client-id]} (some-> request :query-params)
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+)\]" field-name-prefix))}]
(html-response
(account-display-cell {:item item
:field-name-prefix field-name-prefix
:client-id (if (string? client-id) (Long/parseLong client-id) client-id)}))))
```
**Note:** `field-name-prefix` comes from `hx-vals` in the confirm button. `account-input-name` is constructed by appending `[ledger-mapped/account]` to the prefix. The typeahead's hidden input will have this name.
- [ ] **Step 2: Add it to key->handler**
```clojure
::route/save-item-account (-> save-item-account
(mm/wrap-wizard edit-wizard)
(mm/wrap-decode-multi-form-state))
```
Wait — we said no DB writes until wizard submit. So we should NOT wrap with `wrap-wizard` and `wrap-decode-multi-form-state`. The handler just returns HTML. It doesn't need the wizard state. The form params contain the typeahead value, and the query params contain the field name prefix and client-id. Simple.
```clojure
::route/save-item-account save-item-account
```
---
### Task 8: Add `cancel-item-account` handler
**Files:**
- Modify: `src/clj/auto_ap/ssr/pos/sales_summaries.clj`
- [ ] **Step 1: Write the handler**
```clojure
(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+)\]" field-name-prefix))}]
(html-response
(account-display-cell {:item item
:field-name-prefix field-name-prefix
:client-id (if (string? client-id) (Long/parseLong client-id) client-id)}))))
```
- [ ] **Step 2: Add it to key->handler**
```clojure
::route/cancel-item-account cancel-item-account
```
---
### Task 9: Wire routes in key->handler
**Files:**
- Modify: `src/clj/auto_ap/ssr/pos/sales_summaries.clj`
- [ ] **Step 1: Add all three new handlers to key->handler**
The final additions to the handler map (before the closing `}`):
```clojure
::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
```
These get the same middleware applied via `apply-middleware-to-all-handlers` at the bottom of `key->handler`.
---
### Task 10: Handle manual items in the two-column layout
**Files:**
- Modify: `src/clj/auto_ap/ssr/pos/sales_summaries.clj`
Manual items have editable category text inputs and debit/credit money inputs. In the two-column layout, manual items need to stay in "edit mode" with their inputs visible.
- [ ] **Step 1: Add manual item rendering in the debit/credit columns**
In the render-step `for` loop, when `manual?` is true, render the editable fields instead of display-mode:
For a debit manual item:
```clojure
[: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/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/Explanation"
:name (fc/field-name)
:value (fc/field-value)
:class "w-32 text-sm"}))
(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}))
(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-sm"
:name (fc/field-name)
:value (fc/field-value)})))]
```
For a credit manual item, replace `:debit` with `:credit`.
---
### Task 11: Test the complete flow end-to-end
**Files:**
- Manual testing
- [ ] **Step 1: Open a sales summary row in the wizard**
Verify the two-column layout renders correctly with debits on the left, credits on the right.
- [ ] **Step 2: Verify display-mode account cells**
Each item should show the account name (or "Missing acct" pill) + hidden input + pencil icon.
- [ ] **Step 3: Click a pencil icon**
The cell should swap to show a typeahead search + confirm (check) and cancel (X) buttons.
- [ ] **Step 4: Search and select an account in the typeahead**
After selecting, click confirm. The cell should swap back to display mode with the updated account name and hidden input value.
- [ ] **Step 5: Click cancel**
The cell should swap back to the original display mode.
- [ ] **Step 6: Submit the wizard**
All hidden inputs (including the updated account) should be submitted. Verify the transaction updates the correct accounts.
- [ ] **Step 7: Test manual items**
Add a new summary item. Verify it renders with editable category + money inputs. Verify the account cell still uses the pencil-to-typeahead pattern.
- [ ] **Step 8: Test total/unbalanced display**
Verify totals and unbalanced indicators update correctly (if the `expense-account-total` route is fixed — out of scope for this plan but note if broken).

View File

@@ -0,0 +1,145 @@
# Inline Account Editing in Sales Summary Wizard
## Problem
The current edit wizard for sales summaries renders every item in a flat data-grid with a full typeahead component per row for account assignment. This requires heavy scrolling and makes it hard to see the debit/credit structure at a glance.
## Solution
Redesign the wizard's MainStep to mirror the embedded grid's two-column layout (debits / credits), and replace the always-visible typeahead with a click-to-swap inline editing pattern powered by HTMX.
## Current Flow
1. Click pencil icon on a grid row → opens full modal wizard
2. Wizard renders a data-grid where every row has: category (hidden or text input), account (typeahead), debit, credit
3. Every row initializes a typeahead component, even if the user only needs to edit one account
4. Heavy scrolling due to tall rows
## New Flow
1. Click pencil icon on a grid row → opens modal wizard showing two columns (debits / credits) matching the embedded grid layout
2. Each item's account cell renders in **display mode**: account name text + hidden input holding the account ID + pencil icon. If no account is assigned, shows a red "Missing acct" pill + pencil icon.
3. Click pencil on an account cell → `hx-get` to `::route/edit-item-account` → server returns **edit mode** (typeahead + confirm/cancel buttons), replacing just that cell via `hx-swap "innerHTML"`
4. User selects an account in the typeahead → clicks confirm → `hx-put` to `::route/save-item-account` → server returns **display mode** (updated account name text + updated hidden input + pencil icon)
5. Click cancel → `hx-get` to `::route/cancel-item-account` → server returns original display mode
6. When the user submits the entire wizard form, all hidden inputs (including updated account IDs) are collected by the existing multi-form-state decode and saved in a single DB transaction
### Key Constraint
HTMX routes only manage interactivity (swapping cells). No DB writes happen until the wizard form is submitted via the existing submit handler.
## New Routes
| Route Key | Method | Purpose |
|---|---|---|
| `::route/edit-item-account` | GET | Returns typeahead + confirm/cancel for one account cell |
| `::route/save-item-account` | PUT | Returns display mode with updated hidden input value |
| `::route/cancel-item-account` | GET | Returns display mode with original hidden input value |
### Route Parameters
All three routes receive:
- `item-index` — the index of the sales-summary/item in the vector (to construct the correct field name prefix)
- `client-id` — for the typeahead search URL
- The form-cursor field name prefix is derived from `item-index` so the returned hidden input has the correct `name` attribute (e.g. `step-params[sales-summary/items][2][ledger-mapped/account]`)
Additionally:
- `edit-item-account` and `cancel-item-account` receive the `current-account-id` as a query param so cancel can restore the original value
- `save-item-account` receives the selected account ID from the typeahead's form submission in the request body
## Wizard MainStep Changes
### Layout
Replace the current flat data-grid with a two-column layout mirroring the embedded grid:
```
+-------------------------------------------+
| Debits | Credits |
|-------------------------------------------|
| Category Acct Amt | Category Acct Amt |
| ... | ... |
|-------------------------------------------|
| Total: $X,XXX.XX | Total: $X,XXX.XX |
| Delta: $XX.XX | Delta: $XX.XX |
+-------------------------------------------+
| [+ New Summary Item] |
+-------------------------------------------+
```
### Item Rendering (Display Mode)
For each item (non-manual):
- **Category**: text label + hidden input
- **Account**: account name text (or "Missing acct" pill) + hidden input with account ID + pencil icon with `hx-get`
- **Amount**: formatted dollar amount (debit or credit column)
### Item Rendering (Edit Mode — account cell only)
When the pencil is clicked, only the account cell swaps to:
- Typeahead component (same `account-typeahead*` as current)
- Confirm button (small check icon) with `hx-put`
- Cancel button (small X icon) with `hx-get`
### Manual Items
Same as current: category text input, account typeahead, debit/credit money inputs, delete button. The "New Summary Item" button remains. Manual items are always in "edit mode" since they have editable fields beyond just account.
### Hidden Inputs
Every item row must include hidden inputs for:
- `db/id`
- `sales-summary-item/category` (for non-manual items)
- `sales-summary-item/manual?` (for manual items)
- `ledger-mapped/account` — this is the key one that gets updated by the inline edit flow
When the typeahead swaps in (edit mode), the old hidden input for `ledger-mapped/account` is replaced by the typeahead's own hidden input. On confirm, the server returns the updated hidden input. On cancel, the server returns the original hidden input.
### Total / Unbalanced Rows
Same as current: `summary-total-row*` and `unbalanced-row*` with live recalculation via `hx-put` to `::route/expense-account-total`.
## Handler Implementation
### `edit-item-account` handler
1. Parse query params: item index, client-id, current field name prefix
2. Render the typeahead + confirm/cancel buttons
3. The typeahead uses the same `account-typeahead*` pattern
4. Confirm button: `hx-put` to `::route/save-item-account`, `hx-target "closest td"`, `hx-swap "innerHTML"`
5. Cancel button: `hx-get` to `::route/cancel-item-account`, `hx-target "closest td"`, `hx-swap "innerHTML"`
### `save-item-account` handler
1. Parse form body: selected account ID, item index, client-id, field name prefix
2. Resolve account name from DB using `d-accounts/clientize`
3. Return display mode HTML: account name text + hidden input (with new account ID) + pencil icon
### `cancel-item-account` handler
1. Parse query params: item index, client-id, current field name prefix, original account ID
2. Resolve account name from DB (if account ID exists)
3. Return display mode HTML: account name text (or "Missing acct" pill) + hidden input (with original account ID) + pencil icon
## Route Definitions
Add to `routes.cljc`:
```clojure
"/edit/item-account" ::edit-item-account
"/edit/save-item-account" ::save-item-account
"/edit/cancel-item-account" ::cancel-item-account
```
## Files Changed
| File | Change |
|---|---|
| `src/cljc/auto_ap/routes/pos/sales_summaries.cljc` | Add 3 new route keys |
| `src/clj/auto_ap/ssr/pos/sales_summaries.clj` | Rewrite MainStep render-step, add 3 handlers, add helper fns for account display/edit cells |
## Out of Scope
- Changes to the embedded grid table (already redesigned)
- Changes to how the wizard submit handler works
- Adding the missing `::route/expense-account-total` route (pre-existing bug, separate fix)

File diff suppressed because one or more lines are too long

View File

@@ -133,6 +133,47 @@
(str (subs s 0 (- max-len 3)) "...")
s))
(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 {: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 {: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 {: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
@@ -304,11 +345,7 @@
(total-debits))]
(com/data-grid-row {:id "total-row"
:class "bg-slate-50 border-t-2 border-slate-300"
:hx-trigger "change from:closest form target:.amount-field"
:hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/expense-account-total)
:hx-target "this"
:hx-swap "innerHTML"}
: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
@@ -337,11 +374,7 @@
credit-over? (and unbalanced? (> total-credits total-debits))]
(com/data-grid-row {:id "unbalanced-row"
:class (when unbalanced? "bg-red-50 border-t border-red-200")
:hx-trigger "change from:closest form target:.amount-field"
:hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/expense-account-total)
:hx-target "this"
:hx-swap "innerHTML"}
:class (when unbalanced? "bg-red-50 border-t border-red-200")}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"}
(when unbalanced?
@@ -357,7 +390,45 @@
(format "$%,.2f" (- total-credits total-debits))]))
(com/data-grid-cell {}))))
(defn- account-typeahead*
(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 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 account-typeahead*
[{:keys [name value client-id]}]
[:div.flex.flex-col
(com/typeahead {:name name
@@ -446,38 +517,124 @@
(render-step
[this {:keys [multi-form-state] :as request}]
(mm/default-render-step
linear-wizard this
:head [:div.p-2 "New invoice"]
:body (mm/default-step-body
{}
[:div
(fc/with-field :db/id
(com/hidden {:name (fc/field-name)
:value (fc/field-value)}))
(com/data-grid {:headers
[(com/data-grid-header {} "Category")
(com/data-grid-header {} "Account")
(com/data-grid-header {} "Debits")
(com/data-grid-header {} "Credits")
(com/data-grid-header {} "")]}
(fc/with-field :sales-summary/items
(list
(fc/cursor-map #(sales-summary-item-row* {:value %
:client-id (:db/id (:sales-summary/client (:snapshot multi-form-state)))}))
(com/data-grid-new-row {:colspan 5
: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 (:db/id (:sales-summary/client (:snapshot multi-form-state)))})}}
"New Summary Item")))
(summary-total-row* request)
(unbalanced-row* 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.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.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"))]])
: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-[920px] lg:h-[640px]")))
: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 attach-ledger [i]
(cond-> i
@@ -524,7 +681,6 @@
{:db/id (:db/id i)
:ledger-mapped/account (:ledger-mapped/account i)}))
(:sales-summary/items result))}]]
(clojure.pprint/pprint (:sales-summary/items result))
@(dc/transact conn [transaction])
(html-response
(row* identity (dc/pull (dc/db conn) default-read (:db/id result))
@@ -547,6 +703,48 @@
(mm/->MultiStepFormState entity [] entity)))
(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)]
(html-response
(account-edit-cell {:field-name-prefix field-name-prefix
:client-id client-id
:current-account-id current-account-id}))))
(defn save-item-account [request]
(let [{:keys [field-name-prefix client-id]} (:query-params request)
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)]
(html-response
(account-display-cell {:item item
:field-name-prefix field-name-prefix
: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)]
(html-response
(account-display-cell {:item item
:field-name-prefix field-name-prefix
:client-id client-id}))))
(def key->handler
(apply-middleware-to-all-handlers
(->>
@@ -566,10 +764,17 @@
:client-id (:client-id (:query-params request))}))
(fn build-new-row [base _]
(assoc base :sales-summary-item/manual? true)))
(wrap-schema-enforce :query-schema [:map
[:client-id {:optional true}
[:maybe entity-id]]]))
::route/edit-wizard-submit (-> mm/submit-handler
(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))})
(fn [h]

View File

@@ -3,5 +3,8 @@
: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/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/cancel-item-account" ::cancel-item-account})