diff --git a/docs/superpowers/specs/2026-05-21-bulk-code-transactions-requirements.md b/docs/superpowers/specs/2026-05-21-bulk-code-transactions-requirements.md new file mode 100644 index 00000000..19dd6fc5 --- /dev/null +++ b/docs/superpowers/specs/2026-05-21-bulk-code-transactions-requirements.md @@ -0,0 +1,85 @@ +# Bulk Coding Transactions - Requirements Document + +Based on analysis of the master cljs implementation (`src/cljs/auto_ap/views/pages/transactions/bulk_updates.cljs`) and GraphQL resolver (`src/clj/auto_ap/graphql/transactions.clj`). + +## Feature Overview + +Bulk coding allows admin users to apply vendor, approval status, and expense account allocations to multiple transactions simultaneously from the transactions grid page. + +## Functional Requirements + +### 1. Access Control +- **FR-1.1**: Bulk coding must be restricted to admin users only +- **FR-1.2**: The bulk code button should only be visible/enabled when transactions are selected + +### 2. Transaction Selection +- **FR-2.1**: Users can select specific transactions via checkboxes in the grid +- **FR-2.2**: Users can select all visible transactions via a header checkbox +- **FR-2.3**: The system must filter out locked transactions (where client's `locked-until` date is after transaction date) +- **FR-2.4**: The modal must display the count of transactions that will actually be coded (after filtering locked ones) + +### 3. Bulk Code Form Fields +- **FR-3.1**: **Vendor** (optional): Searchable typeahead to select a vendor +- **FR-3.2**: **Approval Status** (optional): Select from: + - No Change (empty) + - Approved + - Unapproved + - Suppressed + - Requires Feedback +- **FR-3.3**: **Expense Accounts** (optional): One or more account allocations with: + - Account: Searchable typeahead for expense accounts + - Location: Dropdown with "Shared" and client-specific locations + - Percentage: Numeric input (0-100), must total exactly 100% across all accounts + +### 4. Account Location Validation +- **FR-4.1**: If an account has a fixed location configured, the selected location MUST match it +- **FR-4.2**: If an account has no fixed location, the selected location must be either "Shared" or one of the client's locations +- **FR-4.3**: Invalid locations must be rejected with a clear error message + +### 5. Percentage Validation +- **FR-5.1**: When accounts are provided, the sum of all percentages must equal exactly 100% +- **FR-5.2**: Values must be between 0 and 100 +- **FR-5.3**: Invalid totals must be rejected with a clear error message showing the actual total + +### 6. Amount Distribution +- **FR-6.1**: Percentages are converted to dollar amounts per transaction based on each transaction's amount +- **FR-6.2**: For "Shared" location, amounts are distributed evenly across all client locations (with proper cent handling) +- **FR-6.3**: Rounding errors are absorbed by the last account row +- **FR-6.4**: Each transaction gets its own set of transaction-account entities + +### 7. Submission Behavior +- **FR-7.1**: Submitting with no accounts, no vendor, and no status should be a no-op (or rejected) +- **FR-7.2**: On success, all selected non-locked transactions are updated +- **FR-7.3**: Success response triggers a table refresh +- **FR-7.4**: Modal closes on success + +## UI/UX Requirements (from Master) + +### SSR-Specific Adaptations +- The SSR version uses a modal wizard with HTMX instead of a re-frame modal +- Form state is managed server-side via `multi-form-state` +- Percentage inputs display as whole numbers (50 for 50%) but are stored as decimals (0.5) + +## Test Scenarios + +### Happy Path +1. Select single transaction, code with 100% to one account +2. Select multiple transactions, code with vendor + status + accounts +3. Select all visible transactions via header checkbox + +### Validation +4. Submit without any changes (no vendor, no status, no accounts) +5. Submit with accounts totaling < 100% +6. Submit with accounts totaling > 100% +7. Submit with invalid location for account +8. Submit with location not belonging to client + +### Edge Cases +9. All selected transactions are locked (count should be 0) +10. Mix of locked and unlocked transactions (only unlocked should be coded) +11. "Shared" location distributes across multiple client locations + +## Known Issues to Verify + +1. **Missing location validation**: The SSR version (`bulk_code.clj`) does not validate account locations against client locations or account fixed locations (present in GraphQL version) +2. **Approval status options**: Verify "excluded" vs "suppressed" naming consistency diff --git a/e2e/bulk-code-transactions.spec.ts b/e2e/bulk-code-transactions.spec.ts new file mode 100644 index 00000000..90dec175 --- /dev/null +++ b/e2e/bulk-code-transactions.spec.ts @@ -0,0 +1,387 @@ +import { test, expect } from '@playwright/test'; + +let testInfoCache: any = null; + +async function getTestInfo(page: any) { + const response = await page.request.get('/test-info'); + testInfoCache = await response.json(); + return testInfoCache; +} + +async function navigateToTransactions(page: any) { + await page.goto('/transaction2'); + await page.waitForSelector('table tbody tr'); +} + +async function selectTransactionByIndex(page: any, index: number) { + const rows = page.locator('table tbody tr'); + const row = rows.nth(index); + const checkbox = row.locator('input[type="checkbox"][name="id"]').first(); + await checkbox.click(); + await page.waitForTimeout(200); +} + +async function selectAllTransactions(page: any) { + const headerCheckbox = page.locator('input#checkbox-all').first(); + await headerCheckbox.click(); + await page.waitForTimeout(200); +} + +async function openBulkCodeModal(page: any) { + const codeButton = page.locator('button:has-text("Code")').first(); + await codeButton.click(); + await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' }); + await page.waitForSelector('#wizardmodal'); +} + +async function closeBulkCodeModal(page: any) { + // The success response swaps the modal content, but the modal holder stays open + // Wait for the success message to appear + await page.waitForSelector('text=Successfully coded', { timeout: 10000 }); +} + +async function selectAccountFromTypeahead(page: any, rowIndex: number, accountKey: string) { + const testInfo = await getTestInfo(page); + const accountId = testInfo.accounts[accountKey]; + + if (!accountId) { + throw new Error(`Could not find account with key ${accountKey}`); + } + + const allRows = page.locator('#account-entries tbody tr'); + const rowCount = await allRows.count(); + + let accountRow = null; + let accountRowIndex = 0; + for (let i = 0; i < rowCount; i++) { + const row = allRows.nth(i); + const hasAccountInput = await row.locator('input[name*="account"]').count() > 0; + if (hasAccountInput) { + if (accountRowIndex === rowIndex) { + accountRow = row; + break; + } + accountRowIndex++; + } + } + + if (!accountRow) { + throw new Error(`Could not find account row at index ${rowIndex}`); + } + + const hiddenInput = accountRow.locator('input[type="hidden"][name*="[account]"]').first(); + + await hiddenInput.evaluate((el: HTMLInputElement, value: string) => { + // Replace the Alpine-managed hidden input with a plain one + const newInput = document.createElement('input'); + newInput.type = 'hidden'; + newInput.name = el.name; + newInput.value = value; + + el.parentNode.replaceChild(newInput, el); + }, accountId.toString()); + + await page.waitForTimeout(300); + + // Trigger the location select reload by dispatching 'changed' event + const locationContainer = accountRow.locator('[x-dispatch\\:changed]').first(); + if (await locationContainer.count() > 0) { + await locationContainer.evaluate((el: HTMLElement) => { + el.dispatchEvent(new CustomEvent('changed', { bubbles: true })); + }); + await page.waitForTimeout(500); + } +} + +async function findAccountRow(page: any, rowIndex: number) { + const allRows = page.locator('#account-entries tbody tr'); + const rowCount = await allRows.count(); + + let accountRowIndex = 0; + for (let i = 0; i < rowCount; i++) { + const row = allRows.nth(i); + const hasAccountInput = await row.locator('input[name*="account"]').count() > 0; + if (hasAccountInput) { + if (accountRowIndex === rowIndex) { + return row; + } + accountRowIndex++; + } + } + + throw new Error(`Could not find account row at index ${rowIndex}`); +} + +async function setAccountPercentage(page: any, rowIndex: number, percentage: string) { + const row = await findAccountRow(page, rowIndex); + const percentageInput = row.locator('input[name*="percentage"]').first(); + await percentageInput.fill(percentage); + await percentageInput.dispatchEvent('change'); + await page.waitForTimeout(300); +} + +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 addNewAccount(page: any) { + const newAccountButton = page.locator('a:has-text("New account")').first(); + await newAccountButton.click(); + await page.waitForTimeout(500); +} + +async function submitBulkCodeForm(page: any) { + const form = page.locator('#wizard-form'); + await form.evaluate((el: HTMLFormElement) => { + el.dispatchEvent(new Event('submit', { bubbles: true })); + }); +} + +async function getModalErrorText(page: any) { + const errorElement = page.locator('#form-errors .error-content'); + try { + await errorElement.waitFor({ state: 'visible', timeout: 3000 }); + return await errorElement.textContent(); + } catch { + return null; + } +} + +test.describe.configure({ mode: 'serial' }); + +test.describe('Bulk Code Transactions - Happy Path', () => { + test('should bulk code a single transaction with vendor, status, and 100% account', async ({ page }) => { + await navigateToTransactions(page); + await selectTransactionByIndex(page, 0); + await openBulkCodeModal(page); + + // Verify modal shows correct count + await expect(page.locator('text=Bulk editing 1 transactions')).toBeVisible(); + + // Select vendor + const vendorHidden = page.locator('input[type="hidden"][name="step-params[vendor]"]').first(); + const testInfo = await getTestInfo(page); + await vendorHidden.evaluate((el: HTMLInputElement, value: string) => { + const newInput = document.createElement('input'); + newInput.type = 'hidden'; + newInput.name = el.name; + newInput.value = value; + el.parentNode.replaceChild(newInput, el); + }, testInfo.accounts.vendor.toString()); + await page.waitForTimeout(300); + + // Select approval status + const statusSelect = page.locator('select[name="step-params[approval-status]"]').first(); + await statusSelect.selectOption('approved'); + + // Add account + await addNewAccount(page); + await selectAccountFromTypeahead(page, 0, 'test-account'); + await setAccountLocation(page, 0, 'Shared'); + await setAccountPercentage(page, 0, '100'); + + // Submit + await submitBulkCodeForm(page); + await closeBulkCodeModal(page); + + // Verify success by checking table refreshed + await page.waitForSelector('table tbody tr'); + }); + + test('should bulk code multiple selected transactions', async ({ page }) => { + await navigateToTransactions(page); + await selectTransactionByIndex(page, 0); + await selectTransactionByIndex(page, 1); + await openBulkCodeModal(page); + + // Should show count of selected transactions + await expect(page.locator('text=Bulk editing 2 transactions')).toBeVisible(); + + // Add account at 100% + await addNewAccount(page); + await selectAccountFromTypeahead(page, 0, 'test-account'); + await setAccountLocation(page, 0, 'Shared'); + await setAccountPercentage(page, 0, '100'); + + await submitBulkCodeForm(page); + await closeBulkCodeModal(page); + + await page.waitForSelector('table tbody tr'); + }); + + test('should bulk code all visible transactions via header checkbox', async ({ page }) => { + await navigateToTransactions(page); + await selectAllTransactions(page); + await openBulkCodeModal(page); + + // Should show all transactions + await expect(page.locator('text=Bulk editing 3 transactions')).toBeVisible(); + + // Add account at 100% + await addNewAccount(page); + await selectAccountFromTypeahead(page, 0, 'test-account'); + await setAccountLocation(page, 0, 'Shared'); + await setAccountPercentage(page, 0, '100'); + + await submitBulkCodeForm(page); + await closeBulkCodeModal(page); + + await page.waitForSelector('table tbody tr'); + }); +}); + +test.describe('Bulk Code Transactions - Validation', () => { + test('should reject when no vendor, status, or accounts provided', async ({ page }) => { + await navigateToTransactions(page); + await selectTransactionByIndex(page, 0); + await openBulkCodeModal(page); + + // Submit without any changes + await submitBulkCodeForm(page); + await page.waitForTimeout(1000); + + // Modal should still be open + await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible(); + }); + + test('should reject when account percentages total less than 100%', async ({ page }) => { + await navigateToTransactions(page); + await selectTransactionByIndex(page, 0); + await openBulkCodeModal(page); + + await addNewAccount(page); + await selectAccountFromTypeahead(page, 0, 'test-account'); + await setAccountLocation(page, 0, 'Shared'); + await setAccountPercentage(page, 0, '50'); + + await submitBulkCodeForm(page); + await page.waitForTimeout(1000); + + // Modal should still be open + await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible(); + + // Should show validation error + const errorText = await getModalErrorText(page); + expect(errorText).toContain('does not equal 100%'); + }); + + test('should reject when account percentages total more than 100%', async ({ page }) => { + await navigateToTransactions(page); + await selectTransactionByIndex(page, 0); + await openBulkCodeModal(page); + + await addNewAccount(page); + await selectAccountFromTypeahead(page, 0, 'test-account'); + await setAccountLocation(page, 0, 'Shared'); + await setAccountPercentage(page, 0, '150'); + + await submitBulkCodeForm(page); + await page.waitForTimeout(1000); + + // Modal should still be open + await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible(); + }); + + test('should reject invalid location for account with fixed location', async ({ page }) => { + await navigateToTransactions(page); + await selectTransactionByIndex(page, 0); + await openBulkCodeModal(page); + + await addNewAccount(page); + // Use the fixed-location account + await selectAccountFromTypeahead(page, 0, 'fixed-location-account'); + // Try to set wrong location (account is fixed to "DT", try "INVALID") + await setAccountLocation(page, 0, 'INVALID'); + await setAccountPercentage(page, 0, '100'); + + await submitBulkCodeForm(page); + await page.waitForTimeout(1000); + + // Modal should still be open + await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible(); + + // Should show validation error about location mismatch + const errorText = await getModalErrorText(page); + expect(errorText).toContain('location'); + }); + + test('should reject location not belonging to client', async ({ page }) => { + await navigateToTransactions(page); + await selectTransactionByIndex(page, 0); + await openBulkCodeModal(page); + + await addNewAccount(page); + await selectAccountFromTypeahead(page, 0, 'test-account'); + // Client only has "DT" location, try "GR" + await setAccountLocation(page, 0, 'GR'); + await setAccountPercentage(page, 0, '100'); + + await submitBulkCodeForm(page); + await page.waitForTimeout(1000); + + // Modal should still be open + await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible(); + + // Should show validation error + const errorText = await getModalErrorText(page); + expect(errorText).toContain('location'); + }); +}); + +test.describe('Bulk Code Transactions - Account Distribution', () => { + test('should split 50/50 between two accounts', async ({ page }) => { + await navigateToTransactions(page); + await selectTransactionByIndex(page, 0); + await openBulkCodeModal(page); + + // First account at 50% + await addNewAccount(page); + await selectAccountFromTypeahead(page, 0, 'test-account'); + await setAccountLocation(page, 0, 'DT'); + await setAccountPercentage(page, 0, '50'); + + // Second account at 50% + await addNewAccount(page); + await selectAccountFromTypeahead(page, 1, 'second-account'); + await setAccountLocation(page, 1, 'DT'); + await setAccountPercentage(page, 1, '50'); + + await submitBulkCodeForm(page); + await closeBulkCodeModal(page); + + await page.waitForSelector('table tbody tr'); + }); + + test('should allow Shared location for account without fixed location', async ({ page }) => { + await navigateToTransactions(page); + await selectTransactionByIndex(page, 0); + await openBulkCodeModal(page); + + await addNewAccount(page); + await selectAccountFromTypeahead(page, 0, 'test-account'); + // "Shared" should be valid for accounts without fixed location + await setAccountLocation(page, 0, 'Shared'); + await setAccountPercentage(page, 0, '100'); + + await submitBulkCodeForm(page); + await closeBulkCodeModal(page); + + await page.waitForSelector('table tbody tr'); + }); +}); diff --git a/e2e/debug-exact.spec.ts b/e2e/debug-exact.spec.ts deleted file mode 100644 index 2bb905a5..00000000 --- a/e2e/debug-exact.spec.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { test, expect } from '@playwright/test'; - -async function openEditModal(page: any) { - await page.goto('/transaction2'); - await page.waitForSelector('table tbody tr'); - const editButton = page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').first(); - await editButton.click(); - await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' }); - await page.waitForSelector('#wizardmodal'); - await page.click('button:has-text("Transaction Actions")'); - await page.waitForSelector('text=Transaction Actions', { state: 'visible' }); - await page.click('button:has-text("Manual")'); - await page.waitForSelector('#account-grid-body'); -} - -async function addNewAccount(page: any) { - await page.click('text=New account'); - await page.waitForTimeout(500); -} - -async function selectAccountFromTypeahead(page: any, rowIndex: number, accountName: string) { - const allRows = page.locator('#account-grid-body tbody tr'); - const rowCount = await allRows.count(); - - let accountRow = null; - let accountRowIndex = 0; - for (let i = 0; i < rowCount; i++) { - const row = allRows.nth(i); - const hasAccountInput = await row.locator('input[name*="transaction-account/account"]').count() > 0; - if (hasAccountInput) { - if (accountRowIndex === rowIndex) { - accountRow = row; - break; - } - accountRowIndex++; - } - } - - if (!accountRow) { - throw new Error(`Could not find account row at index ${rowIndex}`); - } - - const hiddenInput = accountRow.locator('input[type="hidden"][name*="transaction-account/account"]').first(); - - const testInfoResponse = await page.request.get('/test-info'); - const testInfo = await testInfoResponse.json(); - const accountKey = accountName === 'Test' ? 'test-account' : 'second-account'; - const accountId = testInfo.accounts[accountKey]; - - await hiddenInput.evaluate((el: HTMLInputElement, value: string) => { - el.value = value; - const alpineEl = el.closest('[x-data]'); - if (alpineEl && (alpineEl as any).__x) { - (alpineEl as any).__x.$data.value.value = parseInt(value); - (alpineEl as any).__x.$data.value.label = 'Selected Account'; - } - const rowEl = el.closest('tr[x-data]'); - if (rowEl && (rowEl as any).__x) { - (rowEl as any).__x.$data.accountId = parseInt(value); - } - el.dispatchEvent(new Event('change', { bubbles: true })); - }, accountId.toString()); - - await page.waitForTimeout(300); -} - -async function setAccountAmount(page: any, rowIndex: number, amount: string) { - const allRows = page.locator('#account-grid-body tbody tr'); - const rowCount = await allRows.count(); - - let accountRowIndex = 0; - for (let i = 0; i < rowCount; i++) { - const row = allRows.nth(i); - const hasAccountInput = await row.locator('input[name*="transaction-account/account"]').count() > 0; - if (hasAccountInput) { - if (accountRowIndex === rowIndex) { - const amountInput = row.locator('input[name*="transaction-account/amount"]').first(); - await amountInput.fill(amount); - await amountInput.dispatchEvent('change'); - await page.waitForTimeout(300); - return; - } - accountRowIndex++; - } - } - - throw new Error(`Could not find account row at index ${rowIndex}`); -} - -async function toggleToPercentMode(page: any) { - const percentRadio = page.locator('input[name="step-params[amount-mode]"][value="%"]'); - await percentRadio.click(); - await page.waitForResponse(response => - response.url().includes('/toggle-amount-mode') && response.status() === 200 - ); - await page.waitForTimeout(200); -} - -async function saveTransaction(page: any) { - await page.click('button:has-text("Done")'); - await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'hidden', timeout: 10000 }); -} - -test('exact workflow', async ({ page }) => { - await openEditModal(page); - await toggleToPercentMode(page); - await addNewAccount(page); - await selectAccountFromTypeahead(page, 0, 'Test'); - await setAccountAmount(page, 0, '100'); - await saveTransaction(page); -}); diff --git a/e2e/debug-save.spec.ts b/e2e/debug-save.spec.ts deleted file mode 100644 index 003e89d0..00000000 --- a/e2e/debug-save.spec.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { test, expect } from '@playwright/test'; - -async function openEditModal(page: any) { - await page.goto('/transaction2'); - await page.waitForSelector('table tbody tr'); - const editButton = page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').first(); - await editButton.click(); - await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' }); - await page.waitForSelector('#wizardmodal'); - await page.click('button:has-text("Transaction Actions")'); - await page.waitForSelector('text=Transaction Actions', { state: 'visible' }); - await page.click('button:has-text("Manual")'); - await page.waitForSelector('#account-grid-body'); -} - -async function addNewAccount(page: any) { - await page.click('text=New account'); - await page.waitForTimeout(500); -} - -async function selectAccountFromTypeahead(page: any, rowIndex: number, accountName: string) { - const allRows = page.locator('#account-grid-body tbody tr'); - const rowCount = await allRows.count(); - - let accountRow = null; - let accountRowIndex = 0; - for (let i = 0; i < rowCount; i++) { - const row = allRows.nth(i); - const hasAccountInput = await row.locator('input[name*="transaction-account/account"]').count() > 0; - if (hasAccountInput) { - if (accountRowIndex === rowIndex) { - accountRow = row; - break; - } - accountRowIndex++; - } - } - - if (!accountRow) { - throw new Error(`Could not find account row at index ${rowIndex}`); - } - - const hiddenInput = accountRow.locator('input[type="hidden"][name*="transaction-account/account"]').first(); - - // Get account ID from test-info - const testInfoResponse = await page.request.get('/test-info'); - const testInfo = await testInfoResponse.json(); - const accountKey = accountName === 'Test' ? 'test-account' : 'second-account'; - const accountId = testInfo.accounts[accountKey]; - - await hiddenInput.evaluate((el: HTMLInputElement, value: string) => { - el.value = value; - const alpineEl = el.closest('[x-data]'); - if (alpineEl && (alpineEl as any).__x) { - (alpineEl as any).__x.$data.value.value = parseInt(value); - (alpineEl as any).__x.$data.value.label = 'Selected Account'; - } - const rowEl = el.closest('tr[x-data]'); - if (rowEl && (rowEl as any).__x) { - (rowEl as any).__x.$data.accountId = parseInt(value); - } - el.dispatchEvent(new Event('change', { bubbles: true })); - }, accountId.toString()); - - await page.waitForTimeout(300); -} - -async function setAccountAmount(page: any, rowIndex: number, amount: string) { - const allRows = page.locator('#account-grid-body tbody tr'); - const rowCount = await allRows.count(); - - let accountRowIndex = 0; - for (let i = 0; i < rowCount; i++) { - const row = allRows.nth(i); - const hasAccountInput = await row.locator('input[name*="transaction-account/account"]').count() > 0; - if (hasAccountInput) { - if (accountRowIndex === rowIndex) { - const amountInput = row.locator('input[name*="transaction-account/amount"]').first(); - await amountInput.fill(amount); - await amountInput.dispatchEvent('change'); - await page.waitForTimeout(300); - return; - } - accountRowIndex++; - } - } - - throw new Error(`Could not find account row at index ${rowIndex}`); -} - -test('debug save with percentage', async ({ page }) => { - await openEditModal(page); - - // Switch to percentage mode - await page.locator('input[name="step-params[amount-mode]"][value="%"]').click(); - await page.waitForResponse(response => - response.url().includes('/toggle-amount-mode') && response.status() === 200 - ); - await page.waitForTimeout(200); - - await addNewAccount(page); - await selectAccountFromTypeahead(page, 0, 'Test'); - await setAccountAmount(page, 0, '100'); - - // Intercept the form submission to see what happens - let responseStatus = 0; - let responseBody = ''; - - page.on('request', (request: any) => { - if (request.url().includes('/edit-submit')) { - console.log('Request URL:', request.url()); - console.log('Request method:', request.method()); - } - }); - - page.on('response', async (response: any) => { - if (response.url().includes('/edit-submit')) { - responseStatus = response.status(); - try { - responseBody = await response.text(); - } catch (e) { - responseBody = 'Could not read body'; - } - console.log('Response status:', responseStatus); - console.log('Response body:', responseBody.substring(0, 500)); - } - }); - - // Click Done - await page.click('button:has-text("Done")'); - - // Wait a bit - await page.waitForTimeout(2000); - - console.log('Final status:', responseStatus); - console.log('Response body length:', responseBody.length); - - // Check for error messages in the response - if (responseBody.includes('error') || responseBody.includes('Error') || responseBody.includes('has-error')) { - console.log('Response contains errors!'); - // Extract error messages - const errorMatches = responseBody.match(/text-red-600[^>]*>([^<]+)/g); - if (errorMatches) { - errorMatches.forEach((match: string) => console.log('Error:', match)); - } - } - - // Check if modal is open - const modalVisible = await page.locator('#modal-holder[x-show="open"]').isVisible(); - console.log('Modal visible:', modalVisible); -}); diff --git a/e2e/debug-step2.spec.ts b/e2e/debug-step2.spec.ts deleted file mode 100644 index e79537fa..00000000 --- a/e2e/debug-step2.spec.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { test, expect } from '@playwright/test'; - -async function openEditModal(page: any) { - await page.goto('/transaction2'); - await page.waitForSelector('table tbody tr'); - const editButton = page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').first(); - await editButton.click(); - await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' }); - await page.waitForSelector('#wizardmodal'); - await page.click('button:has-text("Transaction Actions")'); - await page.waitForSelector('text=Transaction Actions', { state: 'visible' }); - await page.click('button:has-text("Manual")'); - await page.waitForSelector('#account-grid-body'); -} - -async function addNewAccount(page: any) { - await page.click('text=New account'); - await page.waitForTimeout(500); -} - -async function selectAccountFromTypeahead(page: any, rowIndex: number, accountName: string) { - const allRows = page.locator('#account-grid-body tbody tr'); - const rowCount = await allRows.count(); - - let accountRow = null; - let accountRowIndex = 0; - for (let i = 0; i < rowCount; i++) { - const row = allRows.nth(i); - const hasAccountInput = await row.locator('input[name*="transaction-account/account"]').count() > 0; - if (hasAccountInput) { - if (accountRowIndex === rowIndex) { - accountRow = row; - break; - } - accountRowIndex++; - } - } - - if (!accountRow) { - throw new Error(`Could not find account row at index ${rowIndex}`); - } - - const hiddenInput = accountRow.locator('input[type="hidden"][name*="transaction-account/account"]').first(); - - const testInfoResponse = await page.request.get('/test-info'); - const testInfo = await testInfoResponse.json(); - const accountKey = accountName === 'Test' ? 'test-account' : 'second-account'; - const accountId = testInfo.accounts[accountKey]; - - await hiddenInput.evaluate((el: HTMLInputElement, value: string) => { - el.value = value; - const alpineEl = el.closest('[x-data]'); - if (alpineEl && (alpineEl as any).__x) { - (alpineEl as any).__x.$data.value.value = parseInt(value); - (alpineEl as any).__x.$data.value.label = 'Selected Account'; - } - const rowEl = el.closest('tr[x-data]'); - if (rowEl && (rowEl as any).__x) { - (rowEl as any).__x.$data.accountId = parseInt(value); - } - el.dispatchEvent(new Event('change', { bubbles: true })); - }, accountId.toString()); - - await page.waitForTimeout(300); -} - -async function setAccountAmount(page: any, rowIndex: number, amount: string) { - const allRows = page.locator('#account-grid-body tbody tr'); - const rowCount = await allRows.count(); - - let accountRowIndex = 0; - for (let i = 0; i < rowCount; i++) { - const row = allRows.nth(i); - const hasAccountInput = await row.locator('input[name*="transaction-account/account"]').count() > 0; - if (hasAccountInput) { - if (accountRowIndex === rowIndex) { - const amountInput = row.locator('input[name*="transaction-account/amount"]').first(); - await amountInput.fill(amount); - await amountInput.dispatchEvent('change'); - await page.waitForTimeout(300); - return; - } - accountRowIndex++; - } - } - - throw new Error(`Could not find account row at index ${rowIndex}`); -} - -async function toggleToPercentMode(page: any) { - const percentRadio = page.locator('input[name="step-params[amount-mode]"][value="%"]'); - await percentRadio.click(); - await page.waitForResponse(response => - response.url().includes('/toggle-amount-mode') && response.status() === 200 - ); - await page.waitForTimeout(200); -} - -async function saveTransaction(page: any) { - await page.click('button:has-text("Done")'); - await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'hidden', timeout: 10000 }); -} - -test('debug step 2', async ({ page }) => { - // Step 1 - await openEditModal(page); - await toggleToPercentMode(page); - await addNewAccount(page); - await selectAccountFromTypeahead(page, 0, 'Test'); - await setAccountAmount(page, 0, '100'); - await saveTransaction(page); - - console.log('Step 1 complete'); - - // Step 2: Re-open - await openEditModal(page); - await toggleToPercentMode(page); - - // Debug: check what's in the grid - const allRows = page.locator('#account-grid-body tbody tr'); - const rowCount = await allRows.count(); - console.log('Total rows in grid:', rowCount); - - for (let i = 0; i < rowCount; i++) { - const row = allRows.nth(i); - const hasAccountInput = await row.locator('input[name*="transaction-account/account"]').count() > 0; - const text = await row.textContent(); - console.log(`Row ${i}: hasAccount=${hasAccountInput}, text=${text?.substring(0, 50)}`); - } -}); diff --git a/e2e/debug-typeahead.spec.ts b/e2e/debug-typeahead.spec.ts deleted file mode 100644 index 8a89f02b..00000000 --- a/e2e/debug-typeahead.spec.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test('debug typeahead', async ({ page }) => { - // Navigate to transactions page - await page.goto('/transaction2'); - await page.waitForSelector('table tbody tr'); - - // Click edit - const editButton = page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').first(); - await editButton.click(); - - await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' }); - await page.click('button:has-text("Transaction Actions")'); - await page.waitForSelector('text=Transaction Actions', { state: 'visible' }); - await page.click('button:has-text("Manual")'); - await page.waitForSelector('#account-grid-body'); - - // Switch to percentage mode - await page.locator('input[name="step-params[amount-mode]"][value="%"]').click(); - await page.waitForResponse(response => - response.url().includes('/toggle-amount-mode') && response.status() === 200 - ); - await page.waitForTimeout(200); - - // Click New account - await page.click('text=New account'); - await page.waitForTimeout(500); - - // Find the typeahead - const row = page.locator('#account-grid-body tbody tr').nth(0); - const typeaheadContainer = row.locator('div.relative[x-data*="baseUrl"]').first(); - const typeaheadTrigger = typeaheadContainer.locator('a[x-ref="input"]').first(); - - // Click to open dropdown - await typeaheadTrigger.click(); - await page.waitForTimeout(500); - - // Take a screenshot to see what's on screen - await page.screenshot({ path: '/tmp/typeahead-debug.png' }); - - // Find all tippy dropdowns - const tippies = page.locator('div[id^="tippy"]'); - console.log('Number of tippy elements:', await tippies.count()); - - // Get HTML of first tippy - if (await tippies.count() > 0) { - const html = await tippies.first().innerHTML(); - console.log('First tippy HTML:', html.substring(0, 1000)); - } - - // Try to find search input - const searchInputs = page.locator('input[type="text"]'); - console.log('Number of text inputs:', await searchInputs.count()); - - // Find visible text inputs - for (let i = 0; i < Math.min(await searchInputs.count(), 10); i++) { - const input = searchInputs.nth(i); - const isVisible = await input.isVisible().catch(() => false); - console.log(`Input ${i}: visible=${isVisible}`); - } - - // Type in the search input - const searchInput = page.locator('div[id^="tippy"] input[type="text"]').first(); - await searchInput.fill('Test'); - await page.waitForTimeout(1000); - - // Take another screenshot - await page.screenshot({ path: '/tmp/typeahead-debug-2.png' }); - - // Check for dropdown options - const options = page.locator('div[id^="tippy"] .dropdown-options a'); - console.log('Number of dropdown options:', await options.count()); - - // Get HTML of dropdown options - if (await options.count() > 0) { - for (let i = 0; i < Math.min(await options.count(), 3); i++) { - const html = await options.nth(i).innerHTML(); - console.log(`Option ${i}:`, html); - } - } - - // Also check if there are any li elements - const lis = page.locator('div[id^="tippy"] li'); - console.log('Number of li elements:', await lis.count()); -}); diff --git a/e2e/debug-workflow.spec.ts b/e2e/debug-workflow.spec.ts deleted file mode 100644 index 3533afe7..00000000 --- a/e2e/debug-workflow.spec.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { test, expect } from '@playwright/test'; - -async function openEditModal(page: any) { - await page.goto('/transaction2'); - await page.waitForSelector('table tbody tr'); - const editButton = page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').first(); - await editButton.click(); - await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' }); - await page.waitForSelector('#wizardmodal'); - await page.click('button:has-text("Transaction Actions")'); - await page.waitForSelector('text=Transaction Actions', { state: 'visible' }); - await page.click('button:has-text("Manual")'); - await page.waitForSelector('#account-grid-body'); -} - -async function addNewAccount(page: any) { - await page.click('text=New account'); - await page.waitForTimeout(500); -} - -async function selectAccountFromTypeahead(page: any, rowIndex: number, accountName: string) { - const allRows = page.locator('#account-grid-body tbody tr'); - const rowCount = await allRows.count(); - - let accountRow = null; - let accountRowIndex = 0; - for (let i = 0; i < rowCount; i++) { - const row = allRows.nth(i); - const hasAccountInput = await row.locator('input[name*="transaction-account/account"]').count() > 0; - if (hasAccountInput) { - if (accountRowIndex === rowIndex) { - accountRow = row; - break; - } - accountRowIndex++; - } - } - - if (!accountRow) { - throw new Error(`Could not find account row at index ${rowIndex}`); - } - - const hiddenInput = accountRow.locator('input[type="hidden"][name*="transaction-account/account"]').first(); - - const testInfoResponse = await page.request.get('/test-info'); - const testInfo = await testInfoResponse.json(); - const accountKey = accountName === 'Test' ? 'test-account' : 'second-account'; - const accountId = testInfo.accounts[accountKey]; - - await hiddenInput.evaluate((el: HTMLInputElement, value: string) => { - el.value = value; - const alpineEl = el.closest('[x-data]'); - if (alpineEl && (alpineEl as any).__x) { - (alpineEl as any).__x.$data.value.value = parseInt(value); - (alpineEl as any).__x.$data.value.label = 'Selected Account'; - } - const rowEl = el.closest('tr[x-data]'); - if (rowEl && (rowEl as any).__x) { - (rowEl as any).__x.$data.accountId = parseInt(value); - } - el.dispatchEvent(new Event('change', { bubbles: true })); - }, accountId.toString()); - - await page.waitForTimeout(300); -} - -async function setAccountAmount(page: any, rowIndex: number, amount: string) { - const allRows = page.locator('#account-grid-body tbody tr'); - const rowCount = await allRows.count(); - - let accountRowIndex = 0; - for (let i = 0; i < rowCount; i++) { - const row = allRows.nth(i); - const hasAccountInput = await row.locator('input[name*="transaction-account/account"]').count() > 0; - if (hasAccountInput) { - if (accountRowIndex === rowIndex) { - const amountInput = row.locator('input[name*="transaction-account/amount"]').first(); - await amountInput.fill(amount); - await amountInput.dispatchEvent('change'); - await page.waitForTimeout(300); - return; - } - accountRowIndex++; - } - } - - throw new Error(`Could not find account row at index ${rowIndex}`); -} - -test('debug workflow step 1', async ({ page }) => { - // Step 1 - await openEditModal(page); - await page.locator('input[name="step-params[amount-mode]"][value="%"]').click(); - await page.waitForResponse(response => - response.url().includes('/toggle-amount-mode') && response.status() === 200 - ); - await page.waitForTimeout(200); - await addNewAccount(page); - await selectAccountFromTypeahead(page, 0, 'Test'); - await setAccountAmount(page, 0, '100'); - - console.log('Before save - modal visible:', await page.locator('#modal-holder[x-show="open"]').isVisible()); - - // Save - await page.click('button:has-text("Done")'); - await page.waitForTimeout(3000); - - console.log('After save - modal visible:', await page.locator('#modal-holder[x-show="open"]').isVisible()); - - // Step 2: Re-open - await page.waitForSelector('table tbody tr'); - const editButton = page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').first(); - await editButton.click(); - - await page.waitForTimeout(2000); - console.log('After re-open click - modal visible:', await page.locator('#modal-holder[x-show="open"]').isVisible()); -}); diff --git a/src/clj/auto_ap/ssr/transaction/bulk_code.clj b/src/clj/auto_ap/ssr/transaction/bulk_code.clj index 5a6033bf..5cc2f268 100644 --- a/src/clj/auto_ap/ssr/transaction/bulk_code.clj +++ b/src/clj/auto_ap/ssr/transaction/bulk_code.clj @@ -210,7 +210,7 @@ [:div.col-span-2.pt-4 [:h3.text-lg.font-medium.mb-3 "Expense Accounts"] - [:div.space-y-3#account-entries + [:div#account-entries.space-y-3 (fc/with-field :accounts (com/validated-field {:errors (fc/field-errors)} @@ -271,49 +271,62 @@ bulk-code-schema) (submit [this {:keys [multi-form-state request-method identity] :as request}] (let [ ids (selected->ids (assoc request :query-params (:search-params (:snapshot multi-form-state))) (:search-params (:snapshot multi-form-state))) - all-ids (all-ids-not-locked ids) - vendor (-> request :multi-form-state :snapshot :vendor) - approval-status (-> request :multi-form-state :snapshot :approval-status) - accounts (-> request :multi-form-state :snapshot :accounts) ] - (when (seq accounts) - (assert-percentages-add-up (:snapshot multi-form-state))) - (alog/peek ::ACCOUNTS (-> request :multi-form-state :snapshot)) + all-ids (all-ids-not-locked ids) + vendor (-> request :multi-form-state :snapshot :vendor) + approval-status (-> request :multi-form-state :snapshot :approval-status) + accounts (-> request :multi-form-state :snapshot :accounts) ] + (when (seq accounts) + (assert-percentages-add-up (:snapshot multi-form-state))) + (alog/peek ::ACCOUNTS (-> request :multi-form-state :snapshot)) - ;; Get transactions and filter for locked ones - (let [db (dc/db conn) - transactions (pull-many db [:db/id :transaction/amount {:transaction/client [:db/id]}] (vec all-ids)) + ;; Get transactions and filter for locked ones + (let [db (dc/db conn) + transactions (pull-many db [:db/id :transaction/amount {:transaction/client [:db/id]}] (vec all-ids)) - ;; Get client locations - client->locations (->> (map (comp :db/id :transaction/client) transactions) - (distinct) - (dc/q '[:find (pull ?e [:db/id :client/locations]) - :in $ [?e ...]] - db) - (map (fn [[client]] - [(:db/id client) (:client/locations client)])) - (into {}))] + ;; Get client locations + client->locations (->> (map (comp :db/id :transaction/client) transactions) + (distinct) + (dc/q '[:find (pull ?e [:db/id :client/locations]) + :in $ [?e ...]] + db) + (map (fn [[client]] + [(:db/id client) (:client/locations client)])) + (into {}))] - (audit-transact-batch - (map (fn [t] - (let [locations (client->locations (-> t :transaction/client :db/id))] - [:upsert-transaction (cond-> t - approval-status - (assoc :transaction/approval-status approval-status) + ;; Validate account locations + (doseq [a accounts + :let [{:keys [:account/location :account/name]} (dc/pull db + [:account/location :account/name] + (:account a))]] + (when (and location (not= location (:location a))) + (form-validation-error (str "Account " name " uses location " (:location a) ", but is supposed to be " location))) + (doseq [[_ locations] client->locations] + (when (and (not location) + (not (get (into #{"Shared"} locations) + (:location a)))) + (form-validation-error (str "Account " name " uses location " (:location a) ", but doesn't belong to the client."))))) - vendor - (assoc :transaction/vendor vendor) + (audit-transact-batch + (map (fn [t] + (let [locations (client->locations (-> t :transaction/client :db/id))] + [:upsert-transaction (cond-> t + approval-status + (assoc :transaction/approval-status approval-status) - (seq accounts) - (assoc :transaction/accounts - (maybe-code-accounts t accounts locations)))])) - transactions) - (:identity request)) + vendor + (assoc :transaction/vendor vendor) - ;; Return success modal - (html-response - (com/success-modal {:title "Transactions Coded"} - [:p (str "Successfully coded " (count all-ids) " transactions.")]) - :headers {"hx-trigger" "refreshTable"}))))) + (seq accounts) + (assoc :transaction/accounts + (maybe-code-accounts t accounts locations)))])) + transactions) + (:identity request)) + + ;; Return success modal + (html-response + (com/success-modal {:title "Transactions Coded"} + [:p (str "Successfully coded " (count all-ids) " transactions.")]) + :headers {"hx-trigger" "refreshTable"}))))) (def bulk-code-wizard (->BulkCodeWizard nil nil)) diff --git a/test/clj/auto_ap/test_server.clj b/test/clj/auto_ap/test_server.clj index c043bbe2..41c09559 100644 --- a/test/clj/auto_ap/test_server.clj +++ b/test/clj/auto_ap/test_server.clj @@ -69,6 +69,13 @@ :account/numeric-code 50001 :account/applicability :account-applicability/global :account/default-allowance {:db/ident :allowance/allowed}} + {:db/id "account-id-fixed-loc" + :account/name "Fixed Location Account" + :account/type :account-type/expense + :account/numeric-code 50002 + :account/applicability :account-applicability/global + :account/location "DT" + :account/default-allowance {:db/ident :allowance/allowed}} {:db/id "ap-account-id" :account/name "Accounts Payable" :db/ident :account/accounts-payable @@ -84,6 +91,18 @@ :transaction/bank-account "bank-account-id" :transaction/amount 100.0 :transaction/description-original "Test transaction" + :transaction/approval-status :transaction-approval-status/unapproved) + (test-transaction :db/id "transaction-id-2" + :transaction/client "client-id" + :transaction/bank-account "bank-account-id" + :transaction/amount 200.0 + :transaction/description-original "Second transaction" + :transaction/approval-status :transaction-approval-status/unapproved) + (test-transaction :db/id "transaction-id-3" + :transaction/client "client-id" + :transaction/bank-account "bank-account-id" + :transaction/amount 300.0 + :transaction/description-original "Third transaction" :transaction/approval-status :transaction-approval-status/unapproved)]) tempids (:tempids tx-result) tx-entity-id (get tempids "transaction-id")] @@ -91,6 +110,7 @@ (reset! test-account-ids {:test-account (get tempids "account-id") :second-account (get tempids "account-id-2") + :fixed-location-account (get tempids "account-id-fixed-loc") :ap-account (get tempids "ap-account-id") :vendor (get tempids "vendor-id")}) tx-entity-id))