feat(transactions,search): dense split-aware tables, mass edit, query-language search and show page
This commit is contained in:
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>
|
||||
Reference in new issue
Block a user