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
|
[:button (update params
|
||||||
:class #(cond-> %
|
: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)))
|
(bg-colors (:color params) (:disabled params)))
|
||||||
|
|
||||||
(not (:disabled params))
|
(not (:disabled params))
|
||||||
|
|||||||
@@ -21,4 +21,5 @@
|
|||||||
[:payment/status :xform iol-ion.query/ident] [:db/ident]}]}]
|
[:payment/status :xform iol-ion.query/ident] [:db/ident]}]}]
|
||||||
#_[:payment/_invoices :as :invoice/payments]
|
#_[:payment/_invoices :as :invoice/payments]
|
||||||
[:invoice/status :xform iol-ion.query/ident] [:db/ident]
|
[: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]}])
|
:invoice/vendor [:vendor/name :db/id]}])
|
||||||
@@ -1,33 +1,46 @@
|
|||||||
(ns auto-ap.ssr.invoice.import
|
(ns auto-ap.ssr.invoice.import
|
||||||
(:require [auto-ap.client-routes :as client-routes]
|
(:require [amazonica.aws.s3 :as s3]
|
||||||
[auto-ap.datomic
|
[auto-ap.datomic
|
||||||
:refer [add-sorter-fields apply-pagination apply-sort-3 conn
|
:refer [add-sorter-fields apply-pagination apply-sort-3
|
||||||
merge-query observable-query pull-many]]
|
audit-transact conn merge-query observable-query
|
||||||
[auto-ap.datomic.accounts :as d-accounts]
|
pull-attr pull-many random-tempid]]
|
||||||
[auto-ap.graphql.utils :refer [extract-client-ids]]
|
[auto-ap.datomic.clients :as d-clients]
|
||||||
[auto-ap.permissions :refer [can?]]
|
[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.invoice :as route]
|
||||||
[auto-ap.routes.payments :as payment-route]
|
|
||||||
[auto-ap.ssr-routes :as ssr-routes]
|
[auto-ap.ssr-routes :as ssr-routes]
|
||||||
[auto-ap.ssr.components :as com]
|
[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.grid-page-helper :as helper]
|
||||||
[auto-ap.ssr.hx :as hx]
|
[auto-ap.ssr.hx :as hx]
|
||||||
[auto-ap.ssr.invoice.common :refer [default-read]]
|
[auto-ap.ssr.invoice.common :refer [default-read]]
|
||||||
[auto-ap.ssr.pos.common :refer [date-range-field*]]
|
[auto-ap.ssr.pos.common :refer [date-range-field*]]
|
||||||
[auto-ap.ssr.svg :as svg]
|
[auto-ap.ssr.svg :as svg]
|
||||||
[auto-ap.ssr.utils
|
[auto-ap.ssr.utils
|
||||||
:refer [clj-date-schema entity-id html-response main-transformer
|
:refer [apply-middleware-to-all-handlers clj-date-schema
|
||||||
ref->enum-schema strip wrap-implied-route-param]]
|
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.time :as atime]
|
||||||
[auto-ap.utils :refer [dollars=]]
|
[auto-ap.utils :refer [dollars=]]
|
||||||
[bidi.bidi :as bidi]
|
[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]
|
[clojure.string :as str]
|
||||||
|
[com.brunobonacci.mulog :as mu]
|
||||||
|
[config.core :refer [env]]
|
||||||
[datomic.api :as dc]
|
[datomic.api :as dc]
|
||||||
[hiccup.util :as hu]
|
|
||||||
[hiccup2.core :as hiccup]
|
[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]
|
(defn exact-match-id* [request]
|
||||||
@@ -46,7 +59,7 @@
|
|||||||
(defn filters [request]
|
(defn filters [request]
|
||||||
[:form#invoice-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
[:form#invoice-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
|
||||||
"hx-get" (bidi/path-for ssr-routes/only-routes
|
"hx-get" (bidi/path-for ssr-routes/only-routes
|
||||||
::route/table)
|
::route/import-table)
|
||||||
"hx-target" "#entity-table"
|
"hx-target" "#entity-table"
|
||||||
"hx-indicator" "#entity-table"}
|
"hx-indicator" "#entity-table"}
|
||||||
|
|
||||||
@@ -112,13 +125,15 @@
|
|||||||
valid-clients]}
|
valid-clients]}
|
||||||
(cond-> {:query {:find []
|
(cond-> {:query {:find []
|
||||||
:in '[$ [?clients ?start ?end]]
|
: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
|
:args [db
|
||||||
[valid-clients
|
[valid-clients
|
||||||
(some-> (:start-date query-params) coerce/to-date)
|
(some-> (:start-date query-params) coerce/to-date)
|
||||||
(some-> (:end-date query-params) coerce/to-date)]]}
|
(some-> (:end-date query-params) coerce/to-date)]]}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(:client-id query-params)
|
(:client-id query-params)
|
||||||
(merge-query {:query {:in ['?client-id]
|
(merge-query {:query {:in ['?client-id]
|
||||||
:where ['[?e :invoice/client ?client-id]]}
|
:where ['[?e :invoice/client ?client-id]]}
|
||||||
@@ -283,11 +298,6 @@
|
|||||||
[:end-date {:optional true}
|
[:end-date {:optional true}
|
||||||
[:maybe clj-date-schema]]]]))
|
[:maybe clj-date-schema]]]]))
|
||||||
|
|
||||||
(comment
|
|
||||||
(mc/decode query-schema
|
|
||||||
{:start " "}
|
|
||||||
main-transformer))
|
|
||||||
|
|
||||||
(defn selected->ids [request params]
|
(defn selected->ids [request params]
|
||||||
(let [all-selected (:all-selected params)
|
(let [all-selected (:all-selected params)
|
||||||
selected (:selected params)
|
selected (:selected params)
|
||||||
@@ -300,117 +310,93 @@
|
|||||||
|
|
||||||
|
|
||||||
:else
|
: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))
|
ids))
|
||||||
|
|
||||||
(defn pay-button* [params]
|
(def upload-schema
|
||||||
(let [ids (:ids params)
|
[:map
|
||||||
selected-client-count (if (seq ids)
|
[:force-client {:optional true}
|
||||||
(ffirst
|
[:maybe entity-id]]
|
||||||
(dc/q '[:find (count ?c)
|
[:force-vendor {:optional true}
|
||||||
:in $ [?i ...]
|
[:maybe entity-id]]
|
||||||
:where [?i :invoice/client ?c]]
|
[:force-location {:optional true}
|
||||||
(dc/db conn)
|
[:maybe [:string {:decode/string strip :min 2 :max 2}]]]])
|
||||||
ids))
|
|
||||||
|
|
||||||
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
|
(def grid-page
|
||||||
(helper/build {:id "entity-table"
|
(helper/build {:id "entity-table"
|
||||||
:nav com/main-aside-nav
|
:nav com/main-aside-nav
|
||||||
:check-boxes? true
|
:check-boxes? true
|
||||||
:page-specific-nav filters
|
:page-specific-nav filters
|
||||||
:above-grid (fn [request]
|
:above-grid (fn [{:keys [form-params form-errors] :as request}]
|
||||||
(com/content-card {}
|
(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
|
[:div.px-4.py-3.space-y-4
|
||||||
{:action (bidi/path-for ssr-routes/only-routes
|
|
||||||
::route/import-page)
|
[:div.flex.justify-between.items-center [:h1.text-2xl.mb-3.font-bold "Import new invoices"]
|
||||||
:method "POST"
|
|
||||||
:id "upload"}
|
[:div.flex.gap-2.items-baseline "Trouble with the new upload experience?"
|
||||||
"Drop files to upload here"]
|
[:a {:href (bidi/path-for client-routes/routes :import-invoices )}
|
||||||
[:script
|
(com/pill {:color :secondary}
|
||||||
(hiccup/raw
|
"Go back to previous version")]]]
|
||||||
"
|
[:div#page-notification.notification.block {:style {:display "none"}}]
|
||||||
ezcater_dropzone = new Dropzone (\"#upload\", {
|
|
||||||
success: function (file, response) {
|
|
||||||
document.getElementById(\"page-notification\").innerHTML = response;
|
[:form
|
||||||
document.getElementById(\"page-notification\").style[\"display\"] = \"block\";
|
{:action (bidi/path-for ssr-routes/only-routes
|
||||||
},
|
::route/import-file)
|
||||||
acceptedFiles: '.xls,.xlsx,.pdf,.csv',
|
:method "POST"
|
||||||
disablePreviews: true
|
: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
|
:fetch-page fetch-page
|
||||||
:oob-render
|
:oob-render
|
||||||
@@ -422,43 +408,37 @@
|
|||||||
(mc/decode query-schema p main-transformer))
|
(mc/decode query-schema p main-transformer))
|
||||||
:action-buttons (fn [request]
|
:action-buttons (fn [request]
|
||||||
(let [[_ _ outstanding total] (:page-results request)]
|
(let [[_ _ outstanding total] (:page-results request)]
|
||||||
[(com/pill {:color :primary} "Outstanding: "
|
[
|
||||||
(format "$%,.2f" outstanding))
|
(when (can? (:identity request) {:subject :invoice :activity :import})
|
||||||
(com/pill {:color :secondary} "Total: "
|
(com/button {:hx-put (str (bidi/path-for ssr-routes/only-routes ::route/bulk-approve))
|
||||||
(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))
|
|
||||||
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
|
||||||
"hx-include" "#invoice-filters"
|
"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}
|
:color :red}
|
||||||
"Void selected"))
|
"Disapprove 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"))]))
|
|
||||||
:row-buttons (fn [request entity]
|
:row-buttons (fn [request entity]
|
||||||
[(when (and (= :invoice-status/unpaid (:invoice/status entity))
|
[(when (and (= :import-status/pending (:invoice/import-status entity))
|
||||||
(can? (:identity request) {:subject :invoice :activity :delete}))
|
(can? (:identity request) {:subject :invoice :activity :import}))
|
||||||
(com/icon-button {:hx-delete (bidi/path-for ssr-routes/only-routes
|
(com/icon-button {:hx-delete (bidi/path-for ssr-routes/only-routes
|
||||||
::route/delete
|
::route/disapprove
|
||||||
:db/id (:db/id entity))
|
:db/id (:db/id entity))
|
||||||
:hx-confirm "Are you sure you want to void this invoice?"}
|
:hx-confirm "Are you sure you want to remove this invoice?"}
|
||||||
svg/trash))
|
svg/thumbs-down))
|
||||||
(when (and (can? (:identity request) {:subject :invoice :activity :edit})
|
(when (and (= :import-status/pending (:invoice/import-status entity))
|
||||||
(#{:invoice-status/unpaid :invoice-status/paid} (:invoice/status entity)))
|
(can? (:identity request) {:subject :invoice :activity :import}))
|
||||||
(com/icon-button {:hx-put (bidi/path-for ssr-routes/only-routes
|
(com/icon-button {:hx-put (bidi/path-for ssr-routes/only-routes
|
||||||
::route/edit-wizard
|
::route/approve
|
||||||
:db/id (:db/id entity))}
|
:db/id (:db/id entity))}
|
||||||
svg/pencil))
|
svg/thumbs-up))])
|
||||||
(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))])
|
|
||||||
|
|
||||||
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)}
|
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)}
|
||||||
"Invoices"]]
|
"Invoices"]]
|
||||||
@@ -467,7 +447,7 @@
|
|||||||
(some-> r :route-params :status name str/capitalize (str " "))
|
(some-> r :route-params :status name str/capitalize (str " "))
|
||||||
"Invoices"))
|
"Invoices"))
|
||||||
:entity-name "invoices"
|
:entity-name "invoices"
|
||||||
:route ::route/table
|
:route ::route/import-table
|
||||||
:headers [{:key "client"
|
:headers [{:key "client"
|
||||||
:name "Client"
|
:name "Client"
|
||||||
:sort-key "client"
|
:sort-key "client"
|
||||||
@@ -488,36 +468,6 @@
|
|||||||
:show-starting "lg"
|
:show-starting "lg"
|
||||||
:render (fn [{:invoice/keys [date]}]
|
:render (fn [{:invoice/keys [date]}]
|
||||||
(some-> date (atime/unparse-local atime/normal-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"
|
{:key "outstanding"
|
||||||
:name "Outstanding"
|
:name "Outstanding"
|
||||||
:sort-key "outstanding-balance"
|
:sort-key "outstanding-balance"
|
||||||
@@ -526,44 +476,241 @@
|
|||||||
[:div
|
[:div
|
||||||
(some->> outstanding-balance (format "$%,.2f"))
|
(some->> outstanding-balance (format "$%,.2f"))
|
||||||
(when-not (dollars= outstanding-balance total)
|
(when-not (dollars= outstanding-balance total)
|
||||||
[:div.text-xs.text-gray-400 (format "of $%,.2f" 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"}]))))}]}))
|
|
||||||
|
|
||||||
(def row* (partial helper/row* grid-page))
|
(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
|
(def key->handler
|
||||||
{::route/import-page
|
(apply-middleware-to-all-handlers
|
||||||
(->
|
{::route/import-page
|
||||||
(helper/page-route grid-page :parse-query-params? false)
|
(->
|
||||||
(wrap-implied-route-param :status nil))})
|
(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)}}))))
|
: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
|
(let [request (try
|
||||||
(cond-> request
|
(cond-> request
|
||||||
(and (:params request) params-schema)
|
(and (:params request) params-schema)
|
||||||
@@ -409,6 +409,13 @@
|
|||||||
(:route-params request)
|
(:route-params request)
|
||||||
main-transformer))
|
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)
|
(and form-schema form-params)
|
||||||
(assoc :form-params
|
(assoc :form-params
|
||||||
(mc/coerce
|
(mc/coerce
|
||||||
@@ -453,10 +460,11 @@
|
|||||||
:error (:data (ex-data e))}))))]
|
:error (:data (ex-data e))}))))]
|
||||||
request))
|
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]
|
(fn [request]
|
||||||
(handler (schema-enforce-request request
|
(handler (schema-enforce-request request
|
||||||
:hx-schema hx-schema
|
:hx-schema hx-schema
|
||||||
|
:multipart-schema multipart-schema
|
||||||
:form-schema form-schema
|
:form-schema form-schema
|
||||||
:query-schema query-schema
|
:query-schema query-schema
|
||||||
:route-schema route-schema
|
:route-schema route-schema
|
||||||
|
|||||||
@@ -3,7 +3,13 @@
|
|||||||
"/unpaid" ::unpaid-page
|
"/unpaid" ::unpaid-page
|
||||||
"/paid" ::paid-page
|
"/paid" ::paid-page
|
||||||
"/voided" ::voided-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
|
"/new" {:get ::new-wizard
|
||||||
:post ::new-invoice-submit
|
:post ::new-invoice-submit
|
||||||
:put ::new-invoice-submit
|
:put ::new-invoice-submit
|
||||||
|
|||||||
Reference in New Issue
Block a user