feat(accounts): the account page as a statement, all accounts instead of the audit report

Asset and liability pages open with opening plus income and money moved
in, minus spending and money moved out, equals closing, then thirteen
months of in and out next to the daily balance, and the statement with
its balance column, day subtotals on ; and the last reconciled date.
The audit report becomes one table of all accounts for the period.
This commit is contained in:
hh committed 2026-10-16 15:36:41 +00:00
1 parent b10a6bd21a
commit f3a597d39c
11 files changed
+1118 -548

No files matched your search

@@ -0,0 +1,248 @@
<script lang="ts">
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { owed } from "#lib/accounts/ledger.js";
import { moves, sums } from "#lib/accounts/statement.js";
import { lists } from "#lib/api/lists.js";
import type { Account, Item, TransactionGroup } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import { addDays } from "#lib/dates.js";
import { formatMoney, type Money, toNumber } from "#lib/format.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { pages, reportQueries } from "#lib/reports/queries.js";
import { goto } from "$app/navigation";
import { showUrl, typeOf } from "./links.js";
interface Line {
closing: number;
cur: Omit<Money, "amount">;
id: string;
income: number;
liability: boolean;
moved: number;
name: string;
opening: number;
other: number;
rows: number;
spent: number;
}
const CENT = 0.005;
const NUM = "num whitespace-nowrap text-right";
let {
accounts = [],
start,
end,
}: { accounts?: string[]; start: string; end: string } = $props();
const assets = createQuery(() => lists.accounts("asset"));
const liabilities = createQuery(() => lists.accounts("liabilities"));
const balances = createQuery(() => ({
...reportQueries.balances(start, end),
placeholderData: keepPreviousData,
}));
const ledger = createQuery(() => ({
placeholderData: keepPreviousData,
queryFn: () =>
pages<TransactionGroup>("/v1/transactions", {
end: end > start ? end : addDays(start, 1),
start,
type: "all",
}),
queryKey: ["accounts-audit", start, end],
}));
const range = $derived({ end, start });
const ready = $derived(
Boolean(assets.data && liabilities.data && balances.data && ledger.data)
);
const loading = $derived(
balances.isPlaceholderData || ledger.isPlaceholderData
);
function flip(a: Account): number {
const debt = owed(a);
const balance = toNumber(a.current_balance);
return Math.abs(debt) > CENT && Math.sign(balance) === -Math.sign(debt)
? -1
: 1;
}
function lineOf(item: Item<Account>, liability: boolean): Line {
const a = item.attributes;
const sign = liability ? flip(a) : 1;
const { rows, total } = sums(
moves(item.id, a.currency_code, ledger.data ?? []),
range
);
return {
closing: sign * (balances.data?.after.get(item.id) ?? 0),
cur: {
code: a.currency_code,
decimals: a.currency_decimal_places,
symbol: a.currency_symbol,
},
id: item.id,
income: total.income,
liability,
moved: total.movedIn + total.movedOut,
name: a.name,
opening: sign * (balances.data?.before.get(item.id) ?? 0),
other: total.opening + total.adjusted,
rows,
spent: total.spent,
};
}
const lines = $derived.by(() => {
if (!ready) {
return [];
}
const chosen = new Set(accounts);
return [...(assets.data ?? []), ...(liabilities.data ?? [])]
.filter((item) =>
chosen.size > 0 ? chosen.has(item.id) : item.attributes.active
)
.map((item) => lineOf(item, typeOf(item.attributes) === "liabilities"));
});
const byActivity = (x: Line, y: Line) =>
y.rows - x.rows || x.name.localeCompare(y.name);
const busy = $derived(lines.filter((l) => l.rows > 0));
const sections = $derived(
[
{
key: "asset",
lines: busy.filter((l) => !l.liability).sort(byActivity),
title: "Asset accounts",
words: ["Income", "Spent"],
},
{
key: "liability",
lines: busy.filter((l) => l.liability).sort(byActivity),
title: "Liabilities",
words: ["In", "Out"],
},
].filter((s) => s.lines.length > 0)
);
const ordered = $derived(sections.flatMap((s) => s.lines));
const idle = $derived(
lines
.filter((l) => l.rows === 0)
.sort((x, y) => x.name.localeCompare(y.name))
);
const adjusted = $derived(lines.some((l) => Math.abs(l.other) > CENT));
const href = (id: string) => `${showUrl(id)}/${start}/${end}`;
const nav = new ListNav<Line>({
id: "all-accounts",
items: () => ordered,
key: (line) => line.id,
open: (line) => goto(href(line.id)),
});
nav.use();
const money = (line: Line, amount: number, signed = false) =>
formatMoney({ ...line.cur, amount }, signed);
function tone(value: number, positive: string, negative = positive) {
if (Math.abs(value) < CENT) {
return "text-cladd-fg-softer";
}
return value > 0 ? positive : negative;
}
const real = (line: Line, value: number) =>
line.liability
? tone(value, "text-cladd-fg")
: tone(value, "text-income", "text-expense");
</script>
<Box body="p-0 overflow-x-auto" title="Accounts">
<table
class="w-full min-w-[38rem] border-collapse md:min-w-[46rem] text-[13px] transition-opacity {loading
? "opacity-60"
: ""}"
>
{#each sections as section (section.key)}
<thead>
<tr
class="border-cladd-outline border-b text-left text-[12px] text-cladd-fg-soft [&>th]:h-8 [&>th]:whitespace-nowrap [&>th]:px-2 [&>th]:font-medium {section.key ===
"liability"
? "border-t"
: ""}"
>
<th class="text-cladd-fg">{section.title}</th>
<th class="w-14 max-md:hidden">Currency</th>
<th class="w-32 text-right">Opening</th>
<th class="w-28 text-right">{section.words[0]}</th>
<th class="w-28 text-right">{section.words[1]}</th>
<th class="w-28 text-right">Moved</th>
{#if adjusted}
<th class="w-28 text-right">Adjusted</th>
{/if}
<th class="w-32 text-right">Closing</th>
<th class="w-14 text-right max-md:hidden">Rows</th>
</tr>
</thead>
<tbody>
{#each section.lines as line (line.id)}
{@const index = ordered.indexOf(line)}
<tr
class="h-7 border-cladd-outline/60 border-b [&>td]:px-2 {nav.cursor ===
index
? "bg-row-cursor shadow-[inset_2px_0_0_var(--ff-brand)]"
: "hover:bg-row-hover"}"
id={nav.rowId(index)}
onclick={(event) => nav.click(index, event)}
>
<td class="max-w-0 truncate max-md:min-w-32">
<a class="hover:underline" href={href(line.id)} title={line.name}
>{line.name}</a
>
</td>
<td class="text-cladd-fg-soft max-md:hidden">{line.cur.code}</td>
<td class={NUM}>{money(line, line.opening)}</td>
<td class="{NUM} {real(line, line.income)}">
{money(line, line.income, true)}
</td>
<td class="{NUM} {real(line, line.spent)}">
{money(line, line.spent, true)}
</td>
<td class="{NUM} {tone(line.moved, "text-transfer")}">
{money(line, line.moved, true)}
</td>
{#if adjusted}
<td class="{NUM} {tone(line.other, "text-cladd-fg")}">
{money(line, line.other, true)}
</td>
{/if}
<td class="{NUM} font-semibold">{money(line, line.closing)}</td>
<td class="{NUM} text-cladd-fg-soft max-md:hidden">{line.rows}</td>
</tr>
{/each}
</tbody>
{/each}
{#if idle.length > 0}
<tbody>
<tr class="text-[12px] text-cladd-fg-softer [&>td]:px-2 [&>td]:py-1.5">
<td colspan={adjusted ? 9 : 8}>
No activity:
{#each idle as line (line.id)}
<a
class="hover:text-cladd-fg hover:underline [&:not(:last-child)]:after:content-[',_']"
href={href(line.id)}
>{line.name}</a
>
{/each}
</td>
</tr>
</tbody>
{/if}
</table>
{#if !ready}
<p class="px-3 py-6 text-center text-cladd-fg-soft">Loading accounts...</p>
{:else if lines.length === 0}
<p class="px-3 py-6 text-center text-cladd-fg-soft">
No accounts selected.
</p>
{/if}
</Box>