Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 90 additions & 24 deletions src/lib/stores/transactions.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Transaction[]>([]);
loading = $state(false);
Expand All @@ -18,32 +93,23 @@ class TransactionStore {
filterSeriesId = $state<number | string>('');
filterDateFrom = $state('');
filterDateTo = $state('');
filterAmountMin = $state('');
filterAmountMax = $state('');
filterCategorization = $state<TransactionCategorizationFilter>('');
filterReconciled = $state<TransactionReconciledFilter>('');

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() {
Expand Down
121 changes: 121 additions & 0 deletions src/lib/stores/transactions.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
51 changes: 40 additions & 11 deletions src/routes/transactions/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,10 @@
transactionStore.filterSeriesId = '';
transactionStore.filterDateFrom = '';
transactionStore.filterDateTo = '';
transactionStore.filterAmountMin = '';
transactionStore.filterAmountMax = '';
transactionStore.filterCategorization = '';
transactionStore.filterReconciled = '';
transactionStore.load();
}

Expand All @@ -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);
Expand Down Expand Up @@ -310,7 +308,7 @@
Filtre : <span class="font-semibold text-accent">{externalFilterLabel}</span>
</p>
<button
onclick={() => { clearFilters(); filterReconciled = ''; hasExternalFilter = false; goto('/transactions'); }}
onclick={() => { clearFilters(); hasExternalFilter = false; goto('/transactions'); }}
class="ml-auto text-[12px] text-text-muted hover:text-text-primary transition-smooth"
>
Effacer le filtre
Expand Down Expand Up @@ -572,15 +570,25 @@
{/each}
</select>
<select
bind:value={filterReconciled}
bind:value={transactionStore.filterCategorization}
onchange={handleSearch}
class="rounded-xl border border-border bg-bg-card/60 px-4 py-2.5 text-[13px] text-text-primary outline-none focus-ring"
>
<option value="">Catégorisation</option>
<option value="categorized">Catégorisées</option>
<option value="uncategorized">Non catégorisées</option>
</select>
<select
bind:value={transactionStore.filterReconciled}
onchange={handleSearch}
class="rounded-xl border border-border bg-bg-card/60 px-4 py-2.5 text-[13px] text-text-primary outline-none focus-ring"
>
<option value="">Pointage</option>
<option value="yes">Pointées</option>
<option value="no">Non pointées</option>
</select>
{#if transactionStore.search || transactionStore.filterAccountId || transactionStore.filterSeriesId || filterReconciled || transactionStore.filterDateFrom || transactionStore.filterDateTo}
<button onclick={() => { clearFilters(); filterReconciled = ''; transactionStore.filterDateFrom = ''; transactionStore.filterDateTo = ''; }} class="text-[12px] font-medium text-accent hover:text-accent-hover transition-smooth">
{#if transactionStore.search || transactionStore.filterAccountId || transactionStore.filterSeriesId || transactionStore.filterCategorization || transactionStore.filterReconciled || transactionStore.filterDateFrom || transactionStore.filterDateTo || transactionStore.filterAmountMin || transactionStore.filterAmountMax}
<button onclick={clearFilters} class="text-[12px] font-medium text-accent hover:text-accent-hover transition-smooth">
Effacer
</button>
{/if}
Expand Down Expand Up @@ -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"
/>
<div class="flex items-center gap-2">
<input
type="number"
step="0.01"
bind:value={transactionStore.filterAmountMin}
onchange={handleSearch}
class="w-28 rounded-xl border border-border bg-bg-card/60 px-3 py-2 text-[12px] text-text-primary outline-none focus-ring"
placeholder="Min €"
title="Montant minimum"
/>
<span class="text-[12px] text-text-muted">à</span>
<input
type="number"
step="0.01"
bind:value={transactionStore.filterAmountMax}
onchange={handleSearch}
class="w-28 rounded-xl border border-border bg-bg-card/60 px-3 py-2 text-[12px] text-text-primary outline-none focus-ring"
placeholder="Max €"
title="Montant maximum"
/>
</div>
</div>

<!-- Transaction summary + categorization gauge -->
Expand Down