Files
integreat/src/clj/auto_ap/ssr/admin/clients.clj
Bryce ba87805d4c Add vendor pre-population for bulk code and individual edit forms
- Add vendor-changed HTMX handlers for both bulk code and individual edit
- Pre-populate default account at 100% when vendor is selected and no accounts exist
- Fix render-accounts-section to render from step-params correctly
- Change bulk code vendor-changed from hx-get to hx-post to include form data
- Add routes for vendor-changed endpoints
- Update e2e tests to cover vendor pre-population
- Run lein cljfmt fix across codebase
2026-05-21 14:45:19 -07:00

1914 lines
103 KiB
Clojure

(ns auto-ap.ssr.admin.clients
(:require
[auto-ap.datomic
:refer [add-sorter-fields apply-pagination apply-sort-3
audit-transact conn merge-query pull-attr pull-id
pull-many query2]]
[auto-ap.graphql.utils :refer [extract-client-ids]]
[auto-ap.logging :as alog]
[auto-ap.query-params :refer [wrap-copy-qp-pqp]]
[auto-ap.routes.admin.clients :as route]
[auto-ap.routes.indicators :as indicators]
[auto-ap.routes.queries :as q]
[auto-ap.routes.utils
:refer [wrap-admin wrap-client-redirect-unauthenticated]]
[auto-ap.solr :as solr]
[auto-ap.square.core3 :as square]
[auto-ap.ssr-routes :as ssr-routes]
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler
add-new-primitive-handler]]
[auto-ap.ssr.components :as com]
[auto-ap.ssr.components.multi-modal :as mm]
[auto-ap.ssr.form-cursor :as fc]
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
[auto-ap.ssr.hiccup-helper :as hh]
[auto-ap.ssr.hx :as hx]
[auto-ap.ssr.indicators :as i]
[auto-ap.ssr.svg :as svg]
[auto-ap.ssr.utils
:refer [apply-middleware-to-all-handlers entity-id
form-validation-error html-response many-entity
many-entity-custom modal-response ref->enum-schema strip
temp-id wrap-entity wrap-merge-prior-hx
wrap-schema-enforce]]
[auto-ap.time :as atime]
[bidi.bidi :as bidi]
[cheshire.core :as cheshire]
[clj-time.coerce :as coerce]
[clj-time.core :as time]
[clojure.java.io :as io]
[clojure.string :as str]
[datomic.api :as dc]
[hiccup.util :as hu]
[malli.core :as mc]
[malli.transform :as mt]
[malli.util :as mut]
[manifold.deferred :as de])
(:import
[java.util UUID]))
;; TODO make more reusable malli schemas, use unions if it would be helpful
;; TODO copy save logic from graphql version
;; TODO cash drawer shift
;; TODO a few bug fixes from slack
;; TOOD check pinecone
(def query-schema (mc/schema
[:maybe [:map
[:sort {:optional true} [:maybe [:any]]]
[:per-page {:optional true :default 25} [:maybe :int]]
[:start {:optional true :default 0} [:maybe :int]]
[:code {:optional true} [:maybe {:decode/string strip} :string]]
[:name {:optional true} [:maybe {:decode/string strip} :string]]
[:group {:optional true} [:maybe {:decode/string strip} :string]]
[:select {:optional true :default "all"}
[:maybe
[:enum
"" "all" "only-mine"]]]]]))
(defn filters [request]
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes
::route/table)
"hx-target" "#entity-table"
"hx-indicator" "#entity-table"}
[:fieldset.space-y-6
(com/field {:label "Name"}
(com/text-input {:name "name"
:id "name"
:class "hot-filter"
:value (:name (:query-params request))
:placeholder "Best Restaurant LLC"
:size :small}))
(com/field {:label "Code"}
(com/text-input {:name "code"
:id "code"
:class "hot-filter"
:value (:code (:query-params request))
:placeholder "BRLC"
:size :small}))
(com/field {:label "Group"}
(com/text-input {:name "group"
:id "group"
:class "hot-filter"
:value (:group (:query-params request))
:placeholder "NTG"
:size :small}))
(com/field {:label "Select"}
(com/radio-card {:size :small
:name "select"
:value (:select (:query-params request))
:options [{:value ""
:content "All"}
{:value "only-mine"
:content "Only mine"}]}))]])
(def default-read '[:db/id
:client/name
:client/code
:client/locations
:client/groups
:client/matches
:client/week-a-credits
:client/week-b-credits
:client/week-a-debits
:client/week-b-debits
:client/square-auth-token
:client/feature-flags
{:client/bank-accounts [:bank-account/code
:db/id
:bank-account/bank-name
:bank-account/numeric-code
:bank-account/name
:bank-account/include-in-reports
:bank-account/visible
:bank-account/number
:bank-account/bank-code
:bank-account/sort-order
:bank-account/routing
:bank-account/check-number
:bank-account/use-date-instead-of-post-date?
{:bank-account/yodlee-account [:db/id :yodlee-account/name]}
{:bank-account/plaid-account [:db/id :plaid-account/name]}
{:bank-account/intuit-bank-account [:db/id :intuit-bank-account/name]}
[:bank-account/start-date :xform clj-time.coerce/from-date]
{[:bank-account/type :xform iol-ion.query/ident] [:db/ident]}
{:bank-account/integration-status
[[:integration-status/last-updated :xform clj-time.coerce/from-date]
{[:integration-status/state :xform iol-ion.query/ident] [:db/ident]}]}]
:client/address [:address/street1 :address/street2 :address/city :address/state :address/zip :db/id]
:client/square-locations [:db/id
:square-location/name
:square-location/client-location
:square-location/square-id]}
[:client/locked-until :xform clj-time.coerce/from-date]
{:client/emails [:email-contact/description :email-contact/email :db/id]
:client/location-matches [:location-match/matches :location-match/location :db/id]}])
(defn fetch-ids [db request]
(let [query-params (:query-params request)
valid-clients (extract-client-ids #_(:clients request)
(map first (dc/q '[:find ?c :where [?c :client/code]] (dc/db conn)))
(:client-id query-params)
(when (:client-code query-params)
[:client/code (:client-code query-params)]))
query (cond-> {:query {:find []
:in ['$ '[?e ...]]
:where []}
:args [db valid-clients]}
(:sort query-params) (add-sorter-fields {"name" ['[?e :client/name ?sort-name]]
"code" ['[?e :client/code ?sort-code]]}
query-params)
(= "only-mine" (:select query-params))
(merge-query {:query {:in ['?uid]
:where ['[?uid :user/clients ?e]]}
:args [(:db/id (:identity request))]})
(:group query-params)
(merge-query {:query {:in ['?g]
:where ['[?e :client/groups ?g]]}
:args [(clojure.string/upper-case (:group query-params))]})
(not (str/blank? (some-> query-params :code)))
(merge-query {:query {:in ['?code]
:where ['[?e :client/code ?code]]}
:args [(clojure.string/upper-case (:code query-params))]})
(not (str/blank? (:name query-params)))
(merge-query {:query {:in ['?description]
:where ['[?e :client/name ?d]
'[(clojure.string/lower-case ?d) ?d2]
'[(clojure.string/includes? ?d2 ?description)]]}
:args [(clojure.string/lower-case (:name query-params))]})
true
(merge-query {:query {:find ['?e]
:where ['[?e :client/name]]}}))]
(cond->> (query2 query)
true (apply-sort-3 query-params)
true (apply-pagination query-params))))
(defn hydrate-results [ids db _]
(let [results (->> (pull-many db default-read ids)
(group-by :db/id))
refunds (->> ids
(map results)
(map first))]
refunds))
(defn fetch-page [request]
(let [db (dc/db conn)
{ids-to-retrieve :ids matching-count :count} (fetch-ids db request)]
[(->> (hydrate-results ids-to-retrieve db request))
matching-count]))
(def grid-page
(helper/build {:id "entity-table"
:nav com/admin-aside-nav
:page-specific-nav filters
:fetch-page fetch-page
:action-buttons (fn [_]
[(com/button {:hx-get (str (bidi/path-for ssr-routes/only-routes ::route/new-dialog))
:color :primary}
"New Client")])
:row-buttons (fn [_ entity]
[(com/icon-button {:hx-get (bidi/path-for ssr-routes/only-routes
::route/biweekly-sales-powerquery
:db/id (:db/id entity))}
svg/dollar-tag)
(com/icon-button {:hx-get (bidi/path-for ssr-routes/only-routes
::route/edit-dialog
:db/id (:db/id entity))}
svg/pencil)])
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes :admin)}
"Admin"]
[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)}
"Clients"]]
:title "Clients"
:entity-name "Client"
:query-schema query-schema
:route ::route/table
:headers [{:key "name"
:name "Name"
:sort-key "name"
:render :client/name}
{:key "code"
:name "Code"
:sort-key "code"
:render :client/code}
{:key "locations"
:name "Locations"
:render (fn [{:client/keys [locations]}]
[:div.flex.gap-2
(for [l locations]
(com/pill {:color :primary} l))])}
{:key "emails"
:name "Emails"
:show-starting "lg"
:render (fn [{:client/keys [emails]}]
[:div.flex.gap-2.flex-wrap
(for [{:email-contact/keys [email description]} emails]
(com/pill {:color :secondary} (format "%s - %s" description email)))])}
{:key "status"
:name "Status"
:show-starting "lg"
:render (fn [{:client/keys [locked-until bank-accounts]}]
[:div.flex.gap-2.flex-wrap
(if locked-until
(let [days-since-locked (try (time/in-days (time/interval locked-until (time/now)))
(catch Exception _
0))]
(cond
(< days-since-locked 90)
(com/pill {:color :primary} (format "Locked %s" (atime/unparse-local locked-until atime/normal-date)))
(< days-since-locked 365)
(com/pill {:color :yellow} (format "Locked %s" (atime/unparse-local locked-until atime/normal-date)))
:else
(com/pill {:color :red} (format "Locked %s" (atime/unparse-local locked-until atime/normal-date)))))
(com/pill {:color :red} (format "Not locked")))
(for [ba bank-accounts]
(com/pill {:color (cond (#{:integration-state/failed :integration-state/unauthorized} (-> ba :bank-account/integration-status :integration-status/state))
:red
:else
:secondary)}
(:bank-account/code ba)))]
#_[:div.flex.gap-2.flex-wrap
(for [{:email-contact/keys [email description]} emails]
(com/pill {:color :secondary} (format "%s - %s" description email)))])}]}))
(def row* (partial helper/row* grid-page))
(def bank-account-schema [:and [:map
[:db/id [:or entity-id temp-id]]
[:bank-account/name :string]
[:bank-account/code :string]
[:bank-account/type [:maybe (ref->enum-schema "bank-account-type")]]
[:bank-account/numeric-code {:optional true} [:maybe :int]]
[:bank-account/check-number {:optional true} [:maybe :int]]
[:bank-account/bank-name {:optional true} [:maybe :string]]
[:bank-account/number {:optional true} [:maybe :string]]
[:bank-account/routing {:optional true} [:maybe :string]]
[:bank-account/bank-code {:optional true} [:maybe :string]]
[:bank-account/sort-order {:default 0} [:maybe :int]]
[:bank-account/yodlee-account {:optional true} [:maybe entity-id]]
[:bank-account/plaid-account {:optional true} [:maybe entity-id]]
[:bank-account/intuit-bank-account {:optional true} [:maybe entity-id]]
[:bank-account/include-in-reports {:default false}
[:boolean {:decode/string {:enter #(if (= % "on") true
(boolean %))}}]]
[:bank-account/visible {:default false}
[:boolean {:decode/string {:enter #(if (= % "on") true
(boolean %))}}]]
[:bank-account/use-date-instead-of-post-date? {:default false}
[:boolean {:decode/string {:enter #(if (= % "on") true
(boolean %))}}]]
[:bank-account/start-date {:optional true} [:maybe {:decode/arbitrary (fn [m]
(if (string? m)
(clj-time.coerce/to-date (auto-ap.time/parse m atime/normal-date))
m))}
inst?]]]
[:fn {:error/message "Bank account financial code is required if the 'Include in Reports?' flag is true."
:error/path [:bank-account/numeric-code]}
(fn [{:bank-account/keys [include-in-reports numeric-code] :as g}]
(cond
(and include-in-reports (not numeric-code))
false
:else
true))]])
(def form-schema-2 (mc/schema
[:map
[:db/id {:optional true} [:maybe entity-id]]
[:client/name :string]
[:client/code :string]
[:client/feature-flags {:optional true}
[:maybe [:vector {:decode/arbitrary (fn [m] (if (map? m)
(vals m)
m))}
:string]]]
[:client/locations [:vector {:decode/arbitrary (fn [m] (if (map? m)
(vals m)
m))} :string]]
[:client/groups {:optional true :default []} [:maybe [:vector {:decode/arbitrary (fn [m] (if (map? m)
(vals m)
m))} :string]]]
[:client/emails {:optional true} [:maybe (many-entity {}
[:db/id [:or entity-id temp-id]]
[:email-contact/description :string]
[:email-contact/email :string])]]
[:client/locked-until {:optional true} [:maybe {:decode/arbitrary (fn [m]
(if (string? m)
(auto-ap.time/parse m atime/normal-date)
m))}
inst?]]
[:client/location-matches {:optional true}
[:maybe (many-entity {}
[:db/id [:or entity-id temp-id]]
[:location-match/matches [:vector {:decode/arbitrary (fn [m] (if (map? m)
(vals m)
m))}
:string]]
[:location-match/location :string])]]
[:client/square-auth-token {:optional true} [:maybe :string]]
[:client/square-locations {:optional true}
[:maybe (many-entity {}
[:db/id [:or entity-id temp-id]]
[:square-location/name :string]
[:square-location/square-id :string]
[:square-location/client-location :string])]]
[:client/bank-accounts {:default []}
[:maybe (many-entity-custom {}
[:and
[:map
[:db/id [:or entity-id temp-id]]
[:bank-account/name :string]
[:bank-account/code :string]
[:bank-account/type [:maybe (ref->enum-schema "bank-account-type")]]
[:bank-account/numeric-code {:optional true} [:maybe :int]]
[:bank-account/sort-order {:default 0} [:maybe :int]]
[:bank-account/routing {:optional true} [:maybe :string]]
[:bank-account/bank-code {:optional true} [:maybe :string]]
[:bank-account/bank-name {:optional true} [:maybe :string]]
[:bank-account/number {:optional true} [:maybe :string]]
[:bank-account/check-number {:optional true} [:maybe :int]]
[:bank-account/yodlee-account {:optional true} [:maybe entity-id]]
[:bank-account/plaid-account {:optional true} [:maybe entity-id]]
[:bank-account/intuit-bank-account {:optional true} [:maybe entity-id]]
[:bank-account/include-in-reports {:default false}
[:boolean {:decode/string {:enter #(if (= % "on") true
(boolean %))}}]]
[:bank-account/visible {:default false}
[:boolean {:decode/string {:enter #(if (= % "on") true
(boolean %))}}]]
[:bank-account/use-date-instead-of-post-date? {:default false}
[:boolean {:decode/string {:enter #(if (= % "on") true
(boolean %))}}]]
[:bank-account/start-date {:optional true} [:maybe {:decode/arbitrary (fn [m]
(if (string? m)
(clj-time.coerce/to-date (auto-ap.time/parse m atime/normal-date))
m))}
inst?]]]
[:fn {:error/message "Bank account financial code is required if the 'Include in Reports?' flag is true."
:error/path [:bank-account/numeric-code]}
(fn [{:bank-account/keys [include-in-reports numeric-code] :as g}]
(cond
(and include-in-reports (not numeric-code))
false
:else
true))]])]]
[:client/matches {:optional true :default []} [:vector {:decode/arbitrary (fn [m] (if (map? m)
(vals m)
m))}
:string]]
[:client/address {:optional true}
[:maybe
[:map
[:db/id {:optional true} [:maybe [:or entity-id temp-id]]]
[:address/street1 {:optional true} [:maybe [:string {:min 3 :decode/string strip}]]]
;; I don't like using a default because otherwise it wouldn't allow creating an empty address
[:address/street2 {:optional true} [:maybe [:string {:min 3 :decode/string strip}]]]
[:address/city {:optional true} [:maybe [:string {:min 2 :decode/string strip}]]]
[:address/state {:optional true} [:maybe [:string {:min 2 :max 2 :decode/string strip}]]]
[:address/zip {:optional true} [:maybe [:string {:min 5 :max 5 :decode/string strip}]]]]]]
[:client/week-a-credits {:optional true} [:maybe :double]]
[:client/week-a-debits {:optional true} [:maybe :double]]
[:client/week-b-credits {:optional true} [:maybe :double]]
[:client/week-b-debits {:optional true} [:maybe :double]]]))
(defn email-contact-row [email-contact-cursor]
(com/data-grid-row
(-> {:x-data (hx/json {:show (boolean (not (fc/field-value (:new? email-contact-cursor))))})
:data-key "show"
:x-ref "p"}
hx/alpine-mount-then-appear)
(list
(fc/with-field :db/id
(com/hidden {:name (fc/field-name)
:value (fc/field-value)}))
(com/data-grid-cell {}
(fc/with-field :email-contact/description
(com/validated-field {:errors (fc/field-errors)
:class "shrink-0"}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:placeholder "Suite 300"
:class "w-full"
:value (fc/field-value)}))))
(com/data-grid-cell {}
(fc/with-field :email-contact/email
(com/validated-field {:errors (fc/field-errors)
:class "shrink-0"}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:placeholder "Suite 300"
:class "w-full"
:value (fc/field-value)}))))
(com/data-grid-cell {:class "align-top"}
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x)))))
(defn location-row [_]
(com/data-grid-row
{:x-ref "p"
:x-data (hx/json {})}
(com/data-grid-cell
{}
(com/validated-field {}
(com/text-input {:name (fc/field-name)
:value (fc/field-value)
:class "w-24"})))
(com/data-grid-cell {:class "align-top"}
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x))))
(defn group-row [_]
(com/data-grid-row
{:x-ref "p"
:x-data (hx/json {})}
(com/data-grid-cell
{}
(com/validated-field {}
(com/text-input {:name (fc/field-name)
:value (fc/field-value)
:class "w-24"})))
(com/data-grid-cell {:class "align-top"}
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x))))
(defn feature-flag-row [_]
(com/data-grid-row
{:x-ref "p"
:x-data (hx/json {})}
(com/data-grid-cell
{}
(com/validated-field {}
(com/select {:name (fc/field-name)
:allow-blank? true
:error? (fc/error?)
:class "w-full"
:value (fc/field-value)
:options [["new-square" "New Square+Ezcater (no effect)"]
["manually-pay-cintas" "Manually Pay Cintas"]
["include-in-ntg-corp-reports" "Include in NTG Corporate reports"]
["import-custom-amount" "Import Custom Amount Line Items from Square"]
["code-sysco-items" "Code individual sysco line items"]
["report-pedantic" "Show two decimals in reports"]]})))
(com/data-grid-cell {:class "align-top"}
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x))))
(defn- dialog-header [step]
[:div.flex [:div.p-2 (mm/step-name step)] [:p.ml-2.rounded.bg-gray-50.p-2.dark:bg-gray-600
[:span {:x-text "clientName"}]]])
(defrecord InfoModal [linear-wizard]
mm/ModalWizardStep
(step-name [_]
"Info")
(step-key [_]
:info)
(edit-path [_ _]
[])
(step-schema [_]
(mut/select-keys (mm/form-schema linear-wizard) #{:client/name :client/code :client/locations}))
(render-step [this _]
(mm/default-render-step
linear-wizard this
:head (dialog-header this)
:body (mm/default-step-body
{}
[:div.flex.space-x-2
(fc/with-field :client/name
(com/validated-field {:label "Name"
:errors (fc/field-errors)}
(com/text-input {:name (fc/field-name)
:value (fc/field-value)
:x-model "clientName"
:autofocus true
:class "w-96"})))
(fc/with-field :client/code
(com/validated-field {:label "Code"
:errors (fc/field-errors)}
(list
(com/text-input {:name (fc/field-name)
:disabled (if (:db/id (:entity linear-wizard)) true false)
:value (fc/field-value)
:class "w-24"})
(when (:db/id (:entity linear-wizard))
(com/hidden {:name (fc/field-name)
:value (fc/field-value)})))))]
(fc/with-field :client/locked-until
(com/validated-field {:label "Locked Until"
:errors (fc/field-errors)}
(com/date-input {:name (fc/field-name)
:placeholder "Disallow changes before this date"
:value
(some-> (fc/field-value)
(atime/unparse-local atime/normal-date))
:class "w-24"})))
(fc/with-field :client/locations
(com/validated-field
{:errors (fc/field-errors)
:label "Locations"}
(com/data-grid {:headers [(com/data-grid-header {} "Location")
(com/data-grid-header {:class "w-16"})]}
(fc/cursor-map #(location-row %))
(com/data-grid-new-row {:colspan 2
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-location)
:index (count (fc/field-value))}
"New location")))))
:footer
(mm/default-step-footer linear-wizard this :validation-route ::route/navigate)
:validation-route ::route/navigate)))
(defn match-row [_]
(com/data-grid-row
{:x-ref "p"
:x-data (hx/json {})}
(com/data-grid-cell
{}
(com/validated-field {}
(com/text-input {:name (fc/field-name)
:value (fc/field-value)
:class "w-full"})))
(com/data-grid-cell {:class "align-top"}
(com/a-icon-button {"@click.prevent.stop" "$refs.p.remove()"} svg/x))))
(defn location-match-row [location-match-cursor]
(com/data-grid-row
(-> {:x-data (hx/json {:show (boolean (not (fc/field-value (:new? location-match-cursor))))})
:data-key "show"
:x-ref "p"}
hx/alpine-mount-then-appear)
(fc/with-field :db/id
(com/hidden {:name (fc/field-name)
:value (fc/field-value)}))
(com/data-grid-cell {}
(fc/with-field-default :location-match/matches [""]
(fc/with-field 0
(com/validated-field {:errors (fc/field-errors)
:class "shrink-0"}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:placeholder "Suite 300"
:class "w-full"
:value (fc/field-value)})))))
(com/data-grid-cell {}
(fc/with-field :location-match/location
(com/validated-field {:errors (fc/field-errors)
:class "shrink-0"}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:placeholder "Suite 300"
:class "w-24"
:value (fc/field-value)}))))
(com/data-grid-cell {:class "align-top"}
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x))))
(defrecord MatchesModal [linear-wizard]
mm/ModalWizardStep
(step-name [_]
"Matches")
(step-key [_]
:matches)
(edit-path [_ _]
[])
(step-schema [_]
(mut/select-keys (mm/form-schema linear-wizard) #{:client/matches :client/location-matches}))
(render-step [this _]
(mm/default-render-step
linear-wizard this
:head (dialog-header this)
:body (mm/default-step-body {}
(fc/with-field :client/matches
(com/validated-field
{:errors (fc/field-errors)
:label "Matches"}
(com/data-grid {:headers [(com/data-grid-header {} "Match")
(com/data-grid-header {:class "w-16"})]}
(fc/cursor-map #(match-row %))
(com/data-grid-new-row {:colspan 2
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-match)
:index (count (fc/field-value))}
"New Match"))))
(fc/with-field :client/location-matches
(com/validated-field
{:errors (fc/field-errors)
:label "Location Matches"}
(com/data-grid {:headers [(com/data-grid-header {} "Match")
(com/data-grid-header {} "location")
(com/data-grid-header {:class "w-16"})]}
(fc/cursor-map #(location-match-row %))
(com/data-grid-new-row {:colspan 3
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-location-match)
:index (count (fc/field-value))}
"New Match"))))
[:div])
:footer
(mm/default-step-footer linear-wizard this :validation-route ::route/navigate)
:validation-route ::route/navigate)))
(defrecord ContactModal [linear-wizard]
mm/ModalWizardStep
(step-name [_]
"Contact")
(step-key [_]
:contact)
(edit-path [_ _]
[])
(step-schema [_]
(mut/select-keys (mm/form-schema linear-wizard) #{:client/address}))
(render-step [this _]
(mm/default-render-step
linear-wizard this
:head (dialog-header this)
:body (mm/default-step-body {}
(fc/with-field-default :client/address {}
[:div.flex.flex-col.w-full
(when (:db/id @fc/*current*)
(fc/with-field :db/id
(com/hidden {:name (fc/field-name)
:value (fc/field-value)})))
(fc/with-field :address/street1
(com/validated-field {:label "Street"
:errors (fc/field-errors)}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:autofocus true
:class "w-full"
:placeholder "1200 Pennsylvania Avenue"
:value (fc/field-value)})))
(fc/with-field :address/street2
(com/validated-field {:errors (fc/field-errors)}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:class "w-full"
:placeholder "Suite 300"
:value (fc/field-value)})))
[:div.flex.w-full.space-x-4
(fc/with-field :address/city
(com/validated-field {:errors (fc/field-errors)
:class "w-full grow shrink"}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:class "w-full"
:placeholder "Suite 300"
:value (fc/field-value)})))
(fc/with-field :address/state
(com/validated-field {:errors (fc/field-errors)
:class "w-16 shrink-0"}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:class "w-full"
:placeholder "Suite 300"
:value (fc/field-value)})))
(fc/with-field :address/zip
(com/validated-field {:errors (fc/field-errors)
:class "w-24 shrink-0"}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:placeholder "Suite 300"
:class "w-full"
:value (fc/field-value)})))]])
(fc/with-field :client/emails
(com/validated-field
{:errors (fc/field-errors)
:label "Email Contacts"}
(com/data-grid {:headers [(com/data-grid-header {} "Name")
(com/data-grid-header {} "Email")
(com/data-grid-header {:class "w-16"})]}
(fc/cursor-map #(email-contact-row %))
(com/data-grid-new-row {:colspan 3
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-email-contact)
:index (count (fc/field-value))}
"New email contact")))))
:footer (mm/default-step-footer linear-wizard this :validation-route ::route/navigate)
:validation-route ::route/navigate)))
(defn bank-account-card-base [{:keys [bg-color text-color icon bank-account]}]
[:div {:class "w-[30em] cursor-move"}
(com/card {:class "w-full"}
[:div.flex.items-stretch
(com/hidden {:name "item"
:value (fc/field-value (:db/id bank-account))})
[:div.grow-0.flex.flex-col.justify-center
[:div.p-1.m-2.rounded-full
{:class
bg-color}
[:div {:class
(hh/add-class "p-1.5 w-8 h-8" text-color)}
icon]]]
[:div.flex.flex-col.grow.m-2
[:div.font-medium.text-gray-700 (fc/field-value (:bank-account/name bank-account))]
[:div.font-light.text-gray-600 (fc/field-value (:bank-account/bank-name bank-account))]]
[:div.grow-0.p-4
(com/a-icon-button {:hx-put (hu/url (bidi/path-for ssr-routes/only-routes ::route/navigate)
{:from (mm/encode-step-key :bank-accounts)
:to (mm/encode-step-key [:bank-account (fc/field-value (:db/id bank-account))])})}
svg/pencil)]])])
(defmulti bank-account-card (comp deref :bank-account/type))
(defmethod bank-account-card :bank-account-type/cash [bank-account]
(bank-account-card-base {:bg-color "bg-green-50"
:text-color "text-green-600"
:icon svg/dollar
:bank-account bank-account}))
(defmethod bank-account-card
:bank-account-type/credit
[bank-account]
(bank-account-card-base {:bg-color "bg-purple-50"
:text-color "text-purple-600"
:icon svg/credit-card
:bank-account bank-account}))
(defmethod bank-account-card
:bank-account-type/check [bank-account]
(bank-account-card-base {:bg-color "bg-blue-50"
:text-color "text-blue-600"
:icon svg/check
:bank-account bank-account}))
(defmulti bank-account-form (comp deref :bank-account/type))
(defmethod bank-account-form :bank-account-type/cash [bank-account]
[:div
[:h2.text-lg
(if (:new @bank-account)
"New Cash Account"
(str "Edit Cash Account: " (:bank-account/name @bank-account)))]
(fc/with-field :db/id
(com/hidden {:name (fc/field-name)
:value (fc/field-value)}))
(fc/with-field :bank-account/type
(com/hidden {:name (fc/field-name)
:value (name (fc/field-value))}))
[:div.flex.space-x-2
(fc/with-field :bank-account/name
(com/validated-field {:errors (fc/field-errors)
:label "Nickname"
:class "w-[20em]"}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:autofocus true
:placeholder "BofA Checking"
:class "w-full"
:value (fc/field-value)})))
(fc/with-field :bank-account/code
(com/validated-field {:errors (fc/field-errors)
:label "Code"
:class "w-20"}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:disabled (not (:new? @bank-account))
:placeholder "NGOM-CASH"
:class "w-full"
:value (fc/field-value)})
(when-not (:new? @bank-account)
(com/hidden {:name (fc/field-name)
:value (fc/field-value)}))))]
(fc/with-field :bank-account/numeric-code
(com/validated-field {:errors (fc/field-errors)
:label "Financial code"}
[:div {:class "w-[5em]"}
(com/int-input {:name (fc/field-name)
:error? (fc/error?)
:placeholder "11101"
:class "w-[5em]"
:value (fc/field-value)})]))
(fc/with-field :bank-account/start-date
[:div.flex.space-x-2.items-center
(com/validated-field {:errors (fc/field-errors)
:label "Start Date"}
[:div {:class "w-[7em]"}
(com/date-input {:name (fc/field-name)
:error? (fc/error?)
:placeholder "12/01/2023"
:hx-get (bidi/path-for ssr-routes/only-routes
::indicators/days-ago)
:hx-trigger "change"
:hx-target "#days-indicator"
:hx-vals "js:{date: event.target.value}"
:hx-swap "innerHTML"
:class "w-[5em]"
:value (some-> (fc/field-value)
(clj-time.coerce/to-date-time)
;; todo do date coercion in the input
(atime/unparse-local atime/normal-date))})])
[:div#days-indicator
(i/days-ago* (some-> (fc/field-value)))]])
(fc/with-field :bank-account/include-in-reports
(com/checkbox {:name (fc/field-name)
:value (boolean (fc/field-value))
:checked (fc/field-value)}
"Include in reports"))
[:div
(fc/with-field :bank-account/visible
(com/checkbox {:name (fc/field-name)
:value (boolean (fc/field-value))
:checked (fc/field-value)}
"Visible for payment"))]])
(defn- plaid-account-select [client-id]
(fc/with-field :bank-account/plaid-account
(com/validated-field {:errors (fc/field-errors)
:label "Plaid account"
:class "w-[20em]"
:x-data (hx/json {:plaidAccount (fc/field-value)})}
[:div.flex.gap-2.items-center
(com/select {:name (fc/field-name)
:allow-blank? true
:error? (fc/error?)
:class "w-full"
:value (fc/field-value)
:x-model "plaidAccount"
:options
(when client-id
(dc/q '[:find ?pa ?pn
:in $ ?client
:where [?pi :plaid-item/client ?client]
[?pi :plaid-item/accounts ?pa]
[?pa :plaid-account/name ?pn]]
(dc/db conn)
client-id))})
[:svg {":data-jdenticon-value" "plaidAccount" :width "24" :height "24"
:x-init "$watch('plaidAccount', () => jdenticon())"}]])))
(defn- yodlee-account-select [client-id]
(list
(fc/with-field :bank-account/yodlee-account
(com/validated-field {:errors (fc/field-errors)
:label "Yodlee account"
:class "w-[20em]"}
(com/select {:name (fc/field-name)
:allow-blank? true
:error? (fc/error?)
:class "w-full"
:value (fc/field-value)
:options
(when client-id
(dc/q '[:find ?pa ?pn2
:in $ ?client
:where [?pi :yodlee-provider-account/client ?client]
[?pi :yodlee-provider-account/accounts ?pa]
[?pa :yodlee-account/name ?pn]
[?pa :yodlee-account/number ?num]
[(str ?pn " - " ?num) ?pn2]]
(dc/db conn)
client-id))})))
(fc/with-field :bank-account/use-date-instead-of-post-date?
(com/checkbox {:name (fc/field-name)
:checked (fc/field-value)}
"(Yodlee only) use date instead of post date"))))
(defn- intuit-account-select [client-id]
(list
(fc/with-field :bank-account/intuit-bank-account
(com/validated-field {:errors (fc/field-errors)
:label "Intuit account"
:class "w-[20em]"}
(com/select {:name (fc/field-name)
:allow-blank? true
:error? (fc/error?)
:class "w-full"
:value (fc/field-value)
:options
(sort-by second (dc/q '[:find ?ia ?inn
:in $
:where [?ia :intuit-bank-account/name ?inn]]
(dc/db conn)))})))
(fc/with-field :bank-account/use-date-instead-of-post-date?
(com/checkbox {:name (fc/field-name)
:checked (fc/field-value)}
"(Yodlee only) use date instead of post date"))))
(defmethod bank-account-form
:bank-account-type/credit [bank-account]
[:div
[:h2.text-lg
(if (:new @bank-account)
"New Credit Card Account"
(str "Edit Credit Card: " (:bank-account/name @bank-account)))]
(fc/with-field :db/id
(com/hidden {:name (fc/field-name)
:value (fc/field-value)}))
(fc/with-field :bank-account/type
(com/hidden {:name (fc/field-name)
:value (name (fc/field-value))}))
[:div.flex.space-x-2
(fc/with-field :bank-account/name
(com/validated-field {:errors (fc/field-errors)
:label "Nickname"
:class "w-[20em]"}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:autofocus true
:placeholder "BofA Checking"
:class "w-full"
:value (fc/field-value)})))
(fc/with-field :bank-account/code
(com/validated-field {:errors (fc/field-errors)
:label "Code"
:class "w-20"}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:disabled (not (:new? @bank-account))
:placeholder "NGOM-CASH"
:class "w-full"
:value (fc/field-value)})
(when-not (:new? @bank-account)
(com/hidden {:name (fc/field-name)
:value (fc/field-value)}))))]
(fc/with-field :bank-account/numeric-code
(com/validated-field {:errors (fc/field-errors)
:label "Financial code"}
[:div {:class "w-[5em]"}
(com/int-input {:name (fc/field-name)
:error? (fc/error?)
:placeholder "11101"
:class "w-[5em]"
:value (fc/field-value)})]))
(fc/with-field :bank-account/start-date
[:div.flex.space-x-2.items-center
(com/validated-field {:errors (fc/field-errors)
:label "Start Date"}
[:div {:class "w-[7em]"}
(com/date-input {:name (fc/field-name)
:error? (fc/error?)
:placeholder "12/01/2023"
:hx-get (bidi/path-for ssr-routes/only-routes
::indicators/days-ago)
:hx-trigger "change"
:hx-target "#days-indicator"
:hx-vals "js:{date: event.target.value}"
:hx-swap "innerHTML"
:class "w-[5em]"
:value (some-> (fc/field-value)
(clj-time.coerce/to-date-time)
;; todo do date coercion in the input
(atime/unparse-local atime/normal-date))})])
[:div#days-indicator
(i/days-ago* (some-> (fc/field-value)))]])
(fc/with-field :bank-account/include-in-reports
(com/checkbox {:name (fc/field-name)
:value (boolean (fc/field-value))
:checked (fc/field-value)}
"Include in reports"))
[:div
(fc/with-field :bank-account/visible
(com/checkbox {:name (fc/field-name)
:value (boolean (fc/field-value))
:checked (fc/field-value)}
"Visible for payment"))]
[:h2.text-lg "Bank details"]
(fc/with-field :bank-account/bank-name
(com/validated-field {:errors (fc/field-errors)
:label "Bank Name"
:class "w-[20em]"}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:placeholder "Bank of America"
:value (fc/field-value)})))
[:div.flex.gap-2
(fc/with-field :bank-account/number
(com/validated-field {:errors (fc/field-errors)
:label "Account #"
:class "w-[10em]"}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:class "w-full"
:placeholder "1820190122"
:value (fc/field-value)})))]
[:h2.text-lg "Integration details"]
(plaid-account-select (:db/id (:snapshot fc/*form-data*)))
(yodlee-account-select (:db/id (:snapshot fc/*form-data*)))
(intuit-account-select (:db/id (:snapshot fc/*form-data*)))])
(defmethod bank-account-form
:bank-account-type/check [bank-account]
[:div
[:h2.text-lg
(if (:new @bank-account)
"New Checking Account"
(str "Edit Checking Account: " (:bank-account/name @bank-account)))]
(fc/with-field :db/id
(com/hidden {:name (fc/field-name)
:value (fc/field-value)}))
(fc/with-field :bank-account/type
(com/hidden {:name (fc/field-name)
:value (name (fc/field-value))}))
[:div.flex.space-x-2
(fc/with-field :bank-account/name
(com/validated-field {:errors (fc/field-errors)
:label "Nickname"
:class "w-[20em]"}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:autofocus true
:placeholder "BofA Checking"
:class "w-full"
:value (fc/field-value)})))
(fc/with-field :bank-account/code
(com/validated-field {:errors (fc/field-errors)
:label "Code"
:class "w-20"}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:disabled (not (:new? @bank-account))
:placeholder "NGOM-CASH"
:class "w-full"
:value (fc/field-value)})
(when-not (:new? @bank-account)
(com/hidden {:name (fc/field-name)
:value (fc/field-value)}))))]
(fc/with-field :bank-account/numeric-code
(com/validated-field {:errors (fc/field-errors)
:label "Financial code"}
[:div {:class "w-[5em]"}
(com/int-input {:name (fc/field-name)
:error? (fc/error?)
:placeholder "11101"
:class "w-[5em]"
:value (fc/field-value)})]))
(fc/with-field :bank-account/start-date
[:div.flex.space-x-2.items-center
(com/validated-field {:errors (fc/field-errors)
:label "Start Date"}
[:div {:class "w-[7em]"}
(com/date-input {:name (fc/field-name)
:error? (fc/error?)
:placeholder "12/01/2023"
:hx-get (bidi/path-for ssr-routes/only-routes
::indicators/days-ago)
:hx-trigger "change"
:hx-target "#days-indicator"
:hx-vals "js:{date: event.target.value}"
:hx-swap "innerHTML"
:class "w-[5em]"
:value (some-> (fc/field-value)
(clj-time.coerce/to-date-time)
;; todo do date coercion in the input
(atime/unparse-local atime/normal-date))})])
[:div#days-indicator
(i/days-ago* (some-> (fc/field-value)))]])
(fc/with-field :bank-account/include-in-reports
(com/checkbox {:name (fc/field-name)
:value (boolean (fc/field-value))
:checked (fc/field-value)}
"Include in reports"))
[:div
(fc/with-field :bank-account/visible
(com/checkbox {:name (fc/field-name)
:value (boolean (fc/field-value))
:checked (fc/field-value)}
"Visible for payment"))]
[:h2.text-lg "Bank details"]
(fc/with-field :bank-account/bank-name
(com/validated-field {:errors (fc/field-errors)
:label "Bank Name"
:class "w-[20em]"}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:placeholder "Bank of America"
:value (fc/field-value)})))
[:div.flex.gap-2
(fc/with-field :bank-account/number
(com/validated-field {:errors (fc/field-errors)
:label "Account #"
:class "w-[10em]"}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:class "w-full"
:placeholder "1820190122"
:value (fc/field-value)})))
(fc/with-field :bank-account/routing
(com/validated-field {:errors (fc/field-errors)
:label "Routing #"
:class "w-[8em]"}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:class "w-full"
:placeholder "1238912"
:value (fc/field-value)})))
(fc/with-field :bank-account/bank-code
(com/validated-field {:errors (fc/field-errors)
:label "Bank Code"
:class "w-[8em]"}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:class "w-full"
:placeholder "12/10123"
:value (fc/field-value)})))]
(fc/with-field :bank-account/check-number
(com/validated-field {:errors (fc/field-errors)
:label "Check Number"
:class "w-[8em]"}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:class "w-full"
:placeholder "50000"
:value (fc/field-value)})))
[:h2.text-lg "Integration details"]
(plaid-account-select (:db/id (:snapshot fc/*form-data*)))
(yodlee-account-select (:db/id (:snapshot fc/*form-data*)))
(intuit-account-select (:db/id (:snapshot fc/*form-data*)))])
(defn new-bank-account-card []
[:div {:class "w-[30em]"}
(com/card {:class "w-full border-dotted bg-gray-50"}
[:div.flex.justify-center.items-center.h-16
[:div [:span "Add a new "
(com/link {:hx-put (hu/url (bidi/path-for ssr-routes/only-routes ::route/navigate)
{:to (mm/encode-step-key :new-bank-account)
:from (mm/encode-step-key :bank-accounts)
:bank-account-type "cash"})} "cash account")
", "
(com/link {:hx-put (hu/url (bidi/path-for ssr-routes/only-routes ::route/navigate)
{:to (mm/encode-step-key :new-bank-account)
:from (mm/encode-step-key :bank-accounts)
:bank-account-type "credit"})} "credit card")
", "
(com/link {:hx-put (hu/url (bidi/path-for ssr-routes/only-routes ::route/navigate)
{:to (mm/encode-step-key :new-bank-account)
:from (mm/encode-step-key :bank-accounts)
:bank-account-type "check"})} "checking account")]]])])
(defrecord BankAccountsModal [linear-wizard]
mm/ModalWizardStep
(step-name [_]
"Bank Accounts")
(step-key [_]
:bank-accounts)
(edit-path [_ _] [])
(step-schema [_]
(mut/select-keys (mm/form-schema linear-wizard) #{}))
(render-step
[this _]
(mm/default-render-step
linear-wizard this
:head (dialog-header this)
:body (mm/default-step-body {}
[:div#bank-account-list {:hx-put (bidi/path-for ssr-routes/only-routes ::route/sort-bank-accounts)
:hx-trigger "end"}
(fc/with-field :client/bank-accounts
(com/validated-field
{:errors (fc/field-errors)
:label "Bank Accounts"}
[:div.flex.flex-col.space-y-4.sortable
(fc/cursor-map (fn [ba-cursor]
(when (:bank-account/type @ba-cursor)
(bank-account-card ba-cursor))))
(new-bank-account-card)]))])
:footer
[:fieldset {}
(mm/default-step-footer linear-wizard this
:validation-route ::route/navigate)]
:validation-route ::route/navigate)))
(defn square-location-table []
[:div#square-locations
[:div.htmx-indicator
"Loading..."]
[:div.htmx-indicator-hidden
[:table
[:thead
[:tr
[:td "Square location"]
[:td "Client location"]]]
[:tbody
(fc/cursor-map (fn [square-location]
[:tr
(fc/with-field :db/id
(com/hidden {:name (fc/field-name)
:value (fc/field-value)}))
(fc/with-field :square-location/name
(com/hidden {:name (fc/field-name)
:value (fc/field-value)}))
(fc/with-field :square-location/square-id
(com/hidden {:name (fc/field-name)
:value (fc/field-value)}))
[:td (:square-location/name @square-location)]
[:td (fc/with-field :square-location/client-location
(com/text-input {:name (fc/field-name)
:value (fc/field-value)}))]]))]]]])
(defn refresh-square-locations [request]
(let [locations @(de/timeout!
(de/chain (square/client-locations {:client/square-auth-token (get-in request [:query-params (keyword "step-params[client/square-auth-token]")])})
(fn [client-locations]
(into []
(for [square-location client-locations]
{:db/id (str (java.util.UUID/randomUUID))
:square-location/name (:name square-location)
:square-location/square-id (:id square-location)}))))
2000
:not-found)]
(html-response
(if (= locations :not-found)
[:div#square-locations
"No locations found."]
(fc/start-form-with-prefix
[:step-params :client/square-locations]
locations
[]
(square-location-table))))))
(defrecord IntegrationsModal [linear-wizard]
mm/ModalWizardStep
(step-name [_]
"Integrations")
(step-key [_]
:integrations)
(edit-path [_ _] [])
(step-schema [_]
(mut/select-keys (mm/form-schema linear-wizard) #{}))
(render-step [this _]
(mm/default-render-step
linear-wizard this
:head (dialog-header this)
:body (mm/default-step-body
{}
[:div
[:div.flex.gap-2.items-center
(fc/with-field :client/square-auth-token
(com/validated-field
{:errors (fc/field-errors)
:label "Square Auth Token"}
(com/text-input {:name (fc/field-name)
:id "square-token"
:error? (fc/error?)
:placeholder "Token from square"
:class "w-64"
:value (fc/field-value)})))
(com/button {:hx-get (bidi/path-for ssr-routes/only-routes ::route/refresh-square-locations)
:hx-include "#square-token"
:hx-trigger "click"
:hx-indicator "#square-locations"
:hx-target "#square-locations"}
"Refresh")]
(fc/with-field :client/square-locations
(square-location-table))])
:footer
(mm/default-step-footer linear-wizard this :validation-route ::route/navigate)
:validation-route ::route/navigate)))
(defrecord BankAccountModal [linear-wizard which]
mm/ModalWizardStep
(step-name [_]
"Bank Accounts")
(step-key [_]
[:bank-account which])
(edit-path [_ request]
(let [account-index (->> (:client/bank-accounts (:snapshot (:multi-form-state request)))
(map vector (range))
(filter (fn [[_ ba]]
(= (:db/id ba)
which)))
ffirst)]
[:client/bank-accounts (or account-index
(count (:client/bank-accounts (:snapshot (:multi-form-state request)))))]))
(step-schema [_]
bank-account-schema)
(render-step [this _]
(mm/default-render-step
linear-wizard this
:head (dialog-header this)
:body (mm/default-step-body {}
[:div {:class "htmx-added:opacity-0 opacity-100 transition-opacity duration-300"}
(fc/with-field :new?
(when (fc/field-value)
(com/hidden {:name (fc/field-name)
:value "true"})))
(bank-account-form fc/*current*)])
:footer
(mm/default-step-footer linear-wizard this
:next-button
(com/button {:hx-put (hu/url (bidi/path-for ssr-routes/only-routes ::route/navigate)
{:from (mm/encode-step-key [:bank-account which])
:to (mm/encode-step-key :bank-accounts)})}
;; todo maybe make a helper for progress urls
"Accept")
:discard-button
(com/a-button {:hx-put (hu/url (bidi/path-for ssr-routes/only-routes ::route/discard)
{:from (mm/encode-step-key [:bank-account which])
:to (mm/encode-step-key :bank-accounts)})}
;; todo maybe make a helper for progress urls
"discard"))
:validation-route ::route/navigate))
mm/Initializable
(init-step-params
[_ multi-form-state request]
(let [bank-account-type (get-in request [:query-params :bank-account-type])]
(if (= {} (:step-params multi-form-state))
(cond->
{:db/id (str (java.util.UUID/randomUUID))
:new? true}
bank-account-type (assoc :bank-account/type (keyword "bank-account-type" bank-account-type)
:bank-account/visible true))
(:step-params multi-form-state))))
mm/Discardable
(can-discard? [_ step-params]
(:new? step-params))
(discard-changes [_ multi-form-state]
(-> multi-form-state
(update-in [:snapshot :client/bank-accounts]
(fn [bank-accounts]
(filterv #(not= (get-in multi-form-state [:step-params :db/id]) (:db/id %)) bank-accounts)))
(mm/select-state [] nil))))
(defrecord CashFlowModal [linear-wizard]
mm/ModalWizardStep
(step-name [_]
"Cash Flow")
(step-key [_]
:cash-flow)
(edit-path [_ _] [])
(step-schema [_]
(mut/select-keys (mm/form-schema linear-wizard) #{:client/week-a-credits :client/week-a-debits :client/week-b-credits :client/week-b-debits}))
(render-step [this _]
(mm/default-render-step
linear-wizard this
:head (dialog-header this)
:body (mm/default-step-body {}
[:div.flex.space-x-4
(fc/with-field :client/week-a-credits
(com/validated-field {:errors (fc/field-errors)
:label "Week A Credits"
:class "w-32"}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:placeholder "123.33"
:class "w-32"
:value (fc/field-value)})))
(fc/with-field :client/week-a-debits
(com/validated-field {:errors (fc/field-errors)
:label "Week A Debits"}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:placeholder "123.33"
:class "w-32"
:value (fc/field-value)})))]
[:div.flex.space-x-4
(fc/with-field :client/week-b-credits
(com/validated-field {:errors (fc/field-errors)
:label "Week B Credits"
:class "w-32"}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:placeholder "123.33"
:class "w-32"
:value (fc/field-value)})))
(fc/with-field :client/week-b-debits
(com/validated-field {:errors (fc/field-errors)
:label "Week B Debits"}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:placeholder "123.33"
:class "w-32"
:value (fc/field-value)})))]
[:div])
:footer
(mm/default-step-footer linear-wizard this :validation-route ::route/navigate)
:validation-route ::route/navigate)))
(defrecord OtherSettingsModal [linear-wizard]
mm/ModalWizardStep
(step-name [_]
"Other Settings")
(step-key [_]
:other-settings)
(edit-path [_ _] [])
(step-schema [_]
(mut/select-keys (mm/form-schema linear-wizard) #{:client/feature-flags :client/groups}))
(render-step [this _]
(mm/default-render-step
linear-wizard this
:head (dialog-header this)
:body (mm/default-step-body {}
(fc/with-field :client/feature-flags
(com/validated-field
{:errors (fc/field-errors)
:label "Feature Flags"}
(com/data-grid {:headers [(com/data-grid-header {} "Flag")
(com/data-grid-header {:class "w-16"})]}
(fc/cursor-map #(feature-flag-row %))
(com/data-grid-new-row {:colspan 2
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-feature-flag)
:index (count (fc/field-value))}
"New flag"))))
(fc/with-field :client/groups
(com/validated-field
{:errors (fc/field-errors)
:label "Groups"}
(com/data-grid {:headers [(com/data-grid-header {} "Group")
(com/data-grid-header {:class "w-16"})]}
(fc/cursor-map #(group-row %))
(com/data-grid-new-row {:colspan 2
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-group)
:index (count (fc/field-value))}
"New group")))))
:footer
(mm/default-step-footer linear-wizard this :validation-route ::route/navigate)
:validation-route ::route/navigate)))
(defrecord ClientWizard [form-params current-step entity]
mm/LinearModalWizard
(hydrate-from-request [this request]
(assoc this :entity (:entity request)))
(navigate [this step-key]
(assoc this :current-step step-key))
(get-current-step [this]
(if current-step
(mm/get-step this current-step)
(mm/get-step this :info)))
(render-wizard [this {:keys [multi-form-state] :as request}]
(mm/default-render-wizard
this request
:form-params
(-> mm/default-form-props
(assoc (if (get-in multi-form-state [:snapshot :db/id])
:hx-put
:hx-post)
(str (bidi/path-for ssr-routes/only-routes ::route/save))
:x-data (hx/json {"clientName" (:client/name (:step-params multi-form-state))})))))
(steps [_]
[:info
:matches
:contact
:bank-accounts
:integrations
:cash-flow
:other-settings])
(get-step [this step-key]
(let [step-key-result (mc/parse mm/step-key-schema step-key)
[step-key-type step-key] step-key-result]
(if (= :step step-key-type)
(get {:info (->InfoModal this)
:matches (->MatchesModal this)
:contact (->ContactModal this)
:bank-accounts (->BankAccountsModal this)
:integrations (->IntegrationsModal this)
:cash-flow (->CashFlowModal this)
:other-settings (->OtherSettingsModal this)
:new-bank-account (->BankAccountModal this "new")}
step-key)
(get {:bank-account (->BankAccountModal this (second step-key))}
(first step-key)))))
(form-schema [_] form-schema-2)
(submit [_ {:keys [multi-form-state request-method identity] :as request}]
(let [snapshot (mc/decode
form-schema-2
(:snapshot multi-form-state)
mt/strip-extra-keys-transformer)
entity (cond-> snapshot
(= :post request-method) (assoc :db/id "new")
(= :put request-method) (dissoc :client/code)
(:client/locked-until snapshot) (update :client/locked-until clj-time.coerce/to-date)
(seq (:client/groups snapshot)) (update :client/groups #(mapv str/upper-case %))
(seq (:client/bank-accounts snapshot)) (update :client/bank-accounts
(fn [bank-accounts]
(mapv
(fn [bank-account]
(-> bank-account
(update :bank-account/start-date #(when % (clj-time.coerce/to-date %)))))
bank-accounts))))
_ (alog/info ::peeker :entity (:client/bank-accounts entity))
_ (when (and (:client/code entity) (pull-id (dc/db conn) [:client/code (:client/code entity)]))
(form-validation-error (format "The code '%s' is already in use" (:client/code entity))
:code (:client/code entity)))
{:keys [tempids]} (audit-transact [[:upsert-entity entity]]
(:identity request))
updated-client (dc/pull (dc/db conn)
default-read
(or (get tempids (:db/id entity)) (:db/id entity)))]
(when (:client/name updated-client)
(solr/index-documents-raw solr/impl "clients"
[{"id" (:db/id updated-client)
"name" (conj (or (:client/matches updated-client) [])
(:client/name updated-client))
"code" (:client/code updated-client)
"exact" (map str/upper-case (conj (or (:client/matches updated-client) [])
(:client/name updated-client)))}]))
(html-response
(row* identity updated-client {:flash? true})
:headers (cond-> {"hx-trigger" "modalclose"}
(= :post request-method) (assoc "hx-retarget" "#entity-table tbody"
"hx-reswap" "afterbegin")
(= :put request-method) (assoc "hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id updated-client))
"hx-reswap" "outerHTML"))))))
(def client-wizard
(->ClientWizard nil nil nil))
(defn sort-bank-accounts [{:keys [multi-form-state wizard] :as request}]
(let [sort-index (into {} (map vector (:item (:form-params request)) (range)))
new-bank-accounts (->> multi-form-state
:snapshot
:client/bank-accounts
(map (fn [bank-account]
(assoc bank-account :bank-account/sort-order (sort-index (:db/id bank-account)))))
(sort-by :bank-account/sort-order)
(into []))]
(html-response
(mm/render-wizard wizard (update request :multi-form-state
(comp
#(mm/select-state % [] {})
#(assoc-in % [:snapshot :client/bank-accounts] new-bank-accounts)))))))
(def sales-summary-query
"[:find ?d4 (sum ?total) (sum ?tax) (sum ?tip) (sum ?service-charge) (sum ?discount) (sum ?returns)
:with ?s
:in $
:where
[(ground (iol-ion.query/recent-date 120)) ?min-d]
[(ground #inst \"2040-01-01\") ?max-d]
[?c :client/code \"%s\"]
[(iol-ion.query/sales-orders-in-range $ ?c ?min-d ?max-d) [?s ...]]
[?s :sales-order/date ?d]
[?s :sales-order/total ?total]
[?s :sales-order/tax ?tax]
[?s :sales-order/tip ?tip]
[?s :sales-order/service-charge ?service-charge]
[?s :sales-order/returns ?returns]
[?s :sales-order/discount ?discount]
[(iol-ion.query/excel-date ?d) ?d4]
]")
(def sales-category-query
"[:find ?d4 ?n ?n2 (sum ?total) (sum ?tax) (sum ?discount)
:with ?s ?li
:in $
:where
[(ground (iol-ion.query/recent-date 120)) ?min-d]
[(ground #inst \"2040-01-01\") ?max-d]
[?c :client/code \"%s\"]
[(iol-ion.query/sales-orders-in-range $ ?c ?min-d ?max-d) [?s ...]]
[?s :sales-order/date ?d]
[?s :sales-order/line-items ?li]
[?li :order-line-item/category ?n]
[(get-else $ ?li :order-line-item/item-name \"\") ?n2]
[?li :order-line-item/total ?total]
[?li :order-line-item/tax ?tax]
[?li :order-line-item/discount ?discount]
[(iol-ion.query/excel-date ?d) ?d4]]")
(def expected-deposits-query
"[:find ?d4 ?t ?f
:in $
:where
[(ground (iol-ion.query/recent-date 120)) ?min-d]
[?c :client/code \"%s\"]
[?s :expected-deposit/client ?c]
[?s :expected-deposit/sales-date ?date]
[(>= ?date ?min-d)]
[?s :expected-deposit/total ?t]
[?s :expected-deposit/fee ?f]
[(iol-ion.query/excel-date ?date) ?d4]
]")
(def tenders-query
"[:find ?d4 ?type ?p2 (sum ?total) (sum ?tip)
:with ?charge
:in $
:where
[(ground (iol-ion.query/recent-date 120)) ?min-d]
[?c :client/code \"%s\"]
[?s :sales-order/client ?c]
[?s :sales-order/date ?date]
[(>= ?date ?min-d)]
[?s :sales-order/charges ?charge]
[?charge :charge/type-name ?type]
[?charge :charge/total ?total]
[?charge :charge/tip ?tip]
[(get-else $ ?charge :charge/processor :na) ?ccp]
[(get-else $ ?ccp :db/ident :na) ?p]
[(name ?p) ?p2]
[(iol-ion.query/excel-date ?date) ?d4]
]")
(def tenders2-query
"[:find ?d4 ?type ?p2 (sum ?total) (sum ?tip)
:with ?charge
:in $
:where
[(ground (iol-ion.query/recent-date 120)) ?min-d]
[?charge :charge/date ?date]
[(>= ?date ?min-d)]
[?charge :charge/client ?c]
[?c :client/code \"%s\"]
[?charge :charge/type-name ?type]
[?charge :charge/total ?total]
[?charge :charge/tip ?tip]
(or
(and [_ :expected-deposit/charges ?charge ]
[(ground :settlement) ?ccp]
[(ground :settlement) ?p])
(and
(not [_ :expected-deposit/charges ?charge])
[(get-else $ ?charge :charge/processor :na) ?ccp]
[(get-else $ ?ccp :db/ident :na) ?p]
))
[(name ?p) ?p2]
[(iol-ion.query/excel-date ?date) ?d4]]
")
(def refunds-query
"[:find ?d4 ?t (sum ?total) (sum ?fee)
:with ?r
:in $
:where
[(ground (iol-ion.query/recent-date 120)) ?min-d]
[?r :sales-refund/client [:client/code \"%s\"]]
[?r :sales-refund/date ?date]
[(>= ?date ?min-d)]
[?r :sales-refund/total ?total]
[?r :sales-refund/fee ?fee]
[?r :sales-refund/type ?t]
[(iol-ion.query/excel-date ?date) ?d4]
]")
(def cash-drawer-shift-query
"[:find ?d4 (sum ?paid-in) (sum ?paid-out) (sum ?expected-cash) (sum ?opened-cash)
:with ?cds
:in $
:where
[?cds :cash-drawer-shift/date ?date]
[(ground (iol-ion.query/recent-date 120)) ?min-d]
[(>= ?date ?min-d)]
[?cds :cash-drawer-shift/client [:client/code \"%s\"]]
[?cds :cash-drawer-shift/paid-in ?paid-in]
[?cds :cash-drawer-shift/paid-out ?paid-out]
[?cds :cash-drawer-shift/expected-cash ?expected-cash]
[?cds :cash-drawer-shift/opened-cash ?opened-cash]
[(iol-ion.query/excel-date ?date) ?d4]]")
(defn setup-sales-queries-impl [client-id]
(let [{client-code :client/code feature-flags :client/feature-flags} (dc/pull (dc/db conn) '[:client/code :client/feature-flags] client-id)
is-new-square? ((set feature-flags) "new-square")]
(q/put-query (str (UUID/randomUUID))
(format sales-summary-query client-code)
(str "sales query for " client-code)
(str client-code "-sales-summary")
[:client/code client-code])
(q/put-query (str (UUID/randomUUID))
(format sales-category-query client-code)
(str "sales category query for " client-code)
(str client-code "-sales-category")
[:client/code client-code])
(q/put-query (str (UUID/randomUUID))
(format expected-deposits-query client-code)
(str "expected deposit query for " client-code)
(str client-code "-expected-deposit")
[:client/code client-code])
(q/put-query (str (UUID/randomUUID))
(format (if is-new-square? tenders2-query tenders-query) client-code)
(str "tender query for " client-code)
(str client-code "-tender")
[:client/code client-code])
(q/put-query (str (UUID/randomUUID))
(format refunds-query client-code)
(str "refunds query for " client-code)
(str client-code "-refund")
[:client/code client-code])
(q/put-query (str (UUID/randomUUID))
(format cash-drawer-shift-query client-code)
(str "cash drawer shift query for " client-code)
(str client-code "-cash-drawer-shift")
[:client/code client-code])))
(defn reset-all-queries []
(doseq [[c] (dc/q '[:find ?c :where [?c :client/code]] (dc/db conn))]
(setup-sales-queries-impl c)))
(defn copy-button [{:keys [which url]} & children]
(com/button {"@click" (format "copyToClipboard(%s)"
(cheshire/generate-string (format (slurp (io/resource which)) url)))}
children))
(defn biweekly-sales-powerquery [request]
(setup-sales-queries-impl (:db/id (:route-params request)))
(modal-response
(com/modal {}
(com/modal-card-advanced
{}
(com/modal-header {} [:div.m-2 "Sales exports"])
(com/modal-body {}
(let [client-code (pull-attr (dc/db conn) :client/code (:db/id (:route-params request)))
sales-summary-id (:saved-query/guid (dc/pull (dc/db conn) [:saved-query/guid] [:saved-query/lookup-key (str client-code "-sales-summary")]))
sales-category-id (:saved-query/guid (dc/pull (dc/db conn) [:saved-query/guid] [:saved-query/lookup-key (str client-code "-sales-category")]))
expected-deposit-id (:saved-query/guid (dc/pull (dc/db conn) [:saved-query/guid] [:saved-query/lookup-key (str client-code "-expected-deposit")]))
tender-id (:saved-query/guid (dc/pull (dc/db conn) [:saved-query/guid] [:saved-query/lookup-key (str client-code "-tender")]))
refund-id (:saved-query/guid (dc/pull (dc/db conn) [:saved-query/guid] [:saved-query/lookup-key (str client-code "-refund")]))
cash-drawer-shift-id (:saved-query/guid (dc/pull (dc/db conn) [:saved-query/guid] [:saved-query/lookup-key (str client-code "-cash-drawer-shift")]))]
[:div.flex.flex-col.gap-2
(copy-button {:which "powerqueries/sales_summary.txt" :url sales-summary-id}
"Copy Sales Order Summary Power Query")
(copy-button {:which "powerqueries/sales_category.txt" :url sales-category-id}
"Copy Sales Category Power Query")
(copy-button {:which "powerqueries/tenders.txt" :url tender-id}
"Copy Tenders Power Query")
(copy-button {:which "powerqueries/expected_deposits.txt" :url expected-deposit-id}
"Copy Expected Deposits Power Query")
(copy-button {:which "powerqueries/refunds.txt" :url refund-id}
"Copy Refunds Power Query")
#_(copy-button {:which "powerqueries/cash_drawer_shift.txt" :url cash-drawer-shift-id}
"Copy Cash Drawer Shift Power Query")]))
(com/modal-footer {} [:div])))))
(def key->handler
(apply-middleware-to-all-handlers
{::route/page (helper/page-route grid-page)
::route/table (helper/table-route grid-page)
::route/new-location (add-new-primitive-handler [:step-params :client/locations]
""
location-row)
::route/new-feature-flag (add-new-primitive-handler [:step-params :client/feature-flags]
""
feature-flag-row)
::route/new-match (add-new-primitive-handler [:step-params :client/matches]
""
match-row)
::route/new-group (add-new-primitive-handler [:step-params :client/groups]
""
group-row)
::route/new-location-match (add-new-entity-handler [:step-params :client/location-matches]
(fn [cursor _] (location-match-row cursor)))
::route/new-email-contact (add-new-entity-handler [:step-params :client/emails]
(fn [cursor _] (email-contact-row cursor)))
::route/save (-> mm/submit-handler
(mm/wrap-wizard client-wizard)
(mm/wrap-decode-multi-form-state)
(wrap-entity [:form-params :db/id] default-read))
::route/biweekly-sales-powerquery
(-> biweekly-sales-powerquery
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
::route/refresh-square-locations
refresh-square-locations
::route/navigate
(-> mm/next-handler
(mm/wrap-wizard client-wizard)
(mm/wrap-decode-multi-form-state)
(wrap-entity [:route-params :db/id] default-read)
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
::route/sort-bank-accounts
(-> sort-bank-accounts
(wrap-schema-enforce :form-schema [:map [:item [:vector entity-id]]])
(mm/wrap-wizard client-wizard)
(mm/wrap-decode-multi-form-state))
::route/discard
(-> mm/discard-handler
(mm/wrap-wizard client-wizard)
(mm/wrap-decode-multi-form-state))
::route/edit-dialog (-> mm/open-wizard-handler
(mm/wrap-wizard client-wizard)
(mm/wrap-init-multi-form-state (fn [request]
(let [sorted (-> (:entity request)
(update :client/bank-accounts
(fn [bas]
(into [] (sort-by :bank-account/sort-order bas)))))]
(mm/->MultiStepFormState sorted
[]
sorted))))
(wrap-entity [:route-params :db/id] default-read)
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
::route/new-dialog (-> mm/open-wizard-handler
(mm/wrap-init-multi-form-state (fn [_]
(mm/->MultiStepFormState {}
[]
{})))
(mm/wrap-wizard client-wizard))}
(fn [h]
(-> h
(wrap-copy-qp-pqp)
(wrap-apply-sort grid-page)
(wrap-merge-prior-hx)
(wrap-schema-enforce :query-schema query-schema)
(wrap-schema-enforce :hx-schema query-schema)
(wrap-admin)
(wrap-client-redirect-unauthenticated)))))