diff --git a/resources/sample-ledger-2.csv b/resources/sample-ledger-2.csv new file mode 100644 index 00000000..20d65d16 --- /dev/null +++ b/resources/sample-ledger-2.csv @@ -0,0 +1,13 @@ +Id,Client,Source,Vendor,Date,Account,Location,Debit,Credit,Note,Cleared Against +HELLO,DEMO,Payroll,HelloVend,09/06/2022,24000,A,0,100,, +HELLO,DEMO,Payroll,HelloVend,09/06/2022,21000,A,100,0,, +,,,,,,,,,, +,,,,,,,,,, +,,,,,,,,,, +,,,,,,,,,, +,,,,,,,,,, +,,,,,,,,,, +,,,,,,,,,, +,,,,,,,,,, +,,,,,,,,,, +,,,,,,,,,, diff --git a/src/clj/auto_ap/graphql/ledger.clj b/src/clj/auto_ap/graphql/ledger.clj index 6ce8a43b..164024aa 100644 --- a/src/clj/auto_ap/graphql/ledger.clj +++ b/src/clj/auto_ap/graphql/ledger.clj @@ -4,6 +4,7 @@ [auto-ap.datomic.accounts :as a] [auto-ap.datomic.clients :as d-clients] [auto-ap.datomic.ledger :as l] + [auto-ap.ledger.reports :as l-reports] [auto-ap.datomic.vendors :as d-vendors] [auto-ap.graphql.utils :refer [->graphql <-graphql assert-admin assert-can-see-client result->page]] @@ -503,6 +504,18 @@ :stop (scheduler/stop running-balance-cache-worker)) +(defn get-journal-detail-report [context input _] + (clojure.pprint/pprint + (for [category (:categories input)] + {:category category + :journal_entries (:journal_entries (get-ledger-page context {:filters (doto {:client_ids (:client_ids input) + :date_range (:date_range input) + :from_numeric_code (get-in l-reports/ranges [category 0]) + :to_numeric_code (get-in l-reports/ranges [category 1]) + :per_page Integer/MAX_VALUE} + clojure.pprint/pprint)} + nil))}))) + (def objects {:balance_sheet_account @@ -567,7 +580,13 @@ {:fields {:successful {:type '(list :import_ledger_entry_result)} :existing {:type '(list :import_ledger_entry_result)} :ignored {:type '(list :import_ledger_entry_result)} - :errors {:type '(list :import_ledger_entry_result)}}}}) + :errors {:type '(list :import_ledger_entry_result)}}} + + :journal_detail_report_category + {:fields {:category {:type :ledger_category} + :journal_entries {:type '(list :journal_entry)}}} + :journal_detail_report + {:fields {:categories {:type '(list :journal_detail_report_category)}}}}) (def queries {:balance_sheet {:type :balance_sheet @@ -585,6 +604,13 @@ :column_per_location {:type 'Boolean}} :resolve :get-profit-and-loss} + :journal_detail_report {:type :journal_detail_report + :args {:client_id {:type :id} + :client_ids {:type '(list :id)} + :date_range {:type :date_range} + :categories {:type '(list :ledger_category)}} + :resolve :get-journal-detail-report} + :profit_and_loss_pdf {:type :report_pdf :args {:client_id {:type :id} :client_ids {:type '(list :id)} @@ -650,11 +676,12 @@ :note {:type 'String} :cleared_against {:type 'String} :line_items {:type '(list :import_ledger_line_item)}}} - }) (def enums - {}) + {:ledger_category {:values [{:enum-value :sales} + {:enum-value :cogs} + {:enum-value :payroll}]}}) (def resolvers @@ -663,6 +690,7 @@ :get-profit-and-loss get-profit-and-loss :profit-and-loss-pdf profit-and-loss-pdf :balance-sheet-pdf balance-sheet-pdf + :get-journal-detail-report get-journal-detail-report :mutation/delete-external-ledger delete-external-ledger :mutation/import-ledger import-ledger}) diff --git a/src/cljc/auto_ap/client_routes.cljc b/src/cljc/auto_ap/client_routes.cljc index b183767c..5de1c90d 100644 --- a/src/cljc/auto_ap/client_routes.cljc +++ b/src/cljc/auto_ap/client_routes.cljc @@ -35,6 +35,7 @@ "yodlee2" :yodlee2 "ledger/" {"" :ledger "profit-and-loss" :profit-and-loss + "profit-and-loss-detail" :profit-and-loss-detail "balance-sheet" :balance-sheet "external" :external-ledger "external-import" :external-import-ledger}}]) diff --git a/src/cljs/auto_ap/views/main.cljs b/src/cljs/auto_ap/views/main.cljs index 3de7a80d..3c539c64 100644 --- a/src/cljs/auto_ap/views/main.cljs +++ b/src/cljs/auto_ap/views/main.cljs @@ -15,6 +15,7 @@ [auto-ap.views.pages.ledger.external-import :refer [external-import-page]] [auto-ap.views.pages.ledger.external-ledger :refer [external-ledger-page]] [auto-ap.views.pages.ledger.profit-and-loss :refer [profit-and-loss-page]] + [auto-ap.views.pages.ledger.profit-and-loss-detail :refer [profit-and-loss-detail-page]] [auto-ap.views.pages.login :refer [login-page]] [auto-ap.views.pages.payments :refer [payments-page]] [auto-ap.views.pages.pos.sales-orders :refer [sales-orders-page]] @@ -85,6 +86,10 @@ (defmethod page :profit-and-loss [_] (profit-and-loss-page)) + +(defmethod page :profit-and-loss-detail [_] + (profit-and-loss-detail-page)) + (defmethod page :balance-sheet [_] (balance-sheet-page)) diff --git a/src/cljs/auto_ap/views/pages/ledger/profit_and_loss_detail.cljs b/src/cljs/auto_ap/views/pages/ledger/profit_and_loss_detail.cljs new file mode 100644 index 00000000..56ebdf6b --- /dev/null +++ b/src/cljs/auto_ap/views/pages/ledger/profit_and_loss_detail.cljs @@ -0,0 +1,263 @@ +(ns auto-ap.views.pages.ledger.profit-and-loss-detail + (:require + [auto-ap.forms :as forms] + [auto-ap.forms.builder :as form-builder] + [auto-ap.ledger.reports :as l-reports] + [auto-ap.status :as status] + [auto-ap.subs :as subs] + [auto-ap.views.components :as com] + [auto-ap.views.components.buttons :as buttons] + [auto-ap.views.components.layouts :refer [side-bar-layout]] + [auto-ap.views.components.modal :as modal] + [auto-ap.views.pages.ledger.report-table :as rtable] + [auto-ap.views.pages.ledger.side-bar :refer [ledger-side-bar]] + [auto-ap.views.utils + :refer [date->str date-picker dispatch-event query-params standard with-user local-now]] + [cljs-time.core :as t] + [clojure.string :as str] + [re-frame.core :as re-frame] + [react-dom :as react-dom] + [reagent.core :as reagent] + [vimsical.re-frame.cofx.inject :as inject])) + + +(defn encode-period [p] + {:start (date->str (:start p) standard) + :end (date->str (:end p) standard)}) + + +;; SUBS + +(re-frame/reg-event-db + ::received + [(forms/in-form ::form)] + (fn [db [_ data]] + (-> db (assoc :report (:profit-and-loss data))))) + +;; EVENTS + +(re-frame/reg-event-fx + ::report-requested + [with-user (forms/in-form ::form)] + (fn [{:keys [db user]}] + (cond-> {:graphql {:token user + :owns-state {:single ::page} + :query-obj {:venia/queries [[:journal-detail-report + {:client-ids (map (comp :id :client) (:clients (:data db))) + :date-range {:start (date->str (:start (:date-range (:data db))) standard) + :end (date->str (:end (:date-range (:data db))) standard)} + :categories [:sales + :cogs + :payroll]} + [[:categories [[:journal-entries [:id + :source + :original-entity + :note + :amount + :alternate-description + [:vendor + [:name :id]] + [:client + [:name :id]] + [:line-items + [:id :debit :credit :location :running-balance + [:account [:id :name]]]] + :date]] + :category]]]]]} + :on-success [::received]} + :set-uri-params {:date-range (:date-range (:data db)) + :clients (mapv #(select-keys (:client %) [:name :id]) (:clients (:data db))) } + :db (-> db + (dissoc :report) + (update-in [:data :clients] #(into [] (filter seq %))))}))) + +(defn email-body [report-url] + (js/encodeURIComponent + (str + "Hello, +Click here (" report-url ") to download your financial reports. We have not finished reviewing and reconciling these numbers with you. Please review and let us know if anything seems missing or in need of correction. +Click here (http://app.integreatconsult.com/) to login to the Financials app to review the details here. +Click here (https://share.vidyard.com/watch/MHTo5PyXPxXUpVH93RWFM9?) for a video on how to run a P&L on your own. +To see a history of past financial reports, click here: https://app.integreatconsult.com/reports/ + +NOTE: Please review the transactions we may have question for you here: https://app.integreatconsult.com/transactions/requires-feedback. You can either edit the transaction to what expense account it should be or email back what it should be."))) + +(re-frame/reg-event-fx + ::received-pdf + [(re-frame/inject-cofx ::inject/sub [::subs/clients-by-id])] + (fn [{:keys [db ::subs/clients-by-id]} [_ result]] + (let [selected-clients (-> db ::forms/forms ::form :data :clients) + single-client? (= (count selected-clients) + 1) + client-emails (when single-client? + (-> clients-by-id + (get (:id (:client (first selected-clients)))) + :emails))] + {:dispatch [::modal/modal-requested {:title "Your report is ready" + :body [:div + [:div "Click " + [:a {:href (-> result :profit-and-loss-pdf :url) :target "_new"} "here"] " to view it."] + (when (and single-client? (seq client-emails)) + [:div "Once you've confirmed you're happy with it, click " + [:a {:href (str "mailto:" (str/join ";" (map :email client-emails)) "?body=" (email-body (-> result :profit-and-loss-pdf :url)) + "&subject=" (-> result :profit-and-loss-pdf :name) " is ready")} + "here"] " to open your email client and to send it to " (str/join "," (map (fn [e] + (str (:email e) " (" (:description e) ")")) + client-emails)) "."])]}]}))) +(re-frame/reg-event-fx + ::export-pdf + [with-user (forms/in-form ::form)] + (fn [{:keys [db user]}] + (cond-> {:graphql {:token user + :owns-state {:single ::page} + :query-obj {:venia/queries [[:profit-and-loss-pdf + {:client-ids (map (comp :id :client) (:clients (:data db))) + :include-deltas (:include-deltas (:data db)) + :column-per-location (:column-per-location (:data db)) + :periods (mapv #(select-keys % #{:start :end}) (:periods (:data db)))} + [:url :name]]]} + :on-success [::received-pdf]} + :set-uri-params {:date-range (:date-range (:data db)) + :clients (mapv #(select-keys (:client %) [:name :id]) (:clients (:data db))) } + :db (dissoc db :report)}))) + + +(re-frame/reg-event-fx + ::change + [with-user (forms/in-form ::form)] + (fn [{:keys [db]} [_ & event]] + {:db (dissoc db :report)})) + + + + + +(defn report-control-detail [{:keys [active box which]} children] + (when (and @box + (= which @active)) + (react-dom/createPortal (reagent/as-element + [:div.notification.is-light + [:a.delete {:on-click (fn [] (reset! active nil))}] + children + ]) + @box))) + + +(defn report-controls [] + (let [!box (atom nil) + active (reagent/atom nil)] + (fn [] + (let [{:keys [data]} @(re-frame/subscribe [::forms/form ::form]) + {:keys []} data] + [form-builder/builder {:change-event [::change] + :submit-event [::report-requested] + :id ::form} + [:div.report-controls + [:div.level.mb-2 + [:div.level-left + [:div.level-item + [buttons/dropdown {:on-click (fn [] (reset! active :clients))} + [:span (str "Companies" + (when-let [clients (:clients data)] + (str " (" (str/join ", " (map (comp :name :client) clients)) ")")))]] + [report-control-detail {:active active :box !box :which :clients} + [:div {:style {:width "20em"}} + [:h4.subtitle "Companies"] + [form-builder/raw-field-v2 {:field :clients} + [com/multi-field-v2 {:new-text "Add another company" + :template [[form-builder/raw-field-v2 {:field :client} + [com/entity-typeahead {:entities @(re-frame/subscribe [::subs/clients]) + :style {:width "18em"} + :entity->text :name}]]] + :key-fn :id}]]]]] + [:div.level-item + "From"] + [:div.level-item + [form-builder/raw-field-v2 {:field [:date-range :start]} + [date-picker {:output :cljs-date}]]] + [:div.level-item + " To "] + [:div.level-item + [form-builder/raw-field-v2 {:field [:date-range :end]} + [date-picker {:output :cljs-date}]]] + [:div.level-right + [:div.buttons + (when @(re-frame/subscribe [::subs/is-admin?]) + [:button.button.is-secondary {:on-click (dispatch-event [::export-pdf])} "Export"]) + [:button.button.is-primary "Run"]]]]] + [:div.report-control-detail {:ref (fn [el] + (when (not= @!box el) + (reset! !box el)))}]]])))) + +(defn pnl-report [{:keys [args report-data]}] + (let [pnl-data (->> report-data + :periods + (mapcat (fn [p1 p2] + (map + (fn [a] + (assoc a :period p1 + :amount (js/parseFloat (:amount a))) + ) + (:accounts p2))) + (:periods args))) + client-codes (->> @(re-frame/subscribe [::subs/clients-by-id]) + (map (fn [[k v]] + [k (:code v)])) + (into {})) + pnl-data (l-reports/->PNLData args pnl-data client-codes) + report (l-reports/summarize-pnl pnl-data) + table (rtable/concat-tables (concat (:summaries report) (:details report)))] + [:div + [:h1.title "Profit and Loss - " (str/join ", " (map (comp :name :client) (:clients args)))] + (when (:warning report) + [:div.notification.is-warning.is-light + (:warning report)]) + [rtable/table {:widths (into [20] (take (dec (rtable/cell-count table)) + (mapcat identity + (repeat + (if (-> pnl-data :args :include-deltas) + [13 6 13] + [13 6]))))) + :table table}]])) + + +;; TODO Break out one category per location +;; TODO filter ledger entry lines to only ones that match +;; TODO Render them! +(defn profit-and-loss-content [] + (let [status @(re-frame/subscribe [::status/single ::page]) + {:keys [data report]} @(re-frame/subscribe [::forms/form ::form])] + [:div + [:div + [status/status-notification {:statuses [[::status/single ::page]]}] + [report-controls]] + [status/big-loader status] + (when (and (not= :loading (:state status)) + report) + [pnl-report {:report-data report + :args data}])])) + + +(re-frame/reg-event-fx + ::mounted-pnl + (fn [{:keys [db]} _] + (let [qp (query-params)] + {:db (forms/start-form db ::form {:date-range {:end (local-now) + :start (t/minus (local-now) (t/years 1))} + :clients (mapv (fn [c] {:client c :id (random-uuid)}) + (or (:clients qp) + [(some-> @(re-frame/subscribe [::subs/client]) (select-keys [:name :id]) )]))}) + :dispatch [::status/dispose-single ::page]}))) + +(defn profit-and-loss-detail-page [] + (reagent/create-class + {:display-name "profit-and-loss-detail-page" + :component-did-mount #(re-frame/dispatch [::mounted-pnl]) + :reagent-render + (fn [] + (let [user (re-frame/subscribe [::subs/user])] + (if (not= "manager" (:user/role @user)) + [side-bar-layout + {:side-bar [ledger-side-bar] + :main [profit-and-loss-content]}] + [:div "Not authorized"])))})) diff --git a/src/cljs/auto_ap/views/pages/ledger/side_bar.cljs b/src/cljs/auto_ap/views/pages/ledger/side_bar.cljs index 03552973..3683450e 100644 --- a/src/cljs/auto_ap/views/pages/ledger/side_bar.cljs +++ b/src/cljs/auto_ap/views/pages/ledger/side_bar.cljs @@ -30,6 +30,12 @@ [:span {:class "icon icon-performance-increase-1" :style {:font-size "25px"}}] [:span {:class "name"} "Profit & Loss"]]] + [:li.menu-item + [:a.item {:href (bidi/path-for routes/routes :profit-and-loss-detail) + :class [(active-when ap = :profit-and-loss-detail)]} + + [:span {:class "icon icon-performance-increase-1" :style {:font-size "25px"}}] + [:span {:class "name"} "Profit & Loss Detail"]]] [:li.menu-item [:a.item {:href (bidi/path-for routes/routes :balance-sheet) :class [(active-when ap = :balance-sheet)]}