(ns auto-ap.ssr.admin.accounts (:require [auto-ap.datomic :refer [add-sorter-fields apply-pagination apply-sort-3 audit-transact conn merge-query pull-attr pull-many query2]] [auto-ap.query-params :as query-params :refer [wrap-copy-qp-pqp]] [auto-ap.routes.utils :refer [wrap-admin wrap-client-redirect-unauthenticated]] [auto-ap.solr :as solr] [auto-ap.ssr-routes :as ssr-routes] [auto-ap.ssr.components :as com] [auto-ap.ssr.form-cursor :as fc] [auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]] [auto-ap.ssr.hx :as hx] [auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]] [auto-ap.ssr.payments :refer [wrap-status-from-source]] [auto-ap.ssr.svg :as svg] [auto-ap.ssr.utils :refer [apply-middleware-to-all-handlers clj-date-schema default-grid-fields-schema entity-id field-validation-error form-validation-error html-response main-transformer many-entity modal-response ref->enum-schema ref->select-options strip temp-id wrap-entity wrap-form-4xx-2 wrap-merge-prior-hx wrap-schema-enforce]] [bidi.bidi :as bidi] [clojure.string :as str] [datomic.api :as dc] [malli.core :as mc])) (def query-schema (mc/schema [:maybe (into [:map {:date-range [:date-range :start-date :end-date]} [:type {:optional true} [:maybe (ref->enum-schema "account-type")]] [:name {:optional true} [:maybe [:string {:decode/string strip}]]] [:code {:optional true} [:maybe nat-int?]]] default-grid-fields-schema)])) (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 :admin-account-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 "Cash" :size :small})) (com/field {:label "Code"} (com/text-input {:name "code" :id "code" :class "hot-filter" :value (:code (:query-params request)) :placeholder "11101" :size :small})) (com/field {:label "Type"} (com/radio-card {:size :small :name "type" :value (:type (:query-params request)) :options [{:value "" :content "All"} {:value "dividend" :content "Dividend"} {:value "asset" :content "Asset"} {:value "equity" :content "Equity"} {:value "liability" :content "Liability"} {:value "expense" :content "Expense"} {:value "revenue" :content "Revenue"} {:value "none" :content "None"}]}))]]) (def default-read '[:db/id :account/code :account/name :account/numeric-code :account/location {[:account/type :xform iol-ion.query/ident] [:db/ident] [:account/invoice-allowance :xform iol-ion.query/ident] [:db/ident] [:account/vendor-allowance :xform iol-ion.query/ident] [:db/ident] [:account/applicability :xform iol-ion.query/ident] [:db/ident] :account/client-overrides [{:account-client-override/client [:client/name :db/id]} :account-client-override/name :db/id]}]) (defn fetch-ids [db request] (let [query-params (:query-params request) query (cond-> {:query {:find [] :in '[$] :where '[]} :args [db]} (:sort query-params) (add-sorter-fields {"name" ['[?e :account/name ?n] '[(clojure.string/upper-case ?n) ?sort-name]] "code" ['[(get-else $ ?e :account/numeric-code 0) ?sort-code]] "type" ['[?e :account/type ?t] '[?t :db/ident ?ti] '[(name ?ti) ?sort-type]]} query-params) (some->> query-params :name not-empty) (merge-query {:query {:find [] :in ['?ns] :where ['[?e :account/name ?an] '[(clojure.string/upper-case ?an) ?upper-an] '[(clojure.string/includes? ?upper-an ?ns)]]} :args [(str/upper-case (:name query-params))]}) (some->> query-params :code) (merge-query {:query {:find [] :in ['?nc] :where ['[?e :account/numeric-code ?nc]]} :args [(:code query-params)]}) (some->> query-params :type) (merge-query {:query {:find [] :in ['?r] :where ['[?e :account/type ?r] ]} :args [(some->> query-params :type)]}) true (merge-query {:query {:find ['?sort-default '?e] :where ['[?e :account/numeric-code ?un] '[(clojure.string/upper-case ?un) ?sort-default]]}}))] (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 :admin-account-new-dialog)) :color :primary} "New Account")]) :row-buttons (fn [_ entity] [(com/icon-button {:hx-get (str (bidi/path-for ssr-routes/only-routes :admin-account-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 :admin-accounts)} "Accounts"]] :title "Accounts" :entity-name "Account" :query-schema query-schema :route :admin-account-table :headers [{:key "code" :name "Code" :sort-key "code" :render :account/numeric-code} {:key "name" :name "Name" :sort-key "name" :render :account/name} {:key "type" :name "Type" :sort-key "type" :render #(some->> % :account/type name (com/pill {:color :primary}))} {:key "location" :name "Location" :sort-key "location" :render :account/location}]})) (def row* (partial helper/row* grid-page)) (def table* (partial helper/table* grid-page)) (defn account-save [{:keys [form-params request-method] :as request}] (let [entity (cond-> form-params (= :post request-method) (assoc :db/id "new" :account/default-allowance :allowance/allowed)) _ (cond (= :post request-method) (when (seq (dc/q '[:find ?x :in $ ?nc :where [?x :account/numeric-code ?nc]] (dc/db conn) (:account/numeric-code entity))) (field-validation-error (format "The code %d is already in use." (:account/numeric-code entity)) [:account/numeric-code] :form-params form-params))) _ (some->> form-params :account/client-overrides (group-by :account-client-override/client) (filter (fn [[_ overrides]] (> (count overrides) 1))) (map first) seq (#(form-validation-error (format "Client(s) %s have more than one override." (str/join ", " (map (fn [client] (format "'%s'" (pull-attr (dc/db conn) :client/name (-> client))) ) %))) :form-params form-params)) ;; TODO shouldnt need to bubble this through. See if we can eliminate the passing of form and last-form. ) {:keys [tempids]} (audit-transact [[:upsert-entity (cond-> entity (:account/numeric-code entity) (assoc :account/code (str (:account/numeric-code entity))))]] (:identity request)) updated-account (dc/pull (dc/db conn) default-read (or (get tempids (:db/id entity)) (:db/id entity)))] (solr/index-documents-raw solr/impl "accounts" (into [{"id" (:db/id updated-account) "account_id" (:db/id updated-account) "name" (:account/name updated-account) "numeric_code" (:account/numeric-code updated-account) "location" (:account/location updated-account) "applicability" (some-> updated-account :account/applicability clojure.core/name)}] (for [o (:account/client-overrides updated-account)] {"id" (:db/id o) "account_id" (:db/id updated-account) "name" (:account-client-override/name o) "numeric_code" (:account/numeric-code updated-account) "location" (:account/location updated-account) "applicability" (clojure.core/name (:account/applicability updated-account)) "client_id" (:db/id (:account-client-override/client o)) "account_client_override_id" (:db/id o)}))) (html-response (row* identity updated-account {: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-account))))))) (defn client-override* [override] (com/data-grid-row (-> {:x-ref "p" :data-key "show" :x-data (hx/json {:show (boolean (not (fc/field-value (:new? override))))})} hx/alpine-mount-then-appear) (fc/with-field :db/id (com/hidden {:name (fc/field-name) :value (fc/field-value)})) (fc/with-field :account-client-override/client (com/data-grid-cell {} (com/validated-field {:errors (fc/field-errors)} (com/typeahead {:name (fc/field-name) :placeholder "Search..." :class "w-96" :url (bidi/path-for ssr-routes/only-routes :company-search) :value (fc/field-value) :content-fn #(pull-attr (dc/db conn) :client/name %)})))) (fc/with-field :account-client-override/name (com/data-grid-cell {} (com/validated-field {:errors (fc/field-errors)} (com/text-input {:name (fc/field-name) :class "w-96" :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 dialog* [{:keys [entity form-params form-errors]}] (fc/start-form form-params form-errors [:div {:x-data (hx/json {"accountName" (or (:account/name form-params) (:account/numeric-code entity)) "accountCode" (or (:account/numeric-code form-params) (:account/numeric-code entity) )}) :hx-target "this" } (com/modal {} [:form (-> {:hx-ext "response-targets" :hx-swap "outerHTML swap:300ms" :hx-target-400 "#form-errors .error-content" } (assoc (if (:db/id entity) :hx-put :hx-post) (str (bidi/path-for ssr-routes/only-routes :admin-transaction-rule-edit-save)))) [:fieldset {:class "hx-disable"} (com/modal-card {:class "md:h-[600px]"} [:div.flex [:div.p-2 "Account"] [:p.ml-2.rounded.bg-gray-200.p-2.dark:bg-gray-600 [:span {:x-text "accountCode"}] " - " [:span {:x-text "accountName"}]]] [:div.space-y-1 (when-let [id (:db/id entity)] (com/hidden {:name "db/id" :value id})) (fc/with-field :account/numeric-code (if (nil? (:db/id entity)) (com/validated-field {:label "Numeric code" :errors (fc/field-errors)} (com/text-input {:name (fc/field-name) :value (fc/field-value) :x-model "accountCode" :autofocus true :class "w-32"})) (com/hidden {:name (fc/field-name) :value (fc/field-value)}))) (fc/with-field :account/name (com/validated-field {:label "Name" :errors (fc/field-errors)} (com/text-input {:name (fc/field-name) :x-model "accountName" :class "w-64" :value (fc/field-value)}))) (fc/with-field :account/type (com/validated-field {:label "Account Type" :errors (fc/field-errors)} (com/select {:name (fc/field-name) :class "w-36" :id "type" :value (some-> (fc/field-value) name) :options (ref->select-options "account-type")}))) (fc/with-field :account/location (com/validated-field {:label "Location" :errors (fc/field-errors)} (com/text-input {:name (fc/field-name) :class "w-16" :value (fc/field-value)}))) [:div.flex.flex-wrap.gap-4 (fc/with-field :account/invoice-allowance (com/validated-field {:label "Invoice Allowance" :errors (fc/field-errors)} (com/select {:name (fc/field-name) :value (some-> (fc/field-value) name) :class "w-36" :options (ref->select-options "allowance")}))) (fc/with-field :account/vendor-allowance (com/validated-field {:label "Vendor Allowance" :errors (fc/field-errors)} (com/select {:name (fc/field-name) :class "w-36" :value (some-> (fc/field-value) name) :options (ref->select-options "allowance")})))] (fc/with-field :account/applicability (com/validated-field {:label "Applicability" :errors (fc/field-errors)} (com/select {:name (fc/field-name) :class "w-36" :value (some-> (fc/field-value) name) :options (ref->select-options "account-applicability")}))) (fc/with-field :account/client-overrides (com/field {:label "Client Overrides" :id "client-overrides"} (com/data-grid {:headers [(com/data-grid-header {} "Client") (com/data-grid-header {} "Account name") (com/data-grid-header {})] :id "client-override-table"} (fc/cursor-map #(client-override* %)) (com/data-grid-new-row {:colspan 3 :index (count (fc/field-value)) :hx-get (bidi/path-for ssr-routes/only-routes :admin-account-client-override-new)} "New override"))))] [:div (com/form-errors {:errors (:errors fc/*form-errors*)}) (com/validated-save-button {:errors (seq form-errors)} "Save account")])]])])) (defn new-client-override [{ {:keys [index]} :query-params}] (html-response (fc/start-form-with-prefix [:account/client-overrides (or index 0)] {:db/id (str (java.util.UUID/randomUUID)) :new? true} [] (client-override* fc/*current*)))) (def form-schema (mc/schema [:map [:db/id {:optional true} [:maybe entity-id]] [:account/numeric-code {:optional true} [:maybe :int]] [:account/name [:string {:min 1 :decode/string strip}]] [:account/location {:optional true} [:maybe [:string {:decode/string strip}]]] [:account/type (ref->enum-schema "account-type")] [:account/applicability (ref->enum-schema "account-applicability")] ; [:account/invoice-allowance (ref->enum-schema "allowance")] [:account/vendor-allowance (ref->enum-schema "allowance")] [:account/client-overrides {:optional true} [:maybe (many-entity {} [:db/id [:or entity-id temp-id]] [:account-client-override/client entity-id] [:account-client-override/name [:string {:min 2 :decode/string strip}]])]]])) (defn account-dialog [{:keys [entity form-params form-errors]}] (modal-response (dialog* {:entity entity :form-params (or (when (seq form-params) form-params) (when entity (mc/decode form-schema entity main-transformer)) {}) :form-errors form-errors}))) (def key->handler (apply-middleware-to-all-handlers (->> {:admin-accounts (helper/page-route grid-page) :admin-account-table (helper/table-route grid-page) :admin-account-client-override-new (-> new-client-override (wrap-schema-enforce :query-schema [:map [:index {:optional true :default 0} [nat-int? {:default 0}]]]) wrap-admin wrap-client-redirect-unauthenticated) :admin-account-save (-> account-save (wrap-entity [:form-params :db/id] default-read) (wrap-schema-enforce :form-schema form-schema) (wrap-nested-form-params) (wrap-form-4xx-2 (wrap-entity account-dialog [:form-params :db/id] default-read))) :admin-account-edit-dialog (-> account-dialog (wrap-entity [:route-params :db/id] default-read) (wrap-schema-enforce :route-schema [:map [:db/id entity-id]])) :admin-account-new-dialog account-dialog}) (fn [h] (-> h (wrap-copy-qp-pqp) (wrap-apply-sort grid-page) (wrap-merge-prior-hx) (wrap-status-from-source) (wrap-schema-enforce :query-schema query-schema) (wrap-schema-enforce :hx-schema query-schema) (wrap-admin) (wrap-client-redirect-unauthenticated)))))