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:
1 parent
60b099e917
commit
6f39343e07
9 files changed
+1103
-64
No files matched your search
@@ -0,0 +1,384 @@
|
||||
<script lang="ts">
|
||||
import Plus from "@lucide/svelte/icons/plus";
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import {
|
||||
actionsFor,
|
||||
isFriend,
|
||||
movements,
|
||||
owed,
|
||||
} from "#lib/accounts/ledger.js";
|
||||
import { api } from "#lib/api/client.js";
|
||||
import { all, lists } from "#lib/api/lists.js";
|
||||
import type {
|
||||
Account,
|
||||
Item,
|
||||
Page,
|
||||
TransactionGroup,
|
||||
} from "#lib/api/types.js";
|
||||
import Amount from "#lib/components/Amount.svelte";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||
import { today } from "#lib/dates.js";
|
||||
import { formatDate, formatMoney, 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 {
|
||||
editUrl,
|
||||
LIABILITY_LABEL,
|
||||
PERIOD_LABEL,
|
||||
showUrl,
|
||||
TYPE_ONE,
|
||||
} from "./links.js";
|
||||
|
||||
const KEYS = { lend: "+", offset: "o", settle: "s" };
|
||||
const CREATE = "/accounts/create/liabilities";
|
||||
|
||||
const liabilities = createQuery(() => ({
|
||||
queryFn: () =>
|
||||
all<Account>("/v1/accounts", {
|
||||
active: "true",
|
||||
date: today(),
|
||||
type: "liabilities",
|
||||
}),
|
||||
queryKey: ["ledger-accounts"],
|
||||
}));
|
||||
const inactive = createQuery(() => ({
|
||||
queryFn: () =>
|
||||
api<Page<Account>>("/v1/accounts", {
|
||||
query: { active: "false", limit: 1, type: "liabilities" },
|
||||
}),
|
||||
queryKey: ["accounts-count", "liabilities", false],
|
||||
}));
|
||||
const assets = createQuery(() => lists.accounts("asset"));
|
||||
|
||||
const debts = $derived((liabilities.data ?? []).filter(isFriend));
|
||||
const others = $derived(
|
||||
(liabilities.data ?? []).filter((item) => !isFriend(item))
|
||||
);
|
||||
const history = createQuery(() => ({
|
||||
enabled: debts.length > 0,
|
||||
queryFn: async () =>
|
||||
new Map(
|
||||
await Promise.all(
|
||||
debts.map(
|
||||
async (item) =>
|
||||
[
|
||||
item.id,
|
||||
movements(
|
||||
item,
|
||||
await all<TransactionGroup>(
|
||||
`/v1/accounts/${item.id}/transactions`
|
||||
)
|
||||
),
|
||||
] as const
|
||||
)
|
||||
)
|
||||
),
|
||||
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 lastDate = (item: Item<Account>) =>
|
||||
history.data?.get(item.id)?.[0]?.date ?? "";
|
||||
const friends = $derived(
|
||||
debts.toSorted(
|
||||
(x, y) => rank(x) - rank(y) || lastDate(y).localeCompare(lastDate(x))
|
||||
)
|
||||
);
|
||||
const items = $derived([...friends, ...others]);
|
||||
const index = $derived(new Map(items.map((item, i) => [item.id, i])));
|
||||
|
||||
const plans = $derived(
|
||||
new Map(
|
||||
friends.map((item) => [
|
||||
item.id,
|
||||
history.data && assets.data
|
||||
? actionsFor(
|
||||
item,
|
||||
history.data.get(item.id) ?? [],
|
||||
assets.data,
|
||||
debts
|
||||
)
|
||||
: null,
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
interface Sum {
|
||||
code: string;
|
||||
decimals: number;
|
||||
owe: number;
|
||||
owed: number;
|
||||
symbol: string;
|
||||
}
|
||||
const sums = $derived.by(() => {
|
||||
const map = new Map<string, Sum>();
|
||||
for (const { attributes: a } of debts) {
|
||||
const code = a.currency_code ?? "";
|
||||
const sum = map.get(code) ?? {
|
||||
code,
|
||||
decimals: a.currency_decimal_places ?? 2,
|
||||
owe: 0,
|
||||
owed: 0,
|
||||
symbol: a.currency_symbol ?? code,
|
||||
};
|
||||
const position = owed(a);
|
||||
if (position > 0) {
|
||||
sum.owed += position;
|
||||
} else {
|
||||
sum.owe -= position;
|
||||
}
|
||||
map.set(code, sum);
|
||||
}
|
||||
return [...map.values()];
|
||||
});
|
||||
const owedToMe = $derived(sums.filter((s) => s.owed > 0));
|
||||
const iOwe = $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);
|
||||
}
|
||||
}
|
||||
|
||||
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 [
|
||||
{
|
||||
group: "List",
|
||||
keys: "Enter",
|
||||
label: "Open account",
|
||||
run: () => {
|
||||
const item = current();
|
||||
if (item) {
|
||||
goto(showUrl(item.id));
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
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"),
|
||||
},
|
||||
{
|
||||
group: "Accounts",
|
||||
keys: "c",
|
||||
label: `Create a new ${TYPE_ONE.liabilities}`,
|
||||
run: () => goto(CREATE),
|
||||
},
|
||||
];
|
||||
},
|
||||
id: "ledger",
|
||||
items: () => items,
|
||||
key: (item) => item.id,
|
||||
});
|
||||
nav.use();
|
||||
|
||||
function rowClass(i: number) {
|
||||
if (nav.cursor === i) {
|
||||
return "bg-row-cursor shadow-[inset_2px_0_0_var(--ff-brand)]";
|
||||
}
|
||||
return "hover:bg-row-hover";
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet totals(
|
||||
label: string,
|
||||
list: Sum[],
|
||||
key: "owe" | "owed"
|
||||
)}
|
||||
<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}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<PageHeader crumbs={[{ label: "Liabilities" }]} title="Accounts">
|
||||
{#snippet actions()}
|
||||
<a
|
||||
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable cladd-color-green inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2.5 font-semibold text-[12px] text-cladd-primary"
|
||||
href={CREATE}
|
||||
title="Create a new liability c"
|
||||
>
|
||||
<Plus class="size-3.5" />
|
||||
Create a new liability
|
||||
</a>
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
{#if liabilities.isPending}
|
||||
<div class="h-40"></div>
|
||||
{:else if liabilities.isError}
|
||||
<p class="text-expense">{liabilities.error.message}</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#if debts.length > 0}
|
||||
<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")}
|
||||
</section>
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{#each friends as item (item.id)}
|
||||
{@const i = index.get(item.id) ?? -1}
|
||||
<FriendCard
|
||||
actions={plans.get(item.id) ?? null}
|
||||
current={nav.cursor === i}
|
||||
id={nav.rowId(i)}
|
||||
{item}
|
||||
keys={KEYS}
|
||||
list={history.data?.get(item.id)}
|
||||
onfocusin={() => (nav.cursor = i)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<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>
|
||||
<a class="text-brand underline dark:text-(--ff-series-1)" href={CREATE}
|
||||
>Create a new liability</a
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if others.length > 0}
|
||||
<Box body="p-0" title="Loans and other liabilities">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse text-[13px]">
|
||||
<thead class="border-cladd-outline border-b text-left">
|
||||
<tr class="h-8 text-[12px] text-cladd-fg-soft">
|
||||
<th class="px-3 font-semibold" scope="col">Name</th>
|
||||
<th class="px-3 font-semibold max-md:hidden" scope="col">
|
||||
Type of liability
|
||||
</th>
|
||||
<th class="px-3 font-semibold max-md:hidden" scope="col">
|
||||
Interest
|
||||
</th>
|
||||
<th class="px-3 font-semibold max-md:hidden" scope="col">
|
||||
Last activity
|
||||
</th>
|
||||
<th class="px-3 text-right font-semibold" scope="col">
|
||||
Amount due
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each others as item (item.id)}
|
||||
{@const a = item.attributes}
|
||||
{@const i = index.get(item.id) ?? -1}
|
||||
<tr
|
||||
class="h-7 border-cladd-outline/60 border-t {rowClass(i)}"
|
||||
id={nav.rowId(i)}
|
||||
onclick={(event) => nav.click(i, event)}
|
||||
>
|
||||
<td class="px-3">
|
||||
<a
|
||||
class="text-brand hover:underline dark:text-(--ff-series-1)"
|
||||
href={showUrl(item.id)}
|
||||
>{a.name}</a
|
||||
>
|
||||
</td>
|
||||
<td class="px-3 max-md:hidden">
|
||||
{LIABILITY_LABEL[a.liability_type ?? ""] ?? ""}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 max-md:hidden">
|
||||
<span class="num">{toNumber(a.interest)}%</span>
|
||||
<span class="text-cladd-fg-soft"
|
||||
>{PERIOD_LABEL[a.interest_period ?? ""] ??
|
||||
a.interest_period}</span
|
||||
>
|
||||
</td>
|
||||
<td
|
||||
class="whitespace-nowrap px-3 text-cladd-fg-soft max-md:hidden"
|
||||
>
|
||||
{a.last_activity
|
||||
? formatDate(a.last_activity.slice(0, 10))
|
||||
: "never"}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 text-right">
|
||||
<Amount
|
||||
amount={a.debt_amount}
|
||||
code={a.currency_code}
|
||||
decimals={a.currency_decimal_places}
|
||||
symbol={a.currency_symbol}
|
||||
tone="expense"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Box>
|
||||
{/if}
|
||||
|
||||
{#if (inactive.data?.meta.pagination?.total ?? 0) > 0}
|
||||
{@const count = inactive.data?.meta.pagination?.total ?? 0}
|
||||
<a
|
||||
class="self-start text-[13px] text-cladd-fg-soft hover:underline"
|
||||
href="/inactive-accounts/liabilities"
|
||||
>{count}
|
||||
inactive {count === 1 ? "liability" : "liabilities"}</a
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
Reference in new issue
Block a user