§3 animations: the migrated wizard step cards had no transition, so step→step swaps and modal open were flat. The old mm/* slide system was deleted in Phase 11 (and its classes purged from CSS), and the transaction-edit "reference" uses an undefined `last-modal-step` no-op — so there was no clean slide to restore. Apply the codebase's existing `fade-in transition-opacity duration-300` primitive (`.htmx-added .fade-in` in input.css) to all three wizard step cards (new-invoice basic-details + accounts, vendor step-card, client step-card). Each card now fades in on open and on every step swap. Verified live: cards always settle to opacity 1 (never stuck invisible) on both open and step navigation. Richer directional (forward/back) slide transitions are left for a design pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
839 lines
43 KiB
Clojure
839 lines
43 KiB
Clojure
(ns auto-ap.ssr.invoice.new-invoice-wizard
|
|
"New / Edit Invoice wizard, migrated onto the session-backed engine (wizard2).
|
|
|
|
A dual-purpose wizard (create + edit) with a CONDITIONAL middle step:
|
|
basic-details --(customize-accounts = :default)--> :done (default vendor account)
|
|
\\-(customize-accounts = :customize)-> accounts --> :done
|
|
|
|
Per-step data lives in the Ring session (wizard-state); the engine's get-all merges
|
|
them for the done-fn (`create-invoice!`). Only an opaque wizard-id + current-step ride
|
|
in the form -- no EDN snapshot.
|
|
|
|
The pre-migration `mm` flow routed basic-details \"Save\" through a PUT /navigate whose
|
|
`:to` query-schema 500s on empty query-params (the {}->nil main-transformer quirk); the
|
|
engine's submit is a POST with no query-schema, so that latent bug is gone.
|
|
|
|
Dates in step-data are kept as java.util.Date (#inst) -- EDN-safe for the cookie session
|
|
store, unlike clj-time DateTimes (which have no reader); they are coerced to clj-time only
|
|
for display."
|
|
(:require
|
|
[auto-ap.datomic :refer [audit-transact conn pull-attr]]
|
|
[auto-ap.datomic.accounts :as d-accounts]
|
|
[auto-ap.datomic.invoices :as d-invoices]
|
|
[auto-ap.graphql.utils :refer [assert-can-see-client assert-not-locked
|
|
exception->4xx exception->notification]]
|
|
[auto-ap.logging :as alog]
|
|
[auto-ap.routes.invoice :as route]
|
|
[auto-ap.routes.utils :refer [wrap-client-redirect-unauthenticated]]
|
|
[auto-ap.solr :as solr]
|
|
[auto-ap.ssr-routes :as ssr-routes]
|
|
[auto-ap.ssr.components :as com]
|
|
[auto-ap.ssr.components.wizard-state :as ws]
|
|
[auto-ap.ssr.components.wizard2 :as wizard2]
|
|
[auto-ap.ssr.hx :as hx]
|
|
[auto-ap.ssr.invoice.common :refer [default-read]]
|
|
[auto-ap.ssr.nested-form-params :as nfp]
|
|
[auto-ap.ssr.svg :as svg]
|
|
[auto-ap.ssr.utils
|
|
:refer [->db-id apply-middleware-to-all-handlers assert-schema check-allowance
|
|
check-location-belongs clj-date-schema entity-id form-validation-error
|
|
html-response main-transformer modal-response money path->name2 strip
|
|
wrap-schema-enforce]]
|
|
[auto-ap.time :as atime]
|
|
[bidi.bidi :as bidi]
|
|
[clj-time.coerce :as coerce]
|
|
[clj-time.core :as time]
|
|
[clojure.string :as str]
|
|
[datomic.api :as dc]
|
|
[hiccup.util :as hu]
|
|
[iol-ion.query :refer [dollars=]]
|
|
[malli.core :as mc]
|
|
[malli.util :as mut]
|
|
[slingshot.slingshot :refer [try+]]))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Domain helpers (unchanged from the pre-migration wizard).
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(defn get-vendor [vendor-id]
|
|
(dc/pull
|
|
(dc/db conn)
|
|
[:vendor/terms
|
|
:vendor/automatically-paid-when-due
|
|
{:vendor/default-account d-accounts/default-read
|
|
:vendor/account-overrides
|
|
[:vendor-account-override/client
|
|
{:vendor-account-override/account d-accounts/default-read}]}
|
|
{:vendor/terms-overrides
|
|
[:vendor-terms-override/client :vendor-terms-override/terms]}]
|
|
vendor-id))
|
|
|
|
(defn check-vendor-default-account [vendor-id]
|
|
(some? (:vendor/default-account (get-vendor vendor-id))))
|
|
|
|
(def new-form-schema
|
|
[:map
|
|
[:db/id {:optional true} [:maybe entity-id]]
|
|
[:customize-due-and-scheduled? {:optional true :default false :decode/arbitrary (fn [x] (if (= "" x)
|
|
false
|
|
x))} [:maybe :boolean]]
|
|
[:customize-accounts {:optional true :default :default} [:enum :default :customize]]
|
|
|
|
[:invoice/client {:optional true} [:maybe entity-id]]
|
|
[:invoice/date clj-date-schema]
|
|
[:invoice/due {:optional true} [:maybe clj-date-schema]]
|
|
[:invoice/scheduled-payment {:optional true} [:maybe clj-date-schema]]
|
|
[:invoice/vendor {:optional true}
|
|
[:and entity-id
|
|
[:fn {:error/message "Vendor is missing default expense account"}
|
|
check-vendor-default-account]]]
|
|
[:invoice/invoice-number {:optional true} [:string {:min 1 :decode/string strip}]]
|
|
[:invoice/total money]
|
|
[:invoice/expense-accounts
|
|
[:vector {:coerce? true}
|
|
[:and
|
|
[:map
|
|
[:invoice-expense-account/account [:and entity-id
|
|
[:fn {:error/message "Not an allowed account."}
|
|
#(check-allowance % :account/invoice-allowance)]]]
|
|
[:invoice-expense-account/location :string]
|
|
[:invoice-expense-account/amount :double]]
|
|
[:fn {:error/fn (fn [r x] (:type r))
|
|
:error/path [:invoice-expense-account/location]}
|
|
(fn [iea]
|
|
(check-location-belongs (:invoice-expense-account/location iea)
|
|
(:invoice-expense-account/account iea)))]]]]])
|
|
|
|
(defn clientize-vendor [{:vendor/keys [terms-overrides automatically-paid-when-due default-account account-overrides] :as vendor} client-id]
|
|
(if (nil? vendor)
|
|
nil
|
|
(let [terms-override (->> terms-overrides
|
|
(filter (fn [to]
|
|
(= (->db-id (:vendor-terms-override/client to))
|
|
client-id)))
|
|
(map :vendor-terms-override/terms)
|
|
first)
|
|
account (or (->> account-overrides
|
|
(filter (fn [to]
|
|
(= (->db-id (:vendor-account-override/client to))
|
|
client-id)))
|
|
(map :vendor-account-override/account)
|
|
first)
|
|
default-account)
|
|
account (d-accounts/clientize account client-id)
|
|
|
|
automatically-paid-when-due (->> automatically-paid-when-due
|
|
(filter (fn [to]
|
|
(= (->db-id to)
|
|
client-id)))
|
|
seq
|
|
boolean)
|
|
vendor (cond-> vendor
|
|
terms-override (assoc :vendor/terms terms-override)
|
|
true (assoc :vendor/automatically-paid-when-due automatically-paid-when-due
|
|
:vendor/default-account account)
|
|
true (dissoc :vendor/account-overrides :vendor/terms-overrides))]
|
|
vendor)))
|
|
|
|
(defn location-select*
|
|
[{:keys [name account-location client-locations value]}]
|
|
(let [options (into (cond account-location
|
|
[[account-location account-location]]
|
|
|
|
(seq client-locations)
|
|
(into [["Shared" "Shared"]]
|
|
(for [cl client-locations]
|
|
[cl cl]))
|
|
:else
|
|
[["Shared" "Shared"]]))]
|
|
(com/select {:options options
|
|
:name name
|
|
:value (or value (ffirst options))
|
|
:class "w-full"})))
|
|
|
|
(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)
|
|
{:client-id client-id
|
|
: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)))})])
|
|
|
|
(defn assert-no-conflicting [{:invoice/keys [invoice-number client vendor] :db/keys [id]}]
|
|
(when (seq (d-invoices/find-conflicting {:invoice/invoice-number invoice-number
|
|
:invoice/vendor (->db-id vendor)
|
|
:invoice/client (->db-id client)
|
|
:db/id id}))
|
|
(form-validation-error (str "Invoice '" invoice-number "' already exists."))))
|
|
|
|
(defn assert-invoice-amounts-add-up [{:keys [:invoice/expense-accounts :invoice/total]}]
|
|
(let [expense-account-total (reduce + 0 (map (fn [x] (:invoice-expense-account/amount x)) expense-accounts))]
|
|
(when-not (dollars= total expense-account-total)
|
|
(form-validation-error (str "Expense account total (" expense-account-total ") does not equal invoice total (" total ")")))))
|
|
|
|
(defn- calculate-spread
|
|
"Helper function to calculate the amount to be assigned to each location"
|
|
[shared-amount total-locations]
|
|
(let [base-amount (int (/ shared-amount total-locations))
|
|
remainder (- shared-amount (* base-amount total-locations))]
|
|
{:base-amount base-amount
|
|
:remainder remainder}))
|
|
|
|
(defn- spread-expense-account
|
|
"Spreads the expense account amount across the given locations"
|
|
[locations expense-account]
|
|
(if (= "Shared" (:invoice-expense-account/location expense-account))
|
|
(let [{:keys [base-amount remainder]} (calculate-spread (:invoice-expense-account/amount expense-account) (count locations))]
|
|
(map-indexed (fn [idx _]
|
|
(assoc expense-account
|
|
:invoice-expense-account/amount (+ base-amount (if (< idx remainder) 1 0))
|
|
:invoice-expense-account/location (nth locations idx)))
|
|
locations))
|
|
[expense-account]))
|
|
|
|
(defn $->cents [x]
|
|
(int
|
|
(let [result (* 100M (bigdec x))]
|
|
(.setScale result 0 java.math.BigDecimal/ROUND_HALF_UP))))
|
|
|
|
(defn cents->$ [x]
|
|
(double
|
|
(let [result (* 0.01M (bigdec x))]
|
|
(.setScale result 2 java.math.BigDecimal/ROUND_HALF_UP))))
|
|
|
|
(defn- apply-total-delta-to-account [invoice-total eas]
|
|
(when (seq eas)
|
|
(let [leftover (- invoice-total (reduce + 0 (map :invoice-expense-account/amount eas)))
|
|
leftover-beyond-a-single-cent? (or (< leftover -1)
|
|
(> leftover 1))
|
|
leftover (if leftover-beyond-a-single-cent?
|
|
0
|
|
leftover)
|
|
[first-eas & rest] eas]
|
|
(cons
|
|
(update first-eas :invoice-expense-account/amount #(+ % leftover))
|
|
rest))))
|
|
|
|
(defn maybe-spread-locations
|
|
"Converts any expense account for a \"Shared\" location into a separate expense account for all valid locations for that client"
|
|
([invoice]
|
|
(maybe-spread-locations invoice (pull-attr (dc/db conn) :client/locations (:invoice/client invoice))))
|
|
([invoice locations]
|
|
(update-in invoice
|
|
[:invoice/expense-accounts]
|
|
(fn [expense-accounts]
|
|
(->> expense-accounts
|
|
(map (fn [ea] (update ea :invoice-expense-account/amount $->cents)))
|
|
(mapcat (partial spread-expense-account locations))
|
|
(apply-total-delta-to-account ($->cents (:invoice/total invoice)))
|
|
(map (fn [ea] (update ea :invoice-expense-account/amount cents->$))))))))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; De-cursored field names + per-render errors.
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(def ^:dynamic *errors*
|
|
"Humanized form errors for the current step render, keyed by schema paths.
|
|
Bound by each render fn from the engine ctx :errors."
|
|
{})
|
|
|
|
(defn- ferr [& path] (get-in *errors* (vec path)))
|
|
(defn- err? [& path] (boolean (seq (apply ferr path))))
|
|
(defn- ea-name [index field] (path->name2 :invoice/expense-accounts index field))
|
|
(defn- ea-errors [index field] (ferr :invoice/expense-accounts index field))
|
|
|
|
(defn- fmt-date
|
|
"java.util.Date (#inst, EDN-safe) -> the MM/DD/YYYY display string."
|
|
[d]
|
|
(some-> d coerce/from-date (atime/unparse-local atime/normal-date)))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Schemas + decode (per step).
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(def ^:private basic-details-schema
|
|
(mc/schema
|
|
(mut/select-keys new-form-schema
|
|
#{:invoice/client :invoice/vendor :invoice/date :invoice/due
|
|
:invoice/scheduled-payment :invoice/total :invoice/invoice-number
|
|
:db/id :customize-due-and-scheduled? :customize-accounts})))
|
|
|
|
(def ^:private accounts-schema
|
|
(mc/schema (mut/select-keys new-form-schema #{:invoice/expense-accounts})))
|
|
|
|
(defn- ->edn-safe-dates
|
|
"clj-time DateTimes -> java.util.Date so step-data round-trips through the cookie store."
|
|
[m]
|
|
(reduce (fn [acc k] (cond-> acc (get acc k) (update k coerce/to-date)))
|
|
m
|
|
[:invoice/date :invoice/due :invoice/scheduled-payment]))
|
|
|
|
(defn- decode-basic-details
|
|
"Step 1 posts flat invoice/* fields (the engine already stripped its nav fields)."
|
|
[request]
|
|
(let [nested (:form-params (nfp/nested-params-request request {}))]
|
|
(-> (mc/decode basic-details-schema nested main-transformer)
|
|
->edn-safe-dates)))
|
|
|
|
(defn- decode-accounts
|
|
"Step 2: nested invoice/expense-accounts[i][...] rows."
|
|
[request]
|
|
(let [nested (:form-params (nfp/nested-params-request request {}))]
|
|
(mc/decode accounts-schema nested main-transformer)))
|
|
|
|
(defn- validate-basic-details
|
|
"Mirror the old wrap-schema :fn: a new invoice needs client + vendor + invoice-number;
|
|
date + total are always required; a chosen vendor must have a default expense account."
|
|
[data _request]
|
|
(let [new? (not (:db/id data))
|
|
errs (cond-> {}
|
|
(nil? (:invoice/date data)) (assoc :invoice/date ["required"])
|
|
(nil? (:invoice/total data)) (assoc :invoice/total ["required"])
|
|
(and new? (nil? (:invoice/client data))) (assoc :invoice/client ["required"])
|
|
(and new? (nil? (:invoice/vendor data))) (assoc :invoice/vendor ["required"])
|
|
(and new? (str/blank? (:invoice/invoice-number data))) (assoc :invoice/invoice-number ["required"])
|
|
(and (:invoice/vendor data)
|
|
(not (check-vendor-default-account (->db-id (:invoice/vendor data)))))
|
|
(assoc :invoice/vendor ["Vendor is missing default expense account"]))]
|
|
(when (seq errs) errs)))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Renders (de-cursored: explicit data + path->name2 + *errors*).
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(defn account-prediction-radio
|
|
"The customize-accounts radio (default vendor account vs customize), computed from the
|
|
posted/known vendor + client. Lives in an async fragment so it appears once a vendor is
|
|
chosen."
|
|
[{:keys [vendor-id client-id value]}]
|
|
(let [vendor (clientize-vendor (get-vendor vendor-id) (->db-id client-id))
|
|
account-name (:account/name (:vendor/default-account vendor))]
|
|
(when vendor
|
|
(com/radio-list {:name "customize-accounts"
|
|
:value (name (or value :default))
|
|
:options (filter identity
|
|
[(when account-name {:value (name :default)
|
|
:content (com/pill {:color :primary} account-name)})
|
|
{:value (name :customize)
|
|
:content [:div "Customize accounts"]}])}))))
|
|
|
|
(defn render-basic-details
|
|
[{:keys [step-data request errors]}]
|
|
(binding [*errors* (or errors {})]
|
|
(let [data (or step-data {})
|
|
extant? (:db/id data)
|
|
client-from-req (:db/id (:client request))
|
|
client-val (or (:invoice/client data) client-from-req)]
|
|
(com/modal-card-advanced
|
|
{"@keydown.enter.prevent.stop" "if ($refs.next) {$refs.next.click()}"
|
|
:class " md:w-[750px] md:h-[600px] w-full h-full fade-in transition-opacity duration-300"
|
|
"x-data" ""}
|
|
(com/modal-header {} [:div.p-2 (if extant? "Edit invoice" "New invoice")])
|
|
(com/modal-body
|
|
{}
|
|
[:div {:x-data (hx/json {:clientId client-val
|
|
:vendorId (:invoice/vendor data)
|
|
:date (fmt-date (:invoice/date data))
|
|
:due (fmt-date (:invoice/due data))
|
|
:scheduledPayment (fmt-date (:invoice/scheduled-payment data))
|
|
:customizeDueAndScheduled (boolean (:customize-due-and-scheduled? data))})}
|
|
(when extant?
|
|
(com/hidden {:name "db/id" :value extant?}))
|
|
(com/hidden {:name "customize-due-and-scheduled?"
|
|
:value (boolean (:customize-due-and-scheduled? data))
|
|
:x-model "customizeDueAndScheduled"})
|
|
(if (or (:client request) extant?)
|
|
(com/hidden {:name "invoice/client" :value client-val})
|
|
(com/validated-field
|
|
{:label "Client" :errors (ferr :invoice/client)}
|
|
[:div.w-96
|
|
(com/typeahead {:name "invoice/client"
|
|
:error? (err? :invoice/client)
|
|
:class "w-96"
|
|
:placeholder "Search..."
|
|
:url (bidi/path-for ssr-routes/only-routes :company-search)
|
|
:value (:invoice/client data)
|
|
:content-fn (fn [c] (pull-attr (dc/db conn) :client/name c))
|
|
:x-model "clientId"})]))
|
|
(com/validated-field
|
|
{:label "Vendor" :errors (ferr :invoice/vendor)}
|
|
[:div.w-96
|
|
(com/typeahead {:name "invoice/vendor"
|
|
:error? (err? :invoice/vendor)
|
|
:disabled (boolean extant?)
|
|
:class "w-96"
|
|
:placeholder "Search..."
|
|
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
|
|
:value (:invoice/vendor data)
|
|
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))
|
|
:x-model "vendorId"})])
|
|
[:div.flex.items-center.gap-2
|
|
(com/validated-field
|
|
{:label "Date" :errors (ferr :invoice/date)}
|
|
[:div {:class "w-24"}
|
|
(com/date-input {:value (fmt-date (:invoice/date data))
|
|
:name "invoice/date"
|
|
:error? (err? :invoice/date)
|
|
:x-model "date"
|
|
:placeholder "1/1/2024"})])
|
|
[:div {:x-show "!customizeDueAndScheduled"}
|
|
(com/link {"@click" "customizeDueAndScheduled=true"
|
|
:x-show "!due && !scheduledPayment"}
|
|
"Add due / scheduled payment date")
|
|
(com/link {"@click" "customizeDueAndScheduled=true"
|
|
:x-show "due || scheduledPayment"}
|
|
"Change due / scheduled payment date")]]
|
|
(com/validated-field
|
|
(hx/alpine-appear {:label "Due (optional)"
|
|
:errors (ferr :invoice/due)
|
|
:x-show "customizeDueAndScheduled"})
|
|
[:div {:class "w-24"
|
|
:hx-put (bidi/path-for ssr-routes/only-routes ::route/due-date)
|
|
:x-dispatch:changed "[clientId, vendorId, date]"
|
|
:hx-trigger "changed"
|
|
:hx-target "this"
|
|
:hx-swap "innerHTML"}
|
|
(com/date-input {:value (fmt-date (:invoice/due data))
|
|
:name "invoice/due"
|
|
:x-model "due"
|
|
:error? (err? :invoice/due)
|
|
:placeholder "1/1/2024"})])
|
|
(com/validated-field
|
|
(hx/alpine-appear {:label "Scheduled payment (optional)"
|
|
:errors (ferr :invoice/scheduled-payment)
|
|
:x-show "customizeDueAndScheduled"})
|
|
[:div {:class "w-24"
|
|
:hx-put (bidi/path-for ssr-routes/only-routes ::route/scheduled-payment-date)
|
|
:x-dispatch:changed "[clientId, vendorId, due]"
|
|
:hx-trigger "changed"
|
|
:hx-target "this"
|
|
:hx-swap "innerHTML"}
|
|
(com/date-input {:value (fmt-date (:invoice/scheduled-payment data))
|
|
:name "invoice/scheduled-payment"
|
|
:error? (err? :invoice/scheduled-payment)
|
|
:placeholder "1/1/2024"})])
|
|
(com/validated-field
|
|
{:label "Invoice Number" :errors (ferr :invoice/invoice-number)}
|
|
[:div {:class "w-24"}
|
|
(com/text-input {:value (:invoice/invoice-number data)
|
|
:name "invoice/invoice-number"
|
|
:error? (err? :invoice/invoice-number)
|
|
:placeholder "HA-123"})])
|
|
(com/validated-field
|
|
{:label "Total" :errors (ferr :invoice/total)}
|
|
[:div {:class "w-16"}
|
|
(com/money-input {:value (:invoice/total data)
|
|
:name "invoice/total"
|
|
:class "w-24"
|
|
:error? (err? :invoice/total)
|
|
:placeholder "212.44"})])
|
|
[:div#expense-account-prediction
|
|
(hx/alpine-appear
|
|
{:x-dispatch:bryce "[vendorId]"
|
|
:hx-trigger "bryce"
|
|
:hx-put (bidi/path-for ssr-routes/only-routes ::route/account-prediction)
|
|
:hx-target "this"
|
|
:hx-swap "innerHTML"})
|
|
(account-prediction-radio {:vendor-id (->db-id (:invoice/vendor data))
|
|
:client-id client-val
|
|
:value (:customize-accounts data)})]])
|
|
(com/modal-footer {} (wizard2/nav-footer {:save? true :save-label "Save"}))))))
|
|
|
|
(defn- invoice-expense-account-row*
|
|
"One expense-account row, de-cursored: a plain account map + its index."
|
|
[{:keys [account index client-id client-locations]}]
|
|
(let [acct (:invoice-expense-account/account account)
|
|
acct-id (if (map? acct) (:db/id acct) acct)
|
|
aname (ea-name index :invoice-expense-account/account)
|
|
lname (ea-name index :invoice-expense-account/location)]
|
|
(com/data-grid-row
|
|
(-> {:x-data (hx/json {:show (boolean (not (:new? account)))
|
|
:accountId acct-id})
|
|
:data-key "show"
|
|
:x-ref "p"}
|
|
hx/alpine-mount-then-appear)
|
|
(com/hidden {:name (ea-name index :db/id)
|
|
:value (:db/id account)})
|
|
(com/data-grid-cell
|
|
{}
|
|
(com/validated-field
|
|
{:errors (ea-errors index :invoice-expense-account/account)}
|
|
(account-typeahead* {:value acct-id
|
|
:client-id client-id
|
|
:name aname
|
|
:x-model "accountId"})))
|
|
(com/data-grid-cell
|
|
{}
|
|
(com/validated-field
|
|
{:errors (ea-errors index :invoice-expense-account/location)
|
|
:x-hx-val:account-id "accountId"
|
|
:hx-vals (hx/json {:name lname :client-id client-id})
|
|
: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 lname
|
|
:account-location (:account/location (when (nat-int? acct-id)
|
|
(dc/pull (dc/db conn) '[:account/location] acct-id)))
|
|
:client-locations client-locations
|
|
:value (:invoice-expense-account/location account)})))
|
|
(com/data-grid-cell
|
|
{}
|
|
(com/validated-field
|
|
{:errors (ea-errors index :invoice-expense-account/amount)}
|
|
(com/money-input {:name (ea-name index :invoice-expense-account/amount)
|
|
:class "w-16 amount-field"
|
|
:value (:invoice-expense-account/amount account)})))
|
|
(com/data-grid-cell {:class "align-top"}
|
|
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x)))))
|
|
|
|
(defn- expense-accounts-or-default
|
|
"The accounts grid's rows: the posted rows, or -- on first landing -- a single row
|
|
prefilled with the vendor's default account + the invoice total (the old Initializable)."
|
|
[{:keys [step-data all-data]}]
|
|
(let [rows (:invoice/expense-accounts step-data)]
|
|
(if (seq rows)
|
|
(vec rows)
|
|
[{:db/id "123"
|
|
:invoice-expense-account/location "Shared"
|
|
:invoice-expense-account/account
|
|
(:db/id (:vendor/default-account
|
|
(clientize-vendor (get-vendor (->db-id (:invoice/vendor all-data)))
|
|
(->db-id (:invoice/client all-data)))))
|
|
:invoice-expense-account/amount (:invoice/total all-data)}])))
|
|
|
|
(defn- expense-accounts-total* [rows]
|
|
(->> rows
|
|
(map (fnil :invoice-expense-account/amount 0.0))
|
|
(filter number?)
|
|
(reduce + 0.0)))
|
|
|
|
(defn render-accounts
|
|
[{:keys [all-data errors] :as ctx}]
|
|
(binding [*errors* (or errors {})]
|
|
(let [client-id (->db-id (:invoice/client all-data))
|
|
client-locations (pull-attr (dc/db conn) :client/locations client-id)
|
|
rows (expense-accounts-or-default ctx)
|
|
invoice-total (:invoice/total all-data)
|
|
total (expense-accounts-total* rows)]
|
|
(com/modal-card-advanced
|
|
{"@keydown.enter.prevent.stop" "if ($refs.next) {$refs.next.click()}"
|
|
:class " md:w-[750px] md:h-[600px] w-full h-full fade-in transition-opacity duration-300"
|
|
"x-data" ""}
|
|
(com/modal-header {} [:div.p-2 "Invoice accounts "])
|
|
(com/modal-body
|
|
{}
|
|
[:div {}
|
|
(pull-attr (dc/db conn) :client/name client-id)
|
|
(com/validated-field
|
|
{:errors (ferr :invoice/expense-accounts)}
|
|
(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"})]}
|
|
(map-indexed (fn [i a] (invoice-expense-account-row* {:account a
|
|
:index i
|
|
:client-id client-id
|
|
:client-locations client-locations}))
|
|
rows)
|
|
|
|
(com/data-grid-new-row {:colspan 4
|
|
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-wizard-new-account)
|
|
:index (count rows)
|
|
:tr-params {:hx-vals (hx/json {:client-id client-id})}}
|
|
"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/path-for ssr-routes/only-routes ::route/expense-account-total)
|
|
:hx-target "this"
|
|
:hx-swap "innerHTML"}
|
|
(format "$%,.2f" total))
|
|
(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 "balance"
|
|
:class "text-right"
|
|
:hx-trigger "change from:closest form target:.amount-field"
|
|
:hx-put (bidi/path-for ssr-routes/only-routes ::route/expense-account-balance)
|
|
:hx-target "this"
|
|
:hx-swap "innerHTML"}
|
|
[:span {:class (when-not (dollars= 0.0 (- invoice-total total)) "text-red-300")}
|
|
(format "$%,.2f" (- invoice-total total))])
|
|
(com/data-grid-cell {}))
|
|
(com/data-grid-row {}
|
|
(com/data-grid-cell {})
|
|
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "INVOICE TOTAL"])
|
|
(com/data-grid-cell {:class "text-right"} (format "$%,.2f" invoice-total))
|
|
(com/data-grid-cell {}))))])
|
|
(com/modal-footer {} (wizard2/nav-footer {:back? true :save? true :save-label "Save"}))))))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Async step fragments (read from posted form / session -- no multi-form-state).
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(defn- form-vendor-client
|
|
"Decode just the vendor + client + dates a fragment needs from the posted basic-details
|
|
form (flat invoice/* fields)."
|
|
[request]
|
|
(let [nested (:form-params (nfp/nested-params-request request {}))]
|
|
(mc/decode basic-details-schema nested main-transformer)))
|
|
|
|
(defn account-prediction [request]
|
|
(let [{:invoice/keys [vendor client] :keys [customize-accounts]} (form-vendor-client request)]
|
|
(html-response (account-prediction-radio {:vendor-id (->db-id vendor)
|
|
:client-id client
|
|
:value customize-accounts}))))
|
|
|
|
(defn due-date [request]
|
|
(let [{:invoice/keys [vendor client date due]} (form-vendor-client request)
|
|
vendor (clientize-vendor (get-vendor (->db-id vendor)) (->db-id client))
|
|
good-date (or (when (and date (:vendor/terms vendor))
|
|
(time/plus date (time/days (:vendor/terms vendor))))
|
|
due)]
|
|
(html-response
|
|
(com/date-input {:value (some-> good-date (atime/unparse-local atime/normal-date))
|
|
:name "invoice/due"
|
|
:x-init (format "due='%s'" (or (some-> good-date (atime/unparse-local atime/normal-date)) ""))
|
|
:x-model "due"
|
|
:error? false
|
|
:placeholder "1/1/2024"}))))
|
|
|
|
(defn scheduled-payment-date [request]
|
|
(let [{:invoice/keys [vendor client due]} (form-vendor-client request)
|
|
vendor (clientize-vendor (get-vendor (->db-id vendor)) (->db-id client))
|
|
good-date (when (and due (:vendor/automatically-paid-when-due vendor))
|
|
due)]
|
|
(html-response
|
|
(com/date-input {:value (some-> good-date (atime/unparse-local atime/normal-date))
|
|
:name "invoice/scheduled-payment"
|
|
:error? false
|
|
:placeholder "1/1/2024"}))))
|
|
|
|
(defn location-select [{{:keys [name account-id client-id value]} :query-params}]
|
|
(html-response (location-select* {:name name
|
|
:value value
|
|
:account-location (some->> account-id
|
|
(pull-attr (dc/db conn) :account/location))
|
|
:client-locations (some->> client-id
|
|
(pull-attr (dc/db conn) :client/locations))})))
|
|
|
|
(defn- posted-amount-total
|
|
"Sum the amounts in the posted accounts form."
|
|
[request]
|
|
(let [nested (:form-params (nfp/nested-params-request request {}))
|
|
decoded (mc/decode accounts-schema nested main-transformer)]
|
|
(expense-accounts-total* (:invoice/expense-accounts decoded))))
|
|
|
|
(defn- session-invoice-total
|
|
"The invoice total stored in basic-details step-data (the accounts form doesn't carry it)."
|
|
[request]
|
|
(let [wid (get-in request [:form-params "wizard-id"])]
|
|
(or (:invoice/total (ws/get-all (:session request) wid)) 0.0)))
|
|
|
|
(defn expense-account-total [request]
|
|
(html-response (format "$%,.2f" (posted-amount-total request))))
|
|
|
|
(defn expense-account-balance [request]
|
|
(let [balance (- (session-invoice-total request) (posted-amount-total request))]
|
|
(html-response [:span {:class (when-not (dollars= 0.0 balance) "text-red-300")}
|
|
(format "$%,.2f" balance)])))
|
|
|
|
(defn new-account
|
|
"Add-row: render one fresh expense-account row at the posted index."
|
|
[request]
|
|
(let [idx (-> request :query-params :index)
|
|
idx (if (string? idx) (Integer/parseInt idx) idx)
|
|
client-id (-> request :query-params :client-id)
|
|
client-locations (some->> client-id (pull-attr (dc/db conn) :client/locations))]
|
|
(html-response
|
|
(invoice-expense-account-row* {:account (wizard2/blank-row :invoice-expense-account/location "Shared")
|
|
:index idx
|
|
:client-id client-id
|
|
:client-locations client-locations}))))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; done-fn: create / update the invoice, then show next-steps (or the updated row).
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(defn- next-steps-modal
|
|
"After a NEW invoice is created: offer to pay it now, add another, or close."
|
|
[invoice-id]
|
|
(modal-response
|
|
(com/modal {}
|
|
(com/modal-card-advanced
|
|
{:class "transition duration-300 ease-in-out scale-100 translate-x-0 opacity-100"}
|
|
(com/modal-header {} [:div.p-2 "Invoice accounts "])
|
|
(com/modal-body
|
|
{}
|
|
[:p.text-lg "Would you like to pay this invoice now?"]
|
|
(com/navigation-button-list
|
|
{}
|
|
(com/navigation-button (-> {:class "w-48"
|
|
:hx-get (hu/url (bidi/path-for ssr-routes/only-routes ::route/pay-wizard)
|
|
{:selected invoice-id :replace-modal true})}
|
|
hx/trigger-click-or-enter) "Pay now")
|
|
(com/navigation-button (-> {:class "w-48"
|
|
:hx-get (hu/url (bidi/path-for ssr-routes/only-routes ::route/new-wizard)
|
|
{:replace-modal true})}
|
|
hx/trigger-click-or-enter) "Add another")
|
|
(com/navigation-button {:class "w-48" :next-arrow? false
|
|
"@click" "$dispatch('modalclose') "
|
|
"@keyup.enter.stop" "$dispatch('modalclose')"}
|
|
"Close")))))
|
|
:headers {"hx-trigger" "invalidated"}))
|
|
|
|
(defn- updated-row-response
|
|
"After an EDIT: swap the table row in place (resolve invoices/row* to avoid a cycle)."
|
|
[identity invoice-id request]
|
|
(html-response
|
|
(@(resolve 'auto-ap.ssr.invoices/row*) identity (dc/pull (dc/db conn) default-read invoice-id) {:flash? true
|
|
:request request})
|
|
:headers {"hx-trigger" "modalclose"
|
|
"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" invoice-id)
|
|
"hx-reswap" "outerHTML"}))
|
|
|
|
(defn create-invoice!
|
|
"Engine done-fn: merge the steps into the invoice, validate, upsert, and respond. Default
|
|
accounts use the vendor's default account; :customize uses the posted grid."
|
|
[all-data {:keys [identity] :as request}]
|
|
(let [invoice all-data
|
|
extant? (:db/id invoice)
|
|
client-id (->db-id (:invoice/client invoice))
|
|
vendor-id (->db-id (:invoice/vendor invoice))
|
|
paid-amount (if-let [outstanding-balance
|
|
(and extant?
|
|
(- (pull-attr (dc/db conn) :invoice/total (:db/id invoice))
|
|
(pull-attr (dc/db conn) :invoice/outstanding-balance (:db/id invoice))))]
|
|
outstanding-balance
|
|
0.0)
|
|
outstanding-balance (- (:invoice/total invoice) paid-amount)
|
|
entity (-> invoice
|
|
(assoc :db/id (or (:db/id invoice) "invoice"))
|
|
(dissoc :customize-due-and-scheduled? :customize-accounts)
|
|
(assoc :invoice/expense-accounts
|
|
(if (= :customize (:customize-accounts invoice))
|
|
(:invoice/expense-accounts invoice)
|
|
[{:db/id "123"
|
|
:invoice-expense-account/location "Shared"
|
|
:invoice-expense-account/account
|
|
(:db/id (:vendor/default-account (clientize-vendor (get-vendor vendor-id) client-id)))
|
|
:invoice-expense-account/amount (:invoice/total invoice)}]))
|
|
(assoc :invoice/outstanding-balance outstanding-balance
|
|
:invoice/import-status :import-status/imported
|
|
:invoice/status (if (dollars= 0.0 outstanding-balance)
|
|
:invoice-status/paid
|
|
:invoice-status/unpaid))
|
|
(maybe-spread-locations)
|
|
(update :invoice/date coerce/to-date)
|
|
(update :invoice/due coerce/to-date)
|
|
(update :invoice/scheduled-payment coerce/to-date))]
|
|
(assert-invoice-amounts-add-up entity)
|
|
(assert-no-conflicting invoice)
|
|
(exception->4xx #(assert-can-see-client identity client-id))
|
|
(exception->4xx #(assert-not-locked client-id (:invoice/date invoice)))
|
|
(let [transaction-result (audit-transact [[:upsert-invoice entity]] identity)]
|
|
(try
|
|
(solr/touch-with-ledger (or (:db/id invoice) (get-in transaction-result [:tempids "invoice"])))
|
|
(catch Exception e
|
|
(alog/error ::cant-save-solr :error e)))
|
|
(if extant?
|
|
(updated-row-response identity (:db/id invoice) request)
|
|
(next-steps-modal (get-in transaction-result [:tempids "invoice"]))))))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Engine config + open / submit handlers.
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(defn new-init-fn
|
|
"Engine :init-fn -- new vs edit branch on a route db/id. Dates ride as java.util.Date
|
|
(EDN-safe). For new, the date defaults to today; for edit, the persisted invoice is read
|
|
and customize-accounts is forced to :customize so its accounts are editable."
|
|
[request]
|
|
(if-let [id (->db-id (get-in request [:route-params :db/id]))]
|
|
(let [entity (-> (dc/pull (dc/db conn) default-read id)
|
|
(select-keys (mut/keys new-form-schema))
|
|
->edn-safe-dates
|
|
(assoc :customize-accounts :customize))]
|
|
{:init-data {:basic-details (dissoc entity :invoice/expense-accounts)
|
|
:accounts (select-keys entity [:invoice/expense-accounts])}})
|
|
{:init-data {:basic-details {:invoice/date (coerce/to-date (time/now))
|
|
:customize-accounts :default}}}))
|
|
|
|
(def new-invoice-config
|
|
{:name :new-invoice
|
|
:form-id "wizard-form"
|
|
:submit-route (bidi/path-for ssr-routes/only-routes ::route/new-invoice-submit)
|
|
:form-attrs {:hx-ext "response-targets"
|
|
:hx-target-400 "#form-errors"}
|
|
:open-response (fn [form] (modal-response [:div#transitioner.flex-1 form]))
|
|
:init-fn new-init-fn
|
|
:steps [{:key :basic-details
|
|
:decode decode-basic-details
|
|
:validate validate-basic-details
|
|
:render render-basic-details
|
|
:next (fn [data] (if (= :customize (:customize-accounts data)) :accounts :done))}
|
|
{:key :accounts
|
|
:decode decode-accounts
|
|
:render render-accounts
|
|
:next (fn [_] :done)}]
|
|
:done-fn create-invoice!})
|
|
|
|
(defn open-wizard
|
|
"GET open (new or edit): build the wizard in its modal shell."
|
|
[request]
|
|
(exception->notification
|
|
#(wizard2/open-wizard new-invoice-config request)))
|
|
|
|
(defn new-invoice-step
|
|
"POST handler for every transition: basic-details Save, accounts Save/Back. Surface the
|
|
create-time validation (conflict, amounts, locks) as a 4xx into #form-errors."
|
|
[request]
|
|
(try+
|
|
(wizard2/handle-step-submit new-invoice-config request)
|
|
(catch #(#{:form-validation :schema-validation :field-validation :notification} (:type %)) e
|
|
(html-response
|
|
[:span.error-content.text-red-500
|
|
(or (some->> (:form-validation-errors e) (str/join " "))
|
|
(:message e)
|
|
"Could not save the invoice.")]
|
|
:status 400))))
|
|
|
|
(def key->handler
|
|
(apply-middleware-to-all-handlers
|
|
{::route/new-wizard (partial open-wizard)
|
|
::route/edit-wizard (-> (partial open-wizard)
|
|
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
|
|
::route/new-invoice-submit new-invoice-step
|
|
|
|
::route/account-prediction account-prediction
|
|
::route/due-date due-date
|
|
::route/scheduled-payment-date scheduled-payment-date
|
|
::route/expense-account-total expense-account-total
|
|
::route/expense-account-balance expense-account-balance
|
|
|
|
::route/location-select (-> location-select
|
|
(wrap-schema-enforce :query-schema [:map
|
|
[:name :string]
|
|
[:client-id {:optional true} [:maybe entity-id]]
|
|
[:account-id {:optional true} [:maybe entity-id]]]))
|
|
::route/new-wizard-new-account (-> new-account
|
|
(wrap-schema-enforce :query-schema [:map
|
|
[:index {:optional true} [:maybe nat-int?]]
|
|
[:client-id {:optional true} [:maybe entity-id]]]))}
|
|
(fn [h]
|
|
(-> h
|
|
(wrap-client-redirect-unauthenticated)))))
|