29 KiB
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":
"/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:
(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:
(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-cellfunction
This renders the display-mode account cell: account name (or "Missing acct" pill), hidden input, and pencil icon. Insert after the truncate defn:
(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-cellfunction
This renders the edit-mode account cell: typeahead + confirm/cancel buttons. This is what ::route/edit-item-account returns:
(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:
(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:
(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 itemspreserves actual vector position for hidden input names -
padded-debits/padded-creditsare sequences of[actual-idx item]ornilfor 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:
[: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-displayfunction
Insert after unbalanced-row*:
(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-displayfunction
(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:
[: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:
(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:
::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.
(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
::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.
::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
(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
::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 }):
::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:
[: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).