feat(transactions): the transaction page as a receipt with context

The amount is the headline, notes come first at full width, then the
last twelve visits to the same place as a dot timeline next to the
paying account's days around it with the running balance. Fields edit in
place: j and k move, Enter opens and saves, Esc cancels, s c, s b, s t
and s s jump to classification. A matching subscription shows as a
suggestion, L links it.
This commit is contained in:
hh committed 2026-10-16 15:51:00 +00:00
1 parent 58b356ea8b
commit 349a6a0b32
14 files changed
+2137 -683

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import type { Item, TransactionGroup } from "#lib/api/types.js";
import Place from "./Place.svelte";
import SameDays from "./SameDays.svelte";
import { loadPlace, placeOf, visitOf, visitsIn } from "./usual.js";
const OWN = {
deposit: "destination",
transfer: "source",
withdrawal: "source",
} as const;
const TONE: Record<string, string> = {
deposit: "text-income",
transfer: "text-transfer",
withdrawal: "text-expense",
};
let { group }: { group: Item<TransactionGroup> } = $props();
const splits = $derived(group.attributes.transactions);
const first = $derived(splits[0]);
const day = $derived(first.date.slice(0, 10));
const currency = $derived({
code: first.currency_code,
decimals: first.currency_decimal_places,
symbol: first.currency_symbol,
});
const currencies = $derived(
[
currency,
{
code: first.foreign_currency_code,
decimals: first.foreign_currency_decimal_places,
symbol: first.foreign_currency_symbol,
},
].filter((option) => option.code)
);
const place = $derived(placeOf(splits));
const own = $derived.by(() => {
const side = OWN[first.type as keyof typeof OWN];
const id = side ? first[`${side}_id`] : null;
return side && id ? { id, name: first[`${side}_name`] ?? "" } : null;
});
const history = createQuery(() => ({
enabled: place !== null,
queryFn: () =>
place ? loadPlace(place, group.id, day) : Promise.resolve([]),
queryKey: ["receipt", "place", group.id, place?.id, day],
}));
const best = $derived.by(() => {
if (!place) {
return null;
}
const options = currencies.flatMap((option) => {
const current = visitOf(group, place, option.code ?? "");
return current
? [
{
currency: option,
current,
past: visitsIn(history.data ?? [], place, option.code ?? ""),
},
]
: [];
});
return options.reduce<(typeof options)[number] | null>(
(top, option) =>
top && top.past.length >= option.past.length ? top : option,
null
);
});
</script>
{#if best || own}
<div class="grid items-stretch gap-3 lg:grid-cols-2">
{#if place && best}
<Place
currency={best.currency}
current={best.current}
loading={history.isPending}
past={best.past}
{place}
tone={TONE[first.type] ?? "text-cladd-fg"}
/>
{/if}
{#if own}
<SameDays account={own} {currency} {day} groupId={group.id} />
{/if}
</div>
{/if}