(ns auto-ap.ssr.dashboard (:require [auto-ap.datomic :refer [conn]] [auto-ap.graphql.ledger :refer [get-profit-and-loss-raw]] [auto-ap.graphql.utils :refer [<-graphql]] [auto-ap.ledger.reports :as r] [auto-ap.routes.dashboard :as d-routes] [auto-ap.routes.invoice :as i-routes] [auto-ap.routes.transactions :as transaction-routes] [auto-ap.routes.utils :refer [wrap-admin wrap-client-redirect-unauthenticated]] [auto-ap.ssr-routes :as ssr-routes] [auto-ap.ssr.components :as com] [auto-ap.ssr.hiccup-helper :as hh] [auto-ap.ssr.hx :as hx] [auto-ap.ssr.svg :as svg] [auto-ap.ssr.ui :refer [base-page]] [auto-ap.ssr.utils :refer [apply-middleware-to-all-handlers html-response]] [auto-ap.time :as atime] [bidi.bidi :as bidi] [cemerick.url :as url] [clj-time.coerce :as coerce] [clj-time.core :as time] [datomic.api :as dc] [hiccup.util :as hu])) (defn bank-accounts-card [request] (html-response (com/card {:class "h-full"} [:div.p-4.h-full [:h1.text-2xl.font-bold "Bank Accounts"] [:div (hx/htmx-transition-appear {:class "h-full overflow-scroll"}) (for [c (:valid-trimmed-client-ids request) b (:client/bank-accounts (dc/pull (dc/db conn) '[{:client/bank-accounts [:bank-account/current-balance {[:bank-account/type :xform iol-ion.query/ident] [:db/ident]} [:bank-account/current-balance-synced :xform clj-time.coerce/from-date] :bank-account/name {:bank-account/intuit-bank-account [:intuit-bank-account/current-balance [:intuit-bank-account/last-synced :xform clj-time.coerce/from-date]]} {:bank-account/yodlee-account [:yodlee-account/available-balance :yodlee-account/pending-balance [:yodlee-account/last-synced :xform clj-time.coerce/from-date]]} {:bank-account/plaid-account [:plaid-account/balance [:plaid-account/last-synced :xform clj-time.coerce/from-date]]}]}] c)) :when (not= :bank-account-type/cash (:bank-account/type b))] [:div.flex.flex-col.p-4.border-b-2.border-gray-200 [:div.font-bold.text-gray-700 (:client/name c)] [:div (:bank-account/name b)] [:div.grid.grid-cols-3.gap-x-2.items-baseline [:div "Ledger Balance"] [:div.text-right (format "$%,.2f" (or (:bank-account/current-balance b) 0.0))] [:div.text-xs.text-gray-400.text-right (some-> (:bank-account/current-balance-synced b) (atime/unparse-local atime/standard-time) (#(str "Synced " %)))] #_(when-let [n (cond (-> b :bank-account/intuit-bank-account) "Intuit" (-> b :bank-account/yodlee-account) "Yodlee" (-> b :bank-account/plaid-account) "Plaid" :else nil)] (list [:div (str n " Balance")] [:div.text-right (format "$%,.2f" (or (-> b :bank-account/intuit-bank-account :intuit-bank-account/current-balance) (-> b :bank-account/yodlee-account :yodlee-account/available-balance) (-> b :bank-account/plaid-account :plaid-account/balance) 0.0))] [:div.text-xs.text-gray-400.text-right (or (some-> (:bank-account/intuit-bank-account b) (:intuit-bank-account/last-synced) (atime/unparse-local atime/standard-time) (#(str "Synced " %))) (some-> (:bank-account/yodlee-account b) (:yodlee-account/last-synced) (atime/unparse-local atime/standard-time) (#(str "Synced " %))) (some-> (:bank-account/plaid-account b) (:plaid-account/last-synced) (atime/unparse-local atime/standard-time) (#(str "Synced " %))))] (when-let [pending-balance (-> b :bank-account/yodlee-account :yodlee-account/pending-balance)] (list [:div (str n " Pending Txs")] [:div.text-right (format "$%,.2f" pending-balance)])) [:div.inline-flex.justify-end.text-xs.text-gray-400.it])) #_[:div.inline-flex.justify-between.items-baseline]]])]]))) (defn sales-chart-card [request] (html-response (let [totals (->> (dc/q '[:find ?sd (sum ?total) :with ?e :in $ [?clients ?start-date ?end-date] :where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]] [?e :sales-order/date ?d] [(iol-ion.query/iso-date ?d) ?sd] [?e :sales-order/total ?total]] (dc/db conn) [(:valid-trimmed-client-ids request) (coerce/to-date (time/with-time-at-start-of-day (time/plus (time/now) (time/days -14)))) (coerce/to-date (time/with-time-at-start-of-day (time/plus (time/now) (time/days 1))))]) (sort-by first))] (com/card {:class "w-full h-full p-4"} [:h1.text-2xl.font-bold.text-slate-700 "Gross sales, last 14 days"] [:div.w-full.h-full [:canvas.w-full.h-full.p-8 {:x-data (hx/json {:chart nil :labels (map first totals) :data (map second totals)}) :x-init "new Chart($el, { type: 'bar', data: { labels: labels, datasets: [{ label: 'Gross sales', data: data, borderWidth: 1 }] }, options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } } });"}]])))) (defn expense-pie-card [request] (html-response (let [totals (->> (dc/q '[:find ?an (sum ?amt) :with ?iea :in $ [?clients ?start-date ?end-date] :where [(iol-ion.query/scan-invoices $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]] [?e :invoice/total ?total] [?e :invoice/expense-accounts ?iea] [?iea :invoice-expense-account/account ?ea] [?iea :invoice-expense-account/amount ?amt] [?ea :account/name ?an]] (dc/db conn) [(:valid-trimmed-client-ids request) (coerce/to-date (time/with-time-at-start-of-day (time/plus (time/now) (time/months -1)))) (coerce/to-date (time/plus (time/with-time-at-start-of-day (time/now)) (time/days 1)))]) (sort-by last) (reverse) (take 5))] (com/card {:class "w-full h-full p-4"} [:h1.text-2xl.font-bold.text-slate-700 "Expenses, last month"] [:div.w-full.h-full [:canvas.w-full.h-full.p-8 {:x-data (hx/json {:chart nil :labels (map first totals) :data (map second totals)}) :x-init " new Chart($el, { type: 'pie', data: { labels: labels, datasets: [{ label: 'Total invoices', data: data, borderWidth: 1 }] }, options: { responsive: true, maintainAspectRatio: false, } });"}]])))) (defn pnl-card [request] (html-response (com/card {:class "w-full h-full p-4"} [:h1.text-2xl.font-bold.text-gray-700 "Profit and Loss, last month"] (let [data (<-graphql (get-profit-and-loss-raw (:valid-trimmed-client-ids request) [{:start (time/plus (time/now) (time/months -1)) :end (time/now)}])) data (r/->PNLData {} (:accounts (first (:periods data))) {}) sales (r/aggregate-accounts (r/filter-categories data [:sales])) expenses (r/aggregate-accounts (r/filter-categories data [:cogs :payroll :controllable :fixed-overhead :ownership-controllable]))] (list #_(when (not= (count all-clients) (count clients))) [:canvas.w-full.h-full.p-8 {:x-data (hx/json {:chart nil :labels [(format "Income $%,.2f" sales) (format "Expenses $%,.2f" expenses)] :data [sales expenses]}) :x-init "new Chart($el, { type: 'bar', data: { labels: labels, datasets: [{ label: 'Dollars', data: data, borderWidth: 1 }] }, options: { responsive: true, indexAxis: 'y', maintainAspectRatio: false, scales: { x: { beginAtZero: true } } } });"}] [:div "Income: " (format "$%,.2f" sales)] [:div "Expenses: " (format "$%,.2f" expenses)]))))) (defn tasks-card [request] (html-response (com/card {:class "w-full h-full p-4"} [:h1.text-2xl.font-bold.text-gray-700 "Tasks"] [:div (hx/htmx-transition-appear {:class "space-y-2"}) (let [[unpaid-invoice-count unpaid-invoice-amount] (first (dc/q '[:find (count ?e) (sum ?ab) :in $ [?clients ?start-date ?end-date] :where [(iol-ion.query/scan-invoices $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]] [?e :invoice/status :invoice-status/unpaid] [?e :invoice/outstanding-balance ?ab]] (dc/db conn) [(:valid-trimmed-client-ids request) (coerce/to-date (time/with-time-at-start-of-day (time/plus (time/now) (time/years -1)))) nil])) [uncategorized-transaction-count uncategorized-transaction-amount] (first (dc/q '[:find (count ?e) (sum ?am) :in $ [?clients ?start-date ?end-date] :where [(iol-ion.query/scan-transactions $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]] [?e :transaction/approval-status :transaction-approval-status/requires-feedback] [?e :transaction/amount ?am]] (dc/db conn) [(:valid-trimmed-client-ids request) (coerce/to-date (time/with-time-at-start-of-day (time/plus (time/now) (time/years -1)))) nil]))] (list (when (not= 0 (or unpaid-invoice-count 0)) [:div.bg-gray-50.rounded.p-4 [:span "You have " (str unpaid-invoice-count) " unpaid invoices with an outstanding balance of " (format "$%,.2f" unpaid-invoice-amount) ". "] (com/link {:href (hu/url (bidi.bidi/path-for ssr-routes/only-routes ::i-routes/unpaid-page) {:date-range "year"})} "Pay now")]) (when (not= 0 (or uncategorized-transaction-count 0)) [:div.bg-gray-50.rounded.p-4 [:span "You have " (str uncategorized-transaction-count) " transactions needing your feedback. "] (com/link {:href (str (bidi.bidi/path-for ssr-routes/only-routes ::transaction-routes/requires-feedback-page) "?date-range=" (url/url-encode (pr-str {:start (atime/unparse-local (time/plus (time/now) (time/years -1)) atime/iso-date) :end (atime/unparse-local (time/now) atime/iso-date)})))} "Review now")])))]))) (defn stub-card [params & children] (com/card (-> params (dissoc :title) (update :class #(hh/add-class (or % "") "w-full h-full p-4 space-y-2")) (assoc :hx-swap "outerHTML")) [:h1.text-2xl.font-bold.text-gray-700 (:title params)] [:div.w-full.h-full.flex.justify-center.items-center [:div.htmx-indicator (svg/spinner {:class "inline w-32 h-32 text-green-500"})]])) (defn- page-contents [request] [:div.mb-8 [:div {:class "grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-4 mb-8"} [:div.h-96 (stub-card {:title "Expenses" :hx-get (bidi.bidi/path-for ssr-routes/only-routes ::d-routes/expense-card) :hx-trigger "load"})] [:div.h-96 (stub-card {:title "Tasks" :hx-get (bidi.bidi/path-for ssr-routes/only-routes ::d-routes/tasks-card) :hx-trigger "load"})] [:div {:class " row-span-2 h-[49rem]"} (stub-card {:title "Bank Accounts" :hx-get (bidi.bidi/path-for ssr-routes/only-routes ::d-routes/bank-accounts-card) :hx-trigger "load"})] [:div.h-96 (stub-card {:title "Gross Sales, last 14 days" :hx-get (bidi.bidi/path-for ssr-routes/only-routes ::d-routes/sales-card) :hx-trigger "load"})] [:div.h-96 (stub-card {:title "Profit and Loss, last month" :hx-get (bidi.bidi/path-for ssr-routes/only-routes ::d-routes/pnl-card) :hx-trigger "load"})] [:div.col-span-2.h-96 (stub-card {:title "Expense breakdown" :hx-get (bidi.bidi/path-for ssr-routes/only-routes :company-expense-report-breakdown-card) :hx-trigger "load"})] [:div]]]) (defn page [request] (base-page request (com/page {:nav com/main-aside-nav :client-selection (:client-selection request) :clients (:clients request) :client (:client request) :identity (:identity request) :app-params {:hx-get (bidi/path-for ssr-routes/only-routes ::d-routes/page) :hx-trigger "clientSelected from:body" :hx-select "#app-contents" :hx-swap "outerHTML swap:300ms"} :request request} (com/breadcrumbs {} [:a {:href (bidi/path-for ssr-routes/only-routes ::d-routes/page)} "Dashboard"]) (when (:clients-trimmed? request) [:div.bg-yellow-100.rounded-lg.p-4.my-2.text-yellow-900.border-1 "Warning: These reports are only for twenty of the selected customers. Please select a specific customer to see more detail."]) (page-contents request)) "Dashboard")) (def key->handler (apply-middleware-to-all-handlers {::d-routes/page page ::d-routes/expense-card expense-pie-card ::d-routes/pnl-card pnl-card ::d-routes/sales-card sales-chart-card ::d-routes/bank-accounts-card bank-accounts-card ::d-routes/tasks-card tasks-card} (fn [h] (wrap-client-redirect-unauthenticated (wrap-admin h)))))