From 7b0e8bfd65653f7161f93df0abe9ceea67f3284d Mon Sep 17 00:00:00 2001 From: Bryce Date: Fri, 26 Jun 2026 00:49:56 -0700 Subject: [PATCH] =?UTF-8?q?refactor(ssr):=20Phase=2010=20=E2=80=94=20migra?= =?UTF-8?q?te=20Client=20wizard=20onto=20the=20engine=20(7=20steps=20+=20b?= =?UTF-8?q?ank-account=20sub-editor)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The largest SSR modal, moved off the mm/* multi-step wizard protocol machinery (ClientWizard/*Modal records, MultiStepFormState, fc/* form-cursors, EDN-snapshot round-trip) onto the session-backed engine (wizard2 + wizard-state): flat de-cursored field names, whole-form HTMX swaps, per-step session state combined by the done-fn. Seven linear steps (info → matches → contact → bank-accounts → integrations → cash-flow → other-settings), each a data-driven {:decode :validate :render :next}. The grid, form schemas, and the sales power-query export are preserved unchanged. The parameterized [:bank-account which] mm sub-step (which the linear engine can't model) becomes a sub-editor of the bank-accounts step: the list view and per-account editor are whole-form swaps of #wizard-form, driven by dedicated routes (new/edit/accept/discard/ sort) that mutate the :bank-accounts step-data in the session directly and re-render via the engine's render-wizard. The bank-accounts step's :decode is a pass-through that re-affirms the session-managed list (read via a `wiz` hidden the engine doesn't strip), so Next never wipes it. Notable fixes carried over from prior phases: - New vs edit is keyed off :db/id presence (the engine always POSTs, so the old PUT/POST split no longer distinguishes them). - Client + bank-account dates are coerced to #inst for EDN-safe session storage (clj-time DateTime has no cookie-session reader). - An empty Contact-step address posts blank fields → decodes to an all-nil, db/id-less map; blank-address? drops it before upsert (else datomic: "tempid used only as value"). Routes: drop ::navigate/::discard; add the four bank-account sub-editor routes. Full e2e suite green (71/71); client-wizard acceptance spec rewritten for the engine (flat field names, data-primary nav, bank-account open/accept/discard sub-flows). Co-Authored-By: Claude Opus 4.8 --- e2e/client-wizard.spec.ts | 91 +- src/clj/auto_ap/ssr/admin/clients.clj | 1943 ++++++++------------ src/cljc/auto_ap/routes/admin/clients.cljc | 10 +- 3 files changed, 833 insertions(+), 1211 deletions(-) diff --git a/e2e/client-wizard.spec.ts b/e2e/client-wizard.spec.ts index 6d9b6fea..5030c2cb 100644 --- a/e2e/client-wizard.spec.ts +++ b/e2e/client-wizard.spec.ts @@ -1,14 +1,14 @@ import { test, expect } from '@playwright/test'; -// Characterization spec for the Client wizard — the largest SSR modal in the app: -// seven linear steps (info → matches → contact → bank-accounts → integrations → -// cash-flow → other-settings) PLUS a parameterized bank-account sub-editor reached -// from the bank-accounts step. This pins the CURRENT (pre-migration) behavior so the -// Phase 10 migration onto the session-backed engine preserves it. +// Acceptance spec for the New/Edit Client wizard — the largest SSR modal in the app: +// seven linear steps (info → matches → contact → bank-accounts → integrations → cash-flow → +// other-settings) PLUS a parameterized bank-account sub-editor reached from the +// bank-accounts step. Migrated onto the session-backed engine (wizard2): flat de-cursored +// field names (client/name, not step-params[client/name]), whole-form HTMX swaps, and the +// bank-account add/edit/sort modeled as whole-form swaps of #wizard-form. // // The seed (test_server.clj) exposes client "Test Client" (code TEST, location DT) which -// owns one "Test Checking" (TEST-CHK) bank account. The admin grid's base query requires -// :client/name, so the seed gives TEST a name purely so the row is selectable here. +// owns one "Test Checking" (TEST-CHK, a checking account) bank account. test.beforeEach(async ({ request }) => { await request.post('/test-reset'); }); // The grid lazy-loads its rows into #entity-table after the page renders. @@ -32,23 +32,28 @@ async function openEditTestClient(page: any) { await page.waitForTimeout(400); } -// Jump to a step via its timeline button (validates the current step first). Steps are -// keyed in the navigate URL as `to=:` (the colon is %3A url-encoded in the attr). -async function gotoStep(page: any, step: string) { - await page.locator(`#wizard-form [hx-put*="to=%3A${step}"]`).first().click(); - await page.waitForTimeout(500); +// Advance one step: click the data-primary Next button and wait until the whole-form swap +// has actually changed the current-step hidden (the timeline lists every step name, so a +// text check can't confirm progress — the hidden value can). +async function advance(page: any) { + const before = await page.locator('#wizard-form input[name="current-step"]').first().inputValue(); + await page.locator('#wizard-form button[data-primary]').first().click(); + await page.waitForFunction( + (prev: string) => { + const el = document.querySelector('#wizard-form input[name="current-step"]') as HTMLInputElement | null; + return !!el && el.value !== prev; + }, before, { timeout: 6000 }); } test.describe.configure({ mode: 'serial' }); -test.describe('Client wizard (characterization)', () => { +test.describe('Client wizard (acceptance)', () => { test('new dialog renders the info step with the 7-step timeline', async ({ page }) => { await openNewClient(page); const form = page.locator('#wizard-form'); - await expect(form.locator('input[name="step-params[client/name]"]')).toBeVisible(); - await expect(form.locator('input[name="step-params[client/code]"]').first()).toBeVisible(); + await expect(form.locator('input[name="client/name"]')).toBeVisible(); + await expect(form.locator('input[name="client/code"]').first()).toBeVisible(); await expect(form).toContainText('Locations'); - // the timeline lists all seven steps for (const label of ['Info', 'Matches', 'Contact', 'Bank Accounts', 'Integrations', 'Cash Flow', 'Other Settings']) { await expect(form).toContainText(label); @@ -58,29 +63,63 @@ test.describe('Client wizard (characterization)', () => { test('edit opens prefilled with the name and a disabled code', async ({ page }) => { await openEditTestClient(page); const form = page.locator('#wizard-form'); - await expect(form.locator('input[name="step-params[client/name]"]')).toHaveValue('Test Client'); + await expect(form.locator('input[name="client/name"]')).toHaveValue('Test Client'); // the visible code input is disabled on edit (a hidden twin carries the value on submit) - const code = form.locator('input[name="step-params[client/code]"]').first(); + const code = form.locator('input[name="client/code"]').first(); await expect(code).toHaveValue('TEST'); await expect(code).toBeDisabled(); }); test('bank-accounts step shows the seeded account card and a new-account affordance', async ({ page }) => { await openEditTestClient(page); - await gotoStep(page, 'bank-accounts'); + await advance(page); // info -> matches + await advance(page); // matches -> contact + await advance(page); // contact -> bank-accounts const form = page.locator('#wizard-form'); - // the seeded bank account renders as a card showing its name + await expect(form).toContainText('Bank Accounts'); await expect(form).toContainText('Test Checking'); - // the add-a-bank-account affordance is present - await expect(form).toContainText('Add a new cash account'); + await expect(form).toContainText('Add a new'); + }); + + test('opening the bank-account editor swaps in the per-account form', async ({ page }) => { + await openEditTestClient(page); + await advance(page); await advance(page); await advance(page); // -> bank-accounts + // click the pencil on the seeded account card to open its editor + await page.locator('#wizard-form [hx-get*="/bank-account/edit"]').first().click(); + await page.waitForTimeout(450); + const form = page.locator('#wizard-form'); + await expect(form.locator('input[name="bank-account/name"]')).toHaveValue('Test Checking'); + await expect(form).toContainText('Accept'); + // discard returns to the list + await page.locator('#wizard-form [hx-get*="/bank-account/discard"]').first().click(); + await page.waitForTimeout(450); + await expect(form).toContainText('Test Checking'); + }); + + test('accepting a bank-account edit merges the change back into the card', async ({ page }) => { + await openEditTestClient(page); + await advance(page); await advance(page); await advance(page); // -> bank-accounts + await page.locator('#wizard-form [hx-get*="/bank-account/edit"]').first().click(); + await page.waitForTimeout(450); + const form = page.locator('#wizard-form'); + await form.locator('input[name="bank-account/name"]').fill('Renamed Checking'); + await form.locator('button[data-primary]:has-text("Accept")').first().click(); + await page.waitForTimeout(450); + // back on the list, the card now shows the new nickname + await expect(form).toContainText('Renamed Checking'); }); test('editing through to the last step and saving keeps the client in the grid', async ({ page }) => { await openEditTestClient(page); - // rename, then jump to the final step and save - await page.locator('#wizard-form input[name="step-params[client/name]"]').fill('Test Client RENAMED'); - await gotoStep(page, 'other-settings'); - await page.locator('#wizard-form button[type="submit"]:has-text("Save")').first().click(); + const nameInput = page.locator('#wizard-form input[name="client/name"]'); + await nameInput.fill('Test Client RENAMED'); + await expect(nameInput).toHaveValue('Test Client RENAMED'); + // info -> matches -> contact -> bank-accounts -> integrations -> cash-flow -> other-settings + for (let i = 0; i < 6; i++) await advance(page); + // the last step is the only one with a Feature Flags grid — confirm we really got here + await expect(page.locator('#wizard-form')).toContainText('Feature Flags'); + // Save persists the edit; reload the grid and the rename is there + await page.locator('#wizard-form button[data-primary]').first().click(); await page.waitForTimeout(1200); await openClientList(page); await expect(page.locator('#entity-table')).toContainText('Test Client RENAMED'); diff --git a/src/clj/auto_ap/ssr/admin/clients.clj b/src/clj/auto_ap/ssr/admin/clients.clj index 93847148..9bf9024c 100644 --- a/src/clj/auto_ap/ssr/admin/clients.clj +++ b/src/clj/auto_ap/ssr/admin/clients.clj @@ -1,4 +1,17 @@ (ns auto-ap.ssr.admin.clients + "Client grid + the New/Edit Client wizard, migrated onto the session-backed engine + (wizard2). Seven linear steps (info -> matches -> contact -> bank-accounts -> + integrations -> cash-flow -> other-settings); per-step validated data lives in the Ring + session and is combined by the engine's get-all for the done-fn (`save-client!`). + + The bank-accounts step is special: it owns a *sub-editor* for one bank account at a time + (the old parameterized `[:bank-account which]` mm step). The linear engine has no nested + step, so the list view and the per-account editor are modeled as whole-form swaps of + #wizard-form, driven by dedicated routes that mutate the :bank-accounts step-data in the + session directly (open-editor / accept / discard / sort) and re-render via the engine's + render-wizard. The list ride-alongs a `wiz` hidden so the bank-accounts step's :decode + (a pass-through) can read the session-managed list back on Next (the engine strips + wizard-id/current-step/direction, but not `wiz`)." (:require [auto-ap.datomic :refer [add-sorter-fields apply-pagination apply-sort-3 @@ -15,22 +28,21 @@ [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.components.timeline :as timeline] + [auto-ap.ssr.components.wizard-state :as ws] + [auto-ap.ssr.components.wizard2 :as wizard2] [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.nested-form-params :as nfp] [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]] + :refer [->db-id apply-middleware-to-all-handlers entity-id + form-validation-error html-response main-transformer many-entity + many-entity-custom modal-response path->name2 ref->enum-schema strip + temp-id wrap-merge-prior-hx wrap-schema-enforce]] [auto-ap.time :as atime] [bidi.bidi :as bidi] [cheshire.core :as cheshire] @@ -41,9 +53,11 @@ [datomic.api :as dc] [hiccup.util :as hu] [malli.core :as mc] + [malli.error :as me] [malli.transform :as mt] [malli.util :as mut] - [manifold.deferred :as de]) + [manifold.deferred :as de] + [slingshot.slingshot :refer [try+]]) (:import [java.util UUID])) @@ -437,1204 +451,800 @@ [: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))))) +;; --------------------------------------------------------------------------- +;; Wizard: de-cursored field names + errors, per-step schemas, renders. +;; --------------------------------------------------------------------------- -(defn location-row [_] +(declare client-wizard-config) + +(def ^:dynamic *errors* {}) +(defn- ferr [& path] (get-in *errors* (vec path))) +(defn- err? [& path] (boolean (seq (apply ferr path)))) + +(defn- sub-schema [ks] (mc/schema (mut/select-keys form-schema-2 ks))) + +(def ^:private info-schema (sub-schema #{:db/id :client/name :client/code :client/locations :client/locked-until})) +(def ^:private matches-schema (sub-schema #{:client/matches :client/location-matches})) +(def ^:private contact-schema (sub-schema #{:client/address :client/emails})) +(def ^:private integrations-schema (sub-schema #{:client/square-auth-token :client/square-locations})) +(def ^:private cash-flow-schema (sub-schema #{:client/week-a-credits :client/week-a-debits :client/week-b-credits :client/week-b-debits})) +(def ^:private other-settings-schema (sub-schema #{:client/feature-flags :client/groups})) + +(defn- decode-with [schema request] + ;; An all-empty step posts only blank fields; main-transformer's parse-empty-as-nil then + ;; decodes the resulting all-nil map to nil. Coerce that back to {} so an optional-only + ;; step validates + advances, while a required-field step still fails on the missing key. + (or (mc/decode schema (:form-params (nfp/nested-params-request request {})) main-transformer) {})) + +(defn- ->edn-safe-dates + "clj-time DateTime can't round-trip through the cookie session (#clj-time/date-time has no + edn reader), so coerce client + bank-account dates to java.util.Date (#inst)." + [data] + (cond-> data + (:client/locked-until data) (update :client/locked-until coerce/to-date) + (seq (:client/bank-accounts data)) + (update :client/bank-accounts + (fn [bas] (mapv (fn [ba] (cond-> ba + (:bank-account/start-date ba) + (update :bank-account/start-date coerce/to-date))) + bas))))) + +(defn- decode-info [request] (->edn-safe-dates (decode-with info-schema request))) + +(defn- validate-with + "Per-step validation: a step can't advance until its fields satisfy the step schema. + Returns a humanized errors map or nil." + [schema data _request] + (when-not (mc/validate schema data) + (me/humanize (mc/explain schema data)))) + +(defn- client-timeline [active] + (let [steps ["Info" "Matches" "Contact" "Bank Accounts" "Integrations" "Cash Flow" "Other Settings"] + active-index (.indexOf steps active)] + (timeline/timeline + {} + (for [[n i] (map vector steps (range))] + (timeline/timeline-step (cond-> {} + (= i active-index) (assoc :active? true) + (< i active-index) (assoc :visited? true) + (= i (dec (count steps))) (assoc :last? true)) + n))))) + +(defn- step-card + "A wizard step's modal card: header (title + client-name chip), a side timeline, the body, + and the engine nav footer. x-data carries clientName so the header chip works within the + per-step form scope." + [{:keys [title active all-data nav body]}] + (com/modal-card-advanced + {"@keydown.enter.prevent.stop" "if ($refs.next) {$refs.next.click()}" + :class " md:w-[820px] md:h-[560px] w-full h-full" + :x-data (hx/json {"clientName" (:client/name all-data)})} + (com/modal-header {} [:div.flex [:div.p-2 title] + [:p.ml-2.rounded.bg-gray-50.p-2.dark:bg-gray-600 [:span {:x-text "clientName"}]]]) + [:div.flex.shrink.overflow-auto.grow + [:div.grow-0.pr-6.pt-2.bg-gray-100.self-stretch.hidden.md:block (client-timeline active)] + (com/modal-body {} body)] + (com/modal-footer {} nav))) + +;; --- simple repeated rows (primitive vectors + small entities) ------------- + +(defn- location-row [{:keys [value index]}] (com/data-grid-row - {:x-ref "p" - :x-data (hx/json {})} + {: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/text-input {:name (path->name2 :client/locations index) + :value 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 [_] +(defn- group-row [{:keys [value index]}] (com/data-grid-row - {:x-ref "p" - :x-data (hx/json {})} + {: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/text-input {:name (path->name2 :client/groups index) + :value 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 [_] +(defn- feature-flag-row [{:keys [value index]}] (com/data-grid-row - {:x-ref "p" - :x-data (hx/json {})} + {: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) + (com/select {:name (path->name2 :client/feature-flags index) + :allow-blank? true :class "w-full" :value 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 [_] +(defn- match-row [{:keys [value index]}] (com/data-grid-row - {:x-ref "p" - :x-data (hx/json {})} + {: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/text-input {:name (path->name2 :client/matches index) + :value 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] +(defn- location-match-row [{:keys [data index]}] (com/data-grid-row - (-> {:x-data (hx/json {:show (boolean (not (fc/field-value (:new? location-match-cursor))))}) - :data-key "show" - :x-ref "p"} + (-> {:x-data (hx/json {:show (boolean (not (:new? data)))}) :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/hidden {:name (path->name2 :client/location-matches index :db/id) :value (:db/id data)}) + (com/data-grid-cell + {} + (com/validated-field {:errors (ferr :client/location-matches index :location-match/matches) :class "shrink-0"} + (com/text-input {:name (path->name2 :client/location-matches index :location-match/matches 0) + :placeholder "Suite 300" :class "w-full" + :value (first (:location-match/matches data))}))) + (com/data-grid-cell + {} + (com/validated-field {:errors (ferr :client/location-matches index :location-match/location) :class "shrink-0"} + (com/text-input {:name (path->name2 :client/location-matches index :location-match/location) + :placeholder "Suite 300" :class "w-24" + :value (:location-match/location data)}))) (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 [_ _] - []) +(defn- email-contact-row [{:keys [data index]}] + (com/data-grid-row + (-> {:x-data (hx/json {:show (boolean (not (:new? data)))}) :data-key "show" :x-ref "p"} + hx/alpine-mount-then-appear) + (com/hidden {:name (path->name2 :client/emails index :db/id) :value (:db/id data)}) + (com/data-grid-cell + {} + (com/validated-field {:errors (ferr :client/emails index :email-contact/description) :class "shrink-0"} + (com/text-input {:name (path->name2 :client/emails index :email-contact/description) + :placeholder "Suite 300" :class "w-full" + :value (:email-contact/description data)}))) + (com/data-grid-cell + {} + (com/validated-field {:errors (ferr :client/emails index :email-contact/email) :class "shrink-0"} + (com/text-input {:name (path->name2 :client/emails index :email-contact/email) + :placeholder "Suite 300" :class "w-full" + :value (:email-contact/email data)}))) + (com/data-grid-cell {:class "align-top"} + (com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x)))) - (step-schema [_] - (mut/select-keys (mm/form-schema linear-wizard) #{:client/matches :client/location-matches})) +;; --- step renders ---------------------------------------------------------- - (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))) +(defn render-info [{:keys [step-data all-data errors]}] + (binding [*errors* (or errors {})] + (let [data (or step-data {}) + locations (vec (:client/locations data)) + edit? (boolean (:db/id data))] + (step-card + {:title "Info" :active "Info" :all-data (merge all-data data) + :nav (wizard2/nav-footer {:next "Matches"}) + :body [:div.space-y-1.mt-4 + (when edit? (com/hidden {:name "db/id" :value (:db/id data)})) + [:div.flex.space-x-2 + (com/validated-field + {:label "Name" :errors (ferr :client/name)} + (com/text-input {:name "client/name" :value (:client/name data) + :x-model "clientName" :autofocus true :class "w-96"})) + (com/validated-field + {:label "Code" :errors (ferr :client/code)} + (list + (com/text-input {:name "client/code" :disabled edit? + :value (:client/code data) :class "w-24"}) + (when edit? (com/hidden {:name "client/code" :value (:client/code data)}))))] + (com/validated-field + {:label "Locked Until" :errors (ferr :client/locked-until)} + (com/date-input {:name "client/locked-until" + :placeholder "Disallow changes before this date" + :value (some-> (:client/locked-until data) + coerce/to-date-time + (atime/unparse-local atime/normal-date)) + :class "w-24"})) + (com/validated-field + {:errors (ferr :client/locations) :label "Locations"} + (com/data-grid {:headers [(com/data-grid-header {} "Location") + (com/data-grid-header {:class "w-16"})]} + (map-indexed (fn [i l] (location-row {:value l :index i})) locations) + (com/data-grid-new-row {:colspan 2 + :hx-get (bidi/path-for ssr-routes/only-routes ::route/new-location) + :index (count locations)} + "New location")))]})))) -(defrecord ContactModal [linear-wizard] - mm/ModalWizardStep - (step-name [_] - "Contact") +(defn render-matches [{:keys [step-data all-data errors]}] + (binding [*errors* (or errors {})] + (let [data (or step-data {}) + matches (vec (:client/matches data)) + location-matches (vec (:client/location-matches data))] + (step-card + {:title "Matches" :active "Matches" :all-data all-data + :nav (wizard2/nav-footer {:back? true :next "Contact"}) + :body [:div.space-y-2 + (com/validated-field + {:errors (ferr :client/matches) :label "Matches"} + (com/data-grid {:headers [(com/data-grid-header {} "Match") + (com/data-grid-header {:class "w-16"})]} + (map-indexed (fn [i m] (match-row {:value m :index i})) matches) + (com/data-grid-new-row {:colspan 2 + :hx-get (bidi/path-for ssr-routes/only-routes ::route/new-match) + :index (count matches)} + "New Match"))) + (com/validated-field + {:errors (ferr :client/location-matches) :label "Location Matches"} + (com/data-grid {:headers [(com/data-grid-header {} "Match") + (com/data-grid-header {} "location") + (com/data-grid-header {:class "w-16"})]} + (map-indexed (fn [i lm] (location-match-row {:data lm :index i})) location-matches) + (com/data-grid-new-row {:colspan 3 + :hx-get (bidi/path-for ssr-routes/only-routes ::route/new-location-match) + :index (count location-matches)} + "New Match")))]})))) - (step-key [_] - :contact) +(defn render-contact [{:keys [step-data all-data errors]}] + (binding [*errors* (or errors {})] + (let [data (or step-data {}) + addr (or (:client/address data) {}) + emails (vec (:client/emails data)) + an (fn [f] (path->name2 :client/address f))] + (step-card + {:title "Contact" :active "Contact" :all-data all-data + :nav (wizard2/nav-footer {:back? true :next "Bank Accounts"}) + :body [:div.space-y-2 + [:div.flex.flex-col.w-full + (when (:db/id addr) (com/hidden {:name (an :db/id) :value (:db/id addr)})) + (com/validated-field + {:label "Street" :errors (ferr :client/address :address/street1)} + (com/text-input {:name (an :address/street1) :autofocus true :class "w-full" + :placeholder "1200 Pennsylvania Avenue" :value (:address/street1 addr)})) + (com/validated-field + {:errors (ferr :client/address :address/street2)} + (com/text-input {:name (an :address/street2) :class "w-full" + :placeholder "Suite 300" :value (:address/street2 addr)})) + [:div.flex.w-full.space-x-4 + (com/validated-field + {:errors (ferr :client/address :address/city) :class "w-full grow shrink"} + (com/text-input {:name (an :address/city) :class "w-full" :placeholder "City" :value (:address/city addr)})) + (com/validated-field + {:errors (ferr :client/address :address/state) :class "w-16 shrink-0"} + (com/text-input {:name (an :address/state) :class "w-full" :placeholder "DC" :value (:address/state addr)})) + (com/validated-field + {:errors (ferr :client/address :address/zip) :class "w-24 shrink-0"} + (com/text-input {:name (an :address/zip) :class "w-full" :placeholder "20500" :value (:address/zip addr)}))]] + (com/validated-field + {:errors (ferr :client/emails) :label "Email Contacts"} + (com/data-grid {:headers [(com/data-grid-header {} "Name") + (com/data-grid-header {} "Email") + (com/data-grid-header {:class "w-16"})]} + (map-indexed (fn [i e] (email-contact-row {:data e :index i})) emails) + (com/data-grid-new-row {:colspan 3 + :hx-get (bidi/path-for ssr-routes/only-routes ::route/new-email-contact) + :index (count emails)} + "New email contact")))]})))) - (edit-path [_ _] - []) +(defn render-cash-flow [{:keys [step-data all-data errors]}] + (binding [*errors* (or errors {})] + (let [data (or step-data {}) + fld (fn [k label] + (com/validated-field {:label label :errors (ferr k) :class "w-32"} + (com/text-input {:name (path->name2 k) :placeholder "123.33" + :class "w-32" :value (k data)})))] + (step-card + {:title "Cash Flow" :active "Cash Flow" :all-data all-data + :nav (wizard2/nav-footer {:back? true :next "Other Settings"}) + :body [:div.space-y-4 + [:div.flex.space-x-4 + (fld :client/week-a-credits "Week A Credits") + (fld :client/week-a-debits "Week A Debits")] + [:div.flex.space-x-4 + (fld :client/week-b-credits "Week B Credits") + (fld :client/week-b-debits "Week B Debits")]]})))) - (step-schema [_] - (mut/select-keys (mm/form-schema linear-wizard) #{:client/address})) +(defn render-other-settings [{:keys [step-data all-data errors]}] + (binding [*errors* (or errors {})] + (let [data (or step-data {}) + flags (vec (:client/feature-flags data)) + groups (vec (:client/groups data))] + (step-card + {:title "Other Settings" :active "Other Settings" :all-data all-data + :nav (wizard2/nav-footer {:back? true :save? true}) + :body [:div.space-y-2 + (com/validated-field + {:errors (ferr :client/feature-flags) :label "Feature Flags"} + (com/data-grid {:headers [(com/data-grid-header {} "Flag") + (com/data-grid-header {:class "w-16"})]} + (map-indexed (fn [i f] (feature-flag-row {:value f :index i})) flags) + (com/data-grid-new-row {:colspan 2 + :hx-get (bidi/path-for ssr-routes/only-routes ::route/new-feature-flag) + :index (count flags)} + "New flag"))) + (com/validated-field + {:errors (ferr :client/groups) :label "Groups"} + (com/data-grid {:headers [(com/data-grid-header {} "Group") + (com/data-grid-header {:class "w-16"})]} + (map-indexed (fn [i g] (group-row {:value g :index i})) groups) + (com/data-grid-new-row {:colspan 2 + :hx-get (bidi/path-for ssr-routes/only-routes ::route/new-group) + :index (count groups)} + "New group")))]})))) - (render-step [this _] - (mm/default-render-step - linear-wizard this - :head (dialog-header this) - :body (mm/default-step-body {} +;; --- integrations (Square) ------------------------------------------------- - (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 [] +(defn- square-location-table [locations] [:div#square-locations - [:div.htmx-indicator - "Loading..."] + [:div.htmx-indicator "Loading..."] [:div.htmx-indicator-hidden [:table - [:thead - [:tr - [:td "Square location"] - [:td "Client location"]]] + [: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)}))]]))]]]]) + (map-indexed + (fn [i sl] + [:tr + (com/hidden {:name (path->name2 :client/square-locations i :db/id) :value (:db/id sl)}) + (com/hidden {:name (path->name2 :client/square-locations i :square-location/name) :value (:square-location/name sl)}) + (com/hidden {:name (path->name2 :client/square-locations i :square-location/square-id) :value (:square-location/square-id sl)}) + [:td (:square-location/name sl)] + [:td (com/text-input {:name (path->name2 :client/square-locations i :square-location/client-location) + :value (:square-location/client-location sl)})]]) + (vec locations))]]]]) (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]")])}) + (de/chain (square/client-locations {:client/square-auth-token (get-in request [:query-params :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) + {:db/id (str (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)))))) + [:div#square-locations "No locations found."] + (square-location-table locations))))) -(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 +(defn render-integrations [{:keys [step-data all-data errors]}] + (binding [*errors* (or errors {})] + (let [data (or step-data {}) + locations (vec (:client/square-locations data))] + (step-card + {:title "Integrations" :active "Integrations" :all-data all-data + :nav (wizard2/nav-footer {:back? true :next "Cash Flow"}) + :body [:div + [:div.flex.gap-2.items-center (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")] + {:errors (ferr :client/square-auth-token) :label "Square Auth Token"} + (com/text-input {:name "client/square-auth-token" :id "square-token" + :error? (err? :client/square-auth-token) + :placeholder "Token from square" :class "w-64" + :value (:client/square-auth-token data)})) + (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")] + (square-location-table locations)]})))) - (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))) +;; --- bank accounts: cards, editor, sub-editor routes ----------------------- -(defrecord BankAccountModal [linear-wizard which] - mm/ModalWizardStep - (step-name [_] - "Bank Accounts") - (step-key [_] - [:bank-account which]) +(defn- bank-account-card-base [{:keys [bg-color text-color icon bank-account index wizard-id]}] + [:div {:class "w-[30em] cursor-move"} + (com/card {:class "w-full"} + [:div.flex.items-stretch + (com/hidden {:name "item" :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 (:bank-account/name bank-account)] + [:div.font-light.text-gray-600 (:bank-account/bank-name bank-account)]] + [:div.grow-0.p-4 + (com/a-icon-button {:hx-get (hu/url (bidi/path-for ssr-routes/only-routes ::route/edit-bank-account) + {:wizard-id wizard-id :index index}) + :hx-target "#wizard-form" :hx-swap "outerHTML"} + svg/pencil)]])]) - (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)] +(defn- bank-account-card [bank-account index wizard-id] + (let [base (fn [bg tc icon] + (bank-account-card-base {:bg-color bg :text-color tc :icon icon + :bank-account bank-account :index index :wizard-id wizard-id}))] + (case (:bank-account/type bank-account) + :bank-account-type/cash (base "bg-green-50" "text-green-600" svg/dollar) + :bank-account-type/credit (base "bg-purple-50" "text-purple-600" svg/credit-card) + :bank-account-type/check (base "bg-blue-50" "text-blue-600" svg/check) + (base "bg-gray-50" "text-gray-600" svg/dollar)))) - [:client/bank-accounts (or account-index - (count (:client/bank-accounts (:snapshot (:multi-form-state request)))))])) +(defn- new-bank-account-card [wizard-id] + (let [new-link (fn [type label] + (com/link {:hx-get (hu/url (bidi/path-for ssr-routes/only-routes ::route/new-bank-account) + {:wizard-id wizard-id :bank-account-type type}) + :hx-target "#wizard-form" :hx-swap "outerHTML"} + label))] + [: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 " + (new-link "cash" "cash account") ", " + (new-link "credit" "credit card") ", " + (new-link "check" "checking account")]]])])) - (step-schema [_] - bank-account-schema) +(defn- ba-field + "Bank-account editor field name (flat, single account being edited)." + [f] + (path->name2 f)) - (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*)]) +(defn- ba-start-date-field [ba] + (com/validated-field {:errors (ferr :bank-account/start-date) :label "Start Date"} + [:div {:class "w-[7em]"} + (com/date-input {:name (ba-field :bank-account/start-date) + :error? (err? :bank-account/start-date) + :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-> (:bank-account/start-date ba) + coerce/to-date-time + (atime/unparse-local atime/normal-date))})])) - :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} +(defn- ba-common-head [ba] + (list + (com/hidden {:name (ba-field :db/id) :value (:db/id ba)}) + (com/hidden {:name (ba-field :bank-account/type) :value (name (:bank-account/type ba))}) + [:div.flex.space-x-2 + (com/validated-field {:errors (ferr :bank-account/name) :label "Nickname" :class "w-[20em]"} + (com/text-input {:name (ba-field :bank-account/name) :error? (err? :bank-account/name) + :autofocus true :placeholder "BofA Checking" :class "w-full" + :value (:bank-account/name ba)})) + (com/validated-field {:errors (ferr :bank-account/code) :label "Code" :class "w-20"} + (list + (com/text-input {:name (ba-field :bank-account/code) :error? (err? :bank-account/code) + :disabled (not (:new? ba)) :placeholder "NGOM-CASH" :class "w-full" + :value (:bank-account/code ba)}) + (when-not (:new? ba) + (com/hidden {:name (ba-field :bank-account/code) :value (:bank-account/code ba)}))))] + (com/validated-field {:errors (ferr :bank-account/numeric-code) :label "Financial code"} + [:div {:class "w-[5em]"} + (com/int-input {:name (ba-field :bank-account/numeric-code) :error? (err? :bank-account/numeric-code) + :placeholder "11101" :class "w-[5em]" :value (:bank-account/numeric-code ba)})]) + [:div.flex.space-x-2.items-center + (ba-start-date-field ba) + [:div#days-indicator (i/days-ago* (some-> (:bank-account/start-date ba)))]] + (com/checkbox {:name (ba-field :bank-account/include-in-reports) + :value (boolean (:bank-account/include-in-reports ba)) + :checked (boolean (:bank-account/include-in-reports ba))} + "Include in reports") + [:div (com/checkbox {:name (ba-field :bank-account/visible) + :value (boolean (:bank-account/visible ba)) + :checked (boolean (:bank-account/visible ba))} + "Visible for payment")])) - bank-account-type (assoc :bank-account/type (keyword "bank-account-type" bank-account-type) - :bank-account/visible true)) - (:step-params multi-form-state)))) +(defn- plaid-account-select [ba client-id] + (com/validated-field {:errors (ferr :bank-account/plaid-account) :label "Plaid account" :class "w-[20em]" + :x-data (hx/json {:plaidAccount (:bank-account/plaid-account ba)})} + [:div.flex.gap-2.items-center + (com/select {:name (ba-field :bank-account/plaid-account) :allow-blank? true + :error? (err? :bank-account/plaid-account) :class "w-full" + :value (:bank-account/plaid-account ba) :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())"}]])) - 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)))) +(defn- yodlee-account-select [ba client-id] + (list + (com/validated-field {:errors (ferr :bank-account/yodlee-account) :label "Yodlee account" :class "w-[20em]"} + (com/select {:name (ba-field :bank-account/yodlee-account) :allow-blank? true + :error? (err? :bank-account/yodlee-account) :class "w-full" + :value (:bank-account/yodlee-account ba) + :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))})) + (com/checkbox {:name (ba-field :bank-account/use-date-instead-of-post-date?) + :checked (:bank-account/use-date-instead-of-post-date? ba)} + "(Yodlee only) use date instead of post date"))) -(defrecord CashFlowModal [linear-wizard] - mm/ModalWizardStep - (step-name [_] - "Cash Flow") +(defn- intuit-account-select [ba _client-id] + (com/validated-field {:errors (ferr :bank-account/intuit-bank-account) :label "Intuit account" :class "w-[20em]"} + (com/select {:name (ba-field :bank-account/intuit-bank-account) :allow-blank? true + :error? (err? :bank-account/intuit-bank-account) :class "w-full" + :value (:bank-account/intuit-bank-account ba) + :options (sort-by second (dc/q '[:find ?ia ?inn + :in $ + :where [?ia :intuit-bank-account/name ?inn]] + (dc/db conn)))}))) - (step-key [_] - :cash-flow) +(defn- bank-details [ba check?] + (list + [:h2.text-lg "Bank details"] + (com/validated-field {:errors (ferr :bank-account/bank-name) :label "Bank Name" :class "w-[20em]"} + (com/text-input {:name (ba-field :bank-account/bank-name) :error? (err? :bank-account/bank-name) + :placeholder "Bank of America" :value (:bank-account/bank-name ba)})) + [:div.flex.gap-2 + (com/validated-field {:errors (ferr :bank-account/number) :label "Account #" :class "w-[10em]"} + (com/text-input {:name (ba-field :bank-account/number) :error? (err? :bank-account/number) + :class "w-full" :placeholder "1820190122" :value (:bank-account/number ba)})) + (when check? + (com/validated-field {:errors (ferr :bank-account/routing) :label "Routing #" :class "w-[8em]"} + (com/text-input {:name (ba-field :bank-account/routing) :error? (err? :bank-account/routing) + :class "w-full" :placeholder "1238912" :value (:bank-account/routing ba)}))) + (when check? + (com/validated-field {:errors (ferr :bank-account/bank-code) :label "Bank Code" :class "w-[8em]"} + (com/text-input {:name (ba-field :bank-account/bank-code) :error? (err? :bank-account/bank-code) + :class "w-full" :placeholder "12/10123" :value (:bank-account/bank-code ba)})))] + (when check? + (com/validated-field {:errors (ferr :bank-account/check-number) :label "Check Number" :class "w-[8em]"} + (com/text-input {:name (ba-field :bank-account/check-number) :error? (err? :bank-account/check-number) + :class "w-full" :placeholder "50000" :value (:bank-account/check-number ba)}))))) - (edit-path [_ _] []) +(defn- bank-account-form [ba client-id] + (let [type (:bank-account/type ba) + title (case type + :bank-account-type/cash (if (:new? ba) "New Cash Account" (str "Edit Cash Account: " (:bank-account/name ba))) + :bank-account-type/credit (if (:new? ba) "New Credit Card Account" (str "Edit Credit Card: " (:bank-account/name ba))) + :bank-account-type/check (if (:new? ba) "New Checking Account" (str "Edit Checking Account: " (:bank-account/name ba))) + "Bank Account")] + [:div.space-y-2 + [:h2.text-lg title] + (ba-common-head ba) + (when (not= type :bank-account-type/cash) + (list + (bank-details ba (= type :bank-account-type/check)) + [:h2.text-lg "Integration details"] + (plaid-account-select ba client-id) + (yodlee-account-select ba client-id) + (intuit-account-select ba client-id)))])) - (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})) +(defn- bank-account-editor-form + "The per-account editor, swapped into #wizard-form. Posts Accept to merge the account back + into the session :bank-accounts list; Discard re-renders the list unchanged." + [{:keys [wizard-id index ba client-id]}] + [:form {:id "wizard-form" :hx-ext "response-targets" :hx-target "this" :hx-swap "outerHTML" + :hx-target-400 "#form-errors"} + (com/hidden {:name "wizard-id" :value wizard-id}) + (com/hidden {:name "bank-account-index" :value index}) + (com/modal-card-advanced + {:class " md:w-[820px] md:h-[560px] w-full h-full"} + (com/modal-header {} [:div.p-2 "Bank Account"]) + (com/modal-body {} [:div {:class "htmx-added:opacity-0 opacity-100 transition-opacity duration-300"} + (bank-account-form ba client-id)]) + (com/modal-footer {} + [:div.flex.justify-end.items-baseline.gap-x-4 + [:div#form-errors] + (com/a-button {:hx-get (hu/url (bidi/path-for ssr-routes/only-routes ::route/discard-bank-account) + {:wizard-id wizard-id}) + :hx-target "#wizard-form" :hx-swap "outerHTML"} + "discard") + (com/button {:hx-post (hu/url (bidi/path-for ssr-routes/only-routes ::route/accept-bank-account) + {:wizard-id wizard-id}) + :data-primary "" :color :primary} "Accept")]))]) - (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))) +(defn render-bank-accounts [{:keys [step-data all-data errors wizard-id]}] + (binding [*errors* (or errors {})] + (let [accounts (vec (:client/bank-accounts (or step-data {})))] + (step-card + {:title "Bank Accounts" :active "Bank Accounts" :all-data all-data + :nav (wizard2/nav-footer {:back? true :next "Integrations"}) + :body [:div + (com/hidden {:name "wiz" :value wizard-id}) + (com/validated-field + {:errors (ferr :client/bank-accounts) :label "Bank Accounts"} + [:div#bank-account-list {:hx-put (hu/url (bidi/path-for ssr-routes/only-routes ::route/sort-bank-accounts) + {:wizard-id wizard-id}) + :hx-trigger "end"} + [:div.flex.flex-col.space-y-4.sortable + (map-indexed (fn [i ba] (when (:bank-account/type ba) (bank-account-card ba i wizard-id))) accounts) + (new-bank-account-card wizard-id)]])]})))) -(defrecord OtherSettingsModal [linear-wizard] - mm/ModalWizardStep - (step-name [_] - "Other Settings") +;; --- bank-account sub-editor handlers (operate on session :bank-accounts) -- - (step-key [_] - :other-settings) +(defn- ba-list [session wid] + (vec (:client/bank-accounts (ws/step-data session wid :bank-accounts)))) - (edit-path [_ _] []) +(defn- client-id-of [session wid] + (:client-id (ws/context session wid))) - (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 []))] +(defn new-bank-account [request] + (let [wid (get-in request [:query-params :wizard-id]) + type (get-in request [:query-params :bank-account-type]) + ba {:db/id (str (UUID/randomUUID)) :new? true + :bank-account/type (keyword "bank-account-type" type) + :bank-account/visible true}] (html-response - (mm/render-wizard wizard (update request :multi-form-state - (comp - #(mm/select-state % [] {}) - #(assoc-in % [:snapshot :client/bank-accounts] new-bank-accounts))))))) + (bank-account-editor-form {:wizard-id wid :index (count (ba-list (:session request) wid)) + :ba ba :client-id (client-id-of (:session request) wid)})))) + +(defn edit-bank-account [request] + (let [wid (get-in request [:query-params :wizard-id]) + idx (Integer/parseInt (str (get-in request [:query-params :index]))) + ba (nth (ba-list (:session request) wid) idx nil)] + (html-response + (bank-account-editor-form {:wizard-id wid :index idx + :ba ba :client-id (client-id-of (:session request) wid)})))) + +(defn- clean-ba + "Keep only :db/id + :bank-account/* keys from a decoded editor post (drop wizard-id, + bank-account-index, new?, and other form-control cruft so they never reach datomic)." + [ba] + (select-keys ba (into [:db/id] (filter #(= "bank-account" (namespace %)) (keys ba))))) + +(defn- decode-one-bank-account [request] + (clean-ba (mc/decode bank-account-schema + (:form-params (nfp/nested-params-request request {})) + main-transformer))) + +(defn- render-list [config wid session request] + (-> (html-response (wizard2/render-wizard {:config config :wizard-id wid :session session :request request})) + (assoc :session session))) + +(defn accept-bank-account [request] + (let [wid (get-in request [:query-params :wizard-id]) + session (:session request) + idx (Integer/parseInt (str (get-in request [:form-params "bank-account-index"]))) + new? (>= idx (count (ba-list session wid))) + ba (decode-one-bank-account request)] + (if-not (mc/validate bank-account-schema ba) + (binding [*errors* (me/humanize (mc/explain bank-account-schema ba))] + (html-response + (bank-account-editor-form {:wizard-id wid :index idx :ba (assoc ba :new? new?) + :client-id (client-id-of session wid)}))) + (let [accounts (ba-list session wid) + accounts' (if (< idx (count accounts)) (assoc accounts idx ba) (conj accounts ba)) + session' (ws/put-step session wid :bank-accounts {:client/bank-accounts accounts'})] + (render-list client-wizard-config wid session' request))))) + +(defn discard-bank-account [request] + (let [wid (get-in request [:query-params :wizard-id])] + (render-list client-wizard-config wid (:session request) request))) + +(defn sort-bank-accounts [request] + (let [wid (get-in request [:query-params :wizard-id]) + session (:session request) + order (vec (:item (:form-params request))) + sort-index (into {} (map vector order (range))) + accounts (->> (ba-list session wid) + (sort-by (fn [ba] (get sort-index (:db/id ba) 0))) + vec) + session' (ws/put-step session wid :bank-accounts {:client/bank-accounts accounts})] + (render-list client-wizard-config wid session' request))) + +(defn- decode-bank-accounts + "The bank-accounts step is a pass-through: its accounts are managed out-of-band by the + sub-editor, so on Next just re-affirm the session-stored list (read via the `wiz` hidden, + which the engine does not strip)." + [request] + (let [wid (get-in request [:form-params "wiz"])] + (or (ws/step-data (:session request) wid :bank-accounts) {:client/bank-accounts []}))) + +;; --- done-fn, init, config, handlers --------------------------------------- + +(defn- blank-address? + "A new (db/id-less) address whose every field is nil -- the empty Contact-step address. + An empty address *form* posts blank fields (not absent), so it decodes to an all-nil map + rather than nil; upserting it would create an attribute-less entity (datomic: + \"tempid used only as value\"), so it must be dropped rather than persisted." + [a] + (and (map? a) (not (:db/id a)) (every? nil? (vals a)))) + +(defn save-client! + "Engine done-fn: combine the 7 steps, upsert the client, reindex Solr, return the row. New + vs edit is determined by the presence of :db/id in the combined data (the info step + carries it on edit), since the engine always submits via POST." + [all-data {:keys [identity]}] + (let [snapshot (mc/decode form-schema-2 all-data mt/strip-extra-keys-transformer) + new? (not (:db/id snapshot)) + entity (cond-> snapshot + new? (assoc :db/id "new") + (not new?) (dissoc :client/code) + (blank-address? (:client/address snapshot)) (dissoc :client/address) + (:client/locked-until snapshot) (update :client/locked-until 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 % (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) + 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"} + new? (assoc "hx-retarget" "#entity-table tbody" + "hx-reswap" "afterbegin") + (not new?) (assoc "hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id updated-client)) + "hx-reswap" "outerHTML"))))) + +(defn- sort-bas [bas] (into [] (sort-by :bank-account/sort-order bas))) + +(defn client-init-fn + "New: empty steps. Edit: pull the entity, split it across the steps so each opens + populated, and coerce dates to #inst for EDN-safe session storage. Stash the client id in + :context for the bank-account editor's plaid/yodlee/intuit lookups." + [request] + (if-let [id (->db-id (get-in request [:route-params :db/id]))] + (let [e (dc/pull (dc/db conn) default-read id) + e (->edn-safe-dates (update e :client/bank-accounts sort-bas))] + {:context {:client-id id} + :init-data {:info (select-keys e [:db/id :client/name :client/code :client/locations :client/locked-until]) + :matches (select-keys e [:client/matches :client/location-matches]) + :contact (select-keys e [:client/address :client/emails]) + :bank-accounts (select-keys e [:client/bank-accounts]) + :integrations (select-keys e [:client/square-auth-token :client/square-locations]) + :cash-flow (select-keys e [:client/week-a-credits :client/week-a-debits + :client/week-b-credits :client/week-b-debits]) + :other-settings (select-keys e [:client/feature-flags :client/groups])}}) + {:context {:client-id nil} + :init-data {}})) + +(def client-wizard-config + {:name :client + :form-id "wizard-form" + :submit-route (bidi/path-for ssr-routes/only-routes ::route/save) + :form-attrs {:hx-ext "response-targets" :hx-target-400 "#form-errors"} + :open-response (fn [form] (modal-response [:div#transitioner.flex-1 form])) + :init-fn client-init-fn + :steps [{:key :info :decode decode-info :validate (partial validate-with info-schema) :render render-info :next (fn [_] :matches)} + {:key :matches :decode (partial decode-with matches-schema) :validate (partial validate-with matches-schema) :render render-matches :next (fn [_] :contact)} + {:key :contact :decode (partial decode-with contact-schema) :validate (partial validate-with contact-schema) :render render-contact :next (fn [_] :bank-accounts)} + {:key :bank-accounts :decode decode-bank-accounts :validate (fn [_ _] nil) :render render-bank-accounts :next (fn [_] :integrations)} + {:key :integrations :decode (partial decode-with integrations-schema) :validate (partial validate-with integrations-schema) :render render-integrations :next (fn [_] :cash-flow)} + {:key :cash-flow :decode (partial decode-with cash-flow-schema) :validate (partial validate-with cash-flow-schema) :render render-cash-flow :next (fn [_] :other-settings)} + {:key :other-settings :decode (partial decode-with other-settings-schema) :validate (partial validate-with other-settings-schema) :render render-other-settings :next (fn [_] :done)}] + :done-fn save-client!}) + +(defn client-step + "POST handler for every wizard transition. Surface create-time validation as a 4xx into + #form-errors so the modal stays open." + [request] + (try+ + (wizard2/handle-step-submit client-wizard-config request) + (catch #(#{:form-validation :schema-validation :field-validation :notification} (:type %)) e + (html-response + [:span.error-content.text-red-500 (or (:message e) "Could not save the client.")] + :status 400)))) + +(defn- add-row-handler + "Append one fresh de-cursored row at the posted index (handles both primitive-value rows + and entity-data rows)." + [render request] + (let [idx (-> request :query-params :index) + idx (if (string? idx) (Integer/parseInt idx) idx)] + (html-response (render {:data (wizard2/blank-row) :value "" :index idx})))) (def sales-summary-query "[:find ?d4 (sum ?total) (sum ?tax) (sum ?tip) (sum ?service-charge) (sum ?discount) (sum ?returns) @@ -1842,66 +1452,35 @@ (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/page (helper/page-route grid-page) + ::route/table (helper/table-route grid-page) + ::route/new-dialog (partial wizard2/open-wizard client-wizard-config) + ::route/edit-dialog (-> (partial wizard2/open-wizard client-wizard-config) + (wrap-schema-enforce :route-schema [:map [:db/id entity-id]])) + ::route/save client-step + + ;; add-row handlers (the data-grid "New X" affordances) + ::route/new-location (partial add-row-handler location-row) + ::route/new-match (partial add-row-handler match-row) + ::route/new-group (partial add-row-handler group-row) + ::route/new-feature-flag (partial add-row-handler feature-flag-row) + ::route/new-location-match (partial add-row-handler location-match-row) + ::route/new-email-contact (partial add-row-handler email-contact-row) + + ;; bank-account sub-editor (whole-form swaps of #wizard-form) + ::route/new-bank-account new-bank-account + ::route/edit-bank-account edit-bank-account + ::route/accept-bank-account accept-bank-account + ::route/discard-bank-account discard-bank-account + ::route/sort-bank-accounts sort-bank-accounts + + ;; integrations + ::route/refresh-square-locations refresh-square-locations + + ;; sales power-query export (unchanged) ::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))} + (wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))} (fn [h] (-> h (wrap-copy-qp-pqp) diff --git a/src/cljc/auto_ap/routes/admin/clients.cljc b/src/cljc/auto_ap/routes/admin/clients.cljc index 0d1aba66..46b42314 100644 --- a/src/cljc/auto_ap/routes/admin/clients.cljc +++ b/src/cljc/auto_ap/routes/admin/clients.cljc @@ -4,9 +4,6 @@ :post ::save} "/table" ::table - "/navigate" ::navigate - "/bank-accounts/sort" ::sort-bank-accounts - "/discard" ::discard "/square-locations" ::refresh-square-locations "/location/new" ::new-location @@ -15,6 +12,13 @@ "/email-contact/new" ::new-email-contact "/group/new" ::new-group "/feature-flag/new" ::new-feature-flag + + "/bank-account/new" ::new-bank-account + "/bank-account/edit" ::edit-bank-account + "/bank-account/accept" {:post ::accept-bank-account} + "/bank-account/discard" ::discard-bank-account + "/bank-accounts/sort" ::sort-bank-accounts + "/new" {:get ::new-dialog} ["/" [#"\d+" :db/id] "/sales-powerquery"] ::biweekly-sales-powerquery ["/" [#"\d+" :db/id] "/edit"] ::edit-dialog})