feat(transactions): port manual bank-transaction import to SSR #8
@@ -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
|
||||
387
e2e/bulk-code-transactions.spec.ts
Normal file
387
e2e/bulk-code-transactions.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)}`);
|
||||
}
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user