feat(transactions): port manual bank-transaction import to SSR #8

Closed
notid wants to merge 133 commits from integreat-add-transaction-manual into master
9 changed files with 543 additions and 632 deletions
Showing only changes of commit 8bd0cee1b1 - Show all commits

View File

@@ -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

View File

@@ -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');
});
});

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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)}`);
}
});

View File

@@ -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());
});

View File

@@ -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());
});

View File

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

View File

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