feat(accounts): friends ledger for debts, settle up and offset, asset accounts by currency

Liabilities show one card per friend with who owes whom, last movement,
average days to pay back and twelve months of lending. s settles up,
+ lends more, o offsets two debts through the prefilled editor. Asset
accounts group by currency with totals, inactive ones behind i.
This commit is contained in:
hh committed 2026-10-16 15:05:56 +00:00
1 parent 60b099e917
commit 6f39343e07
9 files changed
+1103 -64

No files matched your search

+122 -36
View File
@@ -13,6 +13,7 @@
import Sparkline from "#lib/components/charts/Sparkline.svelte";
import {
balanceRange,
byCurrency,
type ChartSet,
seriesPoints,
} from "#lib/components/dashboard/data.js";
@@ -123,15 +124,33 @@
let armed = $state(false);
let failure = $state("");
const ROLE_RANK: Record<string, number> = {
cashWalletAsset: 0,
ccAsset: 1,
defaultAsset: 2,
savingAsset: 4,
sharedAsset: 3,
};
const perCurrency = $derived(type === "asset" && active);
const showInactive = $derived(
perCurrency && page.url.searchParams.has("inactive")
);
const accounts = createQuery(() => ({
placeholderData: keepPreviousData,
queryFn: () =>
all<Account>("/v1/accounts", {
...balanceRange(period.start, period.end),
active: String(active),
...(showInactive ? {} : { active: String(active) }),
type,
}),
queryKey: ["accounts-index", type, active, period.start, period.end],
queryKey: [
"accounts-index",
type,
showInactive ? "all" : active,
period.start,
period.end,
],
}));
const others = createQuery(() => ({
queryFn: () =>
@@ -228,8 +247,12 @@
const sorted = $derived.by(() => {
const list = [...matching];
if (!sortKey) {
const rank = (item: Item<Account>) =>
perCurrency ? (ROLE_RANK[item.attributes.account_role ?? ""] ?? 5) : 0;
return list.sort(
(x, y) => (x.attributes.order ?? 0) - (y.attributes.order ?? 0)
(x, y) =>
rank(x) - rank(y) ||
(x.attributes.order ?? 0) - (y.attributes.order ?? 0)
);
}
const dir = descending ? -1 : 1;
@@ -243,9 +266,15 @@
});
});
const grouped = $derived(
sorted.some((item) => item.attributes.object_group_title)
perCurrency || sorted.some((item) => item.attributes.object_group_title)
);
const groups = $derived.by(() => {
if (perCurrency) {
return byCurrency(
sorted,
(item) => item.attributes.currency_code ?? ""
).map(([code, items]) => ({ items, title: code }));
}
if (!grouped) {
return [{ items: sorted, title: "" }];
}
@@ -327,6 +356,18 @@
}
},
},
...(perCurrency
? [
{
group: "Accounts",
keys: "i",
label: showInactive
? "Hide inactive accounts"
: "Show inactive accounts",
run: () => goto(inactiveHref(), { replace: true }),
},
]
: []),
],
id: "accounts",
items: () => rows,
@@ -335,6 +376,18 @@
});
nav.use();
function inactiveHref() {
const url = new URL(page.url.href);
if (showInactive) {
url.searchParams.delete("inactive");
} else {
url.searchParams.set("inactive", "1");
}
url.searchParams.set("start", period.start);
url.searchParams.set("end", period.end);
return url.pathname + url.search;
}
function onfilterkey(event: KeyboardEvent) {
if (event.key === "Escape" && filter) {
filter = "";
@@ -417,6 +470,47 @@
}
</script>
{#snippet sums(
t: Total,
label: string,
head = false
)}
{#each columns as col, c (col.key)}
<td
class="px-3 {col.num ? "text-right" : ""} {col.wide
? "hidden md:table-cell"
: ""}"
>
{#if c === 0}
<span
class="font-semibold text-[12px] {head
? "text-cladd-fg"
: "text-cladd-fg-soft"}"
>{label}</span
>
{#if head}
<Amount
{...t}
amount={t.difference}
class="ml-2 text-[12px] md:hidden"
signed
/>
{/if}
{:else if col.key === "balance"}
<span class="num font-semibold"
>{formatMoney({ ...t, amount: t.balance })}</span
>
{:else if col.key === "due"}
<span class="num font-semibold"
>{formatMoney({ ...t, amount: t.due })}</span
>
{:else if col.key === "difference"}
<Amount {...t} amount={t.difference} class="font-semibold" />
{/if}
</td>
{/each}
{/snippet}
<PageHeader
crumbs={[{ label: title }]}
subtitle={active ? undefined : "Archive"}
@@ -502,7 +596,14 @@
</thead>
<tbody>
{#each groups as group (group.title)}
{#if grouped}
{#if perCurrency}
{@const t = totals.find((x) => x.code === group.title)}
<tr class="h-8 border-cladd-outline border-t-2">
{#if t}
{@render sums(t, group.title, true)}
{/if}
</tr>
{:else if grouped}
<tr class="h-8 border-cladd-outline border-t">
<th
class="px-3 text-left font-semibold text-[12px] text-cladd-fg"
@@ -517,7 +618,9 @@
{@const a = item.attributes}
{@const i = index.get(item.id) ?? -1}
<tr
class="h-7 border-cladd-outline/60 border-t {rowClass(i)}"
class="h-7 border-cladd-outline/60 border-t {rowClass(
i
)} {a.active ? "" : "opacity-60"}"
data-id={item.id}
id={nav.rowId(i)}
onclick={(event) => nav.click(i, event)}
@@ -603,38 +706,11 @@
</tr>
{/if}
</tbody>
{#if totals.length > 0}
{#if totals.length > 0 && !perCurrency}
<tfoot class="border-cladd-outline border-t-2">
{#each totals as t (t.code)}
<tr class="h-7">
{#each columns as col, c (col.key)}
<td
class="px-3 {col.num ? "text-right" : ""} {col.wide
? "hidden md:table-cell"
: ""}"
>
{#if c === 0}
<span
class="font-semibold text-[12px] text-cladd-fg-soft"
>Total in {t.code}</span
>
{:else if col.key === "balance"}
<span class="num font-semibold"
>{formatMoney({ ...t, amount: t.balance })}</span
>
{:else if col.key === "due"}
<span class="num font-semibold"
>{formatMoney({ ...t, amount: t.due })}</span
>
{:else if col.key === "difference"}
<Amount
{...t}
amount={t.difference}
class="font-semibold"
/>
{/if}
</td>
{/each}
{@render sums(t, `Total in ${t.code}`)}
</tr>
{/each}
</tfoot>
@@ -690,7 +766,17 @@
{#if failure}
<span class="text-expense">{failure}</span>
{/if}
{#if active && otherCount > 0}
{#if perCurrency && (showInactive || otherCount > 0)}
<a
class="ml-auto text-cladd-fg-soft hover:underline"
data-sveltekit-replacestate
href={inactiveHref()}
>{showInactive
? "Hide inactive accounts"
: `Show ${otherCount} inactive ${otherCount === 1 ? "account" : "accounts"}`}
<span class="kbd">i</span></a
>
{:else if active && otherCount > 0}
<a
class="ml-auto text-cladd-fg-soft hover:underline"
href="/inactive-accounts/{type}"