(ns auto-ap.ssr.transaction (: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.graphql.utils :refer [extract-client-ids]] [auto-ap.permissions :refer [wrap-must]] [auto-ap.query-params :refer [wrap-copy-qp-pqp]] [auto-ap.routes.transactions :as route] [auto-ap.routes.utils :refer [wrap-client-redirect-unauthenticated]] [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.transaction.edit :as edit] [auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]] [auto-ap.ssr.hx :as hx] [auto-ap.ssr.ledger :refer [wrap-ensure-bank-account-belongs]] [auto-ap.ssr.pos.common :refer [date-range-field*]] [auto-ap.ssr.svg :as svg] [auto-ap.ssr.utils :refer [apply-middleware-to-all-handlers clj-date-schema entity-id html-response strip wrap-merge-prior-hx wrap-schema-enforce]] [auto-ap.time :as atime] [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#transaction-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})) (bank-account-filter* request) (date-range-field* request) (com/field {:label "Description"} (com/text-input {:name "description" :id "description" :class "hot-filter" :value (:description (:query-params request)) :placeholder "e.g., Groceries" :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 query (if (:exact-match-id args) {:query {:find '[?e] :in '[$ ?e [?c ...]] :where '[[?e :transaction/client ?c]]} :args [db (:exact-match-id args) valid-clients]} (cond-> {:query {:find [] :in ['$ '[?clients ?start ?end]] :where '[[(iol-ion.query/scan-transactions $ ?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)]]} (seq (:description args)) (merge-query {:query {:in ['?description] :where ['[?e :transaction/description-original ?do] '[(clojure.string/lower-case ?do) ?do2] '[(.contains ?do2 ?description)]]} :args [(str/lower-case (:description args))]}) (:amount-gte args) (merge-query {:query {:in ['?amount-gte] :where ['[?e :transaction/amount ?a] '[(>= ?a ?amount-gte)]]} :args [(:amount-gte args)]}) (:amount-lte args) (merge-query {:query {:in ['?amount-lte] :where ['[?e :transaction/amount ?a] '[(<= ?a ?amount-lte)]]} :args [(:amount-lte args)]}) (:db/id (:bank-account args)) (merge-query {:query {:in ['?ba] :where ['[?e :transaction/bank-account ?ba]]} :args [(:db/id (:bank-account args))]}) (:vendor args) (merge-query {:query {:in ['?vendor-id] :where ['[?e :transaction/vendor ?vendor-id]]} :args [(:db/id (:vendor args))]}) (:sort args) (add-sorter-fields {"client" ['[?e :transaction/client ?c] '[?c :client/name ?sort-client]] "vendor" '[(or-join [?e ?sort-vendor] (and [?e :transaction/vendor ?v] [?v :vendor/name ?sort-vendor]) (and [(missing? $ ?e :transaction/vendor)] [(ground "") ?sort-vendor]))] "date" ['[?e :transaction/date ?sort-date]] "amount" ['[?e :transaction/amount ?sort-amount]] "description" ['[?e :transaction/description-original ?sort-description]]} 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 '[:transaction/amount :transaction/description-original :transaction/description-simple [ :transaction/date :xform clj-time.coerce/from-date] [ :transaction/post-date :xform clj-time.coerce/from-date] :transaction/type :transaction/status :db/id {:transaction/vendor [:vendor/name :db/id] :transaction/client [:client/name :client/code :db/id] :transaction/bank-account [:bank-account/numeric-code :bank-account/name] :transaction/accounts [{:transaction-account/account [:account/name :db/id]} :transaction-account/location :transaction-account/amount] :transaction/matched-rule [:matched-rule/name]}]) (defn hydrate-results [ids db _] (let [results (->> (pull-many db default-read ids) (group-by :db/id)) results (->> ids (map results) (map first))] results)) (defn sum-amount [ids] (->> (dc/q {:find ['?id '?a] :in ['$ '[?id ...]] :where ['[?id :transaction/amount ?a]]} (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-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]] [:client-id {:optional true} [:maybe entity-id]] [:description {:optional true} [:maybe [:string {:decode/string strip}]]] [: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/numeric-code]}]]] #_[:status {:optional true} [:maybe (ref->enum-schema "transaction-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 :page-specific-nav filters :fetch-page fetch-page :query-schema query-schema :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)]) :action-buttons (fn [request] [(com/button {:color :primary :hx-get (bidi/path-for ssr-routes/only-routes ::route/new)} "Add Transaction")]) :row-buttons (fn [request entity] [(com/icon-button {:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-wizard :db/id (:db/id entity))} svg/pencil)]) :breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)} "Transactions"]] :title (fn [r] "Transaction") :entity-name "register" :route ::route/table :csv-route ::route/csv :table-attributes (fn [_] {:hx-trigger "refreshTable from:body" :hx-get (bidi/path-for ssr-routes/only-routes ::route/table) :hx-target "#entity-table"}) :break-table (fn [request entity] (cond (= (-> request :query-params :sort first :name) "Vendor") (or (-> entity :transaction/vendor :vendor/name) "No vendor") :else nil)) :page->csv-entities (fn [[transactions]] transactions) :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 :transaction/client :client/name)]) :render-csv (fn [x] (-> x :transaction/client :client/name))} {:key "vendor" :name "Vendor" :sort-key "vendor" :render (fn [e] #_(alog/peek :vend e) (or (-> e :transaction/vendor :vendor/name) [:span.italic.text-gray-400 (-> e :transaction/description-simple)])) :render-csv (fn [e] (or (-> e :transaction/vendor :vendor/name) (-> e :transaction/description-simple)))} {:key "description" :name "Description" :sort-key "description" :render :transaction/description-original :render-csv :transaction/description-original} {:key "date" :sort-key "date" :name "Date" :show-starting "lg" :render (fn [{:transaction/keys [date]}] (some-> date (atime/unparse-local atime/normal-date)))} {:key "amount" :name "Amount" :sort-key "amount" :class "text-right" :render #(format "$%,.2f" (:transaction/amount %)) :render-csv :transaction/amount} {:key "links" :name "Links" :show-starting "lg" :class "w-8" :render (fn [i] (link-dropdown (cond-> [] (:transaction/payment i) (conj {:link (hu/url (bidi/path-for ssr-routes/only-routes ::route/payment-page) {:exact-match-id (:db/id (:transaction/payment i))}) :color :primary :content (format "Payment '%s'" (-> i :transaction/payment :payment/invoice-number))}) (and (:transaction/client-overrides i) (seq (:transaction/client-overrides i))) {:link (hu/url (bidi/path-for client-routes/routes :transactions) {:exact-match-id (:db/id i)}) :color :primary :content "Client Overrides"}))) :render-for #{:html}}]})) (def row* (partial helper/row* grid-page)) ;; Handlers (def page (helper/page-route grid-page)) (def table (helper/table-route grid-page)) (def csv (helper/csv-route grid-page)) (def key->handler (merge edit/key->handler (apply-middleware-to-all-handlers {::route/page page ::route/table table ::route/csv csv ::route/bank-account-filter bank-account-filter} (fn [h] (-> h (wrap-copy-qp-pqp) (wrap-apply-sort grid-page) (wrap-ensure-bank-account-belongs) (wrap-merge-prior-hx) (wrap-schema-enforce :query-schema query-schema) (wrap-schema-enforce :hx-schema query-schema) (wrap-must {:activity :view :subject :transaction}) (wrap-client-redirect-unauthenticated))))))