feat(accounts): debts in general words, actions named by what they record
Cards are titled by the account name with the position on its own line: owes you, you owe, settled, and paid back more than borrowed when a debt crossed zero against its direction, so one account per person covers both directions. Actions read Record repayment, Lend more, Pay back, Borrow more and Offset against another debt, which says what it moves. No more friends in the copy; the dashboard box is Debts.
This commit is contained in:
1 parent
4936258f32
commit
54895b9305
11 files changed
+352
-336
No files matched your search
@@ -2,10 +2,13 @@
|
||||
import Plus from "@lucide/svelte/icons/plus";
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import {
|
||||
type Action,
|
||||
actionsFor,
|
||||
isFriend,
|
||||
isDebt,
|
||||
movements,
|
||||
owed,
|
||||
type Stance,
|
||||
stance,
|
||||
} from "#lib/accounts/ledger.js";
|
||||
import { api } from "#lib/api/client.js";
|
||||
import { all, lists } from "#lib/api/lists.js";
|
||||
@@ -17,13 +20,14 @@
|
||||
} from "#lib/api/types.js";
|
||||
import Amount from "#lib/components/Amount.svelte";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import Money from "#lib/components/Money.svelte";
|
||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||
import { today } from "#lib/dates.js";
|
||||
import { formatDate, formatMoney, toNumber } from "#lib/format.js";
|
||||
import { formatDate, toNumber } from "#lib/format.js";
|
||||
import type { Binding } from "#lib/keys.svelte.js";
|
||||
import { ListNav } from "#lib/list-nav.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import FriendCard from "./FriendCard.svelte";
|
||||
import DebtCard from "./DebtCard.svelte";
|
||||
import {
|
||||
editUrl,
|
||||
LIABILITY_LABEL,
|
||||
@@ -32,7 +36,9 @@
|
||||
TYPE_ONE,
|
||||
} from "./links.js";
|
||||
|
||||
const KEYS = { lend: "+", offset: "o", settle: "s" };
|
||||
const KEYS = { more: "+", offset: "o", repay: "s" };
|
||||
const ALIASES = { more: "", offset: "=", repay: "S" };
|
||||
const ORDER: Stance[] = ["owed", "owe", "settled"];
|
||||
const CREATE = "/accounts/create/liabilities";
|
||||
|
||||
const liabilities = createQuery(() => ({
|
||||
@@ -53,9 +59,9 @@
|
||||
}));
|
||||
const assets = createQuery(() => lists.accounts("asset"));
|
||||
|
||||
const debts = $derived((liabilities.data ?? []).filter(isFriend));
|
||||
const debts = $derived((liabilities.data ?? []).filter(isDebt));
|
||||
const others = $derived(
|
||||
(liabilities.data ?? []).filter((item) => !isFriend(item))
|
||||
(liabilities.data ?? []).filter((item) => !isDebt(item))
|
||||
);
|
||||
const history = createQuery(() => ({
|
||||
enabled: debts.length > 0,
|
||||
@@ -79,26 +85,20 @@
|
||||
queryKey: ["ledger-history", debts.map((d) => d.id).join(",")],
|
||||
}));
|
||||
|
||||
const rank = (item: Item<Account>) => {
|
||||
const position = owed(item.attributes);
|
||||
if (position > 0) {
|
||||
return 0;
|
||||
}
|
||||
return position < 0 ? 1 : 2;
|
||||
};
|
||||
const rank = (item: Item<Account>) => ORDER.indexOf(stance(item.attributes));
|
||||
const lastDate = (item: Item<Account>) =>
|
||||
history.data?.get(item.id)?.[0]?.date ?? "";
|
||||
const friends = $derived(
|
||||
const cards = $derived(
|
||||
debts.toSorted(
|
||||
(x, y) => rank(x) - rank(y) || lastDate(y).localeCompare(lastDate(x))
|
||||
)
|
||||
);
|
||||
const items = $derived([...friends, ...others]);
|
||||
const items = $derived([...cards, ...others]);
|
||||
const index = $derived(new Map(items.map((item, i) => [item.id, i])));
|
||||
|
||||
const plans = $derived(
|
||||
new Map(
|
||||
friends.map((item) => [
|
||||
cards.map((item) => [
|
||||
item.id,
|
||||
history.data && assets.data
|
||||
? actionsFor(
|
||||
@@ -140,25 +140,26 @@
|
||||
}
|
||||
return [...map.values()];
|
||||
});
|
||||
const owedToMe = $derived(sums.filter((s) => s.owed > 0));
|
||||
const iOwe = $derived(sums.filter((s) => s.owe > 0));
|
||||
const owedToYou = $derived(sums.filter((s) => s.owed > 0));
|
||||
const youOwe = $derived(sums.filter((s) => s.owe > 0));
|
||||
|
||||
function current(): Item<Account> | undefined {
|
||||
return nav.current;
|
||||
}
|
||||
|
||||
function act(kind: "settle" | "lend" | "offset") {
|
||||
const plan = plans.get(current()?.id ?? "");
|
||||
const target = plan?.[kind];
|
||||
if (target) {
|
||||
goto(target.href);
|
||||
function bind(kind: keyof typeof KEYS, action?: Action | null): Binding[] {
|
||||
if (!action) {
|
||||
return [];
|
||||
}
|
||||
const run = () => goto(action.href);
|
||||
const main = { group: "Debts", keys: KEYS[kind], label: action.hint, run };
|
||||
const alias = ALIASES[kind];
|
||||
return alias ? [main, { ...main, hidden: true, keys: alias }] : [main];
|
||||
}
|
||||
|
||||
const nav = new ListNav<Item<Account>>({
|
||||
edit: (item) => goto(editUrl(item.id)),
|
||||
extra: (): Binding[] => {
|
||||
const name = current()?.attributes.name ?? "a friend";
|
||||
const plan = plans.get(current()?.id ?? "");
|
||||
return [
|
||||
{
|
||||
@@ -172,38 +173,9 @@
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
group: "Friends",
|
||||
keys: "s",
|
||||
label: plan?.settle?.label ?? `Settle up with ${name}`,
|
||||
run: () => act("settle"),
|
||||
},
|
||||
{
|
||||
group: "Friends",
|
||||
hidden: true,
|
||||
keys: "S",
|
||||
label: "Settle up",
|
||||
run: () => act("settle"),
|
||||
},
|
||||
{
|
||||
group: "Friends",
|
||||
keys: "+",
|
||||
label: plan?.lend.label ?? `Lend more to ${name}`,
|
||||
run: () => act("lend"),
|
||||
},
|
||||
{
|
||||
group: "Friends",
|
||||
keys: "o",
|
||||
label: plan?.offset.label ?? `Offset ${name} against another debt`,
|
||||
run: () => act("offset"),
|
||||
},
|
||||
{
|
||||
group: "Friends",
|
||||
hidden: true,
|
||||
keys: "=",
|
||||
label: "Offset",
|
||||
run: () => act("offset"),
|
||||
},
|
||||
...bind("repay", plan?.repay),
|
||||
...bind("more", plan?.more),
|
||||
...bind("offset", plan?.offset),
|
||||
{
|
||||
group: "Accounts",
|
||||
keys: "c",
|
||||
@@ -233,19 +205,18 @@
|
||||
)}
|
||||
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||
<span class="text-[13px] text-cladd-fg-soft">{label}</span>
|
||||
{#each list as sum (sum.code)}
|
||||
<span
|
||||
class="num font-semibold text-[24px] leading-tight {key === "owed"
|
||||
? "text-income"
|
||||
: "text-expense"}"
|
||||
>{formatMoney({ ...sum, amount: sum[key] })}</span
|
||||
>
|
||||
{:else}
|
||||
<span
|
||||
class="num font-semibold text-[24px] text-cladd-fg-soft leading-tight"
|
||||
>0</span
|
||||
>
|
||||
{/each}
|
||||
<span class="text-[24px] leading-tight">
|
||||
{#if list.length > 0}
|
||||
<Money
|
||||
class="font-semibold"
|
||||
inline
|
||||
parts={list.map((sum) => ({ ...sum, amount: sum[key] }))}
|
||||
tone={key === "owed" ? "income" : "expense"}
|
||||
/>
|
||||
{:else}
|
||||
<span class="num font-semibold text-cladd-fg-soft">0</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -272,13 +243,13 @@
|
||||
<section
|
||||
class="cladd-surface cladd-outline grid gap-3 rounded-cladd-lg p-3 md:grid-cols-2"
|
||||
>
|
||||
{@render totals("Owed to me", owedToMe, "owed")}
|
||||
{@render totals("I owe", iOwe, "owe")}
|
||||
{@render totals("Owed to you", owedToYou, "owed")}
|
||||
{@render totals("You owe", youOwe, "owe")}
|
||||
</section>
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{#each friends as item (item.id)}
|
||||
{#each cards as item (item.id)}
|
||||
{@const i = index.get(item.id) ?? -1}
|
||||
<FriendCard
|
||||
<DebtCard
|
||||
actions={plans.get(item.id) ?? null}
|
||||
current={nav.cursor === i}
|
||||
id={nav.rowId(i)}
|
||||
@@ -293,7 +264,7 @@
|
||||
<div
|
||||
class="cladd-surface cladd-outline flex flex-col items-center gap-3 rounded-cladd-lg px-3 py-10 text-center"
|
||||
>
|
||||
<p class="font-semibold text-[15px]">No debts with friends yet</p>
|
||||
<p class="font-semibold text-[15px]">No debts yet</p>
|
||||
<a class="text-brand underline dark:text-(--ff-series-1)" href={CREATE}
|
||||
>Create a new liability</a
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user