feat(transactions,search): dense split-aware tables, mass edit, query-language search and show page

This commit is contained in:
hh committed 2026-10-16 03:33:54 +00:00
1 parent 7e19684cff
commit 772047c0a1
16 files changed
+3300 -2

No files matched your search

@@ -0,0 +1,154 @@
<script lang="ts">
import type { Split } from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import {
type Group,
type Signed,
signedAmount,
sumByCurrency,
} from "#lib/transactions/split.js";
const TOP = 5;
const TOTALS = [
["withdrawal", "Spent"],
["deposit", "Earned"],
["transfer", "Transferred"],
] as const;
interface Entry extends Signed {
href?: string;
name: string;
}
let {
groups,
type,
loading = false,
}: { groups: Group[]; type: string; loading?: boolean } = $props();
const splits = $derived(groups.flatMap((g) => g.attributes.transactions));
const totals = $derived(
TOTALS.map(([kind, label]) => ({
label,
sums: sumByCurrency(splits.filter((s) => s.type === kind)),
})).filter((row) => row.sums.length > 0)
);
function aggregate(
list: Split[],
pick: (split: Split) => [string, string | undefined]
): { entries: Entry[]; more: number } {
const map = new Map<string, Entry>();
for (const split of list) {
const [name, href] = pick(split);
const money = signedAmount(split);
const key = `${name}|${money.code}`;
const entry = map.get(key);
if (entry) {
entry.amount += money.amount;
} else {
map.set(key, { ...money, href, name });
}
}
const sorted = [...map.values()].sort(
(a, b) => Math.abs(b.amount) - Math.abs(a.amount)
);
return { entries: sorted.slice(0, TOP), more: sorted.length - TOP };
}
const blocks = $derived([
{
title: "Categories",
...aggregate(splits, (s) =>
s.category_id
? [s.category_name ?? "", `/categories/show/${s.category_id}`]
: ["(no category)", undefined]
),
},
type === "withdrawal"
? {
title: "Budgets",
...aggregate(splits, (s) =>
s.budget_id
? [s.budget_name ?? "", `/budgets/show/${s.budget_id}`]
: ["(no budget)", undefined]
),
}
: {
title: "Source accounts",
...aggregate(splits, (s) => [
s.source_name ?? "(cash)",
s.source_id ? `/accounts/show/${s.source_id}` : undefined,
]),
},
{
title: "Destination accounts",
...aggregate(splits, (s) => [
s.destination_name ?? "(cash)",
s.destination_id ? `/accounts/show/${s.destination_id}` : undefined,
]),
},
]);
</script>
{#snippet amount(
value: Signed
)}
<Amount
amount={value.amount}
code={value.code}
decimals={value.decimals}
symbol={value.symbol}
tone={value.tone === "transfer" ? "transfer" : "auto"}
/>
{/snippet}
<div class="mb-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<Box body="px-3 py-2" title="Overview">
{#if loading && groups.length === 0}
<p class="text-cladd-fg-soft">Loading...</p>
{:else}
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<dt class="text-cladd-fg-soft">Transactions</dt>
<dd class="num text-right">{groups.length}</dd>
{#each totals as row (row.label)}
<dt class="text-cladd-fg-soft">{row.label}</dt>
<dd class="flex flex-col items-end">
{#each row.sums as sum (sum.code)}
{@render amount(sum)}
{/each}
</dd>
{/each}
</dl>
{/if}
</Box>
{#each blocks as block (block.title)}
<Box body="py-1" class="max-md:hidden" title={block.title}>
<ul>
{#each block.entries as entry (`${entry.name}|${entry.code}`)}
<li class="flex h-6 items-center justify-between gap-3 px-3">
{#if entry.href}
<a class="truncate hover:underline" href={entry.href}
>{entry.name}</a
>
{:else}
<span class="truncate text-cladd-fg-soft">{entry.name}</span>
{/if}
{@render amount(entry)}
</li>
{:else}
<li class="px-3 py-1 text-cladd-fg-soft">
{loading ? "Loading..." : "Nothing in this period."}
</li>
{/each}
{#if block.more > 0}
<li class="px-3 text-[12px] text-cladd-fg-softer">
and {block.more} more
</li>
{/if}
</ul>
</Box>
{/each}
</div>