3 Commits

Author SHA1 Message Date
366781e818 sort fix 2026-08-13 10:13:57 -07:00
bcb1978f44 fix(ledger): ignore external import rows with no debit or credit
A pasted row with both amount columns empty carries no accounting
information, but it still became a line item: it tripped the "Line item
amount 0.0 must be greater than 0." warning, which demoted the entire
journal entry to "ignored" -- so the good rows around it were dropped
and any existing entry with that external id was retracted.

Drop those rows in table->entries before add-errors runs, so they never
reach the balance check, :amount, or :line-items. An entry whose rows are
all blank disappears completely: not imported, not retracted, not
counted. Only genuinely empty values qualify (nil or a whitespace-only
string, since form input decodes "" to nil); an explicit 0 still warns as
before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 12:59:47 -07:00
785c6b3731 feat(ssr): add errors-only filter to the external import grids
Both external import review grids (ledger and transaction) can run to
hundreds of rows, so finding the handful that failed validation meant
scrolling the whole table. Add an "Only show errors" checkbox next to
"Show table" that hides every clean row.

Done with native Alpine: the wrapper's x-data carries errorsOnly, the
checkbox is x-model bound, and each row is rendered server-side knowing
whether it has errors -- flagged rows get no directive, clean rows get
x-show="!errorsOnly". The grid container becomes
x-show="showTable || errorsOnly" so ticking the filter reveals the table
in one click.

Submitting is unaffected: x-show only toggles display, so hidden rows
keep their inputs in the DOM and still post. Verified in the browser that
the full row set serializes while the filter is on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:30:44 -07:00
5 changed files with 141 additions and 15 deletions

View File

@@ -12,9 +12,14 @@
[hiccup2.core :as hiccup]))
(defn header- [params & rest]
(into [:th.px-4.py-3 {:scope "col" :class (:class params)
"@click" (format "$dispatch('sorted', {key: '%s'})" (:sort-key params))
:style (:style params)}]
;; NOTE: only attach the sort dispatcher when there is a :sort-key. Otherwise
;; (format "%s" nil) renders the literal string "null", which sails through the
;; thead's `event.detail.key || ""` guard and reaches the server as
;; toggle-sort=null, ending in a Datomic unbound-variable error on ?sort-null.
(into [:th.px-4.py-3 (cond-> {:scope "col" :class (:class params)
:style (:style params)}
(:sort-key params)
(assoc "@click" (format "$dispatch('sorted', {key: '%s'})" (:sort-key params))))]
(if (:sort-key params)
[(into [:a {:href "#"}] rest)]
rest)))

View File

@@ -159,7 +159,7 @@
(fc/with-field :table
(when (seq (fc/field-value))
[:div {:x-data (hx/json {"showTable" false})}
[:div {:x-data (hx/json {"showTable" false "errorsOnly" false})}
[:form {:hx-post (bidi.bidi/path-for ssr-routes/only-routes ::route/external-import-import)
:autocomplete "off"}
(when (:just-parsed? request)
@@ -180,8 +180,10 @@
[:div.flex.gap-4.items-center
(com/checkbox {"@click" "showTable=!showTable"}
"Show table")
(com/checkbox {:x-model "errorsOnly"}
"Only show errors")
(com/button {:color :primary} "Import")]
[:div {:x-show "showTable"}
[:div {:x-show "showTable || errorsOnly"}
(com/data-grid-card {:id "ledger-import-data"
:route nil
:title "Data to import"
@@ -199,14 +201,18 @@
:rows
(fc/cursor-map
(fn [r]
(let [entry-id (line->id (fc/field-value r))]
(let [entry-id (line->id (fc/field-value r))
row-errors (seq (fc/field-errors))]
;; A ledger entry spans several rows. Each row knows its own
;; entry id and drops itself when any of its siblings' remove
;; buttons announces that id. Removing the row removes its
;; inputs, so the entry is gone from the next import post.
(com/data-grid-row {:data-entry-id entry-id
:x-data (hx/json {"entryId" entry-id})
"@remove-import-entry.window" "if ($event.detail.entryId === entryId) $el.remove()"}
;; Clean rows are only hidden (x-show), never removed, so
;; "Only show errors" never changes what gets posted.
(com/data-grid-row (cond-> {:data-entry-id entry-id
:x-data (hx/json {"entryId" entry-id})
"@remove-import-entry.window" "if ($event.detail.entryId === entryId) $el.remove()"}
(not row-errors) (assoc :x-show "!errorsOnly"))
(com/data-grid-cell {}
(fc/with-field :external-id
(com/validated-field
@@ -520,12 +526,23 @@
ea)))
line-items)))))
(defn blank-amount?
"A row with neither a debit nor a credit carries no amount at all, so it is
skipped entirely on import rather than flagged."
[{:keys [debit credit]}]
(letfn [(blank? [v]
(or (nil? v)
(and (string? v) (str/blank? v))))]
(and (blank? debit) (blank? credit))))
(defn table->entries [table all-vendors all-accounts client-locked-lookup all-client-bank-accounts all-client-locations]
(let [lines-with-indexes (for [[i l] (map vector (range) table)]
(assoc l :index i))]
(into []
(for [[_ lines] (group-by line->id lines-with-indexes)
:let [{:keys [source client-code date vendor-name note cleared-against] :as line} (first lines)]]
(for [[_ grouped-lines] (group-by line->id lines-with-indexes)
:let [lines (remove blank-amount? grouped-lines)
{:keys [source client-code date vendor-name note cleared-against] :as line} (first lines)]
:when (seq lines)]
(add-errors {:source source
:indices (map :index lines)
:external-id (line->id line)

View File

@@ -248,6 +248,19 @@
[?v :vendor/name ?sort-vendor])
(and [(missing? $ ?e :transaction/vendor)]
[(ground "") ?sort-vendor]))]
"bank-account" '[(or-join [?e ?sort-bank-account]
(and [?e :transaction/bank-account ?sort-ba]
[?sort-ba :bank-account/name ?sort-bank-account])
(and [?e :transaction/bank-account ?sort-ba]
[(missing? $ ?sort-ba :bank-account/name)]
[?sort-ba :bank-account/numeric-code ?sort-ba-code]
[(str ?sort-ba-code) ?sort-bank-account])
(and [?e :transaction/bank-account ?sort-ba]
[(missing? $ ?sort-ba :bank-account/name)]
[(missing? $ ?sort-ba :bank-account/numeric-code)]
[(ground "") ?sort-bank-account])
(and [(missing? $ ?e :transaction/bank-account)]
[(ground "") ?sort-bank-account]))]
"date" ['[?e :transaction/date ?sort-date]]
"amount" ['[?e :transaction/amount ?sort-amount]]
"description" ['[?e :transaction/description-original ?sort-description]]}
@@ -538,6 +551,7 @@
:render-csv (fn [x] (-> x :transaction/client :client/name))}
{:key "bank-account"
:name "Bank Account"
:sort-key "bank-account"
:show-starting "lg"
:render (fn [x]
(let [ba (:transaction/bank-account x)]

View File

@@ -177,7 +177,7 @@
(:form-params request) (:form-errors request)
(fc/with-field :table
(when (seq (fc/field-value))
[:div.mt-4 {:x-data (hx/json {"showTable" true})}
[:div.mt-4 {:x-data (hx/json {"showTable" true "errorsOnly" false})}
(when (:just-parsed? request)
(parsed-banner request))
[:form {:hx-post (bidi/path-for ssr-routes/only-routes ::route/external-import-import)
@@ -186,8 +186,9 @@
:autocomplete "off"}
[:div.flex.gap-4.items-center.my-2
(com/checkbox {"@click" "showTable=!showTable"} "Show table")
(com/checkbox {:x-model "errorsOnly"} "Only show errors")
(com/button {:color :primary :type "submit"} "Import")]
[:div {:x-show "showTable"}
[:div {:x-show "showTable || errorsOnly"}
(com/data-grid-card
{:id "transaction-import-data"
:route nil
@@ -204,8 +205,11 @@
(fc/cursor-map
(fn [_]
(let [row-errors (fc/field-errors)]
;; Clean rows are only hidden (x-show), never removed, so
;; "Only show errors" never changes what gets posted.
(com/data-grid-row
{}
(cond-> {}
(not (seq row-errors)) (assoc :x-show "!errorsOnly"))
(com/data-grid-cell {} (fc/with-field :raw-date
(com/text-input {:value (fc/field-value) :name (fc/field-name) :class "w-28"})))
(com/data-grid-cell {} (fc/with-field :description-original

View File

@@ -370,6 +370,55 @@
(is (= "TEST" (:client-code (first entries))))
(is (= 2 (count (:line-items (first entries))))))))
(deftest table->entries-blank-amount-rows-test
(let [line (fn [external-id debit credit account-code]
{:source "manual"
:client-code "TEST"
:external-id external-id
:date (coerce/to-date-time #inst "2021-01-01")
:vendor-name "Vendor"
:debit debit
:credit credit
:account-code account-code
:location "HQ"})
entries (fn [table]
(sut/table->entries table
{"Vendor" {:db/id "vendor-1"}}
#{"1100" "1101"}
{"TEST" #inst "2000-01-01"}
{"TEST" #{}}
{"TEST" #{"HQ"}}))]
(testing "Should skip rows with no debit and no credit without flagging them"
(let [_ (setup-test-data [(test-client :db/id "blank-client-1"
:client/code "TEST"
:client/locations ["HQ"])])
result (entries [(line "ext-blank-1" 100.0 nil 1100)
(line "ext-blank-1" nil nil 1101)
(line "ext-blank-1" nil 100.0 1101)])]
(is (= 1 (count result)))
(is (= 2 (count (:line-items (first result)))))
(is (= [0 2] (vec (:indices (first result)))))
(is (= 100.0 (:amount (first result))))
(is (empty? (sut/entry-errors (first result))))))
(testing "Should treat blank strings in the amount columns as no amount"
(let [_ (setup-test-data [(test-client :db/id "blank-client-2"
:client/code "TEST"
:client/locations ["HQ"])])
result (entries [(line "ext-blank-2" 100.0 nil 1100)
(line "ext-blank-2" "" " " 1101)
(line "ext-blank-2" nil 100.0 1101)])]
(is (= 2 (count (:line-items (first result)))))
(is (empty? (sut/entry-errors (first result))))))
(testing "Should drop an entry entirely when every one of its rows is blank"
(let [_ (setup-test-data [(test-client :db/id "blank-client-3"
:client/code "TEST"
:client/locations ["HQ"])])
result (entries [(line "ext-blank-3" nil nil 1100)
(line "ext-blank-3" nil nil 1101)])]
(is (empty? result))))))
(deftest import-ledger-test
(testing "Should upsert hidden vendors and create transactions"
(let [_ (setup-test-data [(test-client :db/id "import-client-1"
@@ -397,7 +446,44 @@
(let [vendor-id (dc/q '[:find ?e .
:where [?e :vendor/name "New Vendor Import Unique"]]
db-after)]
(is vendor-id)))))
(is vendor-id))))
(testing "Should import the entry while ignoring its rows with no debit and no credit"
(let [_ (setup-test-data [(test-client :db/id "import-client-2"
:client/code "IMPORT-BLANK"
:client/locations ["HQ"])
{:db/id "import-blank-account-1100"
:account/numeric-code 1100
:account/account-set "default"
:account/name "Cash"}
{:db/id "import-blank-account-1101"
:account/numeric-code 1101
:account/account-set "default"
:account/name "Other Cash"}])
row (fn [debit credit account-code]
{:source "manual"
:client-code "IMPORT-BLANK"
:external-id "ext-import-blank-1"
:date (coerce/to-date-time #inst "2021-01-01")
:vendor-name "Blank Row Vendor"
:debit debit
:credit credit
:account-code account-code
:location "HQ"})
result (sut/import-ledger {:form-params {:table [(row 100.0 nil 1100)
(row nil nil 1101)
(row nil 100.0 1101)]}
:identity (admin-token)})
entry (dc/pull (dc/db conn)
[:journal-entry/amount
{:journal-entry/line-items [:journal-entry-line/debit
:journal-entry-line/credit]}]
[:journal-entry/external-id "IMPORT-BLANK-manual-ext-import-blank-1"])]
(is (= 1 (:successful result)))
(is (= 0 (:ignored result)))
(is (empty? (:form-errors result)))
(is (= 100.0 (:journal-entry/amount entry)))
(is (= 2 (count (:journal-entry/line-items entry)))))))
(deftest import-ledger-with-errors-test
(testing "Should throw exception when entries have errors - client not found"