Files
integreat/docs/superpowers/plans/2026-05-18-inline-account-editing.md
2026-05-18 15:38:07 -07:00

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

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

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

[: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*:

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