3 Commits

Author SHA1 Message Date
f239b114c3 Merge branch 'integreat-fix-errors' into staging 2026-05-24 21:54:54 -07:00
8e3aa13f4d fixes 2026-05-24 21:54:37 -07:00
5b2aba561c feat: support exact client code match in dropdown search
When typing in the company dropdown search, check for an exact match
on client code via Datomic before falling back to Solr name search.
This allows users to quickly find clients by typing their code (e.g. NGRV).
2026-05-23 13:24:40 -07:00
7 changed files with 5571 additions and 121 deletions

View File

@@ -5,7 +5,7 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@opencode-ai/plugin": "1.14.31" "@opencode-ai/plugin": "1.15.10"
} }
}, },
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
@@ -87,32 +87,36 @@
] ]
}, },
"node_modules/@opencode-ai/plugin": { "node_modules/@opencode-ai/plugin": {
"version": "1.14.31", "version": "1.15.10",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.31.tgz", "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.15.10.tgz",
"integrity": "sha512-ZF7UoNKtZDtgW/2KrcFw5I7R2HRj/NigBuRwKPonvSZS36LnghZ7PYcXYZFGCjEgBmLUMMrLVgxccKLyxsgB0g==", "integrity": "sha512-V2p7CvpBtKWB+FID7Dl1y0Ci02zUT40A9b2RD9R9BOiuD8ZcKhHWov+irN0xVJA0Eg6OhEBfA0lPKRn1FNKPlw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "1.14.31", "@opencode-ai/sdk": "1.15.10",
"effect": "4.0.0-beta.57", "effect": "4.0.0-beta.66",
"zod": "4.1.8" "zod": "4.1.8"
}, },
"peerDependencies": { "peerDependencies": {
"@opentui/core": ">=0.2.0", "@opentui/core": ">=0.2.15",
"@opentui/solid": ">=0.2.0" "@opentui/keymap": ">=0.2.15",
"@opentui/solid": ">=0.2.15"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@opentui/core": { "@opentui/core": {
"optional": true "optional": true
}, },
"@opentui/keymap": {
"optional": true
},
"@opentui/solid": { "@opentui/solid": {
"optional": true "optional": true
} }
} }
}, },
"node_modules/@opencode-ai/sdk": { "node_modules/@opencode-ai/sdk": {
"version": "1.14.31", "version": "1.15.10",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.31.tgz", "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.15.10.tgz",
"integrity": "sha512-QaV+ti3NYUITmgIDqtNMqGIYBXJOx2zheN1g+7w4HC8QQsbaW1c7glxXExQHRbdUzcQPP2vUQhnXOcEsTw5CcQ==", "integrity": "sha512-CUhpmMGGOqzvPnNNjjWmEIodAfP6Qnuki2ChIUKWYF7UImZ4zUcMZnzO5BtUxu/Ni1P8qzWxDioXs+7aIZQEhA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cross-spawn": "7.0.6" "cross-spawn": "7.0.6"
@@ -149,9 +153,9 @@
} }
}, },
"node_modules/effect": { "node_modules/effect": {
"version": "4.0.0-beta.57", "version": "4.0.0-beta.66",
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.57.tgz", "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.66.tgz",
"integrity": "sha512-rg32VgXnLKaPRs9tbRDaZ5jxmzNY7ojXt85gSHGUTwdlbWH5Ik+OCUY2q14TXliygPGoHwCAvNWS4bQJOqf00g==", "integrity": "sha512-4arEr62cziFa8BBVDUwJCJJmaVepXf/kRg7KtC0h8+bufngscrHbwWFhr9c+HonwOF+31U3iD3xUJmw9KzX7Dw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.1.0", "@standard-schema/spec": "^1.1.0",
@@ -167,9 +171,9 @@
} }
}, },
"node_modules/fast-check": { "node_modules/fast-check": {
"version": "4.7.0", "version": "4.8.0",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz",
"integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", "integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@@ -216,9 +220,9 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/msgpackr": { "node_modules/msgpackr": {
"version": "1.11.10", "version": "1.11.12",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz", "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz",
"integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==", "integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==",
"license": "MIT", "license": "MIT",
"optionalDependencies": { "optionalDependencies": {
"msgpackr-extract": "^3.0.2" "msgpackr-extract": "^3.0.2"
@@ -323,9 +327,9 @@
} }
}, },
"node_modules/uuid": { "node_modules/uuid": {
"version": "13.0.1", "version": "13.0.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.1.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
"integrity": "sha512-9ezox2roIft6ExBVTVqibSd5dc5/47Sw/uY6b4SjQUT2TzQ0tltNquWA46y4xPQmdZYqvnio22SgWd41M86+jw==", "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
"funding": [ "funding": [
"https://github.com/sponsors/broofa", "https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan" "https://github.com/sponsors/ctavan"
@@ -351,9 +355,9 @@
} }
}, },
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.8.3", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"yaml": "bin.mjs" "yaml": "bin.mjs"

View File

@@ -1,14 +1,14 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
async function openEditModal(page: any) { async function openEditModal(page: any, transactionIndex: number = 0) {
// Navigate to transactions page // Navigate to transactions page
await page.goto('/transaction2'); await page.goto('/transaction2');
// Wait for the table to load // Wait for the table to load
await page.waitForSelector('table tbody tr'); await page.waitForSelector('table tbody tr');
// Find and click the edit button for the test transaction // Find and click the edit button for the specified transaction
const editButton = page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').first(); const editButton = page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(transactionIndex);
await editButton.click(); await editButton.click();
// Wait for the modal to open // Wait for the modal to open
@@ -137,6 +137,49 @@ async function addNewAccount(page: any) {
await page.waitForTimeout(500); await page.waitForTimeout(500);
} }
async function setAccountLocation(page: any, rowIndex: number, location: string) {
const row = await findAccountRow(page, rowIndex);
const locationSelect = row.locator('select[name*="location"]').first();
// If the option doesn't exist, add it (for testing invalid locations)
const optionExists = await locationSelect.locator(`option[value="${location}"]`).count() > 0;
if (!optionExists) {
await locationSelect.evaluate((el: HTMLSelectElement, value: string) => {
const option = document.createElement('option');
option.value = value;
option.textContent = value;
el.appendChild(option);
}, location);
}
await locationSelect.selectOption(location);
await locationSelect.dispatchEvent('change');
await page.waitForTimeout(300);
}
async function getAccountLocation(page: any, rowIndex: number): Promise<string> {
const row = await findAccountRow(page, rowIndex);
const locationSelect = row.locator('select[name*="location"]').first();
return await locationSelect.inputValue();
}
async function removeAllAccounts(page: any) {
const allRows = page.locator('#account-grid-body tbody tr');
const rowCount = await allRows.count();
for (let i = rowCount - 1; i >= 0; i--) {
const row = allRows.nth(i);
const hasAccountInput = await row.locator('input[name*="transaction-account/account"]').count() > 0;
if (hasAccountInput) {
// Click the X button to remove (it's an <a> tag, not a <button>)
const removeButton = row.locator('a').first();
await removeButton.click();
// Wait for the Alpine.js removal animation (500ms + buffer)
await page.waitForTimeout(700);
}
}
}
async function saveTransaction(page: any) { async function saveTransaction(page: any) {
// Submit the form directly instead of clicking the button // Submit the form directly instead of clicking the button
// The Done button might not have type="submit" // The Done button might not have type="submit"
@@ -175,6 +218,44 @@ async function toggleToDollarMode(page: any) {
test.describe.configure({ mode: 'serial' }); test.describe.configure({ mode: 'serial' });
test.describe('Transaction Edit Shared Location', () => {
test('should spread Shared location to client locations on save and display correctly on reopen', async ({ page }) => {
// Use the second transaction to avoid interfering with other tests
const transactionIndex = 1;
// Step 1: Open edit modal and add an account with Shared location
await openEditModal(page, transactionIndex);
// Add a new account row
await addNewAccount(page);
// Select the account
await selectAccountFromTypeahead(page, 0, 'Test');
// Set location to Shared
await setAccountLocation(page, 0, 'Shared');
// Set amount to $200 (the full transaction amount for the second transaction)
await setAccountAmount(page, 0, '200');
// Save the transaction
await saveTransaction(page);
// Step 2: Re-open and verify location is not "Shared" but the actual client location
await openEditModal(page, transactionIndex);
// Wait for accounts to load
await page.waitForTimeout(500);
// Get the location of the first account
const location = await getAccountLocation(page, 0);
// The location should be the actual client location ("DT" in test data), not "Shared"
expect(location).not.toBe('Shared');
expect(location).toBe('DT');
});
});
test.describe('Transaction Edit Full Workflow', () => { test.describe('Transaction Edit Full Workflow', () => {
test('should code transaction with vendor using percentage, then split 50/50, then switch to dollars', async ({ page }) => { test('should code transaction with vendor using percentage, then split 50/50, then switch to dollars', async ({ page }) => {
// Step 1: Open edit modal and code with 100% to one account // Step 1: Open edit modal and code with 100% to one account
@@ -246,14 +327,15 @@ test.describe('Transaction Edit Full Workflow', () => {
test.describe('Transaction Edit Validation', () => { test.describe('Transaction Edit Validation', () => {
test('should show validation error when account totals do not match transaction amount', async ({ page }) => { test('should show validation error when account totals do not match transaction amount', async ({ page }) => {
await openEditModal(page); // Use the third transaction to avoid interference from other tests
await openEditModal(page, 2);
// Stay in dollar mode (default) // Stay in dollar mode (default)
// Add an account // Add an account
await addNewAccount(page); await addNewAccount(page);
await selectAccountFromTypeahead(page, 0, 'Test'); await selectAccountFromTypeahead(page, 0, 'Test');
// Set amount to $50 (which doesn't match the $100 transaction) // Set amount to $50 (which doesn't match the $300 transaction)
await setAccountAmount(page, 0, '50'); await setAccountAmount(page, 0, '50');
// Try to save - this should fail because $50 != $100 // Try to save - this should fail because $50 != $100
@@ -279,6 +361,12 @@ test.describe('Transaction Edit Validation', () => {
const amountInput = page.locator('input[name*="transaction-account/amount"]').first(); const amountInput = page.locator('input[name*="transaction-account/amount"]').first();
const value = await amountInput.inputValue(); const value = await amountInput.inputValue();
expect(parseFloat(value)).toBeCloseTo(50.0, 1); expect(parseFloat(value)).toBeCloseTo(50.0, 1);
// Verify the user-friendly error message is displayed
const errorElement = page.locator('#form-errors .error-content');
await expect(errorElement).toBeVisible();
const errorText = await errorElement.textContent();
expect(errorText).toContain('The total of your expense accounts ($50.00) must equal the transaction amount ($300.00)');
}); });
}); });

5342
resources/public/js/htmx.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -21,25 +21,25 @@
[:li [:li
[:div {:class "flex items-center pl-2 rounded hover:bg-green-100 dark:hover:bg-green-600"} [:div {:class "flex items-center pl-2 rounded hover:bg-green-100 dark:hover:bg-green-600"}
(if group (if group
[:a {:href "#" :class "w-full py-2 ml-2 text-sm font-medium text-gray-900 rounded dark:text-gray-300" [:a {:href "#" :class "w-full py-2 ml-2 text-sm font-medium text-gray-900 rounded dark:text-gray-300"
:hx-put (bidi/path-for ssr-routes/only-routes :hx-put (bidi/path-for ssr-routes/only-routes
:active-client :active-client
:request-method :put) :request-method :put)
:hx-target "#company-dropdown" :hx-target "#company-dropdown"
:hx-headers (hx/json {"x-clients" (pr-str [:group group])}) :hx-headers (hx/json {"x-clients" (pr-str [:group group])})
"@click" (format "globalClientSelection={group: %s}" (hx/json group)) "@click" (format "globalClientSelection={group: %s}" (hx/json group))
:hx-swap "outerHTML" :hx-swap "outerHTML"
:hx-trigger "click"} :hx-trigger "click"}
name] name]
[:a {:href "#" :class "w-full py-2 ml-2 text-sm font-medium text-gray-900 rounded dark:text-gray-300" [:a {:href "#" :class "w-full py-2 ml-2 text-sm font-medium text-gray-900 rounded dark:text-gray-300"
:hx-put (bidi/path-for ssr-routes/only-routes :hx-put (bidi/path-for ssr-routes/only-routes
:active-client :active-client
:request-method :put) :request-method :put)
:hx-target "#company-dropdown" :hx-target "#company-dropdown"
:hx-headers (format "{\"x-clients\": \"[%d]\"}" id) :hx-headers (format "{\"x-clients\": \"[%d]\"}" id)
"@click" (format "globalClientSelection={selected: [%d]}" id) "@click" (format "globalClientSelection={selected: [%d]}" id)
:hx-swap "outerHTML" :hx-swap "outerHTML"
:hx-trigger "click"} :hx-trigger "click"}
name])]])]) name])]])])
@@ -49,7 +49,7 @@
cleansed-search-query (str "name:(" query ")") cleansed-search-query (str "name:(" query ")")
exec-search (fn [] exec-search (fn []
(for [n (pull-many (dc/db conn) [:client/name :db/id] (for [n (pull-many (dc/db conn) [:client/name :db/id]
(for [{:keys [id name]} (solr/query solr/impl "clients" {"query" cleansed-search-query (for [{:keys [id name]} (solr/query solr/impl "clients" {"query" cleansed-search-query
"fields" "id, name"}) "fields" "id, name"})
:let [client-id (Long/parseLong id)] :let [client-id (Long/parseLong id)]
:when (can-see-client? identity client-id)] :when (can-see-client? identity client-id)]
@@ -61,15 +61,17 @@
:name (str "All clients matching " (subs query 2))}] :name (str "All clients matching " (subs query 2))}]
raw-query raw-query
(let [code-matches (for [n (pull-many (dc/db conn) [:client/name :db/id] (let [exact-code-matches (for [n (pull-many (dc/db conn) [:client/name :db/id]
(for [{:keys [id name]} (solr/query solr/impl "clients" {"query" (format "code:\"%s\"" raw-query) (for [client-id (map first (dc/q '[:find ?e
"fields" "id, name"}) :in $ ?code
:let [client-id (Long/parseLong id)] :where [?e :client/code ?code]]
:when (can-see-client? identity client-id)] (dc/db conn)
client-id))] query))
{:id (:db/id n) :when (can-see-client? identity client-id)]
:name (:client/name n)})] client-id))]
(or (seq code-matches) (exec-search))) {:id (:db/id n)
:name (:client/name n)})]
(or (seq exact-code-matches) (exec-search)))
cleansed-query cleansed-query
(exec-search)))) (exec-search))))
@@ -93,7 +95,7 @@
[:div [:div
[:button#company-dropdown-button {:class "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2.5 text-center inline-flex items-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800" [:button#company-dropdown-button {:class "text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2.5 text-center inline-flex items-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
"x-tooltip.on.click" "{content: ()=>$refs.tooltip.innerHTML, theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}" "x-tooltip.on.click" "{content: ()=>$refs.tooltip.innerHTML, theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}"
:type "button"} :type "button"}
(cond (cond
(= :mine client-selection) (= :mine client-selection)
"My Companies" "My Companies"
@@ -119,43 +121,43 @@
svg/search]] svg/search]]
[:input#company-search {:placeholder "Company name" [:input#company-search {:placeholder "Company name"
:x-ref "company" :x-ref "company"
:name "search-text" :name "search-text"
:class "block w-full p-2 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" :class "block w-full p-2 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
:autoFocus true :autoFocus true
:tab-index -1 :tab-index -1
:hx-trigger "keyup changed delay:500ms, search" :hx-trigger "keyup changed delay:500ms, search"
:hx-get (bidi/path-for ssr-routes/only-routes :hx-get (bidi/path-for ssr-routes/only-routes
:company-dropdown-search-results) :company-dropdown-search-results)
:hx-target "#company-search-results" :hx-target "#company-search-results"
:hx-swap "innerHTML"}]] :hx-swap "innerHTML"}]]
[:input#company-search-value {:type "hidden" [:input#company-search-value {:type "hidden"
:name "x-clients"}]] :name "x-clients"}]]
[:div.divide-y.divide-gray-100 [:div.divide-y.divide-gray-100
[:div#company-search-results {:class "h-48 px-3 pb-3 overflow-y-auto text-sm text-gray-700 dark:text-gray-200"}] [:div#company-search-results {:class "h-48 px-3 pb-3 overflow-y-auto text-sm text-gray-700 dark:text-gray-200"}]
(when (= "admin" (:user/role identity)) (when (= "admin" (:user/role identity))
[:div {:class "flex items-center pl-2 rounded hover:bg-green-100 dark:hover:bg-green-600"} [:div {:class "flex items-center pl-2 rounded hover:bg-green-100 dark:hover:bg-green-600"}
[:button {:class "w-full py-2 ml-2 text-sm font-medium text-gray-900 rounded dark:text-gray-300" [:button {:class "w-full py-2 ml-2 text-sm font-medium text-gray-900 rounded dark:text-gray-300"
:hx-put (bidi/path-for ssr-routes/only-routes :hx-put (bidi/path-for ssr-routes/only-routes
:active-client :active-client
:request-method :put) :request-method :put)
:hx-target "#company-dropdown" :hx-target "#company-dropdown"
"@click" "globalClientSelection=\"mine\"" "@click" "globalClientSelection=\"mine\""
:hx-headers "{\"x-clients\": \":mine\"}" :hx-headers "{\"x-clients\": \":mine\"}"
:hx-swap "outerHTML" :hx-swap "outerHTML"
:hx-trigger "click"} :hx-trigger "click"}
"Mine"]]) "Mine"]])
[:div {:class "flex items-center pl-2 rounded hover:bg-green-100 dark:hover:bg-green-600"} [:div {:class "flex items-center pl-2 rounded hover:bg-green-100 dark:hover:bg-green-600"}
[:button {:class "w-full py-2 ml-2 text-sm font-medium text-gray-900 rounded dark:text-gray-300" [:button {:class "w-full py-2 ml-2 text-sm font-medium text-gray-900 rounded dark:text-gray-300"
:hx-put (bidi/path-for ssr-routes/only-routes :hx-put (bidi/path-for ssr-routes/only-routes
:active-client :active-client
:request-method :put) :request-method :put)
:hx-target "#company-dropdown" :hx-target "#company-dropdown"
"@click" "globalClientSelection=\"all\"" "@click" "globalClientSelection=\"all\""
:hx-headers "{\"x-clients\": \":all\"}" :hx-headers "{\"x-clients\": \":all\"}"
:hx-swap "outerHTML" :hx-swap "outerHTML"
:hx-trigger "click"} :hx-trigger "click"}
"All"]]]]] "All"]]]]]
[:script {:lang "text/javascript"} [:script {:lang "text/javascript"}
@@ -188,7 +190,7 @@ function initCompanyDropdown() {
(defn active-client [{:keys [identity params] :as request}] (defn active-client [{:keys [identity params] :as request}]
(assoc (assoc
(html-response (html-response
(dropdown {:client-selection (:client-selection request) (dropdown {:client-selection (:client-selection request)
:clients (:clients request) :clients (:clients request)
:client (:client request) :client (:client request)
:identity identity})) :identity identity}))

View File

@@ -215,11 +215,11 @@
(fn [{:keys [wizard multi-form-state] :as request}] (fn [{:keys [wizard multi-form-state] :as request}]
(assert-schema (step-schema (get-current-step wizard)) (:step-params multi-form-state)) (assert-schema (step-schema (get-current-step wizard)) (:step-params multi-form-state))
(handler request)) (handler request))
(wrap-form-4xx-2 (fn [{:keys [wizard] :as request}] ;; THIS MAY BE BETTER TO JUST MAKE THE LINEAR WIZARD POPULATE FROM THE REQUEST (wrap-form-4xx-2 (fn [{:keys [wizard] :as request}] ;; THIS MAY BE BETTER TO JUST MAKE THE LINEAR WIZARD POPULATE FROM THE REQUEST
(html-response (html-response
(render-wizard wizard request) (render-wizard wizard request)
:headers {"x-transition-type" "none" :headers {"x-transition-type" "none"
"HX-reswap" "outerHTML"}))))) "HX-reswap" "outerHTML"})))))
(defn get-transition-type [wizard from-step-key to-step-key] (defn get-transition-type [wizard from-step-key to-step-key]
(let [to-step-index (.indexOf (steps wizard) to-step-key) (let [to-step-index (.indexOf (steps wizard) to-step-key)

View File

@@ -161,7 +161,7 @@
[["Shared" "Shared"]]))] [["Shared" "Shared"]]))]
(com/select {:options options (com/select {:options options
:name name :name name
:value (ffirst options) :value (or value (ffirst options))
:class "w-full"}))) :class "w-full"})))
(defn account-typeahead* (defn account-typeahead*
@@ -313,7 +313,9 @@
(filter number?) (filter number?)
(reduce + 0.0)) (reduce + 0.0))
balance (- balance (-
(Math/abs (-> request :multi-form-state :snapshot :transaction/amount)) (Math/abs (or (-> request :entity :transaction/amount)
(-> request :multi-form-state :snapshot :transaction/amount)
0.0))
total)] total)]
[:span {:class (when-not (dollars= 0.0 balance) [:span {:class (when-not (dollars= 0.0 balance)
"text-red-300")} "text-red-300")}
@@ -355,7 +357,9 @@
(defn account-grid-body* [request] (defn account-grid-body* [request]
(let [snapshot (-> request :multi-form-state :snapshot) (let [snapshot (-> request :multi-form-state :snapshot)
amount-mode (or (:amount-mode snapshot) "$") amount-mode (or (:amount-mode snapshot) "$")
total (Math/abs (or (:transaction/amount snapshot) 0.0))] total (Math/abs (or (-> request :entity :transaction/amount)
(:transaction/amount snapshot)
0.0))]
(com/data-grid {:headers [(com/data-grid-header {} "Account") (com/data-grid {:headers [(com/data-grid-header {} "Account")
(com/data-grid-header {:class "w-32"} "Location") (com/data-grid-header {:class "w-32"} "Location")
(com/data-grid-header {:class "w-16"} (com/data-grid-header {:class "w-16"}
@@ -913,7 +917,7 @@
[:div {:x-show "activeForm === 'manual'", :x-transition:enter "transition ease-out duration-500", :x-transition:enter-start "opacity-0 transform scale-95", :x-transition:enter-end "opacity-100 transform scale-100"} [:div {:x-show "activeForm === 'manual'", :x-transition:enter "transition ease-out duration-500", :x-transition:enter-start "opacity-0 transform scale-95", :x-transition:enter-end "opacity-100 transform scale-100"}
[:div {} [:div {}
[:div {:hx-trigger "change" [:div {:hx-trigger "change"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed) :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed)
:hx-target "#account-grid-body" :hx-target "#account-grid-body"
:hx-swap "outerHTML" :hx-swap "outerHTML"
:hx-include "closest form"} :hx-include "closest form"}
@@ -1246,14 +1250,16 @@
(when (and (= :transaction-approval-status/approved (keyword (:transaction/approval-status tx-data))) (when (and (= :transaction-approval-status/approved (keyword (:transaction/approval-status tx-data)))
(not (seq (:transaction/accounts tx-data)))) (not (seq (:transaction/accounts tx-data))))
(throw (ex-info "Approved transactions must have accounts assigned." (throw (ex-info "Approved transactions must have accounts assigned."
{:validation-error "Approved transactions must have accounts assigned."}))) {:type :form-validation
:form-validation-errors ["Approved transactions must have accounts assigned."]})))
(when (seq (:transaction/accounts tx-data)) (when (seq (:transaction/accounts tx-data))
(let [account-total (reduce + 0 (map :transaction-account/amount (:transaction/accounts tx-data)))] (let [account-total (reduce + 0 (map :transaction-account/amount (:transaction/accounts tx-data)))
(when (not (dollars= (Math/abs (:transaction/amount existing-tx)) account-total)) tx-amount (Math/abs (:transaction/amount existing-tx))]
(throw (ex-info (str "Account total (" account-total ") does not equal transaction amount (" (when (not (dollars= tx-amount account-total))
(Math/abs (:transaction/amount existing-tx)) ")") (throw (ex-info (format "The total of your expense accounts ($%,.2f) must equal the transaction amount ($%,.2f)." account-total tx-amount)
{:validation-error "Account totals must match transaction amount."}))))) {:type :form-validation
:form-validation-errors [(format "The total of your expense accounts ($%,.2f) must equal the transaction amount ($%,.2f)." account-total tx-amount)]})))))
(let [transaction-result (audit-transact [transaction] (:identity request))] (let [transaction-result (audit-transact [transaction] (:identity request))]
(try (try
@@ -1348,8 +1354,6 @@
(mm/get-step this current-step) (mm/get-step this current-step)
(mm/get-step this :basic-details))) (mm/get-step this :basic-details)))
(render-wizard [this {:keys [multi-form-state] :as request}] (render-wizard [this {:keys [multi-form-state] :as request}]
(println "HERE XYZ" (:form-errors request))
(clojure.pprint/pprint (:snapshot multi-form-state))
(mm/default-render-wizard (mm/default-render-wizard
this request this request
:form-params :form-params
@@ -1394,34 +1398,40 @@
[] []
entity))) entity)))
(defn- render-account-grid-body [request]
(fc/start-form (:multi-form-state request) nil
(fc/with-field :step-params
(fc/with-field :transaction/accounts
(account-grid-body* request)))))
(defn edit-vendor-changed-handler [request] (defn edit-vendor-changed-handler [request]
(let [snapshot (:snapshot (:multi-form-state request)) (let [multi-form-state (:multi-form-state request)
snapshot (:snapshot multi-form-state)
step-params (:step-params multi-form-state)
client-id (or (:transaction/client snapshot) client-id (or (:transaction/client snapshot)
(-> request :entity :transaction/client :db/id)) (-> request :entity :transaction/client :db/id))
vendor-id (:transaction/vendor snapshot) vendor-id (or (:transaction/vendor step-params) (:transaction/vendor snapshot))
total (Math/abs (or (:transaction/amount snapshot) 0.0)) total (Math/abs (or (-> request :entity :transaction/amount)
amount-mode (or (:amount-mode snapshot) "$")] (:transaction/amount snapshot)
(if (and (empty? (:transaction/accounts snapshot)) 0.0))
vendor-id amount-mode (or (:amount-mode snapshot) "$")
client-id) existing-accounts (or (seq (:transaction/accounts step-params))
(if-let [default-account (vendor-default-account vendor-id client-id)] (seq (:transaction/accounts snapshot)))
(let [new-account {:db/id (str (java.util.UUID/randomUUID)) default-account (when (and (empty? existing-accounts) vendor-id client-id)
:transaction-account/account (:db/id default-account) (vendor-default-account vendor-id client-id))
:transaction-account/location (or (:account/location default-account) "Shared")} render-request
new-account (if (= amount-mode "%") (if (and (empty? existing-accounts) vendor-id client-id)
(assoc new-account :transaction-account/amount 100.0) (let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID))
(assoc new-account :transaction-account/amount total)) :transaction-account/location (or (:account/location default-account) "Shared")
updated-snapshot (assoc snapshot :transaction/accounts [new-account]) :transaction-account/amount (if (= amount-mode "%") 100.0 total)}
updated-request (assoc-in request [:multi-form-state :snapshot] updated-snapshot)] default-account (assoc :transaction-account/account (:db/id default-account)))]
(html-response (-> request
[:div#account-grid-body (assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account])
(account-grid-body* updated-request)])) (assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account])))
(html-response request)]
[:div#account-grid-body (html-response
(account-grid-body* request)])) [:div#account-grid-body
(html-response (render-account-grid-body render-request)])))
[:div#account-grid-body
(account-grid-body* request)]))))
(def key->handler (def key->handler
(apply-middleware-to-all-handlers (apply-middleware-to-all-handlers
@@ -1443,6 +1453,7 @@
(mm/wrap-decode-multi-form-state)) (mm/wrap-decode-multi-form-state))
::route/edit-vendor-changed (-> edit-vendor-changed-handler ::route/edit-vendor-changed (-> edit-vendor-changed-handler
(mm/wrap-wizard edit-wizard) (mm/wrap-wizard edit-wizard)
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
(mm/wrap-decode-multi-form-state)) (mm/wrap-decode-multi-form-state))
::route/location-select (-> location-select ::route/location-select (-> location-select
(wrap-schema-enforce :query-schema [:map (wrap-schema-enforce :query-schema [:map
@@ -1453,12 +1464,15 @@
[:maybe entity-id]]])) [:maybe entity-id]]]))
::route/account-total (-> account-total ::route/account-total (-> account-total
(mm/wrap-wizard edit-wizard) (mm/wrap-wizard edit-wizard)
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
(mm/wrap-decode-multi-form-state)) (mm/wrap-decode-multi-form-state))
::route/account-balance (-> account-balance ::route/account-balance (-> account-balance
(mm/wrap-wizard edit-wizard) (mm/wrap-wizard edit-wizard)
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
(mm/wrap-decode-multi-form-state)) (mm/wrap-decode-multi-form-state))
::route/toggle-amount-mode (-> toggle-amount-mode ::route/toggle-amount-mode (-> toggle-amount-mode
(mm/wrap-wizard edit-wizard) (mm/wrap-wizard edit-wizard)
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
(mm/wrap-decode-multi-form-state)) (mm/wrap-decode-multi-form-state))
::route/edit-wizard-new-account (-> ::route/edit-wizard-new-account (->
(add-new-entity-handler [:step-params :transaction/accounts] (add-new-entity-handler [:step-params :transaction/accounts]

View File

@@ -30,7 +30,7 @@
[:script {:src "https://cdn.jsdelivr.net/npm/@ryangjchandler/alpine-tooltip@1.x.x/dist/cdn.min.js" :defer true}] [:script {:src "https://cdn.jsdelivr.net/npm/@ryangjchandler/alpine-tooltip@1.x.x/dist/cdn.min.js" :defer true}]
[:link {:rel "stylesheet" :href "/css/tippy/tippy.css"}] [:link {:rel "stylesheet" :href "/css/tippy/tippy.css"}]
[:link {:rel "stylesheet" :href "/css/tippy/light.css"}] [:link {:rel "stylesheet" :href "/css/tippy/light.css"}]
[:script {:src "/js/htmx.min.js" [:script {:src "/js/htmx.js"
:crossorigin= "anonymous"}] :crossorigin= "anonymous"}]
[:script {:src "https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"}] [:script {:src "https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"}]