(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)))))