money: quick-add bar, server-filtered transactions page, CSV import dedupe + Revolut statements
This commit is contained in:
269
src/lib/money-tracker/QuickAdd.svelte
Normal file
269
src/lib/money-tracker/QuickAdd.svelte
Normal file
@@ -0,0 +1,269 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { tick } from 'svelte';
|
||||
import { Button } from '#lib/components/ui/button/index.js';
|
||||
import { Input } from '#lib/components/ui/input/index.js';
|
||||
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import { descriptionSuggestions, type SuggestionSource } from './quick-add.js';
|
||||
|
||||
let {
|
||||
accounts,
|
||||
transactions,
|
||||
today
|
||||
}: {
|
||||
accounts: { id: number; name: string }[];
|
||||
transactions: SuggestionSource[];
|
||||
today: string;
|
||||
} = $props();
|
||||
|
||||
const storageKey = 'money-tracker:last-account';
|
||||
const euro = new Intl.NumberFormat('en-NL', { style: 'currency', currency: 'EUR' });
|
||||
let kind = $state<'expense' | 'income'>('expense');
|
||||
let description = $state('');
|
||||
let amount = $state('');
|
||||
let accountId = $state('');
|
||||
let date = $state('');
|
||||
let listOpen = $state(false);
|
||||
let highlighted = $state(-1);
|
||||
let message = $state('');
|
||||
let failed = $state(false);
|
||||
let saving = $state(false);
|
||||
let descriptionInput = $state<HTMLInputElement | null>(null);
|
||||
let amountInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
let suggestions = $derived(descriptionSuggestions(transactions, today));
|
||||
let matches = $derived.by(() => {
|
||||
const query = description.trim().toLowerCase();
|
||||
if (!query) return [];
|
||||
return suggestions
|
||||
.filter((item) => item.key.includes(query) && item.key !== query)
|
||||
.sort((a, b) => Number(b.key.startsWith(query)) - Number(a.key.startsWith(query)))
|
||||
.slice(0, 6);
|
||||
});
|
||||
let signedAmount = $derived.by(() => {
|
||||
const raw = amount.trim().replace(',', '.').replace(/^[-+]/, '');
|
||||
return raw ? (kind === 'expense' ? '-' : '') + raw : '';
|
||||
});
|
||||
|
||||
$effect.pre(() => {
|
||||
if (!date) date = today;
|
||||
if (!accountId && accounts.length) {
|
||||
const remembered =
|
||||
typeof localStorage === 'undefined' ? null : localStorage.getItem(storageKey);
|
||||
accountId = String(
|
||||
accounts.find((account) => String(account.id) === remembered)?.id ?? accounts[0].id
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
async function choose(index: number) {
|
||||
const item = matches[index];
|
||||
if (!item) return;
|
||||
description = item.description;
|
||||
amount = (Math.abs(item.amountCents) / 100).toFixed(2);
|
||||
kind = item.amountCents < 0 ? 'expense' : 'income';
|
||||
if (item.accountId && accounts.some((account) => account.id === item.accountId))
|
||||
accountId = String(item.accountId);
|
||||
listOpen = false;
|
||||
highlighted = -1;
|
||||
await tick();
|
||||
amountInput?.select();
|
||||
}
|
||||
|
||||
function keydown(event: KeyboardEvent) {
|
||||
if (!matches.length) return;
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
listOpen = true;
|
||||
const step = event.key === 'ArrowDown' ? 1 : -1;
|
||||
highlighted = (highlighted + step + matches.length) % matches.length;
|
||||
} else if (event.key === 'Enter' && listOpen && highlighted >= 0) {
|
||||
event.preventDefault();
|
||||
choose(highlighted);
|
||||
} else if (event.key === 'Escape') {
|
||||
listOpen = false;
|
||||
highlighted = -1;
|
||||
} else if (event.key === 'Tab' && listOpen && highlighted >= 0) choose(highlighted);
|
||||
}
|
||||
</script>
|
||||
|
||||
<form
|
||||
class="quick-add"
|
||||
method="POST"
|
||||
action="?/saveTransaction"
|
||||
aria-label="Quick add transaction"
|
||||
use:enhance={() => {
|
||||
saving = true;
|
||||
const saved = { description, signedAmount };
|
||||
return async ({ result, update }) => {
|
||||
saving = false;
|
||||
if (result.type === 'success') {
|
||||
localStorage.setItem(storageKey, accountId);
|
||||
failed = false;
|
||||
message = `Saved ${saved.description} · ${euro.format(Number(saved.signedAmount))}`;
|
||||
description = '';
|
||||
amount = '';
|
||||
await update({ reset: false });
|
||||
descriptionInput?.focus();
|
||||
} else {
|
||||
failed = true;
|
||||
message =
|
||||
result.type === 'failure' && typeof result.data?.message === 'string'
|
||||
? result.data.message
|
||||
: 'Could not save this transaction.';
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<div class="quick-kind" role="group" aria-label="Transaction type">
|
||||
<Button
|
||||
type="button"
|
||||
variant={kind === 'expense' ? 'default' : 'ghost'}
|
||||
aria-pressed={kind === 'expense'}
|
||||
onclick={() => (kind = 'expense')}>Expense</Button
|
||||
><Button
|
||||
type="button"
|
||||
variant={kind === 'income' ? 'default' : 'ghost'}
|
||||
aria-pressed={kind === 'income'}
|
||||
onclick={() => (kind = 'income')}>Income</Button
|
||||
>
|
||||
</div>
|
||||
<div class="quick-description">
|
||||
<label class="sr-only" for="quick-description">Description</label>
|
||||
<Input
|
||||
id="quick-description"
|
||||
name="description"
|
||||
autocomplete="off"
|
||||
placeholder="What was it? e.g. Albert Heijn"
|
||||
required
|
||||
maxlength={500}
|
||||
role="combobox"
|
||||
aria-expanded={listOpen && matches.length > 0}
|
||||
aria-controls="quick-suggestions"
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={highlighted >= 0 ? `quick-suggestion-${highlighted}` : undefined}
|
||||
bind:ref={descriptionInput}
|
||||
bind:value={description}
|
||||
oninput={() => {
|
||||
listOpen = true;
|
||||
highlighted = -1;
|
||||
}}
|
||||
onkeydown={keydown}
|
||||
onfocus={() => (listOpen = true)}
|
||||
onblur={() => setTimeout(() => (listOpen = false), 120)}
|
||||
/>
|
||||
{#if listOpen && matches.length}
|
||||
<ul id="quick-suggestions" role="listbox" class="quick-suggestions">
|
||||
{#each matches as item, index (item.key)}
|
||||
<li
|
||||
id={`quick-suggestion-${index}`}
|
||||
role="option"
|
||||
aria-selected={index === highlighted}
|
||||
class:highlighted={index === highlighted}
|
||||
onmousedown={(event) => {
|
||||
event.preventDefault();
|
||||
choose(index);
|
||||
}}
|
||||
>
|
||||
<span>{item.description}</span>
|
||||
<span class="muted"
|
||||
>{euro.format(item.amountCents / 100)} · {accounts.find(
|
||||
(a) => a.id === item.accountId
|
||||
)?.name ?? ''}</span
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<label class="sr-only" for="quick-amount">Amount in euros</label>
|
||||
<Input
|
||||
id="quick-amount"
|
||||
class="quick-amount"
|
||||
inputmode="decimal"
|
||||
placeholder="0.00"
|
||||
required
|
||||
pattern="[-+]?[0-9]+([.,][0-9]{(1, 2)})?"
|
||||
bind:ref={amountInput}
|
||||
bind:value={amount}
|
||||
/>
|
||||
<input type="hidden" name="amount" value={signedAmount} />
|
||||
<label class="sr-only" for="quick-account">Account</label>
|
||||
<NativeSelect id="quick-account" name="accountId" bind:value={accountId} required
|
||||
>{#each accounts as account (account.id)}<option value={String(account.id)}
|
||||
>{account.name}</option
|
||||
>{/each}</NativeSelect
|
||||
>
|
||||
<label class="sr-only" for="quick-date">Date</label>
|
||||
<Input id="quick-date" class="quick-date" type="date" name="date" bind:value={date} required />
|
||||
<Button type="submit" disabled={saving || !accounts.length}><Plus size={16} /> Add</Button>
|
||||
{#if message}<p class="quick-message" class:form-error={failed} role="status">{message}</p>{/if}
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.quick-add {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 20px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.quick-kind {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.quick-description {
|
||||
position: relative;
|
||||
flex: 1 1 220px;
|
||||
}
|
||||
.quick-add :global(.quick-amount) {
|
||||
width: 110px;
|
||||
text-align: right;
|
||||
}
|
||||
.quick-add :global(.quick-date) {
|
||||
width: 150px;
|
||||
}
|
||||
.quick-suggestions {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
list-style: none;
|
||||
background: var(--site-bg);
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.12);
|
||||
}
|
||||
.quick-suggestions li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.quick-suggestions li.highlighted,
|
||||
.quick-suggestions li:hover {
|
||||
background: var(--site-border);
|
||||
}
|
||||
.quick-message {
|
||||
flex-basis: 100%;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.muted {
|
||||
opacity: 0.65;
|
||||
}
|
||||
</style>
|
||||
24
src/lib/money-tracker/quick-add.test.ts
Normal file
24
src/lib/money-tracker/quick-add.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { descriptionSuggestions } from './quick-add';
|
||||
|
||||
describe('quick-add suggestions', () => {
|
||||
it('ranks by use and fills the usual amount and latest account', () => {
|
||||
const rows = [
|
||||
{ description: 'PLUS', amountCents: -1200, accountId: 1, date: '2026-10-01' },
|
||||
{ description: 'plus', amountCents: -1200, accountId: 1, date: '2026-10-03' },
|
||||
{ description: 'Plus', amountCents: -800, accountId: 4, date: '2026-10-05' },
|
||||
{ description: 'From Juhee', amountCents: 5000, accountId: 4, date: '2026-10-02' },
|
||||
{ description: 'Future', amountCents: -1, accountId: 1, date: '2026-12-01' }
|
||||
];
|
||||
const [first, second, ...rest] = descriptionSuggestions(rows, '2026-10-24');
|
||||
expect(first).toMatchObject({
|
||||
key: 'plus',
|
||||
description: 'Plus',
|
||||
amountCents: -1200,
|
||||
accountId: 4,
|
||||
count: 3
|
||||
});
|
||||
expect(second).toMatchObject({ description: 'From Juhee', amountCents: 5000 });
|
||||
expect(rest).toEqual([]);
|
||||
});
|
||||
});
|
||||
73
src/lib/money-tracker/quick-add.ts
Normal file
73
src/lib/money-tracker/quick-add.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export type SuggestionSource = {
|
||||
description: string;
|
||||
amountCents: number;
|
||||
accountId: number | null;
|
||||
date: string;
|
||||
};
|
||||
|
||||
export type Suggestion = {
|
||||
key: string;
|
||||
description: string;
|
||||
/** Most common amount for this description (latest wins ties). */
|
||||
amountCents: number;
|
||||
/** Account used most recently with this description. */
|
||||
accountId: number | null;
|
||||
count: number;
|
||||
lastDate: string;
|
||||
};
|
||||
|
||||
/** Past descriptions ranked by use, each with its usual amount and latest account. */
|
||||
export function descriptionSuggestions(rows: SuggestionSource[], today: string): Suggestion[] {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
description: string;
|
||||
amounts: Map<number, number>;
|
||||
accountId: number | null;
|
||||
count: number;
|
||||
lastDate: string;
|
||||
lastAmount: number;
|
||||
}
|
||||
>();
|
||||
for (const row of rows) {
|
||||
if (row.date > today) continue;
|
||||
const key = row.description.trim().toLowerCase();
|
||||
if (!key) continue;
|
||||
const group = groups.get(key) ?? {
|
||||
description: row.description.trim(),
|
||||
amounts: new Map(),
|
||||
accountId: row.accountId,
|
||||
count: 0,
|
||||
lastDate: '',
|
||||
lastAmount: row.amountCents
|
||||
};
|
||||
group.count++;
|
||||
group.amounts.set(row.amountCents, (group.amounts.get(row.amountCents) ?? 0) + 1);
|
||||
if (row.date >= group.lastDate) {
|
||||
group.lastDate = row.date;
|
||||
group.description = row.description.trim();
|
||||
group.accountId = row.accountId;
|
||||
group.lastAmount = row.amountCents;
|
||||
}
|
||||
groups.set(key, group);
|
||||
}
|
||||
return [...groups.entries()]
|
||||
.map(([key, group]) => {
|
||||
let amountCents = group.lastAmount;
|
||||
let best = group.amounts.get(amountCents) ?? 0;
|
||||
for (const [value, uses] of group.amounts)
|
||||
if (uses > best) {
|
||||
best = uses;
|
||||
amountCents = value;
|
||||
}
|
||||
return {
|
||||
key,
|
||||
description: group.description,
|
||||
amountCents,
|
||||
accountId: group.accountId,
|
||||
count: group.count,
|
||||
lastDate: group.lastDate
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.count - a.count || b.lastDate.localeCompare(a.lastDate));
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cents, csvDate, importCents, parseCsv, parseTransactionImport } from './money-tracker';
|
||||
import {
|
||||
cents,
|
||||
csvDate,
|
||||
importCents,
|
||||
parseCsv,
|
||||
parseTransactionImport,
|
||||
planImport
|
||||
} from './money-tracker';
|
||||
|
||||
describe('transaction import parsing', () => {
|
||||
it('handles quoted commas and semicolon-separated European amounts', () => {
|
||||
@@ -63,3 +70,50 @@ describe('transaction import parsing', () => {
|
||||
).toThrow('Quote values containing commas');
|
||||
});
|
||||
});
|
||||
|
||||
describe('revolut statements and import dedupe', () => {
|
||||
const statement =
|
||||
'Type,Product,Started Date,Completed Date,Description,Amount,Fee,Currency,State,Balance\n' +
|
||||
'CARD_PAYMENT,Current,2026-10-01 10:00:00,2026-10-02 09:00:00,Albert Heijn,-12.34,0.00,EUR,COMPLETED,100.00\n' +
|
||||
'TOPUP,Current,2026-10-03 10:00:00,,Pending top-up,50.00,0.00,EUR,PENDING,\n' +
|
||||
'TRANSFER,Current,2026-10-04 10:00:00,2026-10-04 10:00:01,To Joint,-100.00,0.50,EUR,COMPLETED,0.00\n' +
|
||||
'CARD_PAYMENT,Current,2026-10-05 10:00:00,2026-10-05 11:00:00,Coffee,-3.00,0.00,USD,COMPLETED,0.00\n';
|
||||
|
||||
it('keeps completed EUR rows, uses the completed date and deducts fees', () => {
|
||||
const rows = parseTransactionImport(statement, 'Revolut');
|
||||
expect(rows.map((row) => [row.date, row.account, row.description, row.amountCents])).toEqual([
|
||||
['2026-10-02', 'Revolut', 'Albert Heijn', -1234],
|
||||
['2026-10-04', 'Revolut', 'To Joint', -10050]
|
||||
]);
|
||||
expect(rows[0].externalId).toMatch(/^csv:[0-9a-f]{32}$/);
|
||||
expect(parseTransactionImport(statement, 'Revolut')[0].externalId).toBe(rows[0].externalId);
|
||||
expect(() => parseTransactionImport(statement)).toThrow('Choose which account');
|
||||
});
|
||||
|
||||
it('gives identical rows in one file distinct, stable ids', () => {
|
||||
const file =
|
||||
'date,account,description,amount\n2026-10-01,Cash,Kiosk,-2\n2026-10-01,Cash,Kiosk,-2\n';
|
||||
const [a, b] = parseTransactionImport(file);
|
||||
expect(a.externalId).not.toBe(b.externalId);
|
||||
expect(parseTransactionImport(file).map((row) => row.externalId)).toEqual([
|
||||
a.externalId,
|
||||
b.externalId
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips rows already imported or already entered by hand (±1 day)', () => {
|
||||
const rows = [
|
||||
{ accountId: 1, date: '2026-10-02', amountCents: -1234, externalId: 'csv:a' },
|
||||
{ accountId: 1, date: '2026-10-02', amountCents: -1234, externalId: 'csv:b' },
|
||||
{ accountId: 1, date: '2026-10-04', amountCents: -500, externalId: 'csv:c' },
|
||||
{ accountId: 2, date: '2026-10-04', amountCents: -500, externalId: 'csv:d' }
|
||||
];
|
||||
const existing = [
|
||||
{ id: 10, accountId: 1, date: '2026-10-01', amountCents: -1234, externalId: null },
|
||||
{ id: 11, accountId: 1, date: '2026-10-04', amountCents: -500, externalId: 'csv:c' }
|
||||
];
|
||||
const { insert, duplicates } = planImport(rows, existing);
|
||||
expect(insert.map((row) => row.externalId)).toEqual(['csv:b', 'csv:d']);
|
||||
expect(duplicates).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export const utilityKinds = ['electricity', 'gas', 'water'] as const;
|
||||
export const cadences = ['weekly', 'monthly', 'yearly'] as const;
|
||||
@@ -130,21 +131,93 @@ export type ImportedTransaction = {
|
||||
account: string;
|
||||
description: string;
|
||||
amountCents: number;
|
||||
/** Stable per-row id ("csv:<hash>") so importing the same file twice adds nothing. */
|
||||
externalId: string;
|
||||
};
|
||||
|
||||
export function parseTransactionImport(input: string): ImportedTransaction[] {
|
||||
const rows = parseCsv(input);
|
||||
if (rows.length < 2 || rows.length > 20_001)
|
||||
throw new Error('The file must have a header and 1–20,000 rows.');
|
||||
const header = rows.shift()!.map((value) =>
|
||||
export type ImportFormat = 'tracker' | 'revolut';
|
||||
|
||||
function headerOf(rows: string[][]) {
|
||||
return rows[0].map((value) =>
|
||||
value
|
||||
.replace(/^\uFEFF/, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
/** Revolut app statement export: Type, Product, Started Date, Completed Date, Description, Amount, Fee, Currency, State, Balance. */
|
||||
export function isRevolutHeader(header: string[]) {
|
||||
return ['type', 'started date', 'description', 'amount', 'currency', 'state'].every((key) =>
|
||||
header.includes(key)
|
||||
);
|
||||
}
|
||||
|
||||
function importId(parts: (string | number)[], seen: Map<string, number>) {
|
||||
const base = parts.join('\u001f');
|
||||
// Identical rows in one file (two coffees, same day) stay distinct but stable.
|
||||
const occurrence = (seen.get(base) ?? 0) + 1;
|
||||
seen.set(base, occurrence);
|
||||
return (
|
||||
'csv:' + createHash('sha256').update(`${base}\u001f${occurrence}`).digest('hex').slice(0, 32)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses either the tracker's own export (date, account, description, amount) or a Revolut
|
||||
* statement. Revolut files have no account column, so `defaultAccount` names the tracker
|
||||
* account they belong to; only COMPLETED EUR rows are kept and the fee is deducted.
|
||||
*/
|
||||
export function parseTransactionImport(
|
||||
input: string,
|
||||
defaultAccount?: string
|
||||
): ImportedTransaction[] {
|
||||
const rows = parseCsv(input);
|
||||
if (rows.length < 2 || rows.length > 20_001)
|
||||
throw new Error('The file must have a header and 1–20,000 rows.');
|
||||
const header = headerOf(rows);
|
||||
rows.shift();
|
||||
const seen = new Map<string, number>();
|
||||
if (isRevolutHeader(header)) {
|
||||
const account = defaultAccount?.trim();
|
||||
if (!account) throw new Error('Choose which account this Revolut statement belongs to.');
|
||||
const col = (key: string) => header.indexOf(key);
|
||||
const parsed: ImportedTransaction[] = [];
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const state = (row[col('state')] ?? '').trim().toUpperCase();
|
||||
if (state && state !== 'COMPLETED') continue;
|
||||
const currency = (row[col('currency')] ?? 'EUR').trim().toUpperCase();
|
||||
if (currency && currency !== 'EUR') continue;
|
||||
const started = (row[col('started date')] ?? '').trim();
|
||||
const completed = col('completed date') >= 0 ? (row[col('completed date')] ?? '').trim() : '';
|
||||
const date = csvDate((completed || started).slice(0, 10));
|
||||
const amount = importCents(row[col('amount')]);
|
||||
const fee = col('fee') >= 0 ? (importCents(row[col('fee')] || '0') ?? undefined) : 0;
|
||||
const description = (row[col('description')] ?? '').trim().slice(0, 500) || 'Revolut';
|
||||
if (!date || amount == null || fee == null)
|
||||
throw new Error(`Invalid data in statement row ${index + 1}. No rows were imported.`);
|
||||
const amountCents = amount - fee;
|
||||
if (!amountCents) continue;
|
||||
parsed.push({
|
||||
date,
|
||||
account,
|
||||
description,
|
||||
amountCents,
|
||||
externalId: importId(
|
||||
['revolut', account.toLowerCase(), started, amount, fee, description],
|
||||
seen
|
||||
)
|
||||
});
|
||||
}
|
||||
if (!parsed.length) throw new Error('No completed EUR transactions found in this statement.');
|
||||
return parsed.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
const columns = ['date', 'account', 'description', 'amount'].map((key) => header.indexOf(key));
|
||||
if (columns.some((index) => index < 0))
|
||||
throw new Error('Headers must include date, account, description and amount.');
|
||||
const accountColumn = columns[1];
|
||||
if (columns[0] < 0 || columns[2] < 0 || columns[3] < 0 || (accountColumn < 0 && !defaultAccount))
|
||||
throw new Error(
|
||||
'Headers must include date, account, description and amount (or choose an account), or be a Revolut statement.'
|
||||
);
|
||||
const parsed: ImportedTransaction[] = [];
|
||||
for (const [index, row] of rows.entries()) {
|
||||
if (row.length !== header.length)
|
||||
@@ -152,7 +225,8 @@ export function parseTransactionImport(input: string): ImportedTransaction[] {
|
||||
`Data row ${index + 1} has ${row.length} columns; expected ${header.length}. Quote values containing commas. No rows were imported.`
|
||||
);
|
||||
const date = csvDate(row[columns[0]] ?? '');
|
||||
const account = (row[columns[1]] ?? '').trim();
|
||||
const account =
|
||||
(accountColumn >= 0 ? (row[accountColumn] ?? '') : '').trim() || defaultAccount?.trim() || '';
|
||||
const description = (row[columns[2]] ?? '').trim();
|
||||
const amountCents = importCents(row[columns[3]]);
|
||||
if (
|
||||
@@ -164,7 +238,64 @@ export function parseTransactionImport(input: string): ImportedTransaction[] {
|
||||
amountCents == null
|
||||
)
|
||||
throw new Error(`Invalid data in transaction ${index + 1}. No rows were imported.`);
|
||||
parsed.push({ date, account, description, amountCents });
|
||||
parsed.push({
|
||||
date,
|
||||
account,
|
||||
description,
|
||||
amountCents,
|
||||
externalId: importId([date, account.toLowerCase(), description, amountCents], seen)
|
||||
});
|
||||
}
|
||||
return parsed.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
export type ExistingImportRow = {
|
||||
id: number;
|
||||
accountId: number;
|
||||
date: string;
|
||||
amountCents: number;
|
||||
externalId: string | null;
|
||||
};
|
||||
|
||||
function daysApart(a: string, b: string) {
|
||||
return Math.abs(Date.parse(a + 'T00:00:00Z') - Date.parse(b + 'T00:00:00Z')) / 86_400_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits imported rows into new ones and duplicates: same import id already stored, or an
|
||||
* existing row on the same account with the same amount within ±1 day (each existing row
|
||||
* can only absorb one imported row).
|
||||
*/
|
||||
export function planImport<
|
||||
T extends { accountId: number; date: string; amountCents: number; externalId: string }
|
||||
>(rows: T[], existing: ExistingImportRow[]) {
|
||||
const ids = new Set(
|
||||
existing.flatMap((row) => (row.externalId ? [`${row.accountId}:${row.externalId}`] : []))
|
||||
);
|
||||
const byKey = new Map<string, ExistingImportRow[]>();
|
||||
for (const row of existing) {
|
||||
const key = `${row.accountId}:${row.amountCents}`;
|
||||
byKey.set(key, [...(byKey.get(key) ?? []), row]);
|
||||
}
|
||||
const claimed = new Set<number>();
|
||||
const insert: T[] = [];
|
||||
let duplicates = 0;
|
||||
for (const row of rows) {
|
||||
const idKey = `${row.accountId}:${row.externalId}`;
|
||||
if (ids.has(idKey)) {
|
||||
duplicates++;
|
||||
continue;
|
||||
}
|
||||
const match = (byKey.get(`${row.accountId}:${row.amountCents}`) ?? [])
|
||||
.filter((candidate) => !claimed.has(candidate.id) && daysApart(candidate.date, row.date) <= 1)
|
||||
.sort((a, b) => daysApart(a.date, row.date) - daysApart(b.date, row.date))[0];
|
||||
if (match) {
|
||||
claimed.add(match.id);
|
||||
duplicates++;
|
||||
continue;
|
||||
}
|
||||
ids.add(idKey);
|
||||
insert.push(row);
|
||||
}
|
||||
return { insert, duplicates };
|
||||
}
|
||||
|
||||
@@ -18,12 +18,13 @@ import {
|
||||
invalid,
|
||||
optionalText,
|
||||
parseTransactionImport,
|
||||
planImport,
|
||||
positiveId,
|
||||
textField,
|
||||
utilityKind,
|
||||
validDate
|
||||
} from '#lib/server/money-tracker.js';
|
||||
import { and, asc, desc, eq } from 'drizzle-orm';
|
||||
import { and, asc, desc, eq, gte, inArray } from 'drizzle-orm';
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
@@ -48,7 +49,14 @@ export const load: PageServerLoad = async (event) => {
|
||||
.where(eq(moneyAccounts.userId, userId))
|
||||
.orderBy(asc(moneyAccounts.name)),
|
||||
db
|
||||
.select()
|
||||
.select({
|
||||
id: moneyTransactions.id,
|
||||
accountId: moneyTransactions.accountId,
|
||||
date: moneyTransactions.date,
|
||||
description: moneyTransactions.description,
|
||||
amountCents: moneyTransactions.amountCents,
|
||||
source: moneyTransactions.source
|
||||
})
|
||||
.from(moneyTransactions)
|
||||
.where(eq(moneyTransactions.userId, userId))
|
||||
.orderBy(asc(moneyTransactions.date), asc(moneyTransactions.id)),
|
||||
@@ -452,18 +460,18 @@ export const actions: Actions = {
|
||||
const file = form.get('file');
|
||||
if (!(file instanceof File) || !file.size || file.size > 2_000_000)
|
||||
return invalid('Choose a CSV or TSV file under 2 MB.');
|
||||
const chosenId = positiveId(form.get('accountId'));
|
||||
const accounts = await db.select().from(moneyAccounts).where(eq(moneyAccounts.userId, user.id));
|
||||
const chosen = chosenId ? accounts.find((account) => account.id === chosenId) : undefined;
|
||||
if (chosenId && !chosen) return invalid('Choose an account.');
|
||||
let parsed: ImportedTransaction[];
|
||||
try {
|
||||
parsed = parseTransactionImport(await file.text());
|
||||
parsed = parseTransactionImport(await file.text(), chosen?.name);
|
||||
} catch (cause) {
|
||||
return invalid(cause instanceof Error ? cause.message : 'Invalid CSV.');
|
||||
}
|
||||
await db.transaction(async (tx) => {
|
||||
const existing = await tx
|
||||
.select()
|
||||
.from(moneyAccounts)
|
||||
.where(eq(moneyAccounts.userId, user.id));
|
||||
const ids = new Map(existing.map((account) => [account.name.toLowerCase(), account.id]));
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const ids = new Map(accounts.map((account) => [account.name.toLowerCase(), account.id]));
|
||||
for (const name of [...new Set(parsed.map((row) => row.account))]) {
|
||||
if (ids.has(name.toLowerCase())) continue;
|
||||
const [account] = await tx
|
||||
@@ -472,18 +480,50 @@ export const actions: Actions = {
|
||||
.returning({ id: moneyAccounts.id });
|
||||
ids.set(name.toLowerCase(), account.id);
|
||||
}
|
||||
for (let i = 0; i < parsed.length; i += 500) {
|
||||
await tx.insert(moneyTransactions).values(
|
||||
parsed.slice(i, i + 500).map((row) => ({
|
||||
userId: user.id,
|
||||
accountId: ids.get(row.account.toLowerCase())!,
|
||||
date: row.date,
|
||||
description: row.description,
|
||||
amountCents: row.amountCents
|
||||
}))
|
||||
const rows = parsed.map((row) => ({
|
||||
...row,
|
||||
accountId: ids.get(row.account.toLowerCase())!
|
||||
}));
|
||||
const accountIds = [...new Set(rows.map((row) => row.accountId))];
|
||||
const existing = await tx
|
||||
.select({
|
||||
id: moneyTransactions.id,
|
||||
accountId: moneyTransactions.accountId,
|
||||
date: moneyTransactions.date,
|
||||
amountCents: moneyTransactions.amountCents,
|
||||
externalId: moneyTransactions.externalId
|
||||
})
|
||||
.from(moneyTransactions)
|
||||
.where(
|
||||
and(
|
||||
eq(moneyTransactions.userId, user.id),
|
||||
inArray(moneyTransactions.accountId, accountIds),
|
||||
gte(moneyTransactions.date, addDays(rows[0].date, -1))
|
||||
)
|
||||
);
|
||||
const { insert, duplicates } = planImport(rows, existing);
|
||||
for (let i = 0; i < insert.length; i += 500) {
|
||||
await tx
|
||||
.insert(moneyTransactions)
|
||||
.values(
|
||||
insert.slice(i, i + 500).map((row) => ({
|
||||
userId: user.id,
|
||||
accountId: row.accountId,
|
||||
date: row.date,
|
||||
description: row.description,
|
||||
amountCents: row.amountCents,
|
||||
externalId: row.externalId,
|
||||
source: 'csv'
|
||||
}))
|
||||
)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
return { imported: insert.length, duplicates };
|
||||
});
|
||||
return { imported: parsed.length };
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
function addDays(date: string, days: number) {
|
||||
return new Date(Date.parse(date + 'T00:00:00Z') + days * 86_400_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
import Upload from 'lucide-svelte/icons/upload';
|
||||
import Download from 'lucide-svelte/icons/download';
|
||||
import MoneyChart from '#lib/money-tracker/MoneyChart.svelte';
|
||||
import QuickAdd from '#lib/money-tracker/QuickAdd.svelte';
|
||||
import { Badge } from '#lib/components/ui/badge/index.js';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Button } from '#lib/components/ui/button/index.js';
|
||||
import { Input } from '#lib/components/ui/input/index.js';
|
||||
import * as Tabs from '#lib/components/ui/tabs/index.js';
|
||||
@@ -68,6 +71,8 @@
|
||||
let pageNumber = $state(1);
|
||||
let selectedUtility = $state<(typeof kinds)[number]['id']>('electricity');
|
||||
let dialogOpen = $state(false);
|
||||
let importAccountId = $state('');
|
||||
let importNotice = $state('');
|
||||
let actualTransactions = $derived(data.transactions.filter((item) => item.date <= data.today));
|
||||
let futureTransactions = $derived(data.transactions.filter((item) => item.date > data.today));
|
||||
let balances = $derived(accountBalances(data.accounts, actualTransactions));
|
||||
@@ -280,6 +285,9 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<QuickAdd accounts={data.accounts} transactions={data.transactions} today={data.today} />
|
||||
{#if importNotice}<p class="mb-4 text-sm" role="status">{importNotice}</p>{/if}
|
||||
|
||||
<div class="dashboard">
|
||||
<section class="panel span-8">
|
||||
<div class="panel-heading">
|
||||
@@ -380,9 +388,13 @@
|
||||
<h2>Transactions</h2>
|
||||
<p>{filtered.length} of {data.transactions.length} entries</p>
|
||||
</div>
|
||||
<Button variant="outline" onclick={exportCsv} disabled={!data.transactions.length}
|
||||
><Download size={16} /> Export CSV</Button
|
||||
>
|
||||
<div class="header-actions">
|
||||
<Button variant="outline" href={resolve('/projects/money-tracker/transactions')}
|
||||
>All transactions</Button
|
||||
><Button variant="outline" onclick={exportCsv} disabled={!data.transactions.length}
|
||||
><Download size={16} /> Export CSV</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<label class="sr-only" for="transaction-search">Search transactions</label><Input
|
||||
@@ -430,7 +442,10 @@
|
||||
><tbody
|
||||
>{#each shown as row (row.id)}<tr class:future={row.date > data.today}
|
||||
><td class="nowrap">{dateLabel(row.date)}</td><td class="strong"
|
||||
>{row.description}</td
|
||||
>{row.description}{#if row.source !== 'manual'}
|
||||
<Badge variant="secondary" class="ml-2"
|
||||
>{row.source === 'enablebanking' ? 'bank' : row.source}</Badge
|
||||
>{/if}</td
|
||||
><td class="nowrap"
|
||||
><span class="account-cell"
|
||||
>{@render swatch(row.accountId)}{accountName(row.accountId)}</span
|
||||
@@ -743,10 +758,11 @@
|
||||
{:else if mode === 'import'}
|
||||
<p class="dialog-copy">
|
||||
Upload your CSV or tab-separated export with <code>date, account, description, amount</code>
|
||||
columns. Dates like <code>Fri 1 Aug 2025</code> and amounts like <code>-€1,234.50</code> are supported.
|
||||
columns, or a Revolut statement export (only completed EUR rows are imported, fees included).
|
||||
Dates like <code>Fri 1 Aug 2025</code> and amounts like <code>-€1,234.50</code> are supported.
|
||||
Empty rows are skipped, and entries appear in date order. “Start balance” rows import as transactions,
|
||||
so leave those accounts’ opening balances at zero. New account names are created automatically.
|
||||
Importing a file twice adds duplicate transactions.
|
||||
Rows already imported, or already entered with the same amount within a day, are skipped.
|
||||
</p>
|
||||
<form
|
||||
method="POST"
|
||||
@@ -756,7 +772,11 @@
|
||||
async ({ result, update }) => {
|
||||
await update();
|
||||
showActionError = result.type === 'failure';
|
||||
if (result.type === 'success') dialogOpen = false;
|
||||
if (result.type === 'success') {
|
||||
dialogOpen = false;
|
||||
const done = result.data as { imported?: number; duplicates?: number } | undefined;
|
||||
importNotice = `Imported ${done?.imported ?? 0} transactions; skipped ${done?.duplicates ?? 0} duplicates.`;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label class="field"
|
||||
@@ -767,6 +787,16 @@
|
||||
required
|
||||
/></label
|
||||
>
|
||||
<label class="field"
|
||||
>Account (for files without an account column, like Revolut statements)<NativeSelect
|
||||
name="accountId"
|
||||
bind:value={importAccountId}
|
||||
><option value="">From the file</option
|
||||
>{#each data.accounts as account (account.id)}<option value={String(account.id)}
|
||||
>{account.name}</option
|
||||
>{/each}</NativeSelect
|
||||
></label
|
||||
>
|
||||
<div class="dialog-footer">
|
||||
<Button type="button" class="button secondary" onclick={() => (dialogOpen = false)}
|
||||
>Cancel</Button
|
||||
|
||||
114
src/routes/projects/money-tracker/transactions/+page.server.ts
Normal file
114
src/routes/projects/money-tracker/transactions/+page.server.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { moneyAccounts, moneyTransactions } from '#lib/server/db/schema/index.js';
|
||||
import { requireAdmin } from '#lib/server/authorization.js';
|
||||
import { positiveId, validDate } from '#lib/server/money-tracker.js';
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
desc,
|
||||
eq,
|
||||
gt,
|
||||
gte,
|
||||
ilike,
|
||||
inArray,
|
||||
isNotNull,
|
||||
lt,
|
||||
lte,
|
||||
or,
|
||||
sql,
|
||||
sum,
|
||||
type SQL
|
||||
} from 'drizzle-orm';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const sources = ['manual', 'csv', 'enablebanking', 'linked'] as const;
|
||||
const sorts = ['newest', 'oldest', 'largest', 'smallest'] as const;
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
if (!event.locals.user)
|
||||
redirect(303, '/login?returnTo=' + encodeURIComponent(event.url.pathname + event.url.search));
|
||||
const user = requireAdmin(event);
|
||||
const params = event.url.searchParams;
|
||||
const q = (params.get('q') ?? '').trim().slice(0, 100);
|
||||
const accountIds = params
|
||||
.getAll('account')
|
||||
.map(positiveId)
|
||||
.filter((id): id is number => id !== null);
|
||||
const source = sources.find((item) => item === params.get('source')) ?? '';
|
||||
const type = params.get('type') === 'in' ? 'in' : params.get('type') === 'out' ? 'out' : '';
|
||||
const from = validDate(params.get('from')) ?? '';
|
||||
const to = validDate(params.get('to')) ?? '';
|
||||
const sort = sorts.find((item) => item === params.get('sort')) ?? 'newest';
|
||||
const requestedPage = positiveId(params.get('page')) ?? 1;
|
||||
|
||||
const t = moneyTransactions;
|
||||
const filters: (SQL | undefined)[] = [eq(t.userId, user.id)];
|
||||
if (q) {
|
||||
const pattern = '%' + q.replace(/[\\%_]/g, (c) => '\\' + c) + '%';
|
||||
filters.push(or(ilike(t.description, pattern), ilike(t.counterparty, pattern)));
|
||||
}
|
||||
if (accountIds.length) filters.push(inArray(t.accountId, accountIds));
|
||||
if (source === 'linked') filters.push(and(eq(t.source, 'manual'), isNotNull(t.externalId)));
|
||||
else if (source) filters.push(eq(t.source, source));
|
||||
if (type === 'in') filters.push(gt(t.amountCents, 0));
|
||||
if (type === 'out') filters.push(lt(t.amountCents, 0));
|
||||
if (from) filters.push(gte(t.date, from));
|
||||
if (to) filters.push(lte(t.date, to));
|
||||
const where = and(...filters);
|
||||
|
||||
const [accounts, [totals]] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: moneyAccounts.id,
|
||||
name: moneyAccounts.name,
|
||||
color: moneyAccounts.color,
|
||||
scope: moneyAccounts.scope
|
||||
})
|
||||
.from(moneyAccounts)
|
||||
.where(eq(moneyAccounts.userId, user.id))
|
||||
.orderBy(asc(moneyAccounts.name)),
|
||||
db
|
||||
.select({ count: count(), sum: sum(t.amountCents) })
|
||||
.from(t)
|
||||
.where(where)
|
||||
]);
|
||||
const pages = Math.max(1, Math.ceil(totals.count / PAGE_SIZE));
|
||||
const page = Math.min(requestedPage, pages);
|
||||
const order =
|
||||
sort === 'oldest'
|
||||
? [asc(t.date), asc(t.id)]
|
||||
: sort === 'largest'
|
||||
? [desc(t.amountCents), desc(t.date)]
|
||||
: sort === 'smallest'
|
||||
? [asc(t.amountCents), desc(t.date)]
|
||||
: [desc(t.date), desc(t.id)];
|
||||
const rows = await db
|
||||
.select({
|
||||
id: t.id,
|
||||
date: t.date,
|
||||
description: t.description,
|
||||
counterparty: t.counterparty,
|
||||
amountCents: t.amountCents,
|
||||
accountId: t.accountId,
|
||||
source: t.source,
|
||||
linked: sql<boolean>`${t.externalId} is not null`
|
||||
})
|
||||
.from(t)
|
||||
.where(where)
|
||||
.orderBy(...order)
|
||||
.limit(PAGE_SIZE)
|
||||
.offset((page - 1) * PAGE_SIZE);
|
||||
|
||||
return {
|
||||
accounts,
|
||||
rows,
|
||||
total: totals.count,
|
||||
sumCents: Number(totals.sum ?? 0),
|
||||
page,
|
||||
pages,
|
||||
filters: { q, accountIds, source, type, from, to, sort }
|
||||
};
|
||||
};
|
||||
205
src/routes/projects/money-tracker/transactions/+page.svelte
Normal file
205
src/routes/projects/money-tracker/transactions/+page.svelte
Normal file
@@ -0,0 +1,205 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import { Badge } from '#lib/components/ui/badge/index.js';
|
||||
import { Button } from '#lib/components/ui/button/index.js';
|
||||
import { Input } from '#lib/components/ui/input/index.js';
|
||||
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
||||
import * as Table from '#lib/components/ui/table/index.js';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
const euro = new Intl.NumberFormat('en-NL', { style: 'currency', currency: 'EUR' });
|
||||
const sourceLabels: Record<string, string> = {
|
||||
manual: 'Manual',
|
||||
csv: 'CSV',
|
||||
enablebanking: 'Bank'
|
||||
};
|
||||
let names = $derived(new Map(data.accounts.map((account) => [account.id, account.name])));
|
||||
|
||||
function dateLabel(value: string) {
|
||||
return new Date(value + 'T12:00:00Z').toLocaleDateString('en-NL', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
}
|
||||
function href(changes: Record<string, string | number | null>) {
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams);
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (value === null || value === '') params.delete(key);
|
||||
else params.set(key, String(value));
|
||||
}
|
||||
const query = params.toString();
|
||||
return resolve('/projects/money-tracker/transactions') + (query ? '?' + query : '');
|
||||
}
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget as HTMLFormElement);
|
||||
const params = new SvelteURLSearchParams();
|
||||
for (const [key, value] of form)
|
||||
if (typeof value === 'string' && value) params.append(key, value);
|
||||
const query = params.toString();
|
||||
goto(resolve('/projects/money-tracker/transactions') + (query ? '?' + query : ''), {
|
||||
keepFocus: true,
|
||||
noScroll: true
|
||||
});
|
||||
}
|
||||
function autoSubmit(event: Event) {
|
||||
(event.currentTarget as HTMLElement).closest('form')?.requestSubmit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Transactions · Money tracker</title></svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-screen-xl space-y-6 px-4 py-8 sm:px-6 sm:py-10">
|
||||
<header class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<a
|
||||
class="inline-flex items-center gap-1 text-sm opacity-70 hover:opacity-100"
|
||||
href={resolve('/projects/money-tracker')}><ArrowLeft size={14} /> Money tracker</a
|
||||
>
|
||||
<h1 class="text-2xl font-bold">Transactions</h1>
|
||||
<p class="text-sm opacity-70">
|
||||
{data.total.toLocaleString('en-NL')} matching · net
|
||||
<span class:text-green-600={data.sumCents > 0} class:text-red-600={data.sumCents < 0}
|
||||
>{euro.format(data.sumCents / 100)}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form class="grid gap-3 sm:grid-cols-2 lg:grid-cols-6" method="GET" onsubmit={submit}>
|
||||
<label class="space-y-1 text-sm lg:col-span-2"
|
||||
><span>Search</span><Input
|
||||
type="search"
|
||||
name="q"
|
||||
placeholder="Description or counterparty"
|
||||
value={data.filters.q}
|
||||
/></label
|
||||
>
|
||||
<label class="space-y-1 text-sm"
|
||||
><span>Account</span><NativeSelect
|
||||
name="account"
|
||||
value={data.filters.accountIds.length === 1 ? String(data.filters.accountIds[0]) : ''}
|
||||
onchange={autoSubmit}
|
||||
><option value="">All accounts</option>{#each data.accounts as account (account.id)}<option
|
||||
value={String(account.id)}>{account.name}</option
|
||||
>{/each}</NativeSelect
|
||||
></label
|
||||
>
|
||||
<label class="space-y-1 text-sm"
|
||||
><span>Source</span><NativeSelect
|
||||
name="source"
|
||||
value={data.filters.source}
|
||||
onchange={autoSubmit}
|
||||
><option value="">Any source</option><option value="manual">Manual</option><option
|
||||
value="linked">Manual, matched to bank</option
|
||||
><option value="csv">CSV import</option><option value="enablebanking">Bank sync</option
|
||||
></NativeSelect
|
||||
></label
|
||||
>
|
||||
<label class="space-y-1 text-sm"
|
||||
><span>Type</span><NativeSelect name="type" value={data.filters.type} onchange={autoSubmit}
|
||||
><option value="">In and out</option><option value="out">Expenses</option><option value="in"
|
||||
>Income</option
|
||||
></NativeSelect
|
||||
></label
|
||||
>
|
||||
<label class="space-y-1 text-sm"
|
||||
><span>Sort</span><NativeSelect name="sort" value={data.filters.sort} onchange={autoSubmit}
|
||||
><option value="newest">Newest first</option><option value="oldest">Oldest first</option
|
||||
><option value="largest">Largest income</option><option value="smallest"
|
||||
>Largest expense</option
|
||||
></NativeSelect
|
||||
></label
|
||||
>
|
||||
<label class="space-y-1 text-sm"
|
||||
><span>From</span><Input
|
||||
type="date"
|
||||
name="from"
|
||||
value={data.filters.from}
|
||||
onchange={autoSubmit}
|
||||
/></label
|
||||
>
|
||||
<label class="space-y-1 text-sm"
|
||||
><span>To</span><Input
|
||||
type="date"
|
||||
name="to"
|
||||
value={data.filters.to}
|
||||
onchange={autoSubmit}
|
||||
/></label
|
||||
>
|
||||
<div class="flex items-end gap-2">
|
||||
<Button type="submit">Apply</Button><Button
|
||||
variant="ghost"
|
||||
href={resolve('/projects/money-tracker/transactions')}>Reset</Button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Table.Root>
|
||||
<Table.Header
|
||||
><Table.Row
|
||||
><Table.Head>Date</Table.Head><Table.Head>Description</Table.Head><Table.Head
|
||||
>Account</Table.Head
|
||||
><Table.Head>Source</Table.Head><Table.Head class="text-right">Amount</Table.Head
|
||||
></Table.Row
|
||||
></Table.Header
|
||||
>
|
||||
<Table.Body>
|
||||
{#each data.rows as row (row.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="whitespace-nowrap">{dateLabel(row.date)}</Table.Cell>
|
||||
<Table.Cell
|
||||
><span class="font-medium">{row.description}</span
|
||||
>{#if row.counterparty && row.counterparty !== row.description}<span
|
||||
class="block text-xs opacity-60">{row.counterparty}</span
|
||||
>{/if}</Table.Cell
|
||||
>
|
||||
<Table.Cell class="whitespace-nowrap"
|
||||
>{names.get(row.accountId ?? 0) ?? 'Unassigned'}</Table.Cell
|
||||
>
|
||||
<Table.Cell
|
||||
><Badge variant={row.source === 'manual' ? 'outline' : 'secondary'}
|
||||
>{sourceLabels[row.source] ?? row.source}</Badge
|
||||
>{#if row.source === 'manual' && row.linked}<Badge
|
||||
variant="ghost"
|
||||
title="Matched to a bank transaction">✓ bank</Badge
|
||||
>{/if}</Table.Cell
|
||||
>
|
||||
<Table.Cell
|
||||
class={'text-right tabular-nums ' +
|
||||
(row.amountCents < 0 ? 'text-red-600' : 'text-green-600')}
|
||||
>{euro.format(row.amountCents / 100)}</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row
|
||||
><Table.Cell colspan={5} class="py-8 text-center opacity-70"
|
||||
>No matching transactions.</Table.Cell
|
||||
></Table.Row
|
||||
>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
|
||||
{#if data.pages > 1}
|
||||
<nav class="flex items-center justify-between text-sm" aria-label="Pagination">
|
||||
<span>Page {data.page} of {data.pages}</span>
|
||||
<div class="flex gap-2">
|
||||
{#if data.page > 1}<Button variant="outline" href={href({ page: data.page - 1 })}
|
||||
>Previous</Button
|
||||
>{/if}
|
||||
{#if data.page < data.pages}<Button variant="outline" href={href({ page: data.page + 1 })}
|
||||
>Next</Button
|
||||
>{/if}
|
||||
</div>
|
||||
</nav>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user