From e0da8e1866d830f603cb402ffffbb687be80369a Mon Sep 17 00:00:00 2001 From: Bryce Date: Tue, 23 Jun 2026 21:48:39 -0700 Subject: [PATCH] feat(ssr): add delete selected to external ledger Replicate the master CLJS "delete external ledger" feature on the SSR external ledger page: an admin-only bulk delete that retracts the selected journal entries, skipping any in a client's locked period and capping at 1000 per request. Return the result via modal-response (retargets the persistent #modal-content shell) and target #modal-content from the button so the request never relies on the outerHTML swap inherited from the data-grid card, which previously replaced #modal-holder and broke the next click. Co-Authored-By: Claude Opus 4.8 --- src/clj/auto_ap/ssr/ledger.clj | 38 +++++++++++++++- src/clj/auto_ap/ssr/ledger/common.clj | 24 ++++++++-- src/cljc/auto_ap/routes/ledger.cljc | 1 + test/clj/auto_ap/ssr/ledger_test.clj | 64 ++++++++++++++++++++++++++- 4 files changed, 121 insertions(+), 6 deletions(-) diff --git a/src/clj/auto_ap/ssr/ledger.clj b/src/clj/auto_ap/ssr/ledger.clj index 4a24f42d..09bf1152 100644 --- a/src/clj/auto_ap/ssr/ledger.clj +++ b/src/clj/auto_ap/ssr/ledger.clj @@ -31,7 +31,7 @@ [auto-ap.ssr.ui :refer [base-page]] [auto-ap.ssr.utils :refer [apply-middleware-to-all-handlers clj-date-schema - html-response main-transformer money strip + html-response modal-response main-transformer money strip wrap-form-4xx-2 wrap-implied-route-param wrap-merge-prior-hx wrap-schema-decode wrap-schema-enforce]] @@ -69,6 +69,40 @@ selected)] ids)) +(defn all-ids-not-locked + "Filters journal-entry ids to only those whose date is on/after the client's + locked-until date (i.e. not in a reconciled/locked period)." + [all-ids] + (->> all-ids + (dc/q '[:find ?t + :in $ [?t ...] + :where + [?t :journal-entry/client ?c] + [(get-else $ ?c :client/locked-until #inst "2000-01-01") ?lu] + [?t :journal-entry/date ?d] + [(>= ?d ?lu)]] + (dc/db conn)) + (map first))) + +(defn bulk-delete [request] + (assert-admin (:identity request)) + (let [params (:form-params request) + ids (selected->ids (assoc-in request [:route-params :external?] true) params) + all-ids (all-ids-not-locked ids)] + (if (> (count all-ids) 1000) + (modal-response + (com/success-modal {:title "Too many ledger entries"} + [:p "You can only delete 1000 ledger entries at a time."])) + (do + (alog/info ::bulk-delete-ledger :count (count all-ids) :sample (take 3 all-ids)) + (audit-transact-batch + (map (fn [i] [:db/retractEntity i]) all-ids) + (:identity request)) + (modal-response + (com/success-modal {:title "Ledger Entries Deleted"} + [:p (str "Successfully deleted " (count all-ids) " ledger entries.")]) + :headers {"hx-trigger" "invalidated, reset-selection"}))))) + (defn delete [{invoice :entity :as request identity :identity}] (exception->notification #(when-not (= :invoice-status/unpaid (:invoice/status invoice)) @@ -696,6 +730,8 @@ ::route/csv (helper/csv-route grid-page) ::route/external-import-page external-import-page ::route/bank-account-filter bank-account-filter + ::route/bulk-delete (-> bulk-delete + (wrap-schema-enforce :form-schema query-schema)) ::route/external-import-parse (-> external-import-parse (wrap-schema-enforce :form-schema parse-form-schema) (wrap-form-4xx-2 external-import-parse) diff --git a/src/clj/auto_ap/ssr/ledger/common.clj b/src/clj/auto_ap/ssr/ledger/common.clj index 240e804a..2684065e 100644 --- a/src/clj/auto_ap/ssr/ledger/common.clj +++ b/src/clj/auto_ap/ssr/ledger/common.clj @@ -482,10 +482,26 @@ (assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)]) :query-schema query-schema :action-buttons (fn [request] - [(when-not (:external? (:route-params request)) (com/button {:color :primary - :hx-get (bidi/path-for ssr-routes/only-routes - ::route/new)} - "Add journal entry"))]) + [(when-not (:external? (:route-params request)) + (com/button {:color :primary + :hx-get (bidi/path-for ssr-routes/only-routes + ::route/new)} + "Add journal entry")) + (when (and (:external? (:route-params request)) + (= "admin" (:user/role (:identity request)))) + (com/button {:color :red + :hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-delete) + ;; target the persistent modal shell content slot directly so the + ;; request never relies on the outerHTML swap inherited from the + ;; data-grid card (which would replace #modal-holder and break the + ;; next click). modal-response also retargets here. + :hx-target "#modal-content" + :hx-swap "innerHTML" + "x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})" + "x-bind:disabled" "selected.length === 0 && !all_selected" + "hx-include" "#ledger-filters" + :hx-confirm "Are you sure you want to delete these ledger entries?"} + "Delete selected"))]) :row-buttons (fn [request entity] [(when (and (= :invoice-status/unpaid (:invoice/status entity)) (can? (:identity request) {:subject :invoice :activity :delete})) diff --git a/src/cljc/auto_ap/routes/ledger.cljc b/src/cljc/auto_ap/routes/ledger.cljc index d602f1bf..1acf1777 100644 --- a/src/cljc/auto_ap/routes/ledger.cljc +++ b/src/cljc/auto_ap/routes/ledger.cljc @@ -8,6 +8,7 @@ "/line-item" {:get ::new-line-item}} "/external-new" ::external-page + "/bulk-delete" ::bulk-delete "/external-import-new" {"" ::external-import-page "/parse" ::external-import-parse "/import" ::external-import-import} diff --git a/test/clj/auto_ap/ssr/ledger_test.clj b/test/clj/auto_ap/ssr/ledger_test.clj index 96f181ae..1780f164 100644 --- a/test/clj/auto_ap/ssr/ledger_test.clj +++ b/test/clj/auto_ap/ssr/ledger_test.clj @@ -3,7 +3,7 @@ [auto-ap.datomic :refer [conn audit-transact transact-schema install-functions]] [auto-ap.datomic.accounts :as a] [auto-ap.integration.util :refer [wrap-setup test-client test-vendor test-bank-account test-account - setup-test-data admin-token]] + setup-test-data admin-token user-token]] [auto-ap.ssr.ledger :as sut] [auto-ap.ssr.utils :refer [main-transformer]] [auto-ap.ssr.ledger.common :as common] @@ -557,3 +557,65 @@ :identity (admin-token)})] (is (= (format "#entity-table tr[data-id=\"%d\"]" invoice-id) (get-in response [:headers "hx-retarget"]))))))))) + +;; ============================================================================= +;; Bulk Delete - all-ids-not-locked, bulk-delete +;; ============================================================================= + +(defn- create-journal-entry [client-id date external-id] + (let [temp (str (java.util.UUID/randomUUID)) + tx @(dc/transact conn [{:db/id temp + :journal-entry/client client-id + :journal-entry/date date + :journal-entry/external-id external-id + :journal-entry/source "manual" + :journal-entry/amount 100.0}])] + (get-in tx [:tempids temp]))) + +(deftest all-ids-not-locked-test + (testing "Should exclude entries dated before the client's locked-until date" + (let [tempids (setup-test-data [(test-client :db/id "lock-client" + :client/code "LOCKTEST" + :client/locked-until #inst "2099-01-01")]) + client-id (get tempids "lock-client") + locked-id (create-journal-entry client-id #inst "2020-01-01" "ext-locked") + open-id (create-journal-entry client-id #inst "2099-06-01" "ext-open") + result (set (sut/all-ids-not-locked [locked-id open-id]))] + (is (contains? result open-id)) + (is (not (contains? result locked-id)))))) + +(deftest bulk-delete-test + (testing "Admin can delete selected ledger entries" + (let [tempids (setup-test-data [(test-client :db/id "bd-client" + :client/code "BDTEST")]) + client-id (get tempids "bd-client") + id1 (create-journal-entry client-id #inst "2021-01-01" "ext-bd-1") + id2 (create-journal-entry client-id #inst "2021-02-01" "ext-bd-2") + response (sut/bulk-delete {:identity (admin-token) + :form-params {:selected [id1 id2]}}) + db-after (dc/db conn)] + (is (= 200 (:status response))) + ;; modal-response retargets to the persistent #modal-content shell (innerHTML) + ;; so the modal-holder survives repeated deletes; it also appends modalopen. + (is (= "invalidated, reset-selection, modalopen" (get-in response [:headers "hx-trigger"]))) + (is (= "#modal-content" (get-in response [:headers "hx-retarget"]))) + (is (= "innerHTML" (get-in response [:headers "hx-reswap"]))) + (is (nil? (:journal-entry/external-id (dc/pull db-after [:journal-entry/external-id] id1)))) + (is (nil? (:journal-entry/external-id (dc/pull db-after [:journal-entry/external-id] id2)))))) + + (testing "Should preserve entries in a locked period even when selected" + (let [tempids (setup-test-data [(test-client :db/id "bd-lock-client" + :client/code "BDLOCK" + :client/locked-until #inst "2099-01-01")]) + client-id (get tempids "bd-lock-client") + locked-id (create-journal-entry client-id #inst "2020-01-01" "ext-bd-locked") + open-id (create-journal-entry client-id #inst "2099-06-01" "ext-bd-open") + _ (sut/bulk-delete {:identity (admin-token) + :form-params {:selected [locked-id open-id]}}) + db-after (dc/db conn)] + (is (some? (:journal-entry/external-id (dc/pull db-after [:journal-entry/external-id] locked-id)))) + (is (nil? (:journal-entry/external-id (dc/pull db-after [:journal-entry/external-id] open-id)))))) + + (testing "Non-admin cannot bulk-delete" + (is (thrown? Exception (sut/bulk-delete {:identity (user-token) + :form-params {:selected [1]}})))))