bulk edit is now possible

This commit is contained in:
2025-01-20 21:18:52 -08:00
parent 747bf66206
commit 956d233fe1
6 changed files with 416 additions and 91 deletions

View File

@@ -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)))})))
"applicability" (name (:db/ident (:account/applicability result)))})))

View File

@@ -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

View File

@@ -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)])

View File

@@ -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

View File

@@ -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]]))

View File

@@ -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