diff --git a/src/clj/auto_ap/ssr/components/aside.clj b/src/clj/auto_ap/ssr/components/aside.clj index 9714e85c..144c6c86 100644 --- a/src/clj/auto_ap/ssr/components/aside.clj +++ b/src/clj/auto_ap/ssr/components/aside.clj @@ -94,7 +94,7 @@ "sales" (#{::payment-routes/all-page ::payment-routes/pending-page ::payment-routes/cleared-page ::payment-routes/voided-page} (:matched-route request)) "payments" - (#{::ledger-routes/all-page ::ledger-routes/external-page ::ledger-routes/external-import-page ::ledger-routes/balance-sheet} (:matched-route request)) + (#{::ledger-routes/all-page ::ledger-routes/external-page ::ledger-routes/external-import-page ::ledger-routes/balance-sheet ::ledger-routes/cash-flows ::ledger-routes/profit-and-loss} (:matched-route request)) "ledger" :else nil)] @@ -297,8 +297,20 @@ (when (is-admin? (:identity request)) (menu-button- {:href (bidi/path-for client-routes/routes :ledger)} "Old Register")) - (menu-button- {:href (bidi/path-for client-routes/routes - :profit-and-loss)} "Profit & Loss") + (if (is-admin? (:identity request)) + (menu-button- {:href (hu/url (bidi/path-for ssr-routes/only-routes + ::ledger-routes/profit-and-loss)) + :active? (= ::ledger-routes/profit-and-loss (:matched-route request)) + :hx-boost "true"} + [:div.flex.gap-2 + "Profit and loss" + (tags/pill- {:color :secondary} "WIP")]) + (menu-button- {:href (bidi/path-for client-routes/routes + :profit-and-loss)} + "Profit and Loss")) + (when (is-admin? (:identity request)) + (menu-button- {:href (bidi/path-for client-routes/routes + :profit-and-loss)} "Old profit and loss")) (menu-button- {:href (bidi/path-for client-routes/routes :profit-and-loss-detail)} "Profit & Loss Detail") (menu-button- {:href (bidi/path-for client-routes/routes @@ -306,7 +318,7 @@ (if (is-admin? (:identity request)) (menu-button- {:href (hu/url (bidi/path-for ssr-routes/only-routes ::ledger-routes/cash-flows)) - :active? (= ::ledger-routes/balance-sheet (:matched-route request)) + :active? (= ::ledger-routes/cash-flows (:matched-route request)) :hx-boost "true"} [:div.flex.gap-2 "Cash flows" diff --git a/src/clj/auto_ap/ssr/ledger.clj b/src/clj/auto_ap/ssr/ledger.clj index 6804dd4f..530237aa 100644 --- a/src/clj/auto_ap/ssr/ledger.clj +++ b/src/clj/auto_ap/ssr/ledger.clj @@ -23,6 +23,7 @@ [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.ledger.profit-and-loss :as profit-and-loss] [auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]] [auto-ap.ssr.svg :as svg] [auto-ap.ssr.ui :refer [base-page]] @@ -729,5 +730,6 @@ (wrap-must {:activity :import :subject :ledger}) (wrap-client-redirect-unauthenticated)))) balance-sheet/key->handler + profit-and-loss/key->handler cash-flows/key->handler investigate/key->handler)) \ No newline at end of file diff --git a/src/clj/auto_ap/ssr/ledger/cash_flows.clj b/src/clj/auto_ap/ssr/ledger/cash_flows.clj index 893ee468..62365eb0 100644 --- a/src/clj/auto_ap/ssr/ledger/cash_flows.clj +++ b/src/clj/auto_ap/ssr/ledger/cash_flows.clj @@ -176,7 +176,6 @@ :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"} @@ -228,9 +227,7 @@ "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 diff --git a/src/clj/auto_ap/ssr/ledger/profit_and_loss.clj b/src/clj/auto_ap/ssr/ledger/profit_and_loss.clj new file mode 100644 index 00000000..32389265 --- /dev/null +++ b/src/clj/auto_ap/ssr/ledger/profit_and_loss.clj @@ -0,0 +1,343 @@ +(ns auto-ap.ssr.ledger.profit-and-loss + (: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]}]]]] + [:column-per-location {:default false} + [:boolean {:decode/string {:enter #(if (= % "on") true + + (boolean %))}}]] + [:include-deltas {:default false} + [:boolean {:decode/string {:enter #(if (= % "on") true + + (boolean %))}}]] + + [: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-pnl pnl-data)] + (alog/info ::profit-and-loss :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 profit-and-loss* [{ {: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 + (if (-> data :args :include-deltas) + [13 6 13] + [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#profit-and-loss-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-profit-and-loss) + :hx-target "#profit-and-loss-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")]]]])) + (fc/with-field :column-per-location + (com/toggle {:name (fc/field-name) + :checked (fc/field-value)} + "Column per location")) + (fc/with-field :include-deltas + (com/toggle {:name (fc/field-name) + :checked (fc/field-value)} + "Include deltas")) + + (com/button {:color :primary :class "w-32"} + "Run") + (com/button {:formaction (bidi.bidi/path-for ssr-routes/only-routes ::route/export-profit-and-loss)} "Export PDF")]]]] + children]))) + +(defn form [request] + (html-response (form* (assoc request :query-params (:form-params request)) + (profit-and-loss* request)) + :headers {"hx-retarget" "#profit-and-loss-form" + "hx-push-url" (str "?" (:query-string request))})) + +(defn profit-and-loss [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-profit-and-loss-pdf [request report] + (let [ output-stream (ByteArrayOutputStream.) + date (:periods (:form-params request)) + table (concat-tables (:details report))] + (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 table + (into [20] (take (dec (cell-count table)) + (mapcat identity + (repeat + (if (-> (:form-params request) :include-deltas) + [13 6 13] + [13 6])))))))) + 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 profit-and-loss-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-profit-and-loss [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-profit-and-loss-pdf request (:report (get-report request))) + name (profit-and-loss-args->name request) + key (str "reports/profit-and-loss/" 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-profit-and-loss 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/profit-and-loss (-> profit-and-loss + (wrap-schema-enforce :query-schema query-schema) + (wrap-form-4xx-2 profit-and-loss)) + ::route/run-profit-and-loss (-> form + (wrap-schema-enforce :form-schema query-schema) + (wrap-form-4xx-2 form)) + ::route/export-profit-and-loss (-> 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 :profit-and-loss}) + (wrap-nested-form-params) + (wrap-client-redirect-unauthenticated))))) diff --git a/src/cljc/auto_ap/routes/ledger.cljc b/src/cljc/auto_ap/routes/ledger.cljc index 1ac2053d..632beec3 100644 --- a/src/cljc/auto_ap/routes/ledger.cljc +++ b/src/cljc/auto_ap/routes/ledger.cljc @@ -14,4 +14,7 @@ "/export" ::export-balance-sheet} "/reports/cash-flows" {"" ::cash-flows "/run" ::run-cash-flows - "/export" ::export-cash-flows}}) \ No newline at end of file + "/export" ::export-cash-flows} + "/reports/profit-and-loss" {"" ::profit-and-loss + "/run" ::run-profit-and-loss + "/export" ::export-profit-and-loss}}) \ No newline at end of file