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
+1077
-38
No files matched your search
@@ -0,0 +1,228 @@
|
||||
import type { Account, Item, TransactionGroup } from "#lib/api/types.js";
|
||||
import { addMonths, daysBetween, today } from "#lib/dates.js";
|
||||
import { toNumber } from "#lib/format.js";
|
||||
|
||||
export interface Movement {
|
||||
amount: number;
|
||||
counter: { id: string; name: string; type: string };
|
||||
date: string;
|
||||
description: string;
|
||||
group: string;
|
||||
into: boolean;
|
||||
opening: boolean;
|
||||
}
|
||||
|
||||
export interface Month {
|
||||
gave: number;
|
||||
got: number;
|
||||
month: string;
|
||||
}
|
||||
|
||||
const OPENING = new Set(["opening balance", "liability credit"]);
|
||||
const ASSET = "Asset account";
|
||||
const DUST = 0.005;
|
||||
|
||||
export const isFriend = (item: Item<Account>) =>
|
||||
item.attributes.liability_type === "debt";
|
||||
|
||||
export function owed(account: Account): number {
|
||||
const debt = toNumber(account.debt_amount);
|
||||
return account.liability_direction === "debit" ? -debt : debt;
|
||||
}
|
||||
|
||||
export function movements(
|
||||
account: Item<Account>,
|
||||
groups: Item<TransactionGroup>[]
|
||||
): Movement[] {
|
||||
const { id } = account;
|
||||
const code = account.attributes.currency_code;
|
||||
const list: Movement[] = [];
|
||||
for (const group of groups) {
|
||||
for (const split of group.attributes.transactions) {
|
||||
const into = split.destination_id === id;
|
||||
if (!(into || split.source_id === id)) {
|
||||
continue;
|
||||
}
|
||||
const foreign =
|
||||
split.currency_code !== code && split.foreign_currency_code === code;
|
||||
const side = into ? "source" : "destination";
|
||||
list.push({
|
||||
amount: Math.abs(
|
||||
toNumber(foreign ? split.foreign_amount : split.amount)
|
||||
),
|
||||
counter: {
|
||||
id: split[`${side}_id`] ?? "",
|
||||
name: split[`${side}_name`] ?? "",
|
||||
type: split[`${side}_type`] ?? "",
|
||||
},
|
||||
date: split.date.slice(0, 10),
|
||||
description: split.description,
|
||||
group: group.id,
|
||||
into,
|
||||
opening: OPENING.has(split.type),
|
||||
});
|
||||
}
|
||||
}
|
||||
return list.sort((a, b) => b.date.localeCompare(a.date));
|
||||
}
|
||||
|
||||
export function averageDays(list: Movement[], credit: boolean): number | null {
|
||||
const open: { amount: number; date: string }[] = [];
|
||||
let weighted = 0;
|
||||
let total = 0;
|
||||
for (const m of list.toReversed()) {
|
||||
if (m.into === credit) {
|
||||
open.push({ amount: m.amount, date: m.date });
|
||||
continue;
|
||||
}
|
||||
let left = m.amount;
|
||||
while (left > DUST && open.length > 0) {
|
||||
const [head] = open;
|
||||
const used = Math.min(left, head.amount);
|
||||
weighted += used * daysBetween(head.date, m.date);
|
||||
total += used;
|
||||
left -= used;
|
||||
head.amount -= used;
|
||||
if (head.amount < DUST) {
|
||||
open.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
return total > 0 ? Math.round(weighted / total) : null;
|
||||
}
|
||||
|
||||
export function months(list: Movement[], count = 12): Month[] {
|
||||
const first = addMonths(`${today().slice(0, 7)}-01`, 1 - count);
|
||||
const buckets = Array.from({ length: count }, (_, i) => ({
|
||||
gave: 0,
|
||||
got: 0,
|
||||
month: addMonths(first, i).slice(0, 7),
|
||||
}));
|
||||
const index = new Map(buckets.map((b, i) => [b.month, i]));
|
||||
for (const m of list) {
|
||||
const bucket = buckets[index.get(m.date.slice(0, 7)) ?? -1];
|
||||
if (!bucket || m.opening) {
|
||||
continue;
|
||||
}
|
||||
if (m.into) {
|
||||
bucket.gave += m.amount;
|
||||
} else {
|
||||
bucket.got += m.amount;
|
||||
}
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
|
||||
const createUrl = (type: string, query: Record<string, string | undefined>) =>
|
||||
`/transactions/create/${type}?${new URLSearchParams(
|
||||
Object.entries(query).filter((entry): entry is [string, string] =>
|
||||
Boolean(entry[1])
|
||||
)
|
||||
)}`;
|
||||
|
||||
function counterAsset(
|
||||
list: Movement[],
|
||||
assets: Item<Account>[],
|
||||
code: string | undefined
|
||||
): string | undefined {
|
||||
const usable = assets
|
||||
.filter((a) => a.attributes.active && a.attributes.currency_code === code)
|
||||
.map((a) => a.id);
|
||||
return (
|
||||
list.find((m) => usable.includes(m.counter.id))?.counter.id ?? usable[0]
|
||||
);
|
||||
}
|
||||
|
||||
const lastSaid = (list: Movement[], into: boolean) =>
|
||||
list.find((m) => m.into === into && m.counter.type === ASSET)?.description;
|
||||
|
||||
const plain = (account: Account, value: number) =>
|
||||
Math.abs(value).toFixed(account.currency_decimal_places ?? 2);
|
||||
|
||||
interface Action {
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface Actions {
|
||||
lend: Action;
|
||||
offset: Action;
|
||||
settle: Action | null;
|
||||
}
|
||||
|
||||
function settleAction(item: Item<Account>, list: Movement[], asset?: string) {
|
||||
const a = item.attributes;
|
||||
const position = owed(a);
|
||||
if (Math.abs(position) < DUST) {
|
||||
return null;
|
||||
}
|
||||
const into = position < 0;
|
||||
return {
|
||||
href: createUrl(into ? "withdrawal" : "deposit", {
|
||||
amount: plain(a, position),
|
||||
description:
|
||||
lastSaid(list, into) ??
|
||||
(into ? `Paid back ${a.name}` : `${a.name} paid back`),
|
||||
destination: into ? item.id : asset,
|
||||
source: into ? asset : item.id,
|
||||
}),
|
||||
label: `Settle up with ${a.name}`,
|
||||
};
|
||||
}
|
||||
|
||||
function lendAction(item: Item<Account>, list: Movement[], asset?: string) {
|
||||
const a = item.attributes;
|
||||
const credit = a.liability_direction !== "debit";
|
||||
return {
|
||||
href: createUrl(credit ? "withdrawal" : "deposit", {
|
||||
description:
|
||||
lastSaid(list, credit) ??
|
||||
(credit ? `Lent ${a.name}` : `Borrowed from ${a.name}`),
|
||||
destination: credit ? item.id : asset,
|
||||
source: credit ? asset : item.id,
|
||||
}),
|
||||
label: credit ? `Lend more to ${a.name}` : `Borrow more from ${a.name}`,
|
||||
};
|
||||
}
|
||||
|
||||
function offsetAction(item: Item<Account>, debts: Item<Account>[]) {
|
||||
const a = item.attributes;
|
||||
const position = owed(a);
|
||||
const [other] = debts
|
||||
.filter(
|
||||
(d) =>
|
||||
d.attributes.currency_code === a.currency_code &&
|
||||
owed(d.attributes) * position < 0
|
||||
)
|
||||
.sort(
|
||||
(x, y) => Math.abs(owed(y.attributes)) - Math.abs(owed(x.attributes))
|
||||
);
|
||||
const amount = other
|
||||
? Math.min(Math.abs(position), Math.abs(owed(other.attributes)))
|
||||
: position;
|
||||
const out = position >= 0;
|
||||
const names = other ? `${a.name} and ${other.attributes.name}` : a.name;
|
||||
return {
|
||||
href: createUrl("transfer", {
|
||||
amount: amount ? plain(a, amount) : undefined,
|
||||
description: `Offset ${names}`,
|
||||
destination: out ? other?.id : item.id,
|
||||
source: out ? item.id : other?.id,
|
||||
}),
|
||||
label: `Offset ${a.name} against ${other?.attributes.name ?? "another debt"}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function actionsFor(
|
||||
item: Item<Account>,
|
||||
list: Movement[],
|
||||
assets: Item<Account>[],
|
||||
debts: Item<Account>[]
|
||||
): Actions {
|
||||
const asset = counterAsset(list, assets, item.attributes.currency_code);
|
||||
return {
|
||||
lend: lendAction(item, list, asset),
|
||||
offset: offsetAction(item, debts),
|
||||
settle: settleAction(item, list, asset),
|
||||
};
|
||||
}
|
||||
@@ -32,6 +32,7 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import Breakdown from "./Breakdown.svelte";
|
||||
import FriendPanel from "./FriendPanel.svelte";
|
||||
import {
|
||||
canReconcile,
|
||||
createUrl,
|
||||
@@ -69,6 +70,7 @@
|
||||
const a = $derived(account.data?.data.attributes);
|
||||
const type = $derived(a ? typeOf(a) : "asset");
|
||||
const item = $derived(account.data?.data);
|
||||
const debt = $derived(a?.liability_type === "debt");
|
||||
|
||||
const first = createQuery(() => ({
|
||||
enabled: everything,
|
||||
@@ -301,6 +303,14 @@
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
class="grid gap-3 {debt
|
||||
? "xl:grid-cols-[minmax(0,26rem)_minmax(0,1fr)]"
|
||||
: ""}"
|
||||
>
|
||||
{#if debt && item}
|
||||
<FriendPanel {item} />
|
||||
{/if}
|
||||
<Box
|
||||
title={a
|
||||
? `Chart for ${a.name} (${balance}) between ${formatRange(range.start, range.end)}`
|
||||
@@ -314,7 +324,9 @@
|
||||
{:else if chart.isError}
|
||||
<p class="text-expense">{chart.error.message}</p>
|
||||
{:else if series.length === 0}
|
||||
<p class="py-10 text-center text-cladd-fg-soft">No balance to draw.</p>
|
||||
<p class="py-10 text-center text-cladd-fg-soft">
|
||||
No balance to draw.
|
||||
</p>
|
||||
{:else}
|
||||
<LineChart
|
||||
axisFormat={axis(
|
||||
@@ -329,6 +341,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
{#if !everything}
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type Actions,
|
||||
averageDays,
|
||||
type Movement,
|
||||
months,
|
||||
owed,
|
||||
} from "#lib/accounts/ledger.js";
|
||||
import type { Account, Item } from "#lib/api/types.js";
|
||||
import Amount from "#lib/components/Amount.svelte";
|
||||
import { daysBetween, today } from "#lib/dates.js";
|
||||
import { formatDate, formatMoney } from "#lib/format.js";
|
||||
import { showUrl as transactionUrl } from "#lib/transactions/split.js";
|
||||
import { showUrl } from "./links.js";
|
||||
import MonthBars from "./MonthBars.svelte";
|
||||
|
||||
const BUTTON =
|
||||
"cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2.5 font-semibold text-[12px] text-cladd-fg";
|
||||
|
||||
let {
|
||||
item,
|
||||
list,
|
||||
actions,
|
||||
keys,
|
||||
rows = 5,
|
||||
current = false,
|
||||
id,
|
||||
onfocusin,
|
||||
}: {
|
||||
item: Item<Account>;
|
||||
list: Movement[] | undefined;
|
||||
actions: Actions | null;
|
||||
keys: { lend: string; offset: string; settle: string };
|
||||
rows?: number;
|
||||
current?: boolean;
|
||||
id?: string;
|
||||
onfocusin?: () => void;
|
||||
} = $props();
|
||||
|
||||
const a = $derived(item.attributes);
|
||||
const credit = $derived(a.liability_direction !== "debit");
|
||||
const position = $derived(owed(a));
|
||||
const cur = $derived({
|
||||
code: a.currency_code,
|
||||
decimals: a.currency_decimal_places,
|
||||
symbol: a.currency_symbol,
|
||||
});
|
||||
const format = (value: number) => formatMoney({ ...cur, amount: value });
|
||||
const words = $derived(
|
||||
credit
|
||||
? { gave: "Lent", got: "Got back", pays: "Pays back in" }
|
||||
: { gave: "Paid back", got: "Borrowed", pays: "I pay back in" }
|
||||
);
|
||||
const bars = $derived(list ? months(list) : []);
|
||||
const gave = $derived(bars.reduce((s, m) => s + m.gave, 0));
|
||||
const got = $derived(bars.reduce((s, m) => s + m.got, 0));
|
||||
const average = $derived(list ? averageDays(list, credit) : null);
|
||||
const last = $derived(list?.[0]);
|
||||
const shown = $derived(list?.slice(0, rows) ?? []);
|
||||
const tone = $derived.by(() => {
|
||||
if (position > 0) {
|
||||
return "text-income";
|
||||
}
|
||||
return position < 0 ? "text-expense" : "text-cladd-fg-soft";
|
||||
});
|
||||
|
||||
function ago(date: string) {
|
||||
const days = daysBetween(date, today());
|
||||
if (days === 0) {
|
||||
return "today";
|
||||
}
|
||||
return days === 1 ? "yesterday" : `${days} days ago`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<article
|
||||
class="cladd-surface cladd-outline flex min-w-0 flex-col rounded-cladd-lg {current
|
||||
? "outline-2 outline-(--ff-brand)"
|
||||
: ""}"
|
||||
{id}
|
||||
{onfocusin}
|
||||
>
|
||||
<div class="flex flex-1 flex-col gap-3 p-3">
|
||||
<div>
|
||||
<h3 class="text-[15px] text-cladd-fg leading-snug">
|
||||
{#if position < 0}
|
||||
I owe
|
||||
{/if}
|
||||
<a class="font-semibold hover:underline" href={showUrl(item.id)}
|
||||
>{a.name}</a
|
||||
>
|
||||
{#if position > 0}
|
||||
owes me
|
||||
{:else if position === 0}
|
||||
and I are even
|
||||
{/if}
|
||||
</h3>
|
||||
<p class="num font-semibold text-[28px] leading-tight {tone}">
|
||||
{format(Math.abs(position))}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl
|
||||
class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-[13px]"
|
||||
>
|
||||
<dt class="text-cladd-fg-soft">Last movement</dt>
|
||||
<dd class="text-right">
|
||||
{last ? `${formatDate(last.date)}, ${ago(last.date)}` : "none yet"}
|
||||
</dd>
|
||||
<dt class="text-cladd-fg-soft">{words.pays}</dt>
|
||||
<dd class="text-right">
|
||||
{average === null ? "nothing yet" : `${average} days on average`}
|
||||
</dd>
|
||||
<dt class="text-cladd-fg-soft">{words.gave} in 12 months</dt>
|
||||
<dd class="num text-right text-expense">{format(gave)}</dd>
|
||||
<dt class="text-cladd-fg-soft">{words.got} in 12 months</dt>
|
||||
<dd class="num text-right text-income">{format(got)}</dd>
|
||||
</dl>
|
||||
|
||||
<MonthBars {format} gave={words.gave} got={words.got} months={bars} />
|
||||
|
||||
{#if rows > 0}
|
||||
<table class="w-full table-fixed border-collapse text-[13px]">
|
||||
<tbody>
|
||||
{#each shown as m, i (`${m.group}-${i}`)}
|
||||
<tr class="h-7 border-cladd-outline/60 border-t">
|
||||
<td class="w-16 whitespace-nowrap text-cladd-fg-soft">
|
||||
{formatDate(m.date, "short")}
|
||||
</td>
|
||||
<td class="truncate">
|
||||
<a
|
||||
class="hover:underline"
|
||||
href={transactionUrl(m.group)}
|
||||
title={m.description}
|
||||
>{m.opening ? "Opening balance" : m.description}</a
|
||||
>
|
||||
</td>
|
||||
<td class="w-28 whitespace-nowrap text-right">
|
||||
<Amount
|
||||
{...cur}
|
||||
amount={m.into ? -m.amount : m.amount}
|
||||
signed
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr class="h-7 border-cladd-outline/60 border-t">
|
||||
<td class="text-cladd-fg-soft" colspan="3">
|
||||
{list ? "No movements yet" : ""}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if actions}
|
||||
<footer
|
||||
class="flex flex-wrap items-center gap-1.5 border-cladd-outline border-t px-3 py-2"
|
||||
>
|
||||
{#if actions.settle}
|
||||
<a
|
||||
aria-label={actions.settle.label}
|
||||
class="{BUTTON} cladd-color-green text-cladd-primary"
|
||||
href={actions.settle.href}
|
||||
>Settle up <span class="kbd">{keys.settle}</span></a
|
||||
>
|
||||
{/if}
|
||||
<a aria-label={actions.lend.label} class={BUTTON} href={actions.lend.href}
|
||||
>{credit ? "Lend more" : "Borrow more"}
|
||||
<span class="kbd">{keys.lend}</span></a
|
||||
>
|
||||
<a
|
||||
aria-label={actions.offset.label}
|
||||
class={BUTTON}
|
||||
href={actions.offset.href}
|
||||
>Offset <span class="kbd">{keys.offset}</span></a
|
||||
>
|
||||
</footer>
|
||||
{/if}
|
||||
</article>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { actionsFor, isFriend, movements } from "#lib/accounts/ledger.js";
|
||||
import { all, lists } from "#lib/api/lists.js";
|
||||
import type { Account, Item, TransactionGroup } from "#lib/api/types.js";
|
||||
import { useKeys } from "#lib/keys.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import FriendCard from "./FriendCard.svelte";
|
||||
|
||||
const KEYS = { lend: "+", offset: "=", settle: "S" };
|
||||
|
||||
let { item }: { item: Item<Account> } = $props();
|
||||
|
||||
const history = createQuery(() => ({
|
||||
queryFn: () =>
|
||||
all<TransactionGroup>(`/v1/accounts/${item.id}/transactions`),
|
||||
queryKey: ["friend-history", item.id],
|
||||
}));
|
||||
const assets = createQuery(() => lists.accounts("asset"));
|
||||
const liabilities = createQuery(() => lists.accounts("liabilities"));
|
||||
|
||||
const list = $derived(
|
||||
history.data ? movements(item, history.data) : undefined
|
||||
);
|
||||
const plan = $derived(
|
||||
list && assets.data && liabilities.data
|
||||
? actionsFor(
|
||||
item,
|
||||
list,
|
||||
assets.data,
|
||||
liabilities.data.filter((d) => isFriend(d) && d.attributes.active)
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
useKeys(() =>
|
||||
plan
|
||||
? [
|
||||
...(plan.settle
|
||||
? [
|
||||
{
|
||||
group: "Friend",
|
||||
keys: KEYS.settle,
|
||||
label: plan.settle.label,
|
||||
run: () => plan.settle && goto(plan.settle.href),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
group: "Friend",
|
||||
keys: KEYS.lend,
|
||||
label: plan.lend.label,
|
||||
run: () => goto(plan.lend.href),
|
||||
},
|
||||
{
|
||||
group: "Friend",
|
||||
keys: KEYS.offset,
|
||||
label: plan.offset.label,
|
||||
run: () => goto(plan.offset.href),
|
||||
},
|
||||
]
|
||||
: []
|
||||
);
|
||||
</script>
|
||||
|
||||
<FriendCard actions={plan} {item} keys={KEYS} {list} rows={0} />
|
||||
@@ -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}
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import type { Month } from "#lib/accounts/ledger.js";
|
||||
import { currentLocale } from "#lib/format.js";
|
||||
|
||||
let {
|
||||
months,
|
||||
gave,
|
||||
got,
|
||||
format,
|
||||
}: {
|
||||
months: Month[];
|
||||
gave: string;
|
||||
got: string;
|
||||
format: (value: number) => string;
|
||||
} = $props();
|
||||
|
||||
const max = $derived(Math.max(1, ...months.flatMap((m) => [m.gave, m.got])));
|
||||
const letter = (month: string) =>
|
||||
new Date(`${month}-15T12:00:00`).toLocaleDateString(currentLocale(), {
|
||||
month: "narrow",
|
||||
});
|
||||
const name = (month: string) =>
|
||||
new Date(`${month}-15T12:00:00`).toLocaleDateString(currentLocale(), {
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
const height = (value: number) =>
|
||||
value > 0 ? `max(2px, ${(value / max) * 100}%)` : "0";
|
||||
</script>
|
||||
|
||||
<div
|
||||
aria-label="{got} and {gave.toLowerCase()} per month"
|
||||
class="grid grid-cols-12 gap-x-1"
|
||||
role="img"
|
||||
>
|
||||
{#each months as m (m.month)}
|
||||
<div
|
||||
class="flex h-8 items-end"
|
||||
title="{name(m.month)}: {got.toLowerCase()} {format(
|
||||
m.got
|
||||
)}, {gave.toLowerCase()} {format(m.gave)}"
|
||||
>
|
||||
<div
|
||||
class="w-full rounded-t-[2px] bg-income"
|
||||
style:height={height(m.got)}
|
||||
></div>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="col-span-12 h-px bg-cladd-outline"></div>
|
||||
{#each months as m (m.month)}
|
||||
<div
|
||||
class="flex h-8 items-start"
|
||||
title="{name(m.month)}: {got.toLowerCase()} {format(
|
||||
m.got
|
||||
)}, {gave.toLowerCase()} {format(m.gave)}"
|
||||
>
|
||||
<div
|
||||
class="w-full rounded-b-[2px] bg-expense"
|
||||
style:height={height(m.gave)}
|
||||
></div>
|
||||
</div>
|
||||
{/each}
|
||||
{#each months as m (m.month)}
|
||||
<span class="pt-0.5 text-center text-[10px] text-cladd-fg-softer"
|
||||
>{letter(m.month)}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,11 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { AccountType } from "#lib/api/types.js";
|
||||
import AccountsIndex from "#lib/components/accounts/AccountsIndex.svelte";
|
||||
import Ledger from "#lib/components/accounts/Ledger.svelte";
|
||||
import { page } from "$app/state";
|
||||
|
||||
const type = $derived(page.params.type as AccountType);
|
||||
</script>
|
||||
|
||||
{#key type}
|
||||
{#if type === "liabilities"}
|
||||
<Ledger />
|
||||
{:else}
|
||||
<AccountsIndex {type} />
|
||||
{/if}
|
||||
{/key}
|
||||
@@ -22,6 +22,13 @@
|
||||
});
|
||||
const draftKey = $derived(page.url.searchParams.get("draft"));
|
||||
|
||||
function blank() {
|
||||
const form = emptyForm(type);
|
||||
form.splits[0].amount = page.url.searchParams.get("amount") ?? "";
|
||||
form.splits[0].description = page.url.searchParams.get("description") ?? "";
|
||||
return form;
|
||||
}
|
||||
|
||||
const clone = createQuery(() => ({
|
||||
enabled: Boolean(cloneId),
|
||||
queryFn: () => api<One<TransactionGroup>>(`/v1/transactions/${cloneId}`),
|
||||
@@ -42,6 +49,6 @@
|
||||
could not be loaded, so this is an empty form.
|
||||
</p>
|
||||
{/if}
|
||||
<Editor initial={draft ?? emptyForm(type)} {prefill} />
|
||||
<Editor initial={draft ?? blank()} {prefill} />
|
||||
{/if}
|
||||
{/key}
|
||||
Reference in new issue
Block a user