diff --git a/src/clj/auto_ap/ssr/ledger/cash_flows.clj b/src/clj/auto_ap/ssr/ledger/cash_flows.clj new file mode 100644 index 00000000..893ee468 --- /dev/null +++ b/src/clj/auto_ap/ssr/ledger/cash_flows.clj @@ -0,0 +1,324 @@ +(ns auto-ap.ssr.ledger.cash-flows + (:require + [amazonica.aws.s3 :as s3] + [auto-ap.datomic + :refer [conn pull-many]] + [auto-ap.graphql.utils :refer [assert-can-see-client]] + [auto-ap.ledger :refer [build-account-lookup]] + [auto-ap.ledger.reports :as l-reports] + [auto-ap.logging :as alog] + [auto-ap.pdf.ledger :refer [table->pdf]] + [auto-ap.permissions :refer [wrap-must]] + [auto-ap.routes.ledger :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.form-cursor :as fc] + [auto-ap.ssr.hx :as hx] + [auto-ap.ssr.ledger.report-table :refer [cell-count concat-tables table]] + [auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]] + [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 html-response + modal-response wrap-form-4xx-2 wrap-schema-enforce]] + [auto-ap.time :as atime] + [bidi.bidi :as bidi] + [clj-pdf.core :as pdf] + [clj-time.coerce :as coerce] + [clj-time.core :as time] + [clojure.java.io :as io] + [clojure.string :as str] + [config.core :refer [env] :as env] + [datomic.api :as dc] + [iol-ion.utils :refer [by]] + [malli.core :as mc]) + (:import + [java.util UUID] + [org.apache.commons.io.output ByteArrayOutputStream])) + + +(def query-schema (mc/schema + [:maybe [:map + [:client {:unspecified/value :all} + [:or + [:enum :all] + [:vector {:coerce? true :min 1} + [:entity-map {:pull [:db/id :client/name]}]]]] + + [:periods {:unspecified/fn (fn [] (let [now (atime/local-now)] + [{:start (atime/as-local-time (time/date-time (time/year now) + 1 + 1)) + :end (atime/local-now)}]) + + )} + [:vector {:coerce? true} + [:map + [:start clj-date-schema] + [:end clj-date-schema]]]]]])) +;; TODO +;; 1. Rerender form when running +;; 2. Don't throw crazy errors when missing a field +;; 3. General cleanup of the patterns in run-balance-sheet +;; 4. Review ledger dialog + +(defn get-report [{ {:keys [periods client] :as qp} :form-params :as request}] + (when (and (seq periods) client) + (let [client (if (= :all client) (take 5 (:clients request)) client) + client-ids (map :db/id client) + _ (doseq [client-id client-ids] + (assert-can-see-client (:identity request) client-id)) + + lookup-account (->> client-ids + (map (fn build-lookup [client-id] + [client-id (build-account-lookup client-id)])) + (into {})) + data (into [] + (for [client-id client-ids + p periods + [client-id account-id location debits credits balance count] (iol-ion.query/detailed-account-snapshot (dc/db conn) client-id (coerce/to-date (:end p))) + :let [account ((or (lookup-account client-id) {}) account-id)]] + {:client-id client-id + :account-id account-id + :location location + :debits debits + :credits credits + :count count + :amount balance + :account-type (:account_type account) + :numeric-code (:numeric_code account) + :name (:name account) + :period {:start ( coerce/to-date (:start p)) :end ( coerce/to-date (:end p))}})) + args (assoc (:form-params request) + :periods (map (fn [d] {:start ( coerce/to-date (:start d)) :end ( coerce/to-date (:end d))}) periods)) + clients (pull-many (dc/db conn) [:client/code :client/name :db/id] client-ids) + + pnl-data (l-reports/->PNLData args data (by :db/id :client/code clients)) + report (l-reports/summarize-cash-flows pnl-data)] + (alog/info ::cash-flows :params args) + {:data report + :report report}))) + +(defn maybe-trim-clients [request client] + (if (= :all client) + (cond-> {:client (take 20 (:clients request))} + (> (count (:clients request)) 20) + (assoc :warning "You requested a report with more than 20 clients. This report will only contain the first 20.")) + {:client client})) + +(defn cash-flows* [{ {:keys [periods client] } :form-params :as request}] + [:div#report + (when (and periods client) + (let [{:keys [client warning]} (maybe-trim-clients request client) + {:keys [data report]} (get-report (assoc-in request [:form-params :client] client)) + client-count (count (set (map :client-id (:data data)))) + table-contents (concat-tables (:details report))] + (list + [:div.text-2xl.font-bold.text-gray-600 (str "Cash flows - " (str/join ", " (map :client/name client)))] + (table {:widths (into [20] (take (dec (cell-count table-contents)) + (mapcat identity + (repeat + [13 6])))) + :investigate-url (bidi.bidi/path-for ssr-routes/only-routes ::route/investigate) + :table table-contents + :warning (not-empty (str/join "\n " (filter not-empty [warning (:warning report)])))}))))]) + +(defn form* [request & children] + (let [params (or (:query-params request) {})] + (fc/start-form + params + (:form-errors request) + [:div#cash-flows-form.flex.flex-col.gap-4.mt-4 + [:div.flex.gap-8 + [:form {:hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/run-cash-flows) + :hx-target "#cash-flows-form" + :hx-swap "outerHTML" + :hx-disabled-elt "find fieldset"} + [:fieldset + [:div.flex.gap-8 {:x-data (hx/json {})} + (fc/with-field :client + (com/validated-inline-field + {:label "Customers" :errors (fc/field-errors)} + (com/multi-typeahead {:name (fc/field-name) + :placeholder "Search for companies..." + :class "w-64" + :id "client" + :url (bidi/path-for ssr-routes/only-routes :company-search) + :value (fc/field-value) + :value-fn :db/id + :content-fn :client/name}))) + (fc/with-field :periods + (com/validated-inline-field {:label "Periods" + :errors (fc/field-errors)} + [:div {:x-data (hx/json {:periods (map (fn [p] + {:start (atime/unparse-local (:start p) atime/normal-date) + :end (atime/unparse-local (:end p) atime/normal-date)}) + (fc/field-value)) + :source_date (-> (fc/field-value) + first + :end + (atime/unparse-local atime/normal-date))}) + + :x-init "$watch('periods', ds => source_date= ds.length > 0 ? ds[0].end : null)"} + [:template {:x-for "(v,n) in periods"} + [:div + (fc/with-field 0 + (fc/with-field :start + [:input {:type "hidden" + ":name" "'periods[' + n + '][start]'" + :x-model "v.start"}])) + (fc/with-field 0 + (fc/with-field :end + [:input {:type "hidden" + ":name" "'periods[' + n + '][end]'" + :x-model "v.end"}]))]] + (com/a-button {"x-tooltip.on.click.theme.dropdown.placement.bottom.interactive" "{content: ()=> $refs.tooltip.innerHTML, allowHTML: true, appendTo: $root}" + :indicator? false} + + [:template {:x-if "periods.length == 0"} + [:span.text-left.text-gray-400 "None selected"]] + [:template { :x-if "periods.length < 3 && periods.length > 0"} + [:span.inline-flex.gap-2 + [:template {:x-for "p in periods"} + (com/pill {:color :secondary} + [:span {:x-text "p.start"}] + " - " + [:span {:x-text "p.end"}])]]] + [:template {:x-if "periods.length >= 3"} + (com/pill {:color :secondary} + [:span {:x-text "periods.length"}] + " periods selected")] + [:div {:class "w-3 h-3 m-1 inline ml-1 justify-self-end text-gray-500 self-center"} + svg/drop-down]) + [:template {:x-ref "tooltip"} + [:div.p-4 {:class "bg-gray-100 dark:bg-gray-600 rounded-lg shadow-2xl w-max z-50 ring-1 p-4"} + [:div.flex.flex-col.gap-2 + (com/calendar-input {:placeholder "12/21/2020" :x-model "source_date"}) + (com/a-button {"@click" "periods=getFourWeekPeriodsPeriods(source_date)"} "13 periods") + (com/a-button {"@click" "periods=[calendarYearPeriod(source_date)]"} "Calendar year") + (com/a-button {"@click" "periods=[lastYearPeriod(source_date)]"} "Full year") + (com/a-button {"@click" "periods=[]"} "Clear")]]]])) + + (com/button {:color :primary :class "w-32"} + "Run") + (com/button {:formaction (bidi.bidi/path-for ssr-routes/only-routes ::route/export-cash-flows) } "Export PDF")]]]] + children]))) + +(defn form [request] + (html-response (form* (assoc request :query-params (:form-params request)) + (cash-flows* request)) + :headers {"hx-retarget" "#cash-flows-form" + "hx-push-url" (str "?" (:query-string request))})) + +(defn cash-flows [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) + :request request} + (apply com/breadcrumbs {} [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)} + "Ledger"]]) + (form* request)) + "Cash Flows")) + +(defn make-cash-flows-pdf [request report] + + (let [ output-stream (ByteArrayOutputStream.) + + date (:periods (:form-params request))] + (pdf/pdf + (-> [{:left-margin 10 :right-margin 10 :top-margin 15 :bottom-margin 15 + :size :letter + :font {:size 6 + :ttf-name "fonts/calibri-light.ttf"}} + [:heading (str "Balance Sheet - " (str/join ", " (map :client/name (seq (:client (:form-params request))))))]] + + (conj [:paragraph {:color [128 0 0] :size 9} (:warning report)]) + (conj + (table->pdf (concat-tables (:details report)) + (into [20 ] (mapcat identity (repeat (count date) [ 13 13 13])))))) + output-stream) + (.toByteArray output-stream))) + +(defn join-names [client-ids] + (str/replace (->> client-ids (pull-many (dc/db conn) [:client/name]) (map :client/name) (str/join "-")) #"[^\w]" "_")) + +(defn cash-flows-args->name [request] + (let [date (atime/unparse-local + (:date (:query-params request)) + atime/iso-date) + name (->> request :query-params :client (map :db/id) join-names)] + (format "Balance-sheet-%s-for-%s" date name))) + +(defn print-cash-flows [request] + (let [uuid (str (UUID/randomUUID)) + {:keys [client warning]} (maybe-trim-clients request (:client (:form-params request))) + request (assoc-in request [:form-params :client] client) + pdf-data (make-cash-flows-pdf request (:report (get-report request))) + name (cash-flows-args->name request) + key (str "reports/cash-flows/" uuid "/" name ".pdf") + url (str "https://" (:data-bucket env) "/" key)] + (s3/put-object :bucket-name (:data-bucket env/env) + :key key + :input-stream (io/make-input-stream pdf-data {}) + :metadata {:content-length (count pdf-data) + :content-type "application/pdf"}) + @(dc/transact conn + [{:report/name name + :report/client (map :db/id client) + :report/key key + :report/url url + :report/creator (:user (:identity request)) + :report/created (java.util.Date.)}]) + {:report/name name + :report/url url})) + +;; TODO PRINT WARNING +(defn export [request] + (modal-response + (com/modal {} + (com/modal-card + {} + "Ready!" + (com/modal-body {} + (let [bs (print-cash-flows request)] + [:div.flex.flex-col.mt-4.space-y-4.items-center + [:a {:href (:report/url bs)} + [:div.w-24.h-24.bg-green-50.rounded-full.p-4.text-green-300 {:class " hover:scale-110 transition duration-100"} + + svg/download]] + [:span.text-gray-800 + "Click " + (com/link {:href (:report/url bs)} "here") + " to download"]])) + + nil)) + :headers (-> {} + (assoc "hx-retarget" ".modal-stack") + (assoc "hx-reswap" "beforeend")))) + +(def key->handler + (apply-middleware-to-all-handlers + (-> + {::route/cash-flows (-> cash-flows + (wrap-schema-enforce :query-schema query-schema) + (wrap-form-4xx-2 cash-flows)) + ::route/run-cash-flows (-> form + (wrap-schema-enforce :form-schema query-schema) + (wrap-form-4xx-2 form)) + ::route/export-cash-flows (-> export + (wrap-schema-enforce :form-schema query-schema) + (wrap-form-4xx-2 form))}) + + (fn [h] + (-> h + #_(wrap-merge-prior-hx) + (wrap-must {:activity :read :subject :cash-flows}) + (wrap-nested-form-params) + (wrap-client-redirect-unauthenticated))))) diff --git a/src/clj/auto_ap/ssr/ledger/report_table.clj b/src/clj/auto_ap/ssr/ledger/report_table.clj new file mode 100644 index 00000000..3bad82c7 --- /dev/null +++ b/src/clj/auto_ap/ssr/ledger/report_table.clj @@ -0,0 +1,125 @@ +(ns auto-ap.ssr.ledger.report-table + (:require + [auto-ap.ssr.components :as com] + [clojure.string :as str] + [hiccup.util :as hu] + [iol-ion.query :as query])) + + + +(defn cell [{:keys [width investigate-url other-style]} c] + (let [cell-contents (cond + + (= :dollar (:format c)) + (format "$%,.2f" (if (query/dollars-0? (:value c)) + 0.0 + (:value c))) + + + (= :percent (:format c)) + (format "%%%.1f" (if (query/dollars-0? (:value c)) + 0.0 + (:value c))) + + :else + (str (:value c))) + cell-contents (if (:filters c) + (com/link {:hx-get (hu/url investigate-url + (cond-> {} + (:numeric-code (:filters c)) (assoc :numeric-code (into [] (:numeric-code (:filters c)))) + ;; TODO + #_#_(:date-range (:filters c)) (assoc :end-date (atime/unparse-local (:date-range (:filters c)) + atime/normal-date)) + (:client-id (:filters c)) (assoc :client-id (:client-id (:filters c)))) + )} + cell-contents) + cell-contents)] + [:td.px-4.py-2 + (cond-> {:style (cond-> {:width (str width "em")} + other-style (merge other-style))} + + (:border c) (update :style + (fn [s] + (->> (:border c) + (map + (fn [b] + [(keyword (str "border-" (name b))) "1px solid black"]) + ) + (into s)))) + (:colspan c) (assoc :colspan (:colspan c)) + (:align c) (assoc :align (:align c)) + (= :dollar (:format c)) (assoc :align :right) + (= :percent (:format c)) (assoc :align :right) + (:bold c) (assoc-in [:style :font-weight] "bold") + (:color c) (assoc-in [:style :color] (str "rgb(" + (str/join "," + (:color c)) + ")")) + true (assoc-in [:style :background-color] (str "rgb(" + (str/join "," + (or (:bg-color c) [255 255 255])) + ")"))) + + cell-contents])) + +(defn cell-count [table] + (let [counts (map count (:rows table))] + (if (seq counts) + (apply max counts) + 0))) + +(defn table [{:keys [table widths investigate-url warning]}] + (let [cell-count (cell-count table)] + (com/content-card {:class "inline-block overflow-scroll"} + [:div {:class "overflow-scroll h-[70vh] m-4 inline-block"} + (when warning [:div.rounded.bg-red-50.text-red-800.p-4.m-2 + warning]) + (-> [:table {:class "text-sm text-left text-gray-500 dark:text-gray-400"} + [:thead {:class "text-xs text-gray-800 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 font-bold"} + (map + (fn [header-row header] + (into + [:tr {:class " dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700"}] + (map + (fn [w header i] + (cell {:width w + :investigate-url investigate-url + :other-style {:position "sticky" + :top (* header-row (+ 22 18))}} header)) + widths + header + (range)))) + (range) + (:header table))]] + + (conj + (-> [:tbody {:style {}}] + (into + (for [[i row] (map vector (range) (:rows table))] + + [:tr {:class " dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700"} + (for [[i c] (map vector (range) (take cell-count + (reduce + (fn [[acc cnt] cur] + (if (>= (+ cnt (:colspan cur 1)) cell-count) + (reduced (conj acc cur)) + [(conj acc cur) (+ cnt (:colspan cur 1))])) + [[] 0] + (concat row (repeat nil)))))] + + (cell {:investigate-url investigate-url} c))])) + (conj [:tr (for [i (range cell-count)] + + (cell {:investigate-url investigate-url} {:value " "}))]))))]))) + +(defn concat-tables [tables] + (let [[first & rest] tables] + {:header (:header first) + :rows (concat (:rows first) + [[]] + (mapcat + (fn [table] + (-> (:header table) + (into (:rows table)) + (conj []))) + rest))}))