implements import page in new UI
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -86,7 +86,7 @@
|
||||
|
||||
[:button (update params
|
||||
:class #(cond-> %
|
||||
true (str " focus:ring-4 font-bold rounded-lg text-xs p-3 text-center mr-2 inline-flex items-center relative justify-center"
|
||||
true (str " focus:ring-4 font-bold rounded-lg text-xs p-3 text-center mr-2 inline-flex items-center relative justify-center disabled:opacity-50"
|
||||
(bg-colors (:color params) (:disabled params)))
|
||||
|
||||
(not (:disabled params))
|
||||
|
||||
@@ -21,4 +21,5 @@
|
||||
[:payment/status :xform iol-ion.query/ident] [:db/ident]}]}]
|
||||
#_[:payment/_invoices :as :invoice/payments]
|
||||
[:invoice/status :xform iol-ion.query/ident] [:db/ident]
|
||||
[:invoice/import-status :xform iol-ion.query/ident] [:db/ident]
|
||||
:invoice/vendor [:vendor/name :db/id]}])
|
||||
@@ -1,33 +1,46 @@
|
||||
(ns auto-ap.ssr.invoice.import
|
||||
(:require [auto-ap.client-routes :as client-routes]
|
||||
(:require [amazonica.aws.s3 :as s3]
|
||||
[auto-ap.datomic
|
||||
:refer [add-sorter-fields apply-pagination apply-sort-3 conn
|
||||
merge-query observable-query pull-many]]
|
||||
[auto-ap.datomic.accounts :as d-accounts]
|
||||
[auto-ap.graphql.utils :refer [extract-client-ids]]
|
||||
[auto-ap.permissions :refer [can?]]
|
||||
:refer [add-sorter-fields apply-pagination apply-sort-3
|
||||
audit-transact conn merge-query observable-query
|
||||
pull-attr pull-many random-tempid]]
|
||||
[auto-ap.datomic.clients :as d-clients]
|
||||
[auto-ap.datomic.invoices :as d-invoices]
|
||||
[auto-ap.datomic.vendors :as d-vendors]
|
||||
[auto-ap.graphql.utils :refer [assert-can-see-client
|
||||
assert-not-locked
|
||||
exception->notification
|
||||
extract-client-ids]]
|
||||
[auto-ap.logging :as alog]
|
||||
[auto-ap.parse :as parse]
|
||||
[auto-ap.permissions :refer [can? wrap-must]]
|
||||
[auto-ap.routes.invoice :as route]
|
||||
[auto-ap.routes.payments :as payment-route]
|
||||
[auto-ap.ssr-routes :as ssr-routes]
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.components.link-dropdown :refer [link-dropdown]]
|
||||
[auto-ap.ssr.form-cursor :as fc]
|
||||
[auto-ap.ssr.grid-page-helper :as helper]
|
||||
[auto-ap.ssr.hx :as hx]
|
||||
[auto-ap.ssr.invoice.common :refer [default-read]]
|
||||
[auto-ap.ssr.pos.common :refer [date-range-field*]]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [clj-date-schema entity-id html-response main-transformer
|
||||
ref->enum-schema strip wrap-implied-route-param]]
|
||||
:refer [apply-middleware-to-all-handlers clj-date-schema
|
||||
entity-id html-response main-transformer ref->enum-schema
|
||||
strip wrap-entity wrap-implied-route-param
|
||||
wrap-schema-enforce]]
|
||||
[auto-ap.time :as atime]
|
||||
[auto-ap.utils :refer [dollars=]]
|
||||
[bidi.bidi :as bidi]
|
||||
[clj-time.coerce :as coerce]
|
||||
[clj-time.coerce :as coerce :refer [to-date]]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.string :as str]
|
||||
[com.brunobonacci.mulog :as mu]
|
||||
[config.core :refer [env]]
|
||||
[datomic.api :as dc]
|
||||
[hiccup.util :as hu]
|
||||
[hiccup2.core :as hiccup]
|
||||
[malli.core :as mc]))
|
||||
[malli.core :as mc]
|
||||
[auto-ap.client-routes :as client-routes])
|
||||
(:import [java.util UUID]))
|
||||
|
||||
|
||||
(defn exact-match-id* [request]
|
||||
@@ -46,7 +59,7 @@
|
||||
(defn filters [request]
|
||||
[:form#invoice-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||
::route/table)
|
||||
::route/import-table)
|
||||
"hx-target" "#entity-table"
|
||||
"hx-indicator" "#entity-table"}
|
||||
|
||||
@@ -112,13 +125,15 @@
|
||||
valid-clients]}
|
||||
(cond-> {:query {:find []
|
||||
:in '[$ [?clients ?start ?end]]
|
||||
:where '[[(iol-ion.query/scan-invoices $ ?clients ?start ?end) [[?e _ ?sort-default] ...]]]}
|
||||
:where '[[(iol-ion.query/scan-invoices $ ?clients ?start ?end) [[?e _ ?sort-default] ...]]
|
||||
[?e :invoice/import-status :import-status/pending]]}
|
||||
:args [db
|
||||
[valid-clients
|
||||
(some-> (:start-date query-params) coerce/to-date)
|
||||
(some-> (:end-date query-params) coerce/to-date)]]}
|
||||
|
||||
|
||||
|
||||
(:client-id query-params)
|
||||
(merge-query {:query {:in ['?client-id]
|
||||
:where ['[?e :invoice/client ?client-id]]}
|
||||
@@ -283,11 +298,6 @@
|
||||
[:end-date {:optional true}
|
||||
[:maybe clj-date-schema]]]]))
|
||||
|
||||
(comment
|
||||
(mc/decode query-schema
|
||||
{:start " "}
|
||||
main-transformer))
|
||||
|
||||
(defn selected->ids [request params]
|
||||
(let [all-selected (:all-selected params)
|
||||
selected (:selected params)
|
||||
@@ -300,117 +310,93 @@
|
||||
|
||||
|
||||
:else
|
||||
selected)]
|
||||
selected)
|
||||
ids (->> (dc/q '[:find ?i
|
||||
:in $ [?i ...]
|
||||
:where [?i :invoice/import-status :import-status/pending]]
|
||||
(dc/db conn)
|
||||
ids)
|
||||
(map first))]
|
||||
ids))
|
||||
|
||||
(defn pay-button* [params]
|
||||
(let [ids (:ids params)
|
||||
selected-client-count (if (seq ids)
|
||||
(ffirst
|
||||
(dc/q '[:find (count ?c)
|
||||
:in $ [?i ...]
|
||||
:where [?i :invoice/client ?c]]
|
||||
(dc/db conn)
|
||||
ids))
|
||||
(def upload-schema
|
||||
[:map
|
||||
[:force-client {:optional true}
|
||||
[:maybe entity-id]]
|
||||
[:force-vendor {:optional true}
|
||||
[:maybe entity-id]]
|
||||
[:force-location {:optional true}
|
||||
[:maybe [:string {:decode/string strip :min 2 :max 2}]]]])
|
||||
|
||||
0)
|
||||
vendor-totals (if (seq ids)
|
||||
(->>
|
||||
(dc/q '[:find ?i ?v ?ob
|
||||
:in $ [?i ...]
|
||||
:where [?i :invoice/vendor ?v]
|
||||
[?i :invoice/outstanding-balance ?ob]]
|
||||
(dc/db conn)
|
||||
ids)
|
||||
(reduce (fn [acc [_ v ob]]
|
||||
(update acc v (fnil + 0) ob))
|
||||
{})
|
||||
(vals)))
|
||||
all-credits-or-debits (or (every? #(<= % 0.0) vendor-totals)
|
||||
(every? #(>= % 0.0) vendor-totals))
|
||||
total (reduce + 0.0 vendor-totals)]
|
||||
|
||||
|
||||
[:div {:hx-target "this"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes
|
||||
::route/pay-wizard)
|
||||
:hx-trigger "click from:#pay-button"
|
||||
:x-data (hx/json {:popper nil
|
||||
:hovering false})
|
||||
"x-init" "popper = Popper.createPopper($refs.button, $refs.tooltip, {placement: 'bottom', strategy: 'fixed', modifiers: [{name: 'preventOverflow'}, {name: 'offset', options: {offset: [0, 10]}}]});"}
|
||||
(com/button {:color :primary
|
||||
:id "pay-button"
|
||||
:disabled (or (= (count (:ids params)) 0)
|
||||
(not= 1 selected-client-count)
|
||||
(not all-credits-or-debits))
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"hx-include" "#invoice-filters"
|
||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/pay-button)
|
||||
:hx-swap "outerHTML"
|
||||
:hx-trigger "selectedChanged from:body, htmx:afterSwap from:#entity-table"
|
||||
"@mouseover" "hovering=true; $nextTick(() => popper.update())"
|
||||
"@mouseout" "hovering=false;"
|
||||
:x-ref "button"
|
||||
:minimal-loading? true
|
||||
:class "relative"}
|
||||
(if (> (count (:ids params)) 0)
|
||||
|
||||
(format "Pay %d invoices ($%,.2f)"
|
||||
(count (:ids params))
|
||||
(or total 0.0))
|
||||
"Pay")
|
||||
(when (or (= 0 (count ids))
|
||||
(> selected-client-count 1))
|
||||
(com/badge {} "!")))
|
||||
[:div (hx/alpine-appear {:x-ref "tooltip"
|
||||
|
||||
:x-show "hovering"
|
||||
:class "bg-gray-100 dark:bg-gray-600 rounded-lg shadow-2xl w-max z-50 p-4"})
|
||||
(cond
|
||||
(not all-credits-or-debits)
|
||||
[:div "All vendor totals must be either positive or negative."]
|
||||
(= 0 (count ids))
|
||||
[:div "Please select some invoices to pay"]
|
||||
(> selected-client-count 1)
|
||||
[:div "Can only pay for one client at a time"]
|
||||
:else
|
||||
[:div "Click to choose a bank account"])]]))
|
||||
|
||||
|
||||
(defn pay-button [request]
|
||||
(html-response
|
||||
(pay-button* {:ids (selected->ids request
|
||||
(:query-params request))})))
|
||||
|
||||
|
||||
;; TODO test as a real user
|
||||
(def grid-page
|
||||
(helper/build {:id "entity-table"
|
||||
:nav com/main-aside-nav
|
||||
:check-boxes? true
|
||||
:page-specific-nav filters
|
||||
:above-grid (fn [request]
|
||||
(com/content-card {}
|
||||
[:div.px-4.py-3.space-y-4
|
||||
[:h1.text-2xl.mb-3.font-bold "Import new invoices"]
|
||||
|
||||
[:form.bg-blue-100.border-2.border-dashed.rounded-lg.border-blue-300.p-4.max-w-md.w-md.text-center.cursor-pointer
|
||||
{:action (bidi/path-for ssr-routes/only-routes
|
||||
::route/import-page)
|
||||
:method "POST"
|
||||
:id "upload"}
|
||||
"Drop files to upload here"]
|
||||
[:script
|
||||
(hiccup/raw
|
||||
"
|
||||
ezcater_dropzone = new Dropzone (\"#upload\", {
|
||||
success: function (file, response) {
|
||||
document.getElementById(\"page-notification\").innerHTML = response;
|
||||
document.getElementById(\"page-notification\").style[\"display\"] = \"block\";
|
||||
},
|
||||
acceptedFiles: '.xls,.xlsx,.pdf,.csv',
|
||||
disablePreviews: true
|
||||
});")]]))
|
||||
:above-grid (fn [{:keys [form-params form-errors] :as request}]
|
||||
(com/content-card {}
|
||||
|
||||
[:div.px-4.py-3.space-y-4
|
||||
|
||||
[:div.flex.justify-between.items-center [:h1.text-2xl.mb-3.font-bold "Import new invoices"]
|
||||
|
||||
[:div.flex.gap-2.items-baseline "Trouble with the new upload experience?"
|
||||
[:a {:href (bidi/path-for client-routes/routes :import-invoices )}
|
||||
(com/pill {:color :secondary}
|
||||
"Go back to previous version")]]]
|
||||
[:div#page-notification.notification.block {:style {:display "none"}}]
|
||||
|
||||
|
||||
[:form
|
||||
{:action (bidi/path-for ssr-routes/only-routes
|
||||
::route/import-file)
|
||||
:method "POST"
|
||||
:id "upload"}
|
||||
(fc/start-form
|
||||
form-params form-errors
|
||||
[:div.flex.gap-4
|
||||
|
||||
(fc/with-field :force-client
|
||||
(com/validated-field {:label "Force client"
|
||||
:errors (fc/field-errors)}
|
||||
[:div.w-96
|
||||
(com/typeahead {:name (fc/field-name)
|
||||
:error? (fc/error?)
|
||||
:class "w-96"
|
||||
:placeholder "Search..."
|
||||
:url (bidi/path-for ssr-routes/only-routes :company-search)
|
||||
:value (fc/field-value)
|
||||
:content-fn (fn [c] (pull-attr (dc/db conn) :client/name c))})]))
|
||||
(fc/with-field :force-location
|
||||
(com/validated-field {:label "Force location"
|
||||
:errors (fc/field-errors)}
|
||||
|
||||
(com/text-input {:name (fc/field-name)
|
||||
:value (fc/field-value)
|
||||
:size 2})))
|
||||
(fc/with-field :force-vendor
|
||||
(com/validated-field {:label "Force vendor"
|
||||
:errors (fc/field-errors)}
|
||||
[:div.w-96
|
||||
(com/typeahead {:name (fc/field-name)
|
||||
:error? (fc/error?)
|
||||
:class "w-96"
|
||||
:placeholder "Search..."
|
||||
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
|
||||
:value (fc/field-value)
|
||||
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})]))])
|
||||
[:div.bg-blue-100.border-2.border-dashed.rounded-lg.border-blue-300.p-4.w-full.text-center.cursor-pointer.h-64.flex.items-center.justify-center.text-blue-700.text-lg "Drop files to upload here"]]
|
||||
[:script
|
||||
(hiccup/raw
|
||||
"ezcater_dropzone = new Dropzone (\"#upload\", {
|
||||
success: function (file, response) {
|
||||
document.getElementById(\"page-notification\").innerHTML = response;
|
||||
document.getElementById(\"page-notification\").style[\"display\"] = \"block\";
|
||||
document.body.dispatchEvent(new Event('invalidated'));
|
||||
},
|
||||
acceptedFiles: '.xls,.xlsx,.pdf,.csv',
|
||||
disablePreviews: true
|
||||
});")]]))
|
||||
|
||||
:fetch-page fetch-page
|
||||
:oob-render
|
||||
@@ -422,43 +408,37 @@
|
||||
(mc/decode query-schema p main-transformer))
|
||||
:action-buttons (fn [request]
|
||||
(let [[_ _ outstanding total] (:page-results request)]
|
||||
[(com/pill {:color :primary} "Outstanding: "
|
||||
(format "$%,.2f" outstanding))
|
||||
(com/pill {:color :secondary} "Total: "
|
||||
(format "$%,.2f" total))
|
||||
|
||||
(when (can? (:identity request) {:subject :invoice :activity :bulk-delete})
|
||||
(com/button {:hx-get (str (bidi/path-for ssr-routes/only-routes ::route/bulk-delete))
|
||||
[
|
||||
(when (can? (:identity request) {:subject :invoice :activity :import})
|
||||
(com/button {:hx-put (str (bidi/path-for ssr-routes/only-routes ::route/bulk-approve))
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"hx-include" "#invoice-filters"
|
||||
:color :primary
|
||||
":disabled" "$data.all_selected || ($data.selected && $data.selected.length > 0) ? false: true "
|
||||
}
|
||||
"Approve selected"))
|
||||
(when (can? (:identity request) {:subject :invoice :activity :import})
|
||||
(com/button {:hx-delete (str (bidi/path-for ssr-routes/only-routes ::route/bulk-disapprove))
|
||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||
"x-init" ""
|
||||
"hx-include" "#invoice-filters"
|
||||
":disabled" "$data.all_selected || ($data.selected && $data.selected.length > 0) ? false: true "
|
||||
:color :red}
|
||||
"Void selected"))
|
||||
(when (can? (:identity request) {:subject :invoice :activity :pay})
|
||||
(pay-button* {:ids (selected->ids request
|
||||
(:query-params request))}))
|
||||
(when (can? (:identity request) {:subject :invoice :activity :create})
|
||||
(com/button {:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-wizard)}
|
||||
"New invoice"))]))
|
||||
"Disapprove selected"))]))
|
||||
:row-buttons (fn [request entity]
|
||||
[(when (and (= :invoice-status/unpaid (:invoice/status entity))
|
||||
(can? (:identity request) {:subject :invoice :activity :delete}))
|
||||
[(when (and (= :import-status/pending (:invoice/import-status entity))
|
||||
(can? (:identity request) {:subject :invoice :activity :import}))
|
||||
(com/icon-button {:hx-delete (bidi/path-for ssr-routes/only-routes
|
||||
::route/delete
|
||||
::route/disapprove
|
||||
:db/id (:db/id entity))
|
||||
:hx-confirm "Are you sure you want to void this invoice?"}
|
||||
svg/trash))
|
||||
(when (and (can? (:identity request) {:subject :invoice :activity :edit})
|
||||
(#{:invoice-status/unpaid :invoice-status/paid} (:invoice/status entity)))
|
||||
:hx-confirm "Are you sure you want to remove this invoice?"}
|
||||
svg/thumbs-down))
|
||||
(when (and (= :import-status/pending (:invoice/import-status entity))
|
||||
(can? (:identity request) {:subject :invoice :activity :import}))
|
||||
(com/icon-button {:hx-put (bidi/path-for ssr-routes/only-routes
|
||||
::route/edit-wizard
|
||||
:db/id (:db/id entity))}
|
||||
svg/pencil))
|
||||
(when (and (can? (:identity request) {:subject :invoice :activity :edit})
|
||||
(#{:invoice-status/voided} (:invoice/status entity)))
|
||||
(com/icon-button {:hx-put (bidi/path-for ssr-routes/only-routes
|
||||
::route/unvoid
|
||||
:db/id (:db/id entity))}
|
||||
svg/undo))])
|
||||
::route/approve
|
||||
:db/id (:db/id entity))}
|
||||
svg/thumbs-up))])
|
||||
|
||||
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)}
|
||||
"Invoices"]]
|
||||
@@ -467,7 +447,7 @@
|
||||
(some-> r :route-params :status name str/capitalize (str " "))
|
||||
"Invoices"))
|
||||
:entity-name "invoices"
|
||||
:route ::route/table
|
||||
:route ::route/import-table
|
||||
:headers [{:key "client"
|
||||
:name "Client"
|
||||
:sort-key "client"
|
||||
@@ -488,36 +468,6 @@
|
||||
:show-starting "lg"
|
||||
:render (fn [{:invoice/keys [date]}]
|
||||
(some-> date (atime/unparse-local atime/normal-date)))}
|
||||
{:key "status"
|
||||
:name "Status"
|
||||
:render (fn [{:invoice/keys [status]}]
|
||||
(condp = status
|
||||
:invoice-status/paid
|
||||
(com/pill {:color :primary} "Paid")
|
||||
|
||||
:invoice-status/unpaid
|
||||
(com/pill {:color :secondary} "Unpaid")
|
||||
:invoice-status/voided
|
||||
(com/pill {:color :red} "Voided")
|
||||
nil
|
||||
""))}
|
||||
{:key "accounts"
|
||||
:name "Account"
|
||||
:show-starting "lg"
|
||||
:render (fn [{:invoice/keys [expense-accounts client]}]
|
||||
[:div.flex.flex-col.gap-y-2
|
||||
(when (first expense-accounts)
|
||||
[:div.flex-initial
|
||||
(com/pill {:color :primary}
|
||||
(:account/name
|
||||
(d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read (:db/id (:invoice-expense-account/account (first expense-accounts))))
|
||||
|
||||
(:db/id client))))])
|
||||
(when (> (count expense-accounts) 1)
|
||||
[:div.flex-initial
|
||||
(com/pill {:color :secondary}
|
||||
"+ " (dec (count expense-accounts)) " more")])])}
|
||||
|
||||
{:key "outstanding"
|
||||
:name "Outstanding"
|
||||
:sort-key "outstanding-balance"
|
||||
@@ -526,44 +476,241 @@
|
||||
[:div
|
||||
(some->> outstanding-balance (format "$%,.2f"))
|
||||
(when-not (dollars= outstanding-balance total)
|
||||
[:div.text-xs.text-gray-400 (format "of $%,.2f" total)])])}
|
||||
{:key "links"
|
||||
:name "Links"
|
||||
:show-starting "lg"
|
||||
:class "w-8"
|
||||
:render (fn [i]
|
||||
(link-dropdown
|
||||
(concat (->> i
|
||||
:invoice/payments
|
||||
(filter (fn [p]
|
||||
(not= :payment-status/voided
|
||||
(:payment/status p))))
|
||||
(mapcat (fn [p]
|
||||
(cond-> [{:link (hu/url (bidi/path-for ssr-routes/only-routes
|
||||
::payment-route/all-page)
|
||||
{:exact-match-id (:db/id p)})
|
||||
:content (str (format "$%,.2f" (:payment/amount p))
|
||||
(some-> (:payment/date p) coerce/to-date-time (atime/unparse-local atime/normal-date) (#(str " payment on " %))))}]
|
||||
(:payment/transaction p) (conj {:link (hu/url (bidi/path-for client-routes/routes :transactions)
|
||||
{:exact-match-id (:db/id (first (:payment/transaction p)))})
|
||||
:color :secondary
|
||||
:content "Transaction"})))))
|
||||
(when (:invoice/journal-entry i)
|
||||
[{:link (hu/url (bidi/path-for client-routes/routes :ledger)
|
||||
{:exact-match-id (:db/id (first (:invoice/journal-entry i)))})
|
||||
:color :yellow
|
||||
:content "Ledger entry"}])
|
||||
(when (:invoice/source-url i)
|
||||
[{:link (:invoice/source-url i)
|
||||
:color :secondary
|
||||
:content "File"}]))))}]}))
|
||||
[:div.text-xs.text-gray-400 (format "of $%,.2f" total)])])}]}))
|
||||
|
||||
(def row* (partial helper/row* grid-page))
|
||||
|
||||
(defn disapprove [{invoice :entity :as request identity :identity}]
|
||||
(when-not (= :import-status/pending (:invoice/import-status invoice))
|
||||
(throw (ex-info (str "Cannot disapprove an invoice if it is not pending." (:invoice/import-status invoice))
|
||||
{:type :notification})))
|
||||
(exception->notification
|
||||
#(assert-can-see-client identity (:db/id (:invoice/client invoice))))
|
||||
|
||||
(audit-transact [[:db/retractEntity (:db/id invoice)]] identity)
|
||||
|
||||
(html-response (row* (:identity request) invoice
|
||||
{:class "live-removed"})
|
||||
:headers {"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id invoice))}))
|
||||
|
||||
(defn approve [{invoice :entity :as request identity :identity}]
|
||||
(when-not (= :import-status/pending (:invoice/import-status invoice))
|
||||
(throw (ex-info (str "Cannot approve an invoice if it is not pending." (:invoice/import-status invoice))
|
||||
{:type :notification})))
|
||||
(exception->notification
|
||||
#(do (assert-can-see-client identity (:db/id (:invoice/client invoice)))
|
||||
(assert-not-locked (-> invoice :invoice/client :db/id) (-> invoice :invoice/date))))
|
||||
|
||||
(audit-transact [ [:upsert-invoice {:db/id (:db/id invoice) :invoice/import-status :import-status/imported}]] identity)
|
||||
|
||||
(html-response (row* (:identity request) invoice
|
||||
{:class "live-added"})
|
||||
:headers {"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id invoice))}))
|
||||
|
||||
(defn bulk-disapprove [request]
|
||||
(let [ids (selected->ids request (:form-params request))
|
||||
updates (map
|
||||
(fn [i] [:db/retractEntity i])
|
||||
ids) ]
|
||||
(audit-transact updates (:identity request) )
|
||||
|
||||
(html-response [:div]
|
||||
:headers {"hx-trigger" (hx/json { :notification (format "Successfully disapproved %d invoices."
|
||||
(count ids))
|
||||
:invalidated "invalidated"})})))
|
||||
|
||||
(defn bulk-approve [request]
|
||||
(let [ids (selected->ids request (:form-params request))]
|
||||
(exception->notification
|
||||
#(doseq [i ids
|
||||
:let [invoice (dc/pull (dc/db conn) '[{:invoice/client [:db/id]}
|
||||
:invoice/date] i)]]
|
||||
(assert-can-see-client (:identity request) (-> invoice :invoice/client :db/id))
|
||||
(assert-not-locked (-> invoice :invoice/client :db/id) (-> invoice :invoice/date))))
|
||||
(let [transactions (map (fn [i] [:upsert-invoice {:db/id i :invoice/import-status :import-status/imported}]) ids)]
|
||||
(audit-transact transactions (:identity request)))
|
||||
(html-response [:div]
|
||||
:headers {"hx-trigger" (hx/json { :notification (format "Successfully approved %d invoices."
|
||||
(count ids))
|
||||
:invalidated "invalidated"})})))
|
||||
|
||||
(defn upload-invoices [{{files :file
|
||||
files-2 "file"
|
||||
client :client
|
||||
client-2 "client"
|
||||
location :location
|
||||
location-2 "location"
|
||||
vendor :vendor
|
||||
vendor-2 "vendor"} :params
|
||||
user :identity}]
|
||||
(let [files (or files files-2)
|
||||
client (or client client-2)
|
||||
location (or location location-2)
|
||||
vendor (some-> (or vendor vendor-2)
|
||||
(Long/parseLong))
|
||||
]
|
||||
))
|
||||
|
||||
(defn match-vendor [vendor-code forced-vendor]
|
||||
(when (and (not forced-vendor) (str/blank? vendor-code))
|
||||
(throw (ex-info (str "No vendor found. Please supply an forced vendor.")
|
||||
{:vendor-code vendor-code})))
|
||||
(let [vendor-id (or forced-vendor
|
||||
(->> (dc/q
|
||||
{:find ['?vendor]
|
||||
:in ['$ '?vendor-name]
|
||||
:where ['[?vendor :vendor/name ?vendor-name]]}
|
||||
(dc/db conn) vendor-code)
|
||||
first
|
||||
first))]
|
||||
(when-not vendor-id
|
||||
(throw (ex-info (str "Vendor matching name \"" vendor-code "\" not found.")
|
||||
{:vendor-code vendor-code})))
|
||||
|
||||
(if-let [matching-vendor (->> (dc/q
|
||||
{:find [(list 'pull '?vendor-id d-vendors/default-read)]
|
||||
:in ['$ '?vendor-id]}
|
||||
(dc/db conn) vendor-id)
|
||||
first
|
||||
first)]
|
||||
matching-vendor
|
||||
(throw (ex-info (str "No vendor with the name " vendor-code " was found.")
|
||||
{:vendor-code vendor-code})))))
|
||||
|
||||
(defn import->invoice [{:keys [invoice-number source-url customer-identifier account-number total date vendor-code text full-text client-override vendor-override location-override import-status]}]
|
||||
(let [matching-client (cond
|
||||
client-override client-override
|
||||
account-number (:db/id (d-clients/exact-match account-number))
|
||||
customer-identifier (:db/id (d-clients/best-match customer-identifier)))
|
||||
|
||||
matching-vendor (match-vendor vendor-code vendor-override)
|
||||
matching-location (or (when-not (str/blank? location-override)
|
||||
location-override)
|
||||
(parse/best-location-match (dc/pull (dc/db conn)
|
||||
[{:client/location-matches [:location-match/location :location-match/matches]}
|
||||
:client/default-location
|
||||
:client/locations]
|
||||
matching-client)
|
||||
text
|
||||
full-text))]
|
||||
#:invoice {:db/id (random-tempid)
|
||||
:invoice/client matching-client
|
||||
:invoice/client-identifier (or account-number customer-identifier)
|
||||
:invoice/vendor (:db/id matching-vendor)
|
||||
:invoice/source-url source-url
|
||||
:invoice/invoice-number invoice-number
|
||||
:invoice/total (Double/parseDouble total)
|
||||
:invoice/date (to-date date)
|
||||
:invoice/location matching-location
|
||||
:invoice/import-status (or import-status :import-status/pending)
|
||||
:invoice/outstanding-balance (Double/parseDouble total)
|
||||
:invoice/status :invoice-status/unpaid}))
|
||||
|
||||
(defn validate-invoice [invoice user]
|
||||
(when-not (:invoice/client invoice)
|
||||
(throw (ex-info (str "Searched clients for '" (:invoice/client-identifier invoice) "'. No client found in file. Select a client first.")
|
||||
{:invoice-number (:invoice/invoice-number invoice)
|
||||
:customer-identifier (:invoice/client-identifier invoice)})))
|
||||
(assert-can-see-client user (:invoice/client invoice))
|
||||
(doseq [k [:invoice/invoice-number :invoice/client :invoice/vendor :invoice/total :invoice/outstanding-balance :invoice/date]]
|
||||
(when (not (get invoice k))
|
||||
(throw (ex-info (str (name k) "not found on invoice " invoice)
|
||||
invoice))))
|
||||
|
||||
invoice)
|
||||
|
||||
(defn admin-only-if-multiple-clients [is]
|
||||
(let [client-count (->> is
|
||||
(map :invoice/client)
|
||||
set
|
||||
count)]
|
||||
(map #(assoc % :invoice/source-url-admin-only (boolean (> client-count 1))) is)))
|
||||
|
||||
|
||||
(defn import-uploaded-invoice [user imports]
|
||||
(alog/info ::importing-uploaded :count (count imports))
|
||||
(let [potential-invoices (->> imports
|
||||
(map import->invoice)
|
||||
(map #(validate-invoice % user))
|
||||
admin-only-if-multiple-clients
|
||||
(mapv d-invoices/code-invoice)
|
||||
(mapv (fn [i] [:propose-invoice i])))]
|
||||
|
||||
(alog/info ::creating-invoice :invoices potential-invoices)
|
||||
(let [tx (audit-transact potential-invoices user)]
|
||||
(when-not (seq (dc/q '[:find ?i
|
||||
:in $ [?i ...]
|
||||
:where [?i :invoice/invoice-number]]
|
||||
(:db-after tx)
|
||||
(map :e (:tx-data tx))))
|
||||
(throw (ex-info "No new invoices found."
|
||||
{})))
|
||||
tx)))
|
||||
|
||||
(defn import-file [request]
|
||||
(let [{:keys [file force-client force-vendor force-location]} (:multipart-params request)
|
||||
{:keys [filename tempfile]} file]
|
||||
|
||||
(mu/with-context {:parsing-file filename}
|
||||
(try
|
||||
(let [extension (last (str/split (.getName (io/file filename)) #"\."))
|
||||
s3-location (str "invoice-files/" (str (UUID/randomUUID)) "." extension)
|
||||
_ (s3/put-object (:data-bucket env)
|
||||
s3-location
|
||||
(io/input-stream tempfile)
|
||||
{:content-type (if (= "csv" extension)
|
||||
"text/csv"
|
||||
"application/pdf")
|
||||
:content-length (.length tempfile)})
|
||||
imports (->> (parse/parse-file (.getPath tempfile) filename)
|
||||
(map #(assoc %
|
||||
:client-override force-client
|
||||
:location-override force-location
|
||||
:vendor-override force-vendor
|
||||
:source-url (str "https://" (:data-bucket env)
|
||||
"/"
|
||||
s3-location))))]
|
||||
(import-uploaded-invoice (:identity request) imports))
|
||||
(html-response [:div.bg-primary-50.p-4.text-primary-700.rounded-lg "Invoices have been successfully uploaded."])
|
||||
(catch Exception e
|
||||
(alog/warn ::couldnt-import-upload
|
||||
:error e)
|
||||
(html-response [:div.bg-red-50.p-4.text-red-700.rounded-lg (.getMessage e)])
|
||||
#_{:status 400
|
||||
:body (pr-str {:message (.getMessage e)
|
||||
:error (.toString e)
|
||||
:data (ex-data e)})
|
||||
:headers {"Content-Type" "application/edn"}})))))
|
||||
|
||||
#_(defn wrap-test [handler]
|
||||
(fn [request]
|
||||
(clojure.pprint/pprint (:multipart-params request))
|
||||
(handler request )))
|
||||
|
||||
(def key->handler
|
||||
{::route/import-page
|
||||
(->
|
||||
(helper/page-route grid-page :parse-query-params? false)
|
||||
(wrap-implied-route-param :status nil))})
|
||||
(apply-middleware-to-all-handlers
|
||||
{::route/import-page
|
||||
(->
|
||||
(helper/page-route grid-page :parse-query-params? false)
|
||||
(wrap-implied-route-param :status nil))
|
||||
::route/import-table
|
||||
(-> (helper/table-route grid-page :parse-query-params? false)
|
||||
(wrap-implied-route-param :status nil))
|
||||
|
||||
::route/disapprove (-> disapprove
|
||||
(wrap-entity [:route-params :db/id] default-read)
|
||||
(wrap-schema-enforce :route-params [:map [:db/id entity-id]]))
|
||||
::route/approve (-> approve
|
||||
(wrap-entity [:route-params :db/id] default-read)
|
||||
(wrap-schema-enforce :route-params [:map [:db/id entity-id]]))
|
||||
::route/bulk-disapprove (-> bulk-disapprove
|
||||
(wrap-schema-enforce :form-schema query-schema))
|
||||
::route/bulk-approve (-> bulk-approve
|
||||
(wrap-schema-enforce :form-schema query-schema))
|
||||
::route/import-file (-> import-file
|
||||
(wrap-schema-enforce :multipart-schema upload-schema))}
|
||||
(fn [a]
|
||||
(-> a
|
||||
(wrap-must {:subject :invoice :activity :import})))))
|
||||
|
||||
|
||||
|
||||
@@ -392,7 +392,7 @@
|
||||
:error {:explain (mc/explain schema entity)}}))))
|
||||
|
||||
|
||||
(defn schema-enforce-request [{:keys [form-params query-params hx-query-params params] :as request} & {:keys [form-schema hx-schema query-schema route-schema params-schema]}]
|
||||
(defn schema-enforce-request [{:keys [form-params query-params hx-query-params multipart-params params] :as request} & {:keys [form-schema multipart-schema hx-schema query-schema route-schema params-schema]}]
|
||||
(let [request (try
|
||||
(cond-> request
|
||||
(and (:params request) params-schema)
|
||||
@@ -408,6 +408,13 @@
|
||||
route-schema
|
||||
(:route-params request)
|
||||
main-transformer))
|
||||
|
||||
(and (:multipart-params request) multipart-schema)
|
||||
(assoc :multipart-params
|
||||
(mc/coerce
|
||||
multipart-schema
|
||||
(:multipart-params request)
|
||||
main-transformer))
|
||||
|
||||
(and form-schema form-params)
|
||||
(assoc :form-params
|
||||
@@ -453,10 +460,11 @@
|
||||
:error (:data (ex-data e))}))))]
|
||||
request))
|
||||
|
||||
(defn wrap-schema-enforce [handler & {:keys [form-schema query-schema route-schema params-schema hx-schema]}]
|
||||
(defn wrap-schema-enforce [handler & {:keys [form-schema query-schema route-schema params-schema hx-schema multipart-schema]}]
|
||||
(fn [request]
|
||||
(handler (schema-enforce-request request
|
||||
:hx-schema hx-schema
|
||||
:multipart-schema multipart-schema
|
||||
:form-schema form-schema
|
||||
:query-schema query-schema
|
||||
:route-schema route-schema
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
"/unpaid" ::unpaid-page
|
||||
"/paid" ::paid-page
|
||||
"/voided" ::voided-page
|
||||
"/import" ::import-page}
|
||||
"/import" {"" ::import-page
|
||||
"/upload" ::import-file
|
||||
"/table" ::import-table
|
||||
"/disapprove" {:delete ::bulk-disapprove}
|
||||
"/approve" {:put ::bulk-approve}
|
||||
["/" [#"\d+" :db/id] "/disapprove"] ::disapprove
|
||||
["/" [#"\d+" :db/id] "/approve"] ::approve}}
|
||||
"/new" {:get ::new-wizard
|
||||
:post ::new-invoice-submit
|
||||
:put ::new-invoice-submit
|
||||
|
||||
Reference in New Issue
Block a user