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 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 21:48:39 -07:00
parent 2e3c1e3646
commit e0da8e1866
4 changed files with 121 additions and 6 deletions

View File

@@ -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)

View File

@@ -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}))

View File

@@ -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}

View File

@@ -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]}})))))