diff --git a/src/lib/stores/transactions.svelte.ts b/src/lib/stores/transactions.svelte.ts index 868e8fc..da74989 100644 --- a/src/lib/stores/transactions.svelte.ts +++ b/src/lib/stores/transactions.svelte.ts @@ -6,6 +6,81 @@ import { undoStore } from './undo.svelte'; const PAGE_SIZE = 200; +export type TransactionCategorizationFilter = '' | 'categorized' | 'uncategorized'; +export type TransactionReconciledFilter = '' | 'yes' | 'no'; + +export interface TransactionFilters { + accountId: number | string; + seriesId: number | string; + search: string; + dateFrom: string; + dateTo: string; + amountMin: string; + amountMax: string; + categorization: TransactionCategorizationFilter; + reconciled: TransactionReconciledFilter; +} + +function parseAmountFilter(value: string): number | null { + const normalized = value.trim().replace(',', '.'); + if (!normalized) return null; + const parsed = Number(normalized); + return Number.isFinite(parsed) ? toCents(parsed) : null; +} + +export function buildTransactionWhere(filters: TransactionFilters): { clause: string; params: unknown[] } { + let clause = ' WHERE 1=1'; + const params: unknown[] = []; + let i = 1; + + if (filters.accountId) { + clause += ` AND t.account_id = $${i++}`; + params.push(filters.accountId); + } + if (filters.seriesId) { + clause += ` AND t.series_id = $${i++}`; + params.push(filters.seriesId); + } + if (filters.search) { + clause += ` AND t.label LIKE $${i++}`; + params.push(`%${filters.search}%`); + } + if (filters.dateFrom) { + clause += ` AND t.date >= $${i++}`; + params.push(filters.dateFrom); + } + if (filters.dateTo) { + clause += ` AND t.date <= $${i++}`; + params.push(filters.dateTo); + } + + const amountMin = parseAmountFilter(filters.amountMin); + if (amountMin !== null) { + clause += ` AND t.amount >= $${i++}`; + params.push(amountMin); + } + + const amountMax = parseAmountFilter(filters.amountMax); + if (amountMax !== null) { + clause += ` AND t.amount <= $${i++}`; + params.push(amountMax); + } + + if (filters.categorization === 'categorized') { + clause += ' AND t.series_id IS NOT NULL'; + } else if (filters.categorization === 'uncategorized') { + clause += ' AND t.series_id IS NULL'; + } + + if (filters.reconciled === 'yes') { + clause += ' AND t.is_reconciled = 1'; + } else if (filters.reconciled === 'no') { + clause += ' AND COALESCE(t.is_reconciled, 0) = 0'; + } + + return { clause, params }; +} + class TransactionStore { transactions = $state([]); loading = $state(false); @@ -18,32 +93,23 @@ class TransactionStore { filterSeriesId = $state(''); filterDateFrom = $state(''); filterDateTo = $state(''); + filterAmountMin = $state(''); + filterAmountMax = $state(''); + filterCategorization = $state(''); + filterReconciled = $state(''); private buildWhere(): { clause: string; params: unknown[] } { - let clause = ' WHERE 1=1'; - const params: unknown[] = []; - let i = 1; - if (this.filterAccountId) { - clause += ` AND t.account_id = $${i++}`; - params.push(this.filterAccountId); - } - if (this.filterSeriesId) { - clause += ` AND t.series_id = $${i++}`; - params.push(this.filterSeriesId); - } - if (this.search) { - clause += ` AND t.label LIKE $${i++}`; - params.push(`%${this.search}%`); - } - if (this.filterDateFrom) { - clause += ` AND t.date >= $${i++}`; - params.push(this.filterDateFrom); - } - if (this.filterDateTo) { - clause += ` AND t.date <= $${i++}`; - params.push(this.filterDateTo); - } - return { clause, params }; + return buildTransactionWhere({ + accountId: this.filterAccountId, + seriesId: this.filterSeriesId, + search: this.search, + dateFrom: this.filterDateFrom, + dateTo: this.filterDateTo, + amountMin: this.filterAmountMin, + amountMax: this.filterAmountMax, + categorization: this.filterCategorization, + reconciled: this.filterReconciled + }); } async load() { diff --git a/src/lib/stores/transactions.test.ts b/src/lib/stores/transactions.test.ts new file mode 100644 index 0000000..4551ea9 --- /dev/null +++ b/src/lib/stores/transactions.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; + +import { buildTransactionWhere } from './transactions.svelte'; + +describe('buildTransactionWhere', () => { + it('builds account, category, search and date filters with ordered params', () => { + const result = buildTransactionWhere({ + accountId: 2, + seriesId: 7, + search: 'carrefour', + dateFrom: '2026-01-01', + dateTo: '2026-01-31', + amountMin: '', + amountMax: '', + categorization: '', + reconciled: '' + }); + + expect(result.clause).toContain('t.account_id = $1'); + expect(result.clause).toContain('t.series_id = $2'); + expect(result.clause).toContain('t.label LIKE $3'); + expect(result.clause).toContain('t.date >= $4'); + expect(result.clause).toContain('t.date <= $5'); + expect(result.params).toEqual([2, 7, '%carrefour%', '2026-01-01', '2026-01-31']); + }); + + it('converts amount bounds to cents', () => { + const result = buildTransactionWhere({ + accountId: '', + seriesId: '', + search: '', + dateFrom: '', + dateTo: '', + amountMin: '-25,50', + amountMax: '100.25', + categorization: '', + reconciled: '' + }); + + expect(result.clause).toContain('t.amount >= $1'); + expect(result.clause).toContain('t.amount <= $2'); + expect(result.params).toEqual([-2550, 10025]); + }); + + it('ignores invalid amount bounds', () => { + const result = buildTransactionWhere({ + accountId: '', + seriesId: '', + search: '', + dateFrom: '', + dateTo: '', + amountMin: 'abc', + amountMax: ' ', + categorization: '', + reconciled: '' + }); + + expect(result.clause).toBe(' WHERE 1=1'); + expect(result.params).toEqual([]); + }); + + it('filters categorized and uncategorized transactions without extra params', () => { + const categorized = buildTransactionWhere({ + accountId: '', + seriesId: '', + search: '', + dateFrom: '', + dateTo: '', + amountMin: '', + amountMax: '', + categorization: 'categorized', + reconciled: '' + }); + const uncategorized = buildTransactionWhere({ + accountId: '', + seriesId: '', + search: '', + dateFrom: '', + dateTo: '', + amountMin: '', + amountMax: '', + categorization: 'uncategorized', + reconciled: '' + }); + + expect(categorized.clause).toContain('t.series_id IS NOT NULL'); + expect(uncategorized.clause).toContain('t.series_id IS NULL'); + expect(categorized.params).toEqual([]); + expect(uncategorized.params).toEqual([]); + }); + + it('filters reconciled and unreconciled transactions without extra params', () => { + const reconciled = buildTransactionWhere({ + accountId: '', + seriesId: '', + search: '', + dateFrom: '', + dateTo: '', + amountMin: '', + amountMax: '', + categorization: '', + reconciled: 'yes' + }); + const unreconciled = buildTransactionWhere({ + accountId: '', + seriesId: '', + search: '', + dateFrom: '', + dateTo: '', + amountMin: '', + amountMax: '', + categorization: '', + reconciled: 'no' + }); + + expect(reconciled.clause).toContain('t.is_reconciled = 1'); + expect(unreconciled.clause).toContain('COALESCE(t.is_reconciled, 0) = 0'); + expect(reconciled.params).toEqual([]); + expect(unreconciled.params).toEqual([]); + }); +}); diff --git a/src/routes/transactions/+page.svelte b/src/routes/transactions/+page.svelte index d18620b..d75f7b4 100644 --- a/src/routes/transactions/+page.svelte +++ b/src/routes/transactions/+page.svelte @@ -263,6 +263,10 @@ transactionStore.filterSeriesId = ''; transactionStore.filterDateFrom = ''; transactionStore.filterDateTo = ''; + transactionStore.filterAmountMin = ''; + transactionStore.filterAmountMax = ''; + transactionStore.filterCategorization = ''; + transactionStore.filterReconciled = ''; transactionStore.load(); } @@ -273,16 +277,10 @@ toastStore.success(newValue ? 'Transaction pointée' : 'Pointage annulé'); } - let filterReconciled = $state<'' | 'yes' | 'no'>(''); let hasExternalFilter = $state(false); let externalFilterLabel = $state(''); - let filteredTransactions = $derived.by(() => { - let txs = transactionStore.transactions; - if (filterReconciled === 'yes') txs = txs.filter(t => t.is_reconciled); - if (filterReconciled === 'no') txs = txs.filter(t => !t.is_reconciled); - return txs; - }); + let filteredTransactions = $derived(transactionStore.transactions); function formatDateShort(dateStr: string) { const d = new Date(dateStr); @@ -310,7 +308,7 @@ Filtre : {externalFilterLabel}

{/if} @@ -618,6 +626,27 @@ class="rounded-xl border border-border bg-bg-card/60 px-3 py-2 text-[12px] text-text-primary outline-none focus-ring" title="Date de fin" /> +
+ + à + +