From 502a05bdcda7cb168897151d0c137f07a9053997 Mon Sep 17 00:00:00 2001 From: Bryce Date: Sun, 13 Oct 2024 20:55:10 -0700 Subject: [PATCH] preliminary investigate --- src/clj/auto_ap/ssr/ledger.clj | 524 +----------------- src/clj/auto_ap/ssr/ledger/balance_sheet.clj | 19 +- src/clj/auto_ap/ssr/ledger/common.clj | 536 +++++++++++++++++++ src/clj/auto_ap/ssr/ledger/investigate.clj | 37 ++ src/cljc/auto_ap/routes/ledger.cljc | 1 + 5 files changed, 601 insertions(+), 516 deletions(-) create mode 100644 src/clj/auto_ap/ssr/ledger/common.clj create mode 100644 src/clj/auto_ap/ssr/ledger/investigate.clj diff --git a/src/clj/auto_ap/ssr/ledger.clj b/src/clj/auto_ap/ssr/ledger.clj index 2fa93b04..16de6b96 100644 --- a/src/clj/auto_ap/ssr/ledger.clj +++ b/src/clj/auto_ap/ssr/ledger.clj @@ -1,50 +1,46 @@ (ns auto-ap.ssr.ledger - (:require [auto-ap.client-routes :as client-routes] - [auto-ap.datomic - :refer [add-sorter-fields apply-pagination apply-sort-4 - audit-transact audit-transact-batch conn merge-query - observable-query pull-many remove-nils]] - [auto-ap.datomic.accounts :as d-accounts] + (:require [auto-ap.datomic + :refer [audit-transact audit-transact-batch conn pull-many + remove-nils]] [auto-ap.datomic.accounts :as a] [auto-ap.graphql.utils :refer [assert-admin assert-can-see-client exception->notification - extract-client-ids notify-if-locked]] + notify-if-locked]] [auto-ap.logging :as alog] - [auto-ap.permissions :refer [can? wrap-must]] + [auto-ap.permissions :refer [wrap-must]] [auto-ap.query-params :refer [wrap-copy-qp-pqp]] - [auto-ap.routes.invoice :as invoice-route] [auto-ap.routes.ledger :as route] [auto-ap.routes.utils :refer [wrap-client-redirect-unauthenticated]] [auto-ap.solr :as solr] [auto-ap.ssr-routes :as ssr-routes] [auto-ap.ssr.components :as com] - [auto-ap.ssr.components.link-dropdown :refer [link-dropdown]] [auto-ap.ssr.form-cursor :as fc] [auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]] [auto-ap.ssr.hx :as hx] [auto-ap.ssr.ledger.balance-sheet :as balance-sheet] + [auto-ap.ssr.ledger.common :refer [bank-account-filter + default-read fetch-ids + grid-page query-schema]] + [auto-ap.ssr.ledger.investigate :as investigate] [auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]] - [auto-ap.ssr.pos.common :refer [date-range-field*]] [auto-ap.ssr.svg :as svg] [auto-ap.ssr.ui :refer [base-page]] [auto-ap.ssr.utils :refer [apply-middleware-to-all-handlers clj-date-schema - entity-id html-response main-transformer money - ref->enum-schema strip wrap-form-4xx-2 - wrap-implied-route-param wrap-merge-prior-hx - wrap-schema-decode wrap-schema-enforce]] + html-response main-transformer money strip + wrap-form-4xx-2 wrap-implied-route-param + wrap-merge-prior-hx wrap-schema-decode + wrap-schema-enforce]] [auto-ap.time :as atime] - [auto-ap.utils :refer [dollars-0? dollars=]] + [auto-ap.utils :refer [dollars=]] [bidi.bidi :as bidi] [clj-time.coerce :as coerce] [clj-time.core :as t] [clojure.data.csv :as csv] [clojure.java.io :as io] - [clojure.string :as str] [com.brunobonacci.mulog :as mu] [datomic.api :as dc] - [hiccup.util :as hu] [hiccup2.core :as hiccup] [iol-ion.utils :refer [by random-tempid]] [malli.core :as mc] @@ -52,337 +48,6 @@ - -(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"} - (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"} - (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" "change delay:500ms, 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})) - (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))))})) - - (bank-account-filter* request) - - (date-range-field* request) - (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})) - -(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})]) - (exact-match-id* request)]]) - - -(defn fetch-ids [db {:keys [query-params route-params] :as request}] - (let [valid-clients (extract-client-ids (:clients request) - (:client-id request) - (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) - (: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 (:account args)) - (merge-query {:query {:in ['?a3] - :where ['[?li :journal-entry-line/account ?a3] ]} - :args [(: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" ['[?e :journal-entry/vendor ?v] - '[?v :vendor/name ?sort-vendor]] - "amount" ['[?e :journal-entry/amount ?sort-amount]] - "external-id" ['[?e :journal-entry/external-id ?sort-external-id]] - "source" ['[?e :journal-entry/source ?sort-source]]} - args) - - true - (merge-query {:query {:find ['?sort-default '?e]}})))] - - (->> (observable-query query) - (apply-sort-4 (assoc query-params :default-asc? true)) - (apply-pagination query-params)))) - -(def default-read - '[:journal-entry/amount - :journal-entry/alternate-description - :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)])) - -(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]] - [: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}]]] - [: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]]]])) - (comment (mc/decode query-schema {:start " "} @@ -403,168 +68,10 @@ selected)] 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-data "popper()" } - [: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] - (com/tooltip {:x-bind "tooltip" :class "absolute"} - "Running Balance: " (some->> (:journal-entry-line/running-balance jel) - (format "$%,.2f")))] - [: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")))])])) ;; TODO test as a real user -(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 (date-range-field* request) [1 :hx-swap-oob] true) - (assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)]) - :query-schema query-schema - :parse-query-params (fn [p] - (mc/decode query-schema p main-transformer)) - :action-buttons (fn [request] - (let [[_ _ outstanding total] (:page-results request)] - [ #_(when (can? (:identity request) {:subject :invoice :activity :bulk-delete}) - (com/button {:hx-get (str (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)) - "x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})" - "hx-include" "#ledger-filters" - :color :red} - "Void 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 " ")) - "Register")) - :entity-name "register" - :route ::route/table - :break-table (fn [request entity] - (cond - (= (-> request :query-params :sort first :name) "Vendor") - (-> entity :journal-entry/vendor :vendor/name) - - (= (-> request :query-params :sort first :name) "Source") - (-> entity :journal-entry/source) - - :else nil)) - :headers [{: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) ])} - - {: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)]))} - {:key "source" - :name "Source" - :sort-key "source" - :hide? (fn [args] - (not (:external? (:route-params args)))) - :render :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)])} - {: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 "debit" - :name "Debit" - :sort-key "debit" - :class "text-right" - :render (partial render-lines :journal-entry-line/debit)} - {:key "credit" - :name "Credit" - :sort-key "credit" - :class "text-right" - :render (partial render-lines :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) - {: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 client-routes/routes - :transactions) - {:exact-match-id (:db/id (:journal-entry/original-entity i))}) - :color :primary - :content (format "Transaction '%s'" (-> i :journal-entry/original-entity :transaction/description-original))}))))}]})) (def row* (partial helper/row* grid-page)) @@ -1233,4 +740,5 @@ (wrap-schema-enforce :hx-schema query-schema) (wrap-must {:activity :import :subject :ledger}) (wrap-client-redirect-unauthenticated)))) - balance-sheet/key->handler)) \ No newline at end of file + balance-sheet/key->handler + investigate/key->handler)) \ No newline at end of file diff --git a/src/clj/auto_ap/ssr/ledger/balance_sheet.clj b/src/clj/auto_ap/ssr/ledger/balance_sheet.clj index 66fb3702..99155ca3 100644 --- a/src/clj/auto_ap/ssr/ledger/balance_sheet.clj +++ b/src/clj/auto_ap/ssr/ledger/balance_sheet.clj @@ -30,6 +30,7 @@ [clojure.string :as str] [config.core :refer [env] :as env] [datomic.api :as dc] + [hiccup.util :as hu] [iol-ion.utils :refer [by]] [malli.core :as mc]) (:import [java.util UUID] @@ -87,7 +88,7 @@ -(defn cell [{:keys [width click-event other-style]} c] +(defn cell [{:keys [width investigate-url other-style]} c] (let [cell-contents (cond (= :dollar (:format c)) @@ -104,8 +105,9 @@ :else (str (:value c))) cell-contents (if (:filters c) - [:a #_{:on-click (dispatch-event [click-event (:filters c)])} - cell-contents] + (com/link {:hx-get (hu/url investigate-url + (update (:filters c) :numeric-code (fn [nc] (into [] nc))))} + cell-contents) cell-contents)] [:td.px-4.py-2 (cond-> {:style (cond-> {:width (str width "em")} @@ -141,7 +143,7 @@ (apply max counts) 0))) -(defn table [{:keys [table widths click-event]}] +(defn table [{:keys [table widths investigate-url]}] (let [cell-count (cell-count table)] (com/content-card {:class "inline-block overflow-hidden"} [:div { :class "overflow-y-auto h-[70vh] m-4 inline-block"} @@ -153,7 +155,8 @@ [:tr {:class " dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700"}] (map (fn [w header i] - (cell {:width w :click-event click-event + (cell {:width w + :investigate-url investigate-url :other-style {:position "sticky" :top (* header-row (+ 22 18))}} header)) widths @@ -177,10 +180,10 @@ [[] 0] (concat row (repeat nil)))))] - (cell {:click-event click-event} c))])) + (cell {:investigate-url investigate-url} c))])) (conj [:tr (for [i (range cell-count)] - ( cell {:click-event click-event} {:value " "}))]))) + ( cell {:investigate-url investigate-url} {:value " "}))]))) )]))) (defn concat-tables [tables] @@ -280,7 +283,7 @@ [:div.text-2xl.font-bold.text-gray-600 (str "Balance Sheet - " (str/join ", " (map :client/name client))) ] (table {:widths (cond-> (into [30 ] (repeat 13 client-count)) (:include-comparison (:args data)) (into (repeat 13 (* 2 client-count)))) - :click-event ::investigate-clicked + :investigate-url (bidi.bidi/path-for ssr-routes/only-routes ::route/investigate) :table report} ))))]) (defn form* [request] diff --git a/src/clj/auto_ap/ssr/ledger/common.clj b/src/clj/auto_ap/ssr/ledger/common.clj new file mode 100644 index 00000000..ec3f2379 --- /dev/null +++ b/src/clj/auto_ap/ssr/ledger/common.clj @@ -0,0 +1,536 @@ +(ns auto-ap.ssr.ledger.common + (:require [auto-ap.client-routes :as client-routes] + [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.ssr-routes :as ssr-routes] + [auto-ap.ssr.components :as com] + [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.pos.common :refer [date-range-field*]] + [auto-ap.ssr.svg :as svg] + [auto-ap.ssr.utils + :refer [clj-date-schema entity-id html-response main-transformer + 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] + [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"} + (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"} + (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" "change delay:500ms, 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})) + (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))))})) + + (bank-account-filter* request) + + (date-range-field* request) + (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})) + +(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})]) + (exact-match-id* request)]]) + + +(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 + #_#__ (clojure.pprint/pprint 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 {:query {:in ['?a3] + :where ['[?li :journal-entry-line/account ?a3]]} + :args [(: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" ['[?e :journal-entry/vendor ?v] + '[?v :vendor/name ?sort-vendor]] + "amount" ['[?e :journal-entry/amount ?sort-amount]] + "external-id" ['[?e :journal-entry/external-id ?sort-external-id]] + "source" ['[?e :journal-entry/source ?sort-source]]} + args) + + true + (merge-query {:query {:find ['?sort-default '?e]}})))] + + (->> (observable-query query) + (apply-sort-4 (assoc query-params :default-asc? true)) + (apply-pagination query-params)))) + +(def default-read + '[:journal-entry/amount + :journal-entry/alternate-description + :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-data "popper()"} + [: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] + (com/tooltip {:x-bind "tooltip" :class "absolute"} + "Running Balance: " (some->> (:journal-entry-line/running-balance jel) + (format "$%,.2f")))] + [: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]] + [: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}]]] + [: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 (date-range-field* request) [1 :hx-swap-oob] true) + (assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)]) + :query-schema query-schema + :parse-query-params (fn [p] + (mc/decode query-schema p main-transformer)) + :action-buttons (fn [request] + (let [[_ _ outstanding total] (:page-results request)] + [ #_(when (can? (:identity request) {:subject :invoice :activity :bulk-delete}) + (com/button {:hx-get (str (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)) + "x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})" + "hx-include" "#ledger-filters" + :color :red} + "Void 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 " ")) + "Register")) + :entity-name "register" + :route ::route/table + :break-table (fn [request entity] + (cond + (= (-> request :query-params :sort first :name) "Vendor") + (-> entity :journal-entry/vendor :vendor/name) + + (= (-> request :query-params :sort first :name) "Source") + (-> entity :journal-entry/source) + + :else nil)) + :headers [{: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) ])} + + {: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)]))} + {:key "source" + :name "Source" + :sort-key "source" + :hide? (fn [args] + (not (:external? (:route-params args)))) + :render :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)])} + {: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 "debit" + :name "Debit" + :sort-key "debit" + :class "text-right" + :render (partial render-lines :journal-entry-line/debit)} + {:key "credit" + :name "Credit" + :sort-key "credit" + :class "text-right" + :render (partial render-lines :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) + {: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 client-routes/routes + :transactions) + {:exact-match-id (:db/id (:journal-entry/original-entity i))}) + :color :primary + :content (format "Transaction '%s'" (-> i :journal-entry/original-entity :transaction/description-original))}))))}]})) \ No newline at end of file diff --git a/src/clj/auto_ap/ssr/ledger/investigate.clj b/src/clj/auto_ap/ssr/ledger/investigate.clj new file mode 100644 index 00000000..8eeb1148 --- /dev/null +++ b/src/clj/auto_ap/ssr/ledger/investigate.clj @@ -0,0 +1,37 @@ +(ns auto-ap.ssr.ledger.investigate + (:require [auto-ap.permissions :refer [wrap-must]] + [auto-ap.query-params :refer [wrap-copy-qp-pqp]] + [auto-ap.routes.ledger :as route] + [auto-ap.routes.utils :refer [wrap-client-redirect-unauthenticated]] + [auto-ap.ssr.components :as com] + [auto-ap.ssr.grid-page-helper :refer [table*]] + [auto-ap.ssr.ledger.common :refer [grid-page query-schema]] + [auto-ap.ssr.utils :refer [apply-middleware-to-all-handlers + modal-response wrap-merge-prior-hx + wrap-schema-enforce]])) + +(defn investigate [request] + (modal-response + (com/modal {:class "h-[600px]"} + (com/modal-card {} + [:div "Ledger entries"] + [:div + (table* + grid-page + identity + request)] + nil) + ))) + +(def key->handler + (apply-middleware-to-all-handlers + (-> + {::route/investigate investigate } + ) + (fn [h] + (-> h + (wrap-merge-prior-hx) + (wrap-schema-enforce :query-schema query-schema) + (wrap-schema-enforce :hx-schema query-schema) + (wrap-must {:activity :read :subject :ledger}) + (wrap-client-redirect-unauthenticated))))) \ No newline at end of file diff --git a/src/cljc/auto_ap/routes/ledger.cljc b/src/cljc/auto_ap/routes/ledger.cljc index 7da3adbd..b8e536b1 100644 --- a/src/cljc/auto_ap/routes/ledger.cljc +++ b/src/cljc/auto_ap/routes/ledger.cljc @@ -5,6 +5,7 @@ "/external-import-new" {"" ::external-import-page "/parse" ::external-import-parse "/import" ::external-import-import} + "/investigate" ::investigate "/table" ::table "/bank-account-filter" ::bank-account-filter "/reports/balance-sheet" {"" ::balance-sheet