340 lines
19 KiB
Clojure
340 lines
19 KiB
Clojure
(ns auto-ap.ssr.dashboard
|
|
(:require [auto-ap.client-routes :as client-routes]
|
|
[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.utils :refer [wrap-admin
|
|
wrap-client-redirect-unauthenticated]]
|
|
[auto-ap.ssr-routes :as ssr-routes]
|
|
[auto-ap.ssr.company.reports.expense :refer [expense-breakdown-card]]
|
|
[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/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 " %))))]
|
|
[: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 client-routes/routes :requires-feedback-transactions)
|
|
"?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))))) |