diff --git a/src/clj/auto_ap/graphql/accounts.clj b/src/clj/auto_ap/graphql/accounts.clj index ffc03063..cec20fee 100644 --- a/src/clj/auto_ap/graphql/accounts.clj +++ b/src/clj/auto_ap/graphql/accounts.clj @@ -106,6 +106,8 @@ []))) (defn rebuild-search-index [] + (solr/delete solr/impl "accounts") + (Thread/sleep 10000) (solr/index-documents-raw solr/impl "accounts" @@ -141,4 +143,4 @@ "name" (:account/search-terms result) "numeric_code" (:account/numeric-code result) "location" (:account/location result) - "applicability" (name (:db/ident (:account/applicability result)))}))) \ No newline at end of file + "applicability" (name (:db/ident (:account/applicability result)))}))) diff --git a/src/clj/auto_ap/ssr/account.clj b/src/clj/auto_ap/ssr/account.clj index 769afad3..d298631e 100644 --- a/src/clj/auto_ap/ssr/account.clj +++ b/src/clj/auto_ap/ssr/account.clj @@ -39,7 +39,6 @@ {:account_id (first account_id) :name (first name)}))) - (defn account-search [{{:keys [q client-id purpose vendor-id] :as qp} :query-params id :identity :as request}] (let [client-id (or client-id (:db/id (:client request)))] (when client-id diff --git a/src/clj/auto_ap/ssr/components/inputs.clj b/src/clj/auto_ap/ssr/components/inputs.clj index 11f9c691..a2cd3ac6 100644 --- a/src/clj/auto_ap/ssr/components/inputs.clj +++ b/src/clj/auto_ap/ssr/components/inputs.clj @@ -25,43 +25,41 @@ "w-4 h-4 bg-gray-100 indeterminate:bg-gray-300 border-gray-300 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600") (defn select- [params & children] - (into - [:select (-> params - (dissoc :allow-blank? :value :options) - (update - :class (fnil hh/add-class "") default-input-classes)) - (cond->> - (map (fn [[k v]] - [:option {:value k :selected (= k (:value params))} v]) - (:options params)) - (:allow-blank? params) (conj [:option {:value "" :selected (not (:value params))} ""]))] - children)) + (into + [:select (-> params + (dissoc :allow-blank? :value :options) + (update + :class (fnil hh/add-class "") default-input-classes)) + (cond->> + (map (fn [[k v]] + [:option {:value k :selected (= k (:value params))} v]) + (:options params)) + (:allow-blank? params) (conj [:option {:value "" :selected (not (:value params))} ""]))] + children)) (defn checkbox- [params & rest] (if (seq rest) [:label {:class "text-sm text-gray-800 dark:text-gray-300 "} - [:input (merge (dissoc params :indeterminate?) + [:input (merge (dissoc params :indeterminate?) {:type "checkbox" :class (hh/add-class default-checkbox-classes (:class params ""))} (when (:indeterminate? params) {:x-init "$el.indeterminate = true"}))] - [:span.ml-2 + [:span.ml-2 rest]] - [:input (merge (dissoc params :indeterminate params) + [:input (merge (dissoc params :indeterminate params) {:type "checkbox" :class (hh/add-class default-checkbox-classes (:class params ""))} (when (:indeterminate? params) - {:x-init "$el.indeterminate = true"})) - ])) + {:x-init "$el.indeterminate = true"}))])) (defn typeahead- [params] - [:div.relative {:x-data (hx/json { :baseUrl (str (:url params)) + [:div.relative {:x-data (hx/json {:baseUrl (str (:url params)) :value {:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))} :tippy nil :search "" :active -1 :elements (if ((:value-fn params identity) (:value params)) [{:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}] - []) - }) + [])}) :x-modelable "value.value" :x-model (:x-model params)} (if (:disabled params) @@ -72,7 +70,7 @@ "@keydown.down.prevent.stop" "tippy.show();" "@keydown.backspace" "tippy.hide(); value = {value: '', label: '' }" :tabindex 0 -:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params)) + :x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params)) :x-ref "input"} [:input (-> params (dissoc :class) @@ -91,9 +89,9 @@ [:span.flex-grow.text-left {"x-text" "value.label"}] [:div {:class "w-3 h-3 m-1 inline ml-1 justify-self-end text-gray-500 self-center"} svg/drop-down] - [:div {:x-show "value.warning" } + [:div {:x-show "value.warning"} (tags/badge- {:class "peer" - :x-tooltip "value.warning"} "!") ]]]) + :x-tooltip "value.warning"} "!")]]]) [:template {:x-ref "dropdown"} [:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 ring-1" @@ -148,22 +146,19 @@ "x-init" " $el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => reset_elements(data)) }})"}]] [:div.dropdown-options {:class "overflow-hidden divide-y divide-gray-200 "} [:template {:x-for "(element, index) in elements"} - [:li {":style" "index == 0 && 'border: 0 !important;'"} + [:li {":style" "index == 0 && 'border: 0 !important;'"} [:label {:class "p-3 group rounded flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-300 [&.active]:dark:bg-primary-700 [&.implied]:text-gray-500 text-gray-800 dark:text-gray-100 cursor-pointer" - + :href "#" ":class" (hx/json {"active" (hx/js-fn "active==index") - "implied" (hx/js-fn "all_selected && index != 0") - } ) + "implied" (hx/js-fn "all_selected && index != 0")}) "@mouseover" "active = index" "@mouseout" "active = -1" "@click.prevent" "toggle(element)"} - (checkbox- {":checked" "value.has(element.value) || all_selected" - :class "group-[&.implied]:bg-green-200" - - }) - #_[:input {:type "checkbox" }] + (checkbox- {":checked" "value.has(element.value) || all_selected" + :class "group-[&.implied]:bg-green-200"}) + #_[:input {:type "checkbox"}] [:span {"x-html" "element.label"}]]]] [:template {:x-if "elements.length == 0"} [:li {:class "px-4 pt-4 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs " "style" "border: 0 !important"} @@ -172,12 +167,10 @@ (defn multi-typeahead-selected-pill- [params] [:div.flex-grow.flex [:template {:x-if "value.size > 0"} - [:a.bg-blue-100.rounded-full.px-3 - [:span.text-left + [:a.bg-blue-100.rounded-full.px-3 + [:span.text-left [:span {"x-text" "value.has('all') ? 'All' : value.size"}] - " selected"] - - ]] + " selected"]]] [:template {:x-if "value.size == 0"} [:span.text-left.text-gray-400 "None selected"]] [:div {:class "w-4 h-4 ml-2 inline text-gray-500 self-center rounded-full bg-gray-100 text-gray-500" @@ -223,7 +216,7 @@ (sequential? (:value params)) (map (fn [v] ((:value-fn params identity) v)) (:value params)) - + :else []) :tippy nil @@ -238,7 +231,7 @@ :elements (cond-> [{:value "all" :label "All"}] (sequential? (:value params)) (into (map (fn [v] - {:value ((:value-fn params identity) v) + {:value ((:value-fn params identity) v) :label ((:content-fn params identity) v)}) (:value params)))) :x-ref "r"}) @@ -265,8 +258,7 @@ [:input (-> params (dissoc :class :value-fn :content-fn :placeholder :x-model) (assoc :type "hidden" - :value "" - ))]] + :value ""))]] [:div.flex.w-full.justify-items-stretch (multi-typeahead-selected-pill- params) [:div {:class "w-3 h-3 m-1 inline ml-1 justify-self-end text-gray-500 self-center"} @@ -281,7 +273,7 @@ :x-ref "warning_pop" :class "hidden peer-hover:block bg-red-50 dark:bg-gray-600 rounded-lg shadow-2xl w-max z-50 p-4" :x-text "value.warning"}]]] - (multi-typeahead-dropdown- params) ])]) + (multi-typeahead-dropdown- params)])]) (defn use-size [size] @@ -292,26 +284,25 @@ (defn text-input- [{:keys [size error?] :as params}] - [:input - (-> params + [:input + (-> params (dissoc :error?) (assoc :type "text" :autocomplete "off") - (update - :class #(-> "" - (hh/add-class default-input-classes) - (hh/add-class %))) + (update + :class #(-> "" + (hh/add-class default-input-classes) + (hh/add-class %))) (update :class #(str % (use-size size))))]) (defn text-area- [{:keys [] :as params}] - [:textarea - (-> params + [:textarea + (-> params (update :class #(-> "" (hh/add-class default-input-classes) - (hh/add-class %)))) ] - ) + (hh/add-class %))))]) (defn money-input- [{:keys [size] :as params}] - [:input + [:input (-> params (update :class (fnil hh/add-class "") default-input-classes) (update :class hh/add-class "appearance-none text-right") @@ -321,7 +312,7 @@ (dissoc :size))]) (defn int-input- [{:keys [size] :as params}] - [:input + [:input (-> params (update :class (fnil hh/add-class "") default-input-classes) (update :class hh/add-class "appearance-none text-right") @@ -331,12 +322,11 @@ (dissoc :size))]) (defn date-input- [{:keys [size] :as params}] - [:div.shrink {:x-data (hx/json {:value (:value params) + [:div.shrink {:x-data (hx/json {:value (:value params) :tippy nil :dp nil}) "x-effect" "console.log('changed to' +value)" - "@change-date.camel" "$dispatch('change')" - } + "@change-date.camel" "$dispatch('change')"} [:input (-> params (update :class (fnil hh/add-class "") default-input-classes) @@ -349,13 +339,13 @@ (assoc "autocomplete" "off") (assoc "@change" "value = $event.target.value;") - (assoc "@keydown.escape" "tippy.hide(); " ) + (assoc "@keydown.escape" "tippy.hide(); ") #_(assoc "hx-on" (hiccup/raw "changeDate: htmx.trigger(this, \"change\") ")) (update :class #(str % (use-size size) " w-full")) (dissoc :size))] - [:template {:x-ref "tooltip" } - - [:div.shrink + [:template {:x-ref "tooltip"} + + [:div.shrink [:div (-> params (update :class (fnil hh/add-class "") default-input-classes) @@ -369,19 +359,19 @@ (assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)") (assoc "x-destroy" "destroyDatepicker(dp)") (assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");") - + (update :class #(str % (use-size size) " w-full")) (dissoc :size :name :x-model :x-modelable))]]]]) (defn multi-calendar-input- [{:keys [size] :as params}] - (let [value (str/join ", " - (for [v (:value params) - :when v] + (let [value (str/join ", " + (for [v (:value params) + :when v] (some-> v (atime/unparse-local atime/normal-date))))] [:div.shrink {:x-data (hx/json {:value value - :dp nil }) + :dp nil}) :x-modelable "value" - :x-model (:x-model params) } + :x-model (:x-model params)} [:template {:x-for "v in value"} [:input {:type "hidden" :name (:name params) :x-model "v"}]] [:div @@ -393,8 +383,8 @@ (assoc :x-init "$nextTick(() => { dp = initMultiDatepicker($el, value); ;}); ") (assoc "x-effect" "if(dp) { dp.setDate(Array.from(value), {clear: true}); } ") (assoc ":data-date" "Array.prototype.join.call(value, ', ')") - (assoc "@htmx:before-history-save" "destroyDatepicker(dp)" ) - (assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)" ) + (assoc "@htmx:before-history-save" "destroyDatepicker(dp)") + (assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)") (assoc "x-destroy" "destroyDatepicker(dp)") (assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");") @@ -404,9 +394,9 @@ (defn calendar-input- [{:keys [size] :as params}] (let [value (:value params)] [:div.shrink {:x-data (hx/json {:value value - :dp nil }) + :dp nil}) :x-modelable "value" - :x-model (:x-model params) } + :x-model (:x-model params)} [:input {:type "hidden" :name (:name params) :x-model "value"}] [:div (-> params @@ -417,8 +407,8 @@ (assoc :x-init "$nextTick(() => { dp = initCalendar($el); ;}); ") (assoc "x-effect" "if(dp) { dp.setDate(value); } ") (assoc ":data-date" "value") - (assoc "@htmx:before-history-save" "destroyDatepicker(dp)" ) - (assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)" ) + (assoc "@htmx:before-history-save" "destroyDatepicker(dp)") + (assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)") (assoc "x-destroy" "destroyDatepicker(dp)") (assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");") @@ -429,12 +419,12 @@ (defn field-errors- [{:keys [source key]} & rest] (let [errors (:errors (cond-> (meta source) - key (get key)))] + key (get key)))] [:p.mt-2.text-xs.text-red-600.dark:text-red-500.h-4 (str/join ", " errors)])) (defn field- [params & rest] [:div (-> params - (update :class #(hh/add-class (or % "") "group" ))) + (update :class #(hh/add-class (or % "") "group"))) (when (:label params) [:label {:class "block mb-2 text-sm font-medium text-gray-900 dark:text-white"} (:label params)]) rest @@ -444,7 +434,7 @@ (defn inline-field- [params & rest] [:div (-> params - (update :class #(hh/add-class (or % "") "group flex items-baseline gap-2" ))) + (update :class #(hh/add-class (or % "") "group flex items-baseline gap-2"))) (when (:label params) [:label {:class "mb-2 text-sm font-medium text-gray-900 dark:text-white"} (:label params)]) rest @@ -471,10 +461,10 @@ (defn validated-inline-field- [params & rest] (inline-field- (cond-> params - true (dissoc :errors) - (sequential? (:errors params)) (update :class #(hh/add-class (or % "") "has-error"))) - rest - (errors- {:errors (:errors params)}))) + true (dissoc :errors) + (sequential? (:errors params)) (update :class #(hh/add-class (or % "") "has-error"))) + rest + (errors- {:errors (:errors params)}))) (defn hidden- [{:keys [name value] :as params}] [:input (merge {:type "hidden" :value value :name name} params)]) diff --git a/src/clj/auto_ap/ssr/invoice/new_invoice_wizard.clj b/src/clj/auto_ap/ssr/invoice/new_invoice_wizard.clj index d7062aef..d256bfb1 100644 --- a/src/clj/auto_ap/ssr/invoice/new_invoice_wizard.clj +++ b/src/clj/auto_ap/ssr/invoice/new_invoice_wizard.clj @@ -336,7 +336,7 @@ :to-step :next-steps})))) -(defn- location-select* +(defn location-select* [{:keys [name account-location client-locations value]}] (let [options (into (cond account-location [[account-location account-location]] @@ -352,7 +352,7 @@ :value (ffirst options) :class "w-full"}))) -(defn- account-typeahead* +(defn account-typeahead* [{:keys [name value client-id x-model]}] [:div.flex.flex-col (com/typeahead {:name name diff --git a/src/clj/auto_ap/ssr/invoices.clj b/src/clj/auto_ap/ssr/invoices.clj index 6594fb2b..55f160b9 100644 --- a/src/clj/auto_ap/ssr/invoices.clj +++ b/src/clj/auto_ap/ssr/invoices.clj @@ -3,8 +3,8 @@ [auto-ap.client-routes :as client-routes] [auto-ap.datomic :refer [add-sorter-fields apply-pagination apply-sort-3 - audit-transact conn merge-query observable-query - pull-many]] + audit-transact audit-transact-batch conn merge-query + observable-query pull-many]] [auto-ap.datomic.accounts :as d-accounts] [auto-ap.datomic.bank-accounts :as d-bank-accounts] [auto-ap.datomic.clients :as d-clients] @@ -16,14 +16,16 @@ exception->4xx exception->notification extract-client-ids notify-if-locked]] [auto-ap.logging :as alog] - [auto-ap.permissions :refer [can?]] + [auto-ap.permissions :refer [can? wrap-must]] [auto-ap.query-params :refer [wrap-copy-qp-pqp]] [auto-ap.routes.invoice :as route] [auto-ap.routes.payments :as payment-route] [auto-ap.routes.utils :refer [wrap-admin wrap-client-redirect-unauthenticated]] + [auto-ap.rule-matching :as rm] [auto-ap.solr :as solr] [auto-ap.ssr-routes :as ssr-routes] + [auto-ap.ssr.common-handlers :refer [add-new-entity-handler]] [auto-ap.ssr.components :as com] [auto-ap.ssr.components.link-dropdown :refer [link-dropdown]] [auto-ap.ssr.components.multi-modal :as mm] @@ -33,13 +35,14 @@ [auto-ap.ssr.hx :as hx] [auto-ap.ssr.invoice.common :refer [default-read]] [auto-ap.ssr.invoice.import :as invoice-import] - [auto-ap.ssr.invoice.new-invoice-wizard :as new-invoice-wizard] + [auto-ap.ssr.invoice.new-invoice-wizard :as new-invoice-wizard :refer [location-select*]] [auto-ap.ssr.pos.common :refer [date-range-field*]] [auto-ap.ssr.svg :as svg] [auto-ap.ssr.utils :refer [apply-middleware-to-all-handlers assert-schema clj-date-schema dissoc-nil-transformer entity-id - html-response main-transformer modal-response money + form-validation-error html-response main-transformer + many-entity modal-response money percentage ref->enum-schema round-money strip wrap-entity wrap-implied-route-param wrap-merge-prior-hx wrap-schema-enforce]] @@ -52,6 +55,7 @@ [clojure.string :as str] [datomic.api :as dc] [hiccup.util :as hu] + [iol-ion.utils :refer [random-tempid]] [malli.core :as mc] [malli.transform :as mt] [malli.util :as mut])) @@ -480,6 +484,12 @@ "hx-include" "#invoice-filters" :color :red} "Void selected")) + (when (can? (:identity request) {:subject :invoice :activity :bulk-edit}) + (com/button {:hx-get (str (bidi/path-for ssr-routes/only-routes ::route/bulk-edit)) + "x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})" + "hx-include" "#invoice-filters" + :color :secondary} + "Bulk Edit")) (when (can? (:identity request) {:subject :invoice :activity :pay}) (pay-button* {:ids (selected->ids request (:query-params request))})) @@ -1400,6 +1410,298 @@ target-route) (:query-params request)))}})) + +(defn initial-bulk-edit-state [request] + (mm/->MultiStepFormState {:search-params (:query-params request) + :expense-accounts [{:db/id "123" + :location "Shared" + :account nil + :percentage 1.0}]} + [] + {:search-params (:query-params request) + :expense-accounts [{:db/id "123" + :location "Shared" + :account nil + :percentage 1.0}]})) + +(defn- account-typeahead* + [{:keys [name value client-id x-model]}] + [:div.flex.flex-col + (com/typeahead {:name name + :placeholder "Search..." + :url (hu/url (bidi/path-for ssr-routes/only-routes :account-search) + { :purpose "invoice"}) + :id name + :x-model x-model + :value value + :content-fn (fn [value] + (:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value) + client-id)))})]) + + + +;; TODO clientize +(defn all-ids-not-locked [all-ids] + (->> all-ids + (dc/q '[:find ?i + :in $ [?i ...] + :where + [?i :invoice/client ?c] + [(get-else $ ?c :client/locked-until #inst "2000-01-01") ?lu] + [?i :invoice/date ?d] + [(>= ?d ?lu)]] + (dc/db conn)) + (map first))) +(defn- bulk-edit-account-row* [{:keys [value client-id]}] + + (com/data-grid-row + (-> {:x-data (hx/json {:show (boolean (not (fc/field-value (:new? value)))) + :accountId (fc/field-value (:account value))}) + :data-key "show" + :x-ref "p"} + hx/alpine-mount-then-appear) + (fc/with-field :db/id + (com/hidden {:name (fc/field-name) + :value (fc/field-value)})) + (fc/with-field :account + (com/data-grid-cell + {} + (com/validated-field + {:errors (fc/field-errors)} + (account-typeahead* {:value (fc/field-value) + :client-id client-id + :name (fc/field-name) + :x-model "accountId"})))) + (fc/with-field :location + (com/data-grid-cell + {} + (com/validated-field + {:errors (fc/field-errors) + :x-hx-val:account-id "accountId" + :hx-vals (hx/json {:name (fc/field-name) }) + :x-dispatch:changed "accountId" + :hx-trigger "changed" + :hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select) + :hx-target "find *" + :hx-swap "outerHTML"} + (location-select* {:name (fc/field-name) + :account-location (:account/location (cond->> (:account @value) + (nat-int? (:account @value)) (dc/pull (dc/db conn) + '[:account/location]))) + :value (fc/field-value)})))) + (fc/with-field :percentage + (com/data-grid-cell + {} + (com/validated-field + {:errors (fc/field-errors)} + (com/money-input {:name (fc/field-name) + :class "w-16 amount-field" + :value (some-> (fc/field-value) + (* 100) + (long))})))) + (com/data-grid-cell {:class "align-top"} + (com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x)))) + +(defrecord AccountsStep [linear-wizard] + mm/ModalWizardStep + (step-name [_] + "Expense Accounts") + (step-key [_] + :accounts) + + (edit-path [_ _] + []) + + (step-schema [_] + (mut/select-keys (mm/form-schema linear-wizard) #{:expense-accounts})) + + (render-step [this {{:keys [snapshot] :as multi-form-state} :multi-form-state :as request}] + (let [selected-ids (selected->ids (assoc request :query-params (:search-params snapshot)) (:search-params snapshot)) + all-ids (all-ids-not-locked selected-ids)] + (mm/default-render-step + linear-wizard this + :head [:div.p-2 "Bulk editing " (count all-ids) " invoices"] + :body (mm/default-step-body + {} + [:div {} + (fc/with-field :expense-accounts + (com/validated-field + {:errors (fc/field-errors)} + (com/data-grid {:headers [(com/data-grid-header {} "Account") + (com/data-grid-header {:class "w-32"} "Location") + (com/data-grid-header {:class "w-16"} "%") + (com/data-grid-header {:class "w-16"})]} + (fc/cursor-map #(bulk-edit-account-row* {:value % + :client-id (:invoice/client snapshot)})) + + (com/data-grid-new-row {:colspan 4 + :hx-get (bidi/path-for ssr-routes/only-routes + ::route/bulk-edit-new-account) + :row-offset 0 + :index (count (fc/field-value)) } + "New account") + (com/data-grid-row {} + (com/data-grid-cell {}) + (com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"]) + (com/data-grid-cell {:id "total" + :class "text-right" + :hx-trigger "change from:closest form target:.amount-field" + :hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/bulk-edit-total) + :hx-target "this" + :hx-swap "innerHTML"} + #_(invoice-expense-account-total* request)) + (com/data-grid-cell {})) + + (com/data-grid-row {} + (com/data-grid-cell {}) + (com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"]) + (com/data-grid-cell {:id "total" + :class "text-right" + :hx-trigger "change from:closest form target:.amount-field" + :hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/bulk-edit-balance) + :hx-target "this" + :hx-swap "innerHTML"} + #_(invoice-expense-account-balance* request)) + (com/data-grid-cell {})))))]) + :footer + (mm/default-step-footer linear-wizard this :validation-route ::route/new-wizard-navigate + :next-button (com/button {:color :primary :x-ref "next" :class "w-32"} "Save")) + :validation-route ::route/new-wizard-navigate)))) + + +(defn maybe-code-accounts [invoice account-rules valid-locations] + (with-precision 2 + (let [accounts (vec (mapcat + (fn [ar] + (let [cents-to-distribute (int (Math/round (Math/abs (* (:percentage ar) + (:invoice/total invoice) + 100))))] + (if (= "Shared" (:location ar)) + (do + (->> valid-locations + (map + (fn [cents location] + {:db/id (random-tempid) + :invoice-expense-account/account (:account ar) + :invoice-expense-account/amount (* 0.01 cents) + :invoice-expense-account/location location}) + (rm/spread-cents cents-to-distribute (count valid-locations))))) + [(cond-> {:db/id (random-tempid) + :invoice-expense-account/account (:account ar) + :invoice-expense-account/amount (* 0.01 cents-to-distribute)} + (:location ar) (assoc :invoice-expense-account/location (:location ar)))]))) + account-rules)) + accounts (mapv + (fn [a] + (update a :invoice-expense-account/amount + #(with-precision 2 + (double (.setScale (bigdec %) 2 java.math.RoundingMode/HALF_UP))))) + accounts) + leftover (with-precision 2 (.round (bigdec (- (Math/abs (:invoice/total invoice)) + (Math/abs (reduce + 0.0 (map #(:invoice-expense-account/amount %) accounts))))) + *math-context*)) + accounts (if (seq accounts) + (update-in accounts [(dec (count accounts)) :invoice-expense-account/amount] #(+ % (double leftover))) + [])] + accounts))) + +(defn assert-percentages-add-up [{:keys [expense-accounts]}] + (let [expense-account-total (reduce + 0 (map (fn [x] (:percentage x)) expense-accounts))] + (when-not (dollars= 1.0 expense-account-total) + (form-validation-error (str "Expense account total (" expense-account-total ") does not equal 100%"))))) + +(defrecord BulkEditWizard [_ current-step] + mm/LinearModalWizard + (hydrate-from-request + [this request] + this) + (navigate [this step-key] + (assoc this :current-step step-key)) + (get-current-step [this] + (if current-step + (mm/get-step this current-step) + (mm/get-step this :accounts))) + (render-wizard [this {:keys [multi-form-state] :as request}] + (mm/default-render-wizard + this request + :form-params + (-> mm/default-form-props + (assoc :hx-put + (str (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-submit)))) + :render-timeline? false)) + (steps [_] + [:accounts]) + (get-step [this step-key] + (let [step-key-result (mc/parse mm/step-key-schema step-key) + [step-key-type step-key] step-key-result] + (get {:accounts (->AccountsStep this) } + step-key))) + (form-schema [_] + (mc/schema [:map + [:expense-accounts + (many-entity {:min 1} + [:account entity-id] + [:location [:string {:min 1 :error/message "required"}]] + [:percentage percentage])]])) + (submit [this {:keys [multi-form-state request-method identity] :as request}] + (let [selected-ids (selected->ids (assoc request :query-params (:search-params (:snapshot multi-form-state))) (:search-params (:snapshot multi-form-state))) + all-ids (all-ids-not-locked selected-ids) + invoices (pull-many (dc/db conn) '[:db/id :invoice/total {:invoice/client [:client/locations]}] (vec all-ids)) ] +(assert-percentages-add-up (:snapshot multi-form-state)) + + (doseq [a (-> multi-form-state :snapshot :expense-accounts) + :let [{:keys [:account/location :account/name]} (dc/pull (dc/db conn) [:account/location :account/name] (:account a))]] + (when (and location (not= location (:location a))) + (let [err (str "Account " name " uses location " (:location a) ", but is supposed to be " location)] + (throw (ex-info err {:validation-error err}))))) + (alog/info ::bulk-code :count (count all-ids)) + (audit-transact-batch + (map (fn [i] + [:upsert-invoice {:db/id (:db/id i) + :invoice/expense-accounts (maybe-code-accounts i (-> multi-form-state :snapshot :expense-accounts) (-> i :invoice/client :client/locations))}]) + invoices) + (:identity request)) + + (html-response + [:div] + :headers (cond-> {"hx-trigger" (hx/json { "modalclose" "" + "invalidated" "" + "notification" (str "Successfully coded " (count all-ids) " invoices.")}) + "hx-reswap" "outerHTML"}))))) + +(def bulk-edit-wizard (->BulkEditWizard nil nil)) + + +(defn bulk-edit-total* [request] + (let [total (->> (-> request + :multi-form-state + :step-params + :expense-accounts) + (map (fnil :percentage 0.0)) + (filter number?) + (reduce + 0.0))] + (format "%.1f%%" (* 100.0 total)))) + +(defn bulk-edit-balance* [request] + (let [total (->> (-> request + :multi-form-state + :step-params + :expense-accounts) + (map (fnil :percentage 0.0)) + (filter number?) + (reduce + 0.0)) + balance (- 100.0 + (* 100.0 total))] + [:span {:class (when-not (dollars= 0.0 balance) + "text-red-300")} + (format "%.1f%%" balance)])) + +(defn bulk-edit-total [request] + (html-response (bulk-edit-total* request))) + +(defn bulk-edit-balance [request] + (html-response (bulk-edit-balance* request))) + (def key->handler (apply-middleware-to-all-handlers (-> @@ -1417,9 +1719,36 @@ ::route/legacy-paid-invoices (redirect-handler ::route/paid-page) ::route/legacy-voided-invoices (redirect-handler ::route/voided-page) ::route/legacy-new-invoice (redirect-handler ::route/new-wizard) -::route/undo-autopay (-> undo-autopay - (wrap-entity [:route-params :db/id] default-read) - (wrap-schema-enforce :route-params [:map [:db/id entity-id]])) + ::route/bulk-edit (-> mm/open-wizard-handler + (mm/wrap-wizard bulk-edit-wizard) + (mm/wrap-init-multi-form-state initial-bulk-edit-state)) +::route/bulk-edit-submit (-> mm/submit-handler + (mm/wrap-wizard bulk-edit-wizard) + (mm/wrap-decode-multi-form-state) + (wrap-must {:subject :invoice :activity :bulk-edit})) +::route/bulk-edit-total (-> bulk-edit-total + (mm/wrap-wizard bulk-edit-wizard) + (mm/wrap-decode-multi-form-state) + (wrap-must {:subject :invoice :activity :bulk-edit})) +::route/bulk-edit-balance (-> bulk-edit-balance + + (mm/wrap-wizard bulk-edit-wizard) + (mm/wrap-decode-multi-form-state) + (wrap-must {:subject :invoice :activity :bulk-edit})) +::route/bulk-edit-new-account (-> + (add-new-entity-handler [:step-params :expense-accounts] + (fn render [cursor request] + (bulk-edit-account-row* + {:value cursor })) + (fn build-new-row [base _] + (assoc base :invoice-expense-account/location "Shared"))) + (wrap-schema-enforce :query-schema [:map + [:client-id {:optional true} + [:maybe entity-id]]])) + + ::route/undo-autopay (-> undo-autopay + (wrap-entity [:route-params :db/id] default-read) + (wrap-schema-enforce :route-params [:map [:db/id entity-id]])) ::route/unvoid (-> unvoid-invoice (wrap-entity [:route-params :db/id] default-read) (wrap-schema-enforce :route-params [:map [:db/id entity-id]])) diff --git a/src/cljc/auto_ap/routes/invoice.cljc b/src/cljc/auto_ap/routes/invoice.cljc index 7daaf2e2..d1fd2632 100644 --- a/src/cljc/auto_ap/routes/invoice.cljc +++ b/src/cljc/auto_ap/routes/invoice.cljc @@ -31,6 +31,11 @@ :post ::pay-submit} "/bulk-delete" {:get ::bulk-delete :delete ::bulk-delete-confirm} + "/bulk-edit" {:get ::bulk-edit + :put ::bulk-edit-submit + "/account" ::bulk-edit-new-account + "/total" ::bulk-edit-total + "/balance" ::bulk-edit-balance} ["/" [#"\d+" :db/id]] {:delete ::delete "/undo-autopay" ::undo-autopay "/unvoid" ::unvoid