A journal entry with a line posted to a bank account flagged :bank-account/include-in-reports false shows on the SSR register but not on the GraphQL/CLJS ledger page, which has always dropped those entries in auto-ap.datomic.ledger/graphql-results (and the CSV export does the same in auto-ap.routes.exports). Same entry, same filters, visible on one page and missing from the other. The admin client form defaults the flag to false, so any bank account saved without ticking the box is affected -- 33 of 676 bank accounts carry an explicit false today. Resolve the affected entries up front off VAET and drop them before sorting rather than excluding them inside the query: the equivalent not-join measured ~36ms against ~3ms for the bare scan on a wide date range, while the two lookups cost ~2ms and are skipped entirely for clients with nothing flagged. Unlike the GraphQL page, which filters after pagination and so quietly serves short pages against an unfiltered total, this runs before pagination, so the row count matches what the register renders. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
750 lines
40 KiB
Clojure
750 lines
40 KiB
Clojure
(ns auto-ap.ssr.ledger.common
|
|
(:require
|
|
[auto-ap.datomic
|
|
:refer [add-sorter-fields apply-pagination apply-sort-4 conn
|
|
merge-query observable-query pull-many]]
|
|
[auto-ap.datomic.accounts :as d-accounts]
|
|
[auto-ap.graphql.utils :refer [extract-client-ids]]
|
|
[auto-ap.permissions :refer [can?]]
|
|
[auto-ap.routes.invoice :as invoice-route]
|
|
[auto-ap.routes.ledger :as route]
|
|
[auto-ap.routes.transactions :as transaction-routes]
|
|
[auto-ap.ssr-routes :as ssr-routes]
|
|
[auto-ap.ssr.components :as com]
|
|
[auto-ap.ssr.components.date-range :as dr]
|
|
[auto-ap.ssr.components.link-dropdown :refer [link-dropdown]]
|
|
[auto-ap.ssr.grid-page-helper :as helper]
|
|
[auto-ap.ssr.hx :as hx]
|
|
[auto-ap.ssr.svg :as svg]
|
|
[auto-ap.ssr.utils
|
|
:refer [clj-date-schema entity-id html-response ref->enum-schema
|
|
strip]]
|
|
[auto-ap.time :as atime]
|
|
[auto-ap.utils :refer [dollars-0?]]
|
|
[bidi.bidi :as bidi]
|
|
[clj-time.coerce :as coerce]
|
|
[clojure.string :as str]
|
|
[datomic.api :as dc]
|
|
[hiccup.util :as hu]
|
|
[iol-ion.query :refer [dollars=]]
|
|
[malli.core :as mc]))
|
|
|
|
(defn exact-match-id* [request]
|
|
(if (nat-int? (:exact-match-id (:query-params request)))
|
|
[:div {:x-data (hx/json {:exact_match (:exact-match-id (:query-params request))}) :id "exact-match-id-tag" :class "filter-trigger"}
|
|
(com/hidden {:name "exact-match-id"
|
|
"x-model" "exact_match"})
|
|
(com/pill {:color :primary}
|
|
[:span.inline-flex.space-x-2.items-center
|
|
[:div "exact match"]
|
|
[:div.w-3.h-3
|
|
(com/link {"@click" "exact_match=null; $nextTick(() => $dispatch('change'))"}
|
|
svg/x)]])]
|
|
[:div {:id "exact-match-id-tag"}]))
|
|
|
|
(defn bank-account-filter* [request]
|
|
[:div {:hx-trigger "clientSelected from:body"
|
|
:hx-get (bidi.bidi/path-for ssr-routes/only-routes ::route/bank-account-filter)
|
|
:hx-target "this"
|
|
:hx-swap "outerHTML"
|
|
:class "filter-trigger"}
|
|
(when (:client request)
|
|
(let [bank-account-belongs-to-client? (get (set (map :db/id (:client/bank-accounts (:client request))))
|
|
(:db/id (:bank-account (:query-params request))))]
|
|
(com/field {:label "Bank Account"}
|
|
(com/radio-card {:size :small
|
|
:name "bank-account"
|
|
:value (or (when bank-account-belongs-to-client?
|
|
(:db/id (:bank-account (:query-params request))))
|
|
"")
|
|
:options
|
|
(into [{:value ""
|
|
:content "All"}]
|
|
(for [ba (:client/bank-accounts (:client request))]
|
|
{:value (:db/id ba)
|
|
:content (:bank-account/name ba)}))}))))])
|
|
|
|
(defn bank-account-filter [request]
|
|
(html-response (bank-account-filter* request)))
|
|
|
|
(defn filters [request]
|
|
[:form#ledger-filters {"hx-trigger" "datesApplied, change delay:500ms from:.filter-trigger, keyup changed from:.hot-filter delay:1000ms"
|
|
"hx-get" (bidi/path-for ssr-routes/only-routes
|
|
::route/table)
|
|
"hx-target" "#entity-table"
|
|
"hx-indicator" "#entity-table"}
|
|
|
|
(com/hidden {:name "status"
|
|
:value (some-> (:status (:query-params request)) name)})
|
|
[:fieldset.space-y-6
|
|
(com/field {:label "Vendor"}
|
|
(com/typeahead {:name "vendor"
|
|
:id "vendor"
|
|
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
|
|
:value (:vendor (:query-params request))
|
|
:value-fn :db/id
|
|
:content-fn :vendor/name
|
|
:class "filter-trigger"}))
|
|
(com/field {:label "Account"}
|
|
(com/typeahead {:name "account"
|
|
:id "account"
|
|
:url (bidi/path-for ssr-routes/only-routes :account-search)
|
|
:value (:account (:query-params request))
|
|
:value-fn :db/id
|
|
:content-fn #(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read (:db/id %))
|
|
(:db/id (:client request))))
|
|
:class "filter-trigger"}))
|
|
|
|
(bank-account-filter* request)
|
|
|
|
(dr/date-range-field {:value {:start (:start-date (:query-params request))
|
|
:end (:end-date (:query-params request))}
|
|
:id "date-range"
|
|
:apply-button? true})
|
|
(com/field {:label "Invoice #"}
|
|
(com/text-input {:name "invoice-number"
|
|
:id "invoice-number"
|
|
:class "hot-filter"
|
|
:value (:invoice-number (:query-params request))
|
|
:placeholder "e.g., ABC-456"
|
|
:size :small}))
|
|
|
|
(when (:external? (:route-params request))
|
|
(com/field {:label "Source"}
|
|
(com/text-input {:name "source"
|
|
:id "source"
|
|
:class "hot-filter"
|
|
:value (:source (:query-params request))
|
|
:placeholder "e.g., invoice"
|
|
:size :small})))
|
|
|
|
(when (:external? (:route-params request))
|
|
(com/field {:label "External Id"}
|
|
(com/text-input {:name "external-id-like"
|
|
:id "external-id-like"
|
|
:class "hot-filter"
|
|
:value (:external-id-like (:query-params request))
|
|
:placeholder "e.g., ABC-123"
|
|
:size :small})))
|
|
|
|
(when (:external? (:route-params request))
|
|
(com/field {:label "Location"}
|
|
(com/text-input {:name "location"
|
|
:id "location"
|
|
:class "hot-filter"
|
|
:value (:location (:query-params request))
|
|
:placeholder "SC"
|
|
:size :small})))
|
|
|
|
(com/field {:label "Account Code"}
|
|
[:div.flex.space-x-4.items-baseline
|
|
(com/int-input {:name "numeric-code-gte"
|
|
:id "numeric-code-gte"
|
|
:hx-preserve "true"
|
|
:class "hot-filter w-20"
|
|
:value (:numeric-code-gte (:query-params request))
|
|
:placeholder "40000"
|
|
:size :small})
|
|
[:div.align-baseline
|
|
"to"]
|
|
(com/int-input {:name "numeric-code-lte"
|
|
:hx-preserve "true"
|
|
:id "numeric-code-lte"
|
|
:class "hot-filter w-20"
|
|
:value (:numeric-code-lte (:query-params request))
|
|
:placeholder "50000"
|
|
:size :small})])
|
|
|
|
(com/field {:label "Amount"}
|
|
[:div.flex.space-x-4.items-baseline
|
|
(com/money-input {:name "amount-gte"
|
|
:id "amount-gte"
|
|
:hx-preserve "true"
|
|
:class "hot-filter w-20"
|
|
:value (:amount-gte (:query-params request))
|
|
:placeholder "0.01"
|
|
:size :small})
|
|
[:div.align-baseline
|
|
"to"]
|
|
(com/money-input {:name "amount-lte"
|
|
:hx-preserve "true"
|
|
:id "amount-lte"
|
|
:class "hot-filter w-20"
|
|
:value (:amount-lte (:query-params request))
|
|
:placeholder "9999.34"
|
|
:size :small})])
|
|
[:div.mt-4 {:x-data (hx/json {:onlyUnbalanced (:only-unbalanced (:query-params request))})}
|
|
(com/hidden {:name "only-unbalanced"
|
|
":value" "onlyUnbalanced ? 'on' : ''"})
|
|
(com/checkbox {:value (:only-unbalanced (:query-params request))
|
|
:class "filter-trigger"
|
|
:x-model "onlyUnbalanced"}
|
|
"Show unbalanced")]
|
|
(exact-match-id* request)]])
|
|
|
|
(defn- apply-only-unbalanced [query-params results]
|
|
(if (:only-unbalanced query-params)
|
|
(let [unbalanced-ids (->>
|
|
(dc/q '[:find (sum ?debit) (sum ?credit) ?je
|
|
:in $ [?je ...]
|
|
:with ?li
|
|
:where
|
|
[?je :journal-entry/line-items ?li]
|
|
[(get-else $ ?li :journal-entry-line/credit 0.0) ?credit]
|
|
[(get-else $ ?li :journal-entry-line/debit 0.0) ?debit]]
|
|
(dc/db conn)
|
|
(map last results))
|
|
(filter (fn [[debits credits]]
|
|
(not (dollars= debits credits))))
|
|
(map last)
|
|
(into #{}))]
|
|
(for [result results
|
|
:when (get unbalanced-ids (last result))]
|
|
result))
|
|
results))
|
|
|
|
(defn- entries-off-reports
|
|
"Journal entries with a line posted to one of `clients`' bank accounts flagged
|
|
`:bank-account/include-in-reports` false, or nil when there are none.
|
|
|
|
The GraphQL ledger page has always dropped these (see
|
|
auto-ap.datomic.ledger/graphql-results), so the register has to agree --
|
|
otherwise the same entry is visible on one ledger page and missing from the
|
|
other. Resolved up front off VAET, which is ~150 entries db-wide and costs
|
|
~2ms; excluding them inside the query with a not-join instead measured ~10x
|
|
the cost of the whole scan on a wide date range."
|
|
[db clients]
|
|
(when-let [off-reports (seq (dc/q '[:find [?ba ...]
|
|
|
|
:in $ [?client ...]
|
|
|
|
:where
|
|
[?client :client/bank-accounts ?ba]
|
|
[?ba :bank-account/include-in-reports false]]
|
|
db clients))]
|
|
(set (dc/q '[:find [?e ...]
|
|
|
|
:in $ [?ba ...]
|
|
|
|
:where
|
|
[?li :journal-entry-line/account ?ba]
|
|
[?e :journal-entry/line-items ?li]]
|
|
db off-reports))))
|
|
|
|
(defn- apply-include-in-reports [db clients results]
|
|
(if-let [hidden (entries-off-reports db clients)]
|
|
(remove (comp hidden last) results)
|
|
results))
|
|
|
|
;; TODO
|
|
;; 1. Sorting in investigate dialog
|
|
;; 2. actual date range filtering in investigate dialog
|
|
;; 3. CSVs
|
|
;; 4. better date range / advanced mode for dialog
|
|
|
|
(defn- accounts-sharing-code
|
|
"The searched account, plus any of `clients`' bank accounts on the same numeric
|
|
code. :journal-entry-line/account points at either entity, so a search for the
|
|
financial account has to match lines posted to the bank account too."
|
|
[db clients account-id]
|
|
(into [account-id]
|
|
(when-let [code (:account/numeric-code (dc/entity db account-id))]
|
|
(dc/q '[:find [?ba ...]
|
|
|
|
:in $ [?client ...] ?code
|
|
|
|
:where
|
|
[?client :client/bank-accounts ?ba]
|
|
[?ba :bank-account/numeric-code ?code]]
|
|
db clients code))))
|
|
|
|
(defn- account-filter-query
|
|
"Ledger clause for the Account search. Stays a scalar binding when nothing
|
|
shares the account's code, so the common case costs what it always did."
|
|
[db clients account-id]
|
|
(let [ids (accounts-sharing-code db clients account-id)]
|
|
(if (second ids)
|
|
{:query {:in ['[?a3 ...]]
|
|
|
|
:where ['[?li :journal-entry-line/account ?a3]]}
|
|
:args [ids]}
|
|
{:query {:in ['?a3]
|
|
|
|
:where ['[?li :journal-entry-line/account ?a3]]}
|
|
:args [account-id]})))
|
|
|
|
(defn fetch-ids [db {:keys [query-params route-params] :as request}]
|
|
(let [valid-clients (extract-client-ids (:clients request)
|
|
(:client-id request)
|
|
(:client-id query-params)
|
|
(when (:client-code request)
|
|
[:client/code (:client-code request)]))
|
|
args query-params
|
|
query
|
|
(if (:exact-match-id args)
|
|
{:query {:find '[?e]
|
|
:in '[$ ?e [?c ...]]
|
|
:where '[[?e :journal-entry/client ?c]]}
|
|
:args [db
|
|
(:exact-match-id args)
|
|
valid-clients]}
|
|
(cond-> {:query {:find []
|
|
:in ['$ '[?clients ?start ?end]]
|
|
:where '[[(iol-ion.query/scan-ledger $ ?clients ?start ?end) [[?e _ ?sort-default] ...]]]}
|
|
:args [db
|
|
[valid-clients
|
|
(some-> (:start-date query-params) coerce/to-date)
|
|
(some-> (:end-date query-params) coerce/to-date)]]}
|
|
|
|
(:only-external args)
|
|
(merge-query {:query {:where ['(not [?e :journal-entry/original-entity])]}})
|
|
|
|
(seq (:external-id-like args))
|
|
(merge-query {:query {:in ['?external-id-like]
|
|
:where ['[?e :journal-entry/external-id ?external-id]
|
|
'[(.contains ^String ?external-id ?external-id-like)]]}
|
|
:args [(:external-id-like args)]})
|
|
|
|
(seq (:source args))
|
|
(merge-query {:query {:in ['?source]
|
|
:where ['[?e :journal-entry/source ?source]]}
|
|
:args [(:source args)]})
|
|
(:external? route-params)
|
|
(merge-query {:query {:where ['[?e :journal-entry/external-id]]}})
|
|
|
|
(:vendor args)
|
|
(merge-query {:query {:in ['?vendor-id]
|
|
:where ['[?e :journal-entry/vendor ?vendor-id]]}
|
|
:args [(:db/id (:vendor args))]})
|
|
|
|
(:invoice-number args)
|
|
(merge-query {:query {:in ['?invoice-number]
|
|
:where ['[?e :journal-entry/original-entity ?oe]
|
|
'[?oe :invoice/invoice-number ?invoice-number]]}
|
|
:args [(:invoice-number args)]})
|
|
|
|
(or (:numeric-code-lte args)
|
|
(:numeric-code-gte args)
|
|
(seq (:numeric-code args))
|
|
(:account args)
|
|
(:db/id (:bank-account args))
|
|
(not-empty (:location args)))
|
|
(merge-query {:query {:where ['[?e :journal-entry/line-items ?li]]}})
|
|
|
|
(or (:numeric-code-gte args)
|
|
(:numeric-code-lte args))
|
|
(merge-query {:query {:in '[?from-numeric-code ?to-numeric-code]
|
|
:where ['[?li :journal-entry-line/account ?a]
|
|
'(or-join [?a ?c]
|
|
[?a :account/numeric-code ?c]
|
|
[?a :bank-account/numeric-code ?c])
|
|
'[(>= ?c ?from-numeric-code)]
|
|
'[(<= ?c ?to-numeric-code)]]}
|
|
:args [(or (:numeric-code-gte args) 0) (or (:numeric-code-lte args) 99999)]})
|
|
|
|
(seq (:numeric-code args))
|
|
(merge-query {:query {:in '[[[?from-numeric-code ?to-numeric-code] ...]]
|
|
:where ['[?li :journal-entry-line/account ?a]
|
|
'(or-join [?a ?c]
|
|
[?a :account/numeric-code ?c]
|
|
[?a :bank-account/numeric-code ?c])
|
|
'[(>= ?c ?from-numeric-code)]
|
|
'[(<= ?c ?to-numeric-code)]]}
|
|
:args [(map (juxt :from :to) (:numeric-code args))]})
|
|
(seq (:account args))
|
|
(merge-query (account-filter-query db valid-clients (:db/id (:account args))))
|
|
|
|
(:amount-gte args)
|
|
(merge-query {:query {:in ['?amount-gte]
|
|
:where ['[?e :journal-entry/amount ?a]
|
|
'[(>= ?a ?amount-gte)]]}
|
|
:args [(:amount-gte args)]})
|
|
|
|
(:amount-lte args)
|
|
(merge-query {:query {:in ['?amount-lte]
|
|
:where ['[?e :journal-entry/amount ?a]
|
|
'[(<= ?a ?amount-lte)]]}
|
|
:args [(:amount-lte args)]})
|
|
|
|
(:db/id (:bank-account args))
|
|
(merge-query {:query {:in ['?a]
|
|
:where ['[?li :journal-entry-line/account ?a]]}
|
|
:args [(:db/id (:bank-account args))]})
|
|
|
|
(:account-id args)
|
|
(merge-query {:query {:in ['?a2]
|
|
:where ['[?e :journal-entry/line-items ?li2]
|
|
'[?li2 :journal-entry-line/account ?a2]]}
|
|
:args [(:account-id args)]})
|
|
|
|
(not-empty (:location args))
|
|
(merge-query {:query {:in ['?location]
|
|
:where ['[?li :journal-entry-line/location ?location]]}
|
|
:args [(:location args)]})
|
|
|
|
(not-empty (:locations args))
|
|
(merge-query {:query {:in ['[?location ...]]
|
|
:where ['[?li :journal-entry-line/location ?location]]}
|
|
:args [(:locations args)]})
|
|
|
|
(:sort args) (add-sorter-fields {"client" ['[?e :journal-entry/client ?c]
|
|
'[?c :client/name ?sort-client]]
|
|
"date" ['[?e :journal-entry/date ?sort-date]]
|
|
"vendor" '[(or-join [?e ?sort-vendor]
|
|
(and
|
|
[?e :journal-entry/vendor ?v]
|
|
[?v :vendor/name ?sort-vendor])
|
|
(and [(missing? $ ?e :journal-entry/vendor)]
|
|
[(ground "") ?sort-vendor]))]
|
|
"amount" ['[?e :journal-entry/amount ?sort-amount]]
|
|
"external-id" ['[?e :journal-entry/external-id ?sort-external-id]]
|
|
"source" '[(or-join [?e ?sort-source]
|
|
[?e :journal-entry/source ?sort-source]
|
|
(and [(missing? $ ?e :journal-entry/source)]
|
|
[(ground "") ?sort-source]))]}
|
|
args)
|
|
|
|
true
|
|
(merge-query {:query {:find ['?sort-default '?e]}})))]
|
|
|
|
(->> (observable-query query)
|
|
(apply-include-in-reports db valid-clients)
|
|
(apply-sort-4 (assoc query-params :default-asc? true))
|
|
(apply-only-unbalanced query-params)
|
|
(apply-pagination query-params))))
|
|
|
|
#_(dc/q '{:find [?sort-vendor (count ?e)],
|
|
:in [$ [?clients ?start ?end]],
|
|
:where [[(iol-ion.query/scan-ledger $ ?clients ?start ?end)
|
|
[[?e _ ?sort-default] ...]]
|
|
#_(not [?e :journal-entry/vendor])
|
|
[(missing? $ ?e :journal-entry/vendor)]
|
|
[(ground "ih") ?sort-vendor]]}
|
|
(dc/db conn)
|
|
args)
|
|
|
|
(def default-read
|
|
'[:journal-entry/amount
|
|
:journal-entry/alternate-description
|
|
:journal-entry/memo
|
|
:journal-entry/source
|
|
:journal-entry/external-id
|
|
:db/id
|
|
[:journal-entry/date :xform clj-time.coerce/from-date]
|
|
{:journal-entry/vendor [:vendor/name :db/id]
|
|
:journal-entry/original-entity [:invoice/invoice-number
|
|
:invoice/source-url
|
|
:transaction/description-original :db/id]
|
|
:journal-entry/client [:client/name :client/code :db/id]
|
|
:journal-entry/line-items [:journal-entry-line/debit
|
|
:journal-entry-line/location
|
|
:journal-entry-line/running-balance
|
|
:journal-entry-line/credit
|
|
{:journal-entry-line/account [:account/name :db/id :account/numeric-code
|
|
:bank-account/name :bank-account/numeric-code
|
|
{[:account/type :xform iol-ion.query/ident] [:db/ident :db/id]}
|
|
{:account/client-overrides [:account-client-override/name
|
|
{:account-client-override/client [:db/id]}]}
|
|
{[:bank-account/type :xform iol-ion.query/ident]
|
|
[:db/ident :db/id]}]}]}])
|
|
|
|
(defn hydrate-results [ids db _]
|
|
(let [results (->> (pull-many db default-read ids)
|
|
(group-by :db/id))
|
|
refunds (->> ids
|
|
(map results)
|
|
(map first))]
|
|
refunds))
|
|
|
|
(defn sum-outstanding [ids]
|
|
|
|
(->>
|
|
(dc/q {:find ['?id '?o]
|
|
:in ['$ '[?id ...]]
|
|
:where ['[?id :invoice/outstanding-balance ?o]]}
|
|
(dc/db conn)
|
|
ids)
|
|
(map last)
|
|
(reduce
|
|
+
|
|
0.0)))
|
|
|
|
(defn sum-total-amount [ids]
|
|
|
|
(->>
|
|
(dc/q {:find ['?id '?o]
|
|
:in ['$ '[?id ...]]
|
|
:where ['[?id :invoice/total ?o]]}
|
|
(dc/db conn)
|
|
ids)
|
|
(map last)
|
|
(reduce
|
|
+
|
|
0.0)))
|
|
|
|
(defn fetch-page [request]
|
|
(let [db (dc/db conn)
|
|
{ids-to-retrieve :ids matching-count :count
|
|
all-ids :all-ids} (fetch-ids db request)]
|
|
|
|
[(->> (hydrate-results ids-to-retrieve db request))
|
|
matching-count
|
|
(sum-outstanding all-ids)
|
|
(sum-total-amount all-ids)]))
|
|
(defn render-lines [key {:journal-entry/keys [line-items client]}]
|
|
(let [lines (for [jel line-items
|
|
:when (and (key jel)
|
|
(not (dollars-0? (key jel))))]
|
|
jel)]
|
|
[:div.grid.grid-cols-2.gap-1.auto-cols-min.grid-flow-row.shrink
|
|
|
|
(for [jel lines
|
|
:let [account (d-accounts/clientize (:journal-entry-line/account jel) (:db/id client))
|
|
account-name (or (:account/name account)
|
|
(:bank-account/name (:journal-entry-line/account jel)))]]
|
|
(list
|
|
|
|
(if account-name
|
|
[:div {:x-tooltip (hx/json (str "Running Balance: " (some->> (:journal-entry-line/running-balance jel)
|
|
(format "$%,.2f"))))}
|
|
[:div.text-left.underline.cursor-pointer {:x-ref "source"}
|
|
(:journal-entry-line/location jel) ": "
|
|
(or (:account/numeric-code account) (:bank-account/numeric-code account))
|
|
" - " account-name]]
|
|
[:div.text-left (com/pill {:color :yellow} "Unassigned")])
|
|
[:div.text-right.text-underline (format "$%,.2f" (key jel))]))
|
|
|
|
(when-not (= 1 (count lines))
|
|
[:div.col-span-2 (com/pill {:color :primary} "Total: " (->> lines
|
|
(map #(or (key %) 0.0))
|
|
(reduce + 0.0)
|
|
(format "$%,.2f")))])]))
|
|
|
|
(def query-schema (mc/schema
|
|
[:maybe [:map {:date-range [:date-range :start-date :end-date]}
|
|
[:sort {:optional true} [:maybe [:any]]]
|
|
[:per-page {:optional true :default 25} [:maybe :int]]
|
|
[:start {:optional true :default 0} [:maybe :int]]
|
|
[:amount-gte {:optional true} [:maybe :double]]
|
|
[:amount-lte {:optional true} [:maybe :double]]
|
|
[:client-id {:optional true} [:maybe entity-id]]
|
|
[:only-unbalanced {:optional true}
|
|
[:maybe [:boolean {:decode/string {:enter #(cond (= % "on") true
|
|
(= % "") false
|
|
:else
|
|
|
|
(boolean %))}
|
|
:encode/string {:enter #(if % "on" "")}}]]]
|
|
[:numeric-code {:optional true :decode/string clojure.edn/read-string}
|
|
[:maybe [:vector [:map [:from nat-int?]
|
|
[:to nat-int?]]]]]
|
|
[:numeric-code-gte {:optional true} [:maybe nat-int?]]
|
|
[:numeric-code-lte {:optional true} [:maybe nat-int?]]
|
|
[:vendor {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :vendor/name]}]]]
|
|
[:bank-account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :bank-account/name]}]]]
|
|
[:account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :account/name]}]]]
|
|
[:check-number {:optional true} [:maybe [:string {:decode/string strip}]]]
|
|
[:invoice-number {:optional true} [:maybe [:string {:decode/string strip}]]]
|
|
[:source {:optional true} [:maybe [:string {:decode/string strip}]]]
|
|
[:external-id-like {:optional true} [:maybe [:string {:decode/string strip}]]]
|
|
[:location {:optional true} [:maybe [:string {:decode/string strip}]]]
|
|
[:status {:optional true} [:maybe (ref->enum-schema "invoice-status")]]
|
|
[:exact-match-id {:optional true} [:maybe entity-id]]
|
|
[:all-selected {:optional true :default nil} [:maybe :boolean]]
|
|
[:selected {:optional true :default nil} [:maybe [:vector {:coerce? true}
|
|
entity-id]]]
|
|
[:start-date {:optional true}
|
|
[:maybe clj-date-schema]]
|
|
[:end-date {:optional true}
|
|
[:maybe clj-date-schema]]]]))
|
|
|
|
(def grid-page
|
|
(helper/build {:id "entity-table"
|
|
:nav com/main-aside-nav
|
|
:check-boxes? true
|
|
:check-box-warning? (fn [e]
|
|
(some? (:invoice/scheduled-payment e)))
|
|
:page-specific-nav filters
|
|
:fetch-page fetch-page
|
|
:oob-render
|
|
(fn [request]
|
|
[(assoc-in (dr/date-range-field {:value {:start (:start-date (:query-params request))
|
|
:end (:end-date (:query-params request))}
|
|
:id "date-range"
|
|
:apply-button? true}) [1 :hx-swap-oob] true)
|
|
(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 (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}))
|
|
(com/icon-button {:hx-delete (bidi/path-for ssr-routes/only-routes
|
|
::route/delete
|
|
:db/id (:db/id entity))
|
|
:hx-confirm "Are you sure you want to void this invoice?"}
|
|
svg/trash))
|
|
(when (and (can? (:identity request) {:subject :invoice :activity :edit})
|
|
(#{:invoice-status/unpaid :invoice-status/paid} (:invoice/status entity)))
|
|
(com/icon-button {:hx-put (bidi/path-for ssr-routes/only-routes
|
|
::route/edit-wizard
|
|
:db/id (:db/id entity))}
|
|
svg/pencil))
|
|
(when (and (can? (:identity request) {:subject :invoice :activity :edit})
|
|
(#{:invoice-status/voided} (:invoice/status entity)))
|
|
(com/icon-button {:hx-put (bidi/path-for ssr-routes/only-routes
|
|
::route/unvoid
|
|
:db/id (:db/id entity))}
|
|
svg/undo))])
|
|
|
|
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)}
|
|
"Ledger"]]
|
|
:title (fn [r]
|
|
(str
|
|
(some-> r :route-params :status name str/capitalize (str " "))
|
|
(if (:external? (:route-params r))
|
|
"External Register"
|
|
"Register")))
|
|
:entity-name "register"
|
|
:route ::route/table
|
|
:csv-route ::route/csv
|
|
:break-table (fn [request entity]
|
|
(cond
|
|
(= (-> request :query-params :sort first :name) "Vendor")
|
|
(or (-> entity :journal-entry/vendor :vendor/name)
|
|
"No vendor")
|
|
|
|
(= (-> request :query-params :sort first :name) "Source")
|
|
(or (-> entity :journal-entry/source)
|
|
"No external source")
|
|
|
|
:else nil))
|
|
:page->csv-entities (fn [[journal-entries]]
|
|
(for [je journal-entries
|
|
jel (:journal-entry/line-items je)]
|
|
(merge jel je)))
|
|
:headers [{:key "id"
|
|
:name "Id"
|
|
:render-csv :db/id
|
|
:render-for #{:csv}}
|
|
{:key "client"
|
|
:name "Client"
|
|
:sort-key "client"
|
|
:hide? (fn [args]
|
|
(and (= (count (:clients args)) 1)
|
|
(= 1 (count (:client/locations (:client args))))))
|
|
:render (fn [x] [:div.flex.items-center.gap-2 (-> x :journal-entry/client :client/name)])
|
|
:render-csv (fn [x] (-> x :journal-entry/client :client/name))}
|
|
|
|
{:key "vendor"
|
|
:name "Vendor"
|
|
:sort-key "vendor"
|
|
:render (fn [e] (or (-> e :journal-entry/vendor :vendor/name)
|
|
[:span.italic.text-gray-400 (-> e :journal-entry/alternate-description)]))
|
|
:render-csv (fn [e] (or (-> e :journal-entry/vendor :vendor/name)
|
|
(-> e :journal-entry/alternate-description)))}
|
|
{:key "source"
|
|
:name "Source"
|
|
:sort-key "source"
|
|
:hide? (fn [args]
|
|
(not (:external? (:route-params args))))
|
|
:render :journal-entry/source
|
|
:render-csv :journal-entry/source}
|
|
{:key "external-id"
|
|
:name "External Id"
|
|
:sort-key "external-id"
|
|
:class "max-w-[12rem]"
|
|
:hide? (fn [args]
|
|
(not (:external? (:route-params args))))
|
|
:render (fn [x] [:p.truncate (:journal-entry/external-id x)])
|
|
:render-csv :journal-entry/external-id}
|
|
{:key "date"
|
|
:sort-key "date"
|
|
:name "Date"
|
|
:show-starting "lg"
|
|
:render (fn [{:journal-entry/keys [date]}]
|
|
(some-> date (atime/unparse-local atime/normal-date)))}
|
|
{:key "amount"
|
|
:sort-key "amount"
|
|
:name "Amount"
|
|
:show-starting "lg"
|
|
:render (fn [{:journal-entry/keys [amount]}]
|
|
(some->> amount
|
|
(format "$%,.2f")))}
|
|
{:key "account-number"
|
|
:name "Account Number"
|
|
:class "text-right"
|
|
:render-csv #(or (-> % :journal-entry-line/account :account/numeric-code)
|
|
(-> % :journal-entry-line/account :bank-account/numeric-code))
|
|
:render-for #{:csv}}
|
|
{:key "account"
|
|
:name "Account"
|
|
:sort-key "account"
|
|
:class "text-right"
|
|
:render-csv #(or (-> % :journal-entry-line/account :account/name)
|
|
(-> % :journal-entry-line/account :bank-account/name))
|
|
:render-for #{:csv}}
|
|
{:key "debit"
|
|
:name "Debit"
|
|
:class "text-right"
|
|
:render (partial render-lines :journal-entry-line/debit)
|
|
:render-csv :journal-entry-line/debit}
|
|
|
|
{:key "credit"
|
|
:name "Credit"
|
|
:class "text-right"
|
|
:render (partial render-lines :journal-entry-line/credit)
|
|
:render-csv :journal-entry-line/credit}
|
|
|
|
{:key "links"
|
|
:name "Links"
|
|
:show-starting "lg"
|
|
:class "w-8"
|
|
:render (fn [i]
|
|
(link-dropdown
|
|
(cond-> []
|
|
(-> i :journal-entry/original-entity :invoice/invoice-number)
|
|
(conj
|
|
{:link (hu/url (bidi/path-for ssr-routes/only-routes
|
|
::invoice-route/all-page)
|
|
{:exact-match-id (:db/id (:journal-entry/original-entity i))})
|
|
:color :primary
|
|
:content (format "Invoice '%s'" (-> i :journal-entry/original-entity :invoice/invoice-number))})
|
|
(-> i :journal-entry/original-entity :invoice/source-url)
|
|
(conj
|
|
{:link (-> i :journal-entry/original-entity :invoice/source-url)
|
|
:color :secondary
|
|
:content (str "File")})
|
|
|
|
(-> i :journal-entry/original-entity :transaction/description-original)
|
|
(conj
|
|
{:link (hu/url (bidi/path-for ssr-routes/only-routes
|
|
::transaction-routes/page)
|
|
{:exact-match-id (:db/id (:journal-entry/original-entity i))})
|
|
:color :primary
|
|
:content (format "Transaction '%s'" (-> i :journal-entry/original-entity :transaction/description-original))})
|
|
(-> i :journal-entry/memo)
|
|
(conj {:color :secondary
|
|
:content (str "Memo: " (:journal-entry/memo i))}))))
|
|
:render-for #{:html}}]}))
|
|
|
|
(def row* (partial helper/row* grid-page)) |