money: quick-add bar, server-filtered transactions page, CSV import dedupe + Revolut statements
This commit is contained in:
@@ -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