feat(subscriptions,piggy-banks,groups): grouped subscriptions with yearly cost, inline piggy money, object groups

This commit is contained in:
hh committed 2026-10-16 03:38:31 +00:00
1 parent 8b3f7e78a3
commit 4a693a83c9
27 files changed
+3492 -1

No files matched your search

@@ -0,0 +1,16 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { lists } from "#lib/api/lists.js";
import { Input } from "#lib/components/ui/input/index.js";
let { id, value = $bindable("") }: { id: string; value: string } = $props();
const groups = createQuery(() => lists.objectGroups());
</script>
<Input autocomplete="off" {id} list="{id}-list" bind:value />
<datalist id="{id}-list">
{#each groups.data ?? [] as group (group.id)}
<option value={group.attributes.title}></option>
{/each}
</datalist>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import Amount from "#lib/components/Amount.svelte";
import { formatMoney } from "#lib/format.js";
import type { Total } from "./object-groups.js";
let {
totals,
tone = "plain",
empty = "",
}: {
totals: Total[];
tone?: "income" | "expense" | "plain";
empty?: string;
} = $props();
</script>
{#each totals as total (total.code)}
<div>
{#if tone === "plain"}
<span class="num">{formatMoney(total)}</span>
{:else}
<Amount
amount={total.amount}
code={total.code}
decimals={total.decimals}
symbol={total.symbol}
{tone}
/>
{/if}
</div>
{:else}
{#if empty}
<span class="text-cladd-fg-softer">{empty}</span>
{/if}
{/each}
@@ -0,0 +1,95 @@
import { queryClient } from "#lib/api/lists.js";
import type { Item } from "#lib/api/types.js";
import type { Money } from "#lib/format.js";
import { toNumber } from "#lib/format.js";
interface Groupable {
object_group_id?: string | null;
object_group_order?: number | null;
object_group_title?: string | null;
order?: number;
}
export interface Section<T> {
id: string;
items: T[];
order: number;
title: string;
}
export const UNGROUPED = "(ungrouped)";
export function byObjectGroup<T extends Groupable>(
items: Item<T>[]
): Section<Item<T>>[] {
const sections = new Map<string, Section<Item<T>>>();
for (const item of items) {
const { object_group_id, object_group_order, object_group_title } =
item.attributes;
const id = object_group_id ?? "";
let section = sections.get(id);
if (!section) {
section = {
id,
items: [],
order: id ? (object_group_order ?? 0) : -1,
title: object_group_title ?? UNGROUPED,
};
sections.set(id, section);
}
section.items.push(item);
}
const list = [...sections.values()].sort((a, b) => a.order - b.order);
for (const section of list) {
section.items.sort(
(a, b) => (a.attributes.order ?? 0) - (b.attributes.order ?? 0)
);
}
return list;
}
export interface Total {
amount: number;
code: string;
decimals: number;
symbol: string;
}
export function sumByCurrency(entries: Money[]): Total[] {
const totals = new Map<string, Total>();
for (const entry of entries) {
const code = entry.code ?? "";
const total = totals.get(code) ?? {
amount: 0,
code,
decimals: entry.decimals ?? 2,
symbol: entry.symbol ?? code,
};
total.amount += toNumber(entry.amount);
totals.set(code, total);
}
return [...totals.values()];
}
export const LINK = "text-brand hover:underline dark:text-(--ff-series-1)";
export function swapOrder<T extends { order?: number }>(
key: readonly unknown[],
a: Item<T>,
b: Item<T>
): number | undefined {
const { order: first } = a.attributes;
const { order: second } = b.attributes;
queryClient.setQueryData<Item<T>[]>(key, (old) =>
old?.map((item) => {
if (item.id === a.id) {
return { ...item, attributes: { ...item.attributes, order: second } };
}
if (item.id === b.id) {
return { ...item, attributes: { ...item.attributes, order: first } };
}
return item;
})
);
return second;
}
@@ -0,0 +1,138 @@
<script lang="ts">
import { invalidate } from "#lib/api/lists.js";
import type { Item, PiggyBank } from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import { evalAmount } from "#lib/components/inputs/parse.js";
import { Action } from "#lib/components/misc/action.svelte.js";
import Status from "#lib/components/misc/Status.svelte";
import { Button } from "#lib/components/ui/button/index.js";
import { Input } from "#lib/components/ui/input/index.js";
import { formatMoney } from "#lib/format.js";
import {
changeMoney,
limit,
type Mode,
piggyMoney,
type Room,
} from "./piggy.js";
let {
piggy,
mode,
rooms,
onclose,
}: {
piggy: Item<PiggyBank>;
mode: Mode;
rooms: Map<string, Room>;
onclose: () => void;
} = $props();
const action = new Action();
const values = $state<Record<string, string>>({});
const links = $derived(
(piggy.attributes.accounts ?? []).map((link) => {
const id = link.account_id ?? "";
return {
id,
max: limit(piggy.attributes, id, mode, rooms.get(id)),
name: link.name ?? id,
};
})
);
const open = $derived(links.filter((l) => l.max > 0));
function onEscape(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
onclose();
}
}
function fail(message: string) {
action.failed = true;
action.message = message;
}
async function save() {
const deltas: Record<string, number> = {};
for (const link of open) {
const raw = values[link.id]?.trim();
if (!raw) {
continue;
}
const amount = evalAmount(raw);
if (amount === null || amount <= 0) {
return fail("Enter an amount above zero");
}
if (amount > link.max + 1e-9) {
const max = formatMoney(piggyMoney(piggy.attributes, link.max));
return fail(`${link.name} allows at most ${max}`);
}
deltas[link.id] = mode === "add" ? amount : -amount;
}
if (Object.keys(deltas).length === 0) {
return fail("Enter an amount");
}
const ok = await action.run(
() => changeMoney(piggy, deltas),
mode === "add" ? "Added" : "Removed"
);
if (ok) {
await invalidate();
onclose();
}
}
</script>
<form
class="flex flex-wrap items-center gap-x-4 gap-y-2"
onsubmit={(e) => {
e.preventDefault();
save();
}}
>
<span class="font-semibold">
{mode === "add" ? "Add money to" : "Remove money from"}
{piggy.attributes.name}
</span>
{#each open as link, i (link.id)}
<div class="flex items-center gap-2">
{#if open.length > 1}
<span>{link.name}</span>
{/if}
<Input
aria-label="Amount for {link.name}"
class="num h-7 w-28"
inputmode="decimal"
onkeydown={onEscape}
bind:value={values[link.id]}
{@attach (node) => {
if (i === 0) {
node.focus();
}
}}
/>
<span class="text-[12px] text-cladd-fg-soft">
up to
<Amount {...piggyMoney(piggy.attributes, link.max)} tone="neutral" />
</span>
</div>
{:else}
<span class="text-cladd-fg-soft">
{mode === "add"
? "You have no money to put in this piggy bank."
: "There is no money in this piggy bank."}
</span>
{/each}
{#if open.length > 0}
<Button disabled={action.busy} size="sm" type="submit">
{mode === "add" ? "Add" : "Remove"}
</Button>
{/if}
<Button onclick={onclose} size="sm" type="button" variant="ghost"
>Cancel <span class="kbd">Esc</span></Button
>
<Status {action} />
</form>
@@ -0,0 +1,282 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { untrack } from "svelte";
import { api } from "#lib/api/client.js";
import { invalidate, lists } from "#lib/api/lists.js";
import type { Item, One, PiggyBank } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import GroupInput from "#lib/components/groups/GroupInput.svelte";
import { amountString } from "#lib/components/inputs/parse.js";
import { Action } from "#lib/components/misc/action.svelte.js";
import Field from "#lib/components/misc/Field.svelte";
import Status from "#lib/components/misc/Status.svelte";
import { select, textarea } from "#lib/components/misc/styles.js";
import { Button } from "#lib/components/ui/button/index.js";
import { Input } from "#lib/components/ui/input/index.js";
import { today } from "#lib/dates.js";
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
import { session } from "#lib/session.svelte.js";
import { goto } from "$app/navigation";
let { piggy }: { piggy?: Item<PiggyBank> } = $props();
const initial = untrack(() => piggy?.attributes);
const form = $state({
currency_code: initial?.currency_code ?? session.primary?.code ?? "",
name: initial?.name ?? "",
notes: initial?.notes ?? "",
object_group_title: initial?.object_group_title ?? "",
start_date: initial?.start_date?.slice(0, 10) ?? today(),
target_amount: initial?.target_amount ?? "",
target_date: initial?.target_date?.slice(0, 10) ?? "",
});
const linked = $state<Record<string, string>>(
Object.fromEntries(
(initial?.accounts ?? []).map((a) => [
a.account_id ?? "",
a.current_amount ?? "0",
])
)
);
const assets = createQuery(() => lists.accounts("asset"));
const liabilities = createQuery(() => lists.accounts("liabilities"));
const accounts = $derived(
[...(assets.data ?? []), ...(liabilities.data ?? [])].filter(
(a) =>
a.id in linked ||
(a.attributes.active &&
a.attributes.currency_code === form.currency_code)
)
);
const currencies = $derived(
session.currencies.filter(
(c) => c.attributes.enabled || c.attributes.code === form.currency_code
)
);
const decimals = $derived(
currencies.find((c) => c.attributes.code === form.currency_code)?.attributes
.decimal_places ?? 2
);
const action = new Action();
function toggle(id: string, on: boolean) {
if (on) {
linked[id] = "0";
} else {
delete linked[id];
}
}
const snapshot = (entries: [string, string][]) =>
JSON.stringify(
entries
.map(([id, amount]) => [id, amountString(amount, decimals) || "0"])
.sort()
);
const before = snapshot(Object.entries(linked));
function body() {
const entries = Object.entries(linked);
return {
accounts:
piggy && snapshot(entries) === before
? undefined
: entries.map(([id, amount]) => ({
account_id: id,
current_amount: amountString(amount, decimals) || "0",
})),
name: form.name.trim(),
notes: form.notes.trim() || null,
object_group_title:
form.object_group_title.trim() ||
(initial?.object_group_title ? "" : undefined),
start_date: form.start_date || today(),
target_amount: amountString(form.target_amount, decimals) || "0",
target_date: form.target_date || null,
transaction_currency_code:
form.currency_code === initial?.currency_code
? undefined
: form.currency_code,
};
}
async function save() {
let id = piggy?.id;
const ok = await action.run(async () => {
const saved = await api<One<PiggyBank>>(
piggy ? `/v1/piggy-banks/${piggy.id}` : "/v1/piggy-banks",
{ body: body(), method: piggy ? "PUT" : "POST" }
);
({ id } = saved.data);
});
if (ok) {
await invalidate();
await goto(`/piggy-banks/show/${id}`);
}
}
async function remove() {
if (!piggy) {
return;
}
const ok = await action.run(() =>
api(`/v1/piggy-banks/${piggy.id}`, { method: "DELETE" })
);
if (ok) {
await goto("/piggy-banks");
await invalidate();
}
}
useKeys(() => [
{
group: "Form",
input: true,
keys: "mod+Enter",
label: piggy ? "Update piggy bank" : "Store new piggy bank",
run: save,
},
]);
</script>
<form
class="flex flex-col gap-3"
onsubmit={(e) => {
e.preventDefault();
save();
}}
>
<div class="grid items-start gap-3 lg:grid-cols-2">
<Box body="flex flex-col gap-3 p-4" title="Mandatory fields">
<Field for="piggy-name" label="Name" problem={action.field("name")}>
<Input id="piggy-name" required bind:value={form.name} />
</Field>
<Field
for="piggy-target"
help="Leave empty to save without a target."
label="Target amount"
problem={action.field("target_amount")}
>
<Input
class="num"
id="piggy-target"
inputmode="decimal"
bind:value={form.target_amount}
/>
</Field>
<Field
for="piggy-currency"
help="Piggy banks can only save money in a single currency."
label="Currency"
problem={action.field("transaction_currency_code")}
>
<select
class={select}
id="piggy-currency"
bind:value={form.currency_code}
>
{#each currencies as currency (currency.id)}
<option value={currency.attributes.code}>
{currency.attributes.name}
({currency.attributes.symbol})
</option>
{/each}
</select>
</Field>
<Field
help="Only accounts in the selected currency are listed. The amount is what this piggy bank holds on each account."
label="Save on account(s)"
problem={action.field("accounts") ??
Object.entries(action.errors).find(([k]) =>
k.startsWith("accounts.")
)?.[1][0]}
>
<div
class="cladd-cut flex max-h-64 flex-col overflow-y-auto rounded-cladd-md py-1"
>
{#each accounts as account (account.id)}
{@const on = account.id in linked}
<div class="flex h-8 items-center gap-2 px-2.5">
<label class="flex min-w-0 flex-1 items-center gap-2">
<input
checked={on}
class="size-4 accent-(--ff-brand)"
onchange={(e) => toggle(account.id, e.currentTarget.checked)}
type="checkbox"
>
<span class="truncate">{account.attributes.name}</span>
<span class="text-[12px] text-cladd-fg-softer"
>{account.attributes.currency_code}</span
>
</label>
{#if on}
<Input
aria-label="Saved on {account.attributes.name}"
class="num h-7 w-28"
inputmode="decimal"
bind:value={linked[account.id]}
/>
{/if}
</div>
{:else}
<p class="px-2.5 py-1.5 text-cladd-fg-soft">
{assets.isPending
? "Loading accounts"
: "No asset account or liability uses this currency."}
</p>
{/each}
</div>
</Field>
</Box>
<Box body="flex flex-col gap-3 p-4" title="Optional fields">
{#if piggy}
<Field
for="piggy-start"
label="Start date"
problem={action.field("start_date")}
>
<Input id="piggy-start" type="date" bind:value={form.start_date} />
</Field>
{/if}
<Field
for="piggy-target-date"
label="Target date"
problem={action.field("target_date")}
>
<Input
id="piggy-target-date"
type="date"
bind:value={form.target_date}
/>
</Field>
<Field
for="piggy-group"
label="Group"
problem={action.field("object_group_title")}
>
<GroupInput id="piggy-group" bind:value={form.object_group_title} />
</Field>
<Field for="piggy-notes" label="Notes" problem={action.field("notes")}>
<textarea
class={textarea}
id="piggy-notes"
rows="4"
bind:value={form.notes}
></textarea>
</Field>
</Box>
</div>
<div class="flex flex-wrap items-center gap-3">
<Button disabled={action.busy} type="submit">
{piggy ? "Update piggy bank" : "Store new piggy bank"}
<span class="kbd">{prettyKeys("mod+Enter")[0]}</span>
</Button>
<Status {action} />
{#if piggy}
<ConfirmButton class="ms-auto" onconfirm={remove}>Delete</ConfirmButton>
{/if}
</div>
</form>
+94
View File
@@ -0,0 +1,94 @@
import { api } from "#lib/api/client.js";
import type { Account, Item, One, PiggyBank } from "#lib/api/types.js";
import type { Money } from "#lib/format.js";
import { toNumber } from "#lib/format.js";
export type Mode = "add" | "remove";
export interface Room {
account: Item<Account> | undefined;
balance: number;
earmarked: number;
free: number;
}
export function rooms(
piggies: Item<PiggyBank>[],
accounts: Item<Account>[]
): Map<string, Room> {
const byId = new Map(accounts.map((a) => [a.id, a]));
const result = new Map<string, Room>();
for (const piggy of piggies) {
for (const link of piggy.attributes.accounts ?? []) {
const id = link.account_id ?? "";
const account = byId.get(id);
const room = result.get(id) ?? {
account,
balance: toNumber(account?.attributes.current_balance),
earmarked: 0,
free: 0,
};
room.earmarked += toNumber(link.current_amount);
room.free = room.balance - room.earmarked;
result.set(id, room);
}
}
return result;
}
export function limit(
piggy: PiggyBank,
accountId: string,
mode: Mode,
room: Room | undefined
): number {
const link = piggy.accounts?.find((a) => a.account_id === accountId);
if (mode === "remove") {
return toNumber(link?.current_amount);
}
const free = Math.max(0, room?.free ?? 0);
const target = toNumber(piggy.target_amount);
return target > 0 ? Math.min(free, toNumber(piggy.left_to_save)) : free;
}
export const piggyMoney = (piggy: PiggyBank, amount: unknown): Money => ({
amount: amount as string,
code: piggy.currency_code,
decimals: piggy.currency_decimal_places,
symbol: piggy.currency_symbol,
});
export async function changeMoney(
piggy: Item<PiggyBank>,
deltas: Record<string, number>
): Promise<Item<PiggyBank>> {
const decimals = piggy.attributes.currency_decimal_places ?? 2;
const accounts = (piggy.attributes.accounts ?? []).map((link) => ({
account_id: link.account_id,
current_amount: (
toNumber(link.current_amount) + (deltas[link.account_id ?? ""] ?? 0)
).toFixed(decimals),
}));
const saved = await api<One<PiggyBank>>(`/v1/piggy-banks/${piggy.id}`, {
body: { accounts },
method: "PUT",
});
const after = new Map(
(saved.data.attributes.accounts ?? []).map((a) => [
a.account_id,
toNumber(a.current_amount),
])
);
const refused = accounts.find(
(a) =>
!after.has(a.account_id) ||
Math.abs((after.get(a.account_id) ?? 0) - toNumber(a.current_amount)) >
10 ** -decimals / 2
);
if (refused) {
throw new Error(
"Firefly kept the old amount: there is not enough money on the account"
);
}
return saved.data;
}
+1 -1
View File
@@ -34,7 +34,7 @@
<span class="truncate text-[13px] text-cladd-fg-soft">{subtitle}</span> <span class="truncate text-[13px] text-cladd-fg-soft">{subtitle}</span>
{/if} {/if}
</div> </div>
<div class="flex items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
{@render actions?.()} {@render actions?.()}
<ol <ol
aria-label="Breadcrumbs" aria-label="Breadcrumbs"
@@ -0,0 +1,261 @@
<script lang="ts">
import { untrack } from "svelte";
import { api } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js";
import type { Bill, Item, One } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import GroupInput from "#lib/components/groups/GroupInput.svelte";
import { amountString } from "#lib/components/inputs/parse.js";
import { Action } from "#lib/components/misc/action.svelte.js";
import Field from "#lib/components/misc/Field.svelte";
import Status from "#lib/components/misc/Status.svelte";
import { select, textarea } from "#lib/components/misc/styles.js";
import { Button } from "#lib/components/ui/button/index.js";
import { Input } from "#lib/components/ui/input/index.js";
import { today } from "#lib/dates.js";
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
import { session } from "#lib/session.svelte.js";
import { goto } from "$app/navigation";
import { FREQUENCIES, FREQUENCY_LABELS } from "./bills.js";
let { bill }: { bill?: Item<Bill> } = $props();
const initial = untrack(() => bill?.attributes);
const form = $state({
active: initial?.active ?? true,
amount_max: initial?.amount_max ?? "",
amount_min: initial?.amount_min ?? "",
currency_code: initial?.currency_code ?? session.primary?.code ?? "",
date: initial?.date?.slice(0, 10) ?? today(),
end_date: initial?.end_date?.slice(0, 10) ?? "",
extension_date: initial?.extension_date?.slice(0, 10) ?? "",
name: initial?.name ?? "",
notes: initial?.notes ?? "",
object_group_title: initial?.object_group_title ?? "",
repeat_freq: initial?.repeat_freq ?? "monthly",
skip: String(initial?.skip ?? 0),
});
const action = new Action();
const currencies = $derived(
session.currencies.filter(
(c) => c.attributes.enabled || c.attributes.code === form.currency_code
)
);
const decimals = $derived(
currencies.find((c) => c.attributes.code === form.currency_code)?.attributes
.decimal_places ?? 2
);
function body() {
const min = amountString(form.amount_min, decimals);
return {
active: form.active,
amount_max: amountString(form.amount_max, decimals) || min,
amount_min: min,
currency_code:
form.currency_code === initial?.currency_code
? undefined
: form.currency_code,
date: form.date,
end_date: form.end_date || undefined,
extension_date: form.extension_date || undefined,
name: form.name.trim(),
notes: form.notes.trim() || null,
object_group_title:
form.object_group_title.trim() || (bill ? "" : undefined),
repeat_freq: form.repeat_freq,
skip: Number(form.skip) || 0,
};
}
async function save() {
let id = bill?.id;
const ok = await action.run(async () => {
const saved = await api<One<Bill>>(
bill ? `/v1/bills/${bill.id}` : "/v1/bills",
{ body: body(), method: bill ? "PUT" : "POST" }
);
({ id } = saved.data);
});
if (ok) {
await invalidate();
await goto(`/subscriptions/show/${id}`);
}
}
async function remove() {
if (!bill) {
return;
}
const ok = await action.run(() =>
api(`/v1/bills/${bill.id}`, { method: "DELETE" })
);
if (ok) {
await goto("/subscriptions");
await invalidate();
}
}
useKeys(() => [
{
group: "Form",
input: true,
keys: "mod+Enter",
label: bill ? "Update subscription" : "Store new subscription",
run: save,
},
]);
</script>
<form
class="flex flex-col gap-3"
onsubmit={(e) => {
e.preventDefault();
save();
}}
>
<div class="grid items-start gap-3 lg:grid-cols-2">
<Box body="flex flex-col gap-3 p-4" title="Mandatory fields">
<Field for="bill-name" label="Name" problem={action.field("name")}>
<Input id="bill-name" required bind:value={form.name} />
</Field>
<Field
for="bill-currency"
label="Currency"
problem={action.field("currency_code") ?? action.field("currency_id")}
>
<select
class={select}
id="bill-currency"
bind:value={form.currency_code}
>
{#each currencies as currency (currency.id)}
<option value={currency.attributes.code}>
{currency.attributes.name}
({currency.attributes.symbol})
</option>
{/each}
</select>
</Field>
<Field
for="bill-min"
label="Minimum amount"
problem={action.field("amount_min")}
>
<Input
class="num"
id="bill-min"
inputmode="decimal"
required
bind:value={form.amount_min}
/>
</Field>
<Field
for="bill-max"
help="Leave empty when the amount is always the same."
label="Maximum amount"
problem={action.field("amount_max")}
>
<Input
class="num"
id="bill-max"
inputmode="decimal"
bind:value={form.amount_max}
/>
</Field>
<Field
for="bill-date"
help="The first date this subscription is expected."
label="Date"
problem={action.field("date")}
>
<Input id="bill-date" required type="date" bind:value={form.date} />
</Field>
<Field
for="bill-repeat"
label="Repeats"
problem={action.field("repeat_freq")}
>
<select class={select} id="bill-repeat" bind:value={form.repeat_freq}>
{#each FREQUENCIES as frequency (frequency)}
<option value={frequency}>{FREQUENCY_LABELS[frequency]}</option>
{/each}
</select>
</Field>
<Field
for="bill-skip"
help="Skip 1 with monthly means every other month."
label="Skip"
problem={action.field("skip")}
>
<Input
class="num w-24"
id="bill-skip"
max="31"
min="0"
type="number"
bind:value={form.skip}
/>
</Field>
</Box>
<Box body="flex flex-col gap-3 p-4" title="Optional fields">
<Field
for="bill-end"
help="The subscription stops being expected after this date."
label="End date"
problem={action.field("end_date")}
>
<Input id="bill-end" type="date" bind:value={form.end_date} />
</Field>
<Field
for="bill-extension"
help="The date to extend or cancel the subscription."
label="Extension date"
problem={action.field("extension_date")}
>
<Input
id="bill-extension"
type="date"
bind:value={form.extension_date}
/>
</Field>
<Field
for="bill-group"
label="Group"
problem={action.field("object_group_title")}
>
<GroupInput id="bill-group" bind:value={form.object_group_title} />
</Field>
<Field for="bill-notes" label="Notes" problem={action.field("notes")}>
<textarea
class={textarea}
id="bill-notes"
rows="4"
bind:value={form.notes}
></textarea>
</Field>
{#if bill}
<Field for="bill-active" label="Active">
<input
class="mt-2 size-4 accent-(--ff-brand)"
id="bill-active"
type="checkbox"
bind:checked={form.active}
>
</Field>
{/if}
</Box>
</div>
<div class="flex flex-wrap items-center gap-3">
<Button disabled={action.busy} type="submit">
{bill ? "Update subscription" : "Store new subscription"}
<span class="kbd">{prettyKeys("mod+Enter")[0]}</span>
</Button>
<Status {action} />
{#if bill}
<ConfirmButton class="ms-auto" onconfirm={remove}>Delete</ConfirmButton>
{/if}
</div>
</form>
@@ -0,0 +1,20 @@
<script lang="ts">
import { formatDate } from "#lib/format.js";
import { STATUS_TONE, type Status, statusText } from "./bills.js";
let { state }: { state: Status } = $props();
</script>
{#if state.kind === "paid"}
<span class="inline-flex flex-wrap gap-x-2">
{#each state.paid as paid (paid.transaction_journal_id)}
<a
class="text-income hover:underline"
href="/transactions/show/{paid.transaction_group_id}"
>✓ {formatDate(paid.date, "short")}</a
>
{/each}
</span>
{:else}
<span class={STATUS_TONE[state.kind]}>{statusText(state)}</span>
{/if}
@@ -0,0 +1,30 @@
<script lang="ts">
import type { Cell } from "./bills.js";
let { cells, months }: { cells: Cell[]; months: string[] } = $props();
const LOOK: Record<Cell, string> = {
due: "border border-warn",
idle: "border border-cladd-fg-softest",
missed: "border border-expense bg-expense/15",
off: "bg-cladd-fg-softest/25 scale-50",
paid: "bg-income",
};
const TITLE: Record<Cell, string> = {
due: "expected, not paid yet",
idle: "expected",
missed: "expected, not paid",
off: "not expected",
paid: "paid",
};
</script>
<span class="inline-flex gap-[3px] align-middle">
{#each cells as cell, i (months[i])}
<span
class="size-2.5 rounded-[2px] {LOOK[cell]}"
title="{months[i]}: {TITLE[cell]}"
></span>
{/each}
</span>
+176
View File
@@ -0,0 +1,176 @@
import type { Bill, Item } from "#lib/api/types.js";
import { addMonths, daysBetween, type Range, today } from "#lib/dates.js";
import type { Money } from "#lib/format.js";
import { toNumber } from "#lib/format.js";
export const FREQUENCIES = [
"weekly",
"monthly",
"quarterly",
"half-year",
"yearly",
] as const;
export type Frequency = (typeof FREQUENCIES)[number];
const PER_YEAR: Record<Frequency, number> = {
"half-year": 2,
monthly: 12,
quarterly: 4,
weekly: 52.17,
yearly: 1,
};
const MONTHS_PER: Record<Frequency, number> = {
"half-year": 6,
monthly: 1,
quarterly: 3,
weekly: 0,
yearly: 12,
};
const UNIT: Record<Frequency, [string, string]> = {
"half-year": ["every half year", "half years"],
monthly: ["monthly", "months"],
quarterly: ["quarterly", "quarters"],
weekly: ["weekly", "weeks"],
yearly: ["yearly", "years"],
};
export const FREQUENCY_LABELS: Record<Frequency, string> = {
"half-year": "Every half year",
monthly: "Monthly",
quarterly: "Quarterly",
weekly: "Weekly",
yearly: "Yearly",
};
const freq = (bill: Bill): Frequency =>
(bill.repeat_freq ?? "monthly") as Frequency;
export const average = (bill: Bill) =>
(toNumber(bill.amount_min) + toNumber(bill.amount_max)) / 2;
export const timesPerYear = (bill: Bill) =>
PER_YEAR[freq(bill)] / ((bill.skip ?? 0) + 1);
export function repeats(bill: Bill): string {
const [single, plural] = UNIT[freq(bill)];
const every = (bill.skip ?? 0) + 1;
return every > 1 ? `every ${every} ${plural}` : single;
}
export const money = (bill: Bill, amount: number): Money => ({
amount,
code: bill.currency_code,
decimals: bill.currency_decimal_places,
symbol: bill.currency_symbol,
});
export const monthly = (bill: Bill) =>
money(bill, (average(bill) * timesPerYear(bill)) / 12);
export const yearly = (bill: Bill) =>
money(bill, average(bill) * timesPerYear(bill));
export const dueIn = (range: Range, bill: Bill) =>
(bill.pay_dates ?? [])
.map((date) => date.slice(0, 10))
.filter((date) => date >= range.start && date <= range.end);
export type Status =
| { kind: "inactive" }
| { kind: "paid"; paid: NonNullable<Bill["paid_dates"]> }
| { kind: "due" | "overdue"; date: string; days: number }
| { kind: "none" };
export function status(bill: Bill, range: Range): Status {
if (!bill.active) {
return { kind: "inactive" };
}
const paid = bill.paid_dates ?? [];
if (paid.length > 0) {
return { kind: "paid", paid };
}
const [date] = dueIn(range, bill);
if (!date) {
return { kind: "none" };
}
const days = daysBetween(today(), date);
return { date, days, kind: days < 0 ? "overdue" : "due" };
}
export function statusText(state: Status): string {
switch (state.kind) {
case "inactive":
return "Inactive";
case "none":
return "Not expected this period";
case "overdue":
return `Overdue ${-state.days} ${-state.days === 1 ? "day" : "days"}`;
case "due":
if (state.days === 0) {
return "Due today";
}
return `Due in ${state.days} ${state.days === 1 ? "day" : "days"}`;
default:
return "Paid";
}
}
export const STATUS_TONE: Record<Status["kind"], string> = {
due: "text-warn",
inactive: "text-cladd-fg-softer",
none: "text-cladd-fg-softer",
overdue: "text-expense",
paid: "text-income",
};
export const nextExpected = (bill: Bill) =>
bill.pay_dates?.[0]?.slice(0, 10) ?? bill.next_expected_match ?? null;
export type Cell = "paid" | "missed" | "due" | "off" | "idle";
export function expectedIn(bill: Bill, month: string): boolean {
const start = (bill.date ?? "").slice(0, 7);
const end = bill.end_date?.slice(0, 7);
if (!start || month < start || (end && month > end)) {
return false;
}
const step = MONTHS_PER[freq(bill)] * ((bill.skip ?? 0) + 1);
if (step === 0) {
return true;
}
const [y1, m1] = start.split("-").map(Number);
const [y2, m2] = month.split("-").map(Number);
return ((y2 - y1) * 12 + (m2 - m1)) % step === 0;
}
export function lastMonths(end: string, count = 12): string[] {
const first = addMonths(`${end.slice(0, 7)}-01`, 1 - count);
return Array.from({ length: count }, (_, i) =>
addMonths(first, i).slice(0, 7)
);
}
export function strip(bill: Bill, months: string[]): Cell[] {
const paid = new Set((bill.paid_dates ?? []).map((p) => p.date?.slice(0, 7)));
const day = today();
const now = day.slice(0, 7);
const late = (bill.pay_dates ?? []).some(
(date) => date.slice(0, 7) === now && date.slice(0, 10) < day
);
return months.map((month) => {
if (paid.has(month)) {
return "paid";
}
if (!(bill.active && expectedIn(bill, month))) {
return "off";
}
if (month > now) {
return "idle";
}
return month === now && !late ? "due" : "missed";
});
}
export const billKey = (item: Item<Bill>) => item.id;
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "@sveltejs/kit";
export function load({ params, url }) {
const rest = params.rest ? `/${params.rest}` : "";
redirect(307, `/subscriptions${rest}${url.search}`);
}
+309
View File
@@ -0,0 +1,309 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { invalidate, lists } from "#lib/api/lists.js";
import type { Item, ObjectGroup } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import { LINK, swapOrder } from "#lib/components/groups/object-groups.js";
import { errorText } from "#lib/components/misc/action.svelte.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "#lib/components/ui/context-menu/index.js";
import { Input } from "#lib/components/ui/input/index.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { oldUiUrl } from "#lib/spa-paths.js";
import { goto } from "$app/navigation";
const groupQuery = lists.objectGroups();
const groups = createQuery(() => groupQuery);
const bills = createQuery(() => lists.bills());
const piggies = createQuery(() => lists.piggyBanks());
const accounts = createQuery(() => lists.accounts("all"));
const rows = $derived(
[...(groups.data ?? [])].sort(
(a, b) => a.attributes.order - b.attributes.order
)
);
interface Member {
href: string;
id: string;
name: string;
}
const members = $derived.by(() => {
const map = new Map<string, Record<string, Member[]>>();
const add = (
group: string | null | undefined,
kind: string,
member: Member
) => {
if (!group) {
return;
}
const entry = map.get(group) ?? {};
entry[kind] = [...(entry[kind] ?? []), member];
map.set(group, entry);
};
for (const b of bills.data ?? []) {
add(b.attributes.object_group_id, "Subscriptions", {
href: `/subscriptions/show/${b.id}`,
id: b.id,
name: b.attributes.name ?? b.id,
});
}
for (const p of piggies.data ?? []) {
add(p.attributes.object_group_id, "Piggy banks", {
href: `/piggy-banks/show/${p.id}`,
id: p.id,
name: p.attributes.name,
});
}
for (const a of accounts.data ?? []) {
add(a.attributes.object_group_id, "Accounts", {
href: `/accounts/show/${a.id}`,
id: a.id,
name: a.attributes.name,
});
}
return map;
});
let renaming = $state<string | null>(null);
let title = $state("");
let menuFor = $state<Item<ObjectGroup> | null>(null);
let armed = $state(false);
let failure = $state("");
async function run(task: () => Promise<unknown>) {
failure = "";
try {
await task();
} catch (e) {
failure = errorText(e);
}
await invalidate();
}
function rename(group: Item<ObjectGroup> | undefined): void {
if (group) {
nav.cursor = rows.indexOf(group);
({ title } = group.attributes);
renaming = group.id;
}
}
async function saveTitle(group: Item<ObjectGroup>) {
const next = title.trim();
renaming = null;
if (next && next !== group.attributes.title) {
await run(() =>
api(`/v1/object-groups/${group.id}`, {
body: { title: next },
method: "PUT",
})
);
}
}
function move(dir: 1 | -1): void {
const { current } = nav;
const other = rows[nav.cursor + dir];
if (!(current && other)) {
return;
}
const order = swapOrder(groupQuery.queryKey, current, other);
nav.move(nav.cursor + dir);
run(() =>
api(`/v1/object-groups/${current.id}`, {
body: { order },
method: "PUT",
})
);
}
const nav: ListNav<Item<ObjectGroup>> = new ListNav<Item<ObjectGroup>>({
edit: (g) => goto(`/groups/edit/${g.id}`),
extra: () => [
{
group: "Groups",
keys: "r",
label: "Rename group",
run: () => rename(nav.current),
},
{
group: "Groups",
keys: "alt+j",
label: "Move group down",
run: () => move(1),
},
{
group: "Groups",
keys: "alt+k",
label: "Move group up",
run: () => move(-1),
},
{
group: "Groups",
hidden: true,
keys: "alt+∆",
label: "Move group down",
run: () => move(1),
},
{
group: "Groups",
hidden: true,
keys: "alt+˚",
label: "Move group up",
run: () => move(-1),
},
],
id: "groups",
items: () => rows,
key: (g) => g.id,
open: (g) => goto(`/groups/edit/${g.id}`),
});
nav.use();
const head =
"px-3 py-1.5 text-left font-semibold text-[12px] text-cladd-fg-soft";
</script>
<PageHeader crumbs={[{ label: "Groups" }]} subtitle="Overview" title="Groups" />
<Box body="p-0" title="Groups">
{#snippet tools()}
{#if failure}
<span class="text-[12px] text-expense">{failure}</span>
{/if}
{/snippet}
{#if groups.isPending}
<p class="px-3 py-6 text-cladd-fg-soft">Loading groups</p>
{:else if rows.length === 0}
<p class="max-w-prose px-3 py-6 text-cladd-fg-soft">
Some things in Firefly III can be divided into groups. Piggy banks and
subscriptions have a "Group" field on their edit page. Give them a group
and it shows up here.
</p>
{:else}
<ContextMenu onOpenChange={(open) => open || (armed = false)}>
<ContextMenuTrigger>
<table class="w-full text-[13px]">
<thead>
<tr class="border-cladd-outline border-b">
<th class="{head} w-10 text-right">#</th>
<th class="{head} w-1/4">Title</th>
<th class={head}>Members</th>
</tr>
</thead>
<tbody>
{#each rows as group, i (group.id)}
{@const kinds = members.get(group.id) ?? {}}
<tr
class="border-cladd-outline/60 border-t align-top {nav.cursor ===
i
? "bg-row-cursor"
: "hover:bg-row-hover"}"
id={nav.rowId(i)}
onclick={(e) => nav.click(i, e)}
oncontextmenu={() => {
menuFor = group;
nav.cursor = i;
}}
>
<td class="num px-3 py-1.5 text-right text-cladd-fg-softer">
{i + 1}
</td>
<td class="px-3 py-1">
{#if renaming === group.id}
<form
onsubmit={(e) => {
e.preventDefault();
saveTitle(group);
}}
>
<Input
aria-label="Group title"
class="h-7"
onblur={() => (renaming = null)}
onkeydown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
renaming = null;
}
}}
bind:value={title}
{@attach (node) => node.focus()}
/>
</form>
{:else}
<button
class="py-0.5 text-left font-semibold"
ondblclick={() => rename(group)}
title="Double-click or press r to rename"
type="button"
>
{group.attributes.title}
</button>
{/if}
</td>
<td class="px-3 py-1.5">
{#each Object.entries(kinds) as [kind, list] (kind)}
<div>
<span class="text-cladd-fg-soft">{kind}:</span>
{#each list as member, m (member.id)}
{#if m > 0}
,
{/if}
<a class={LINK} href={member.href}>{member.name}</a>
{/each}
</div>
{:else}
<span class="text-cladd-fg-softer">Empty</span>
{/each}
</td>
</tr>
{/each}
</tbody>
</table>
</ContextMenuTrigger>
<ContextMenuContent class="w-48">
{#if menuFor}
{@const target = menuFor}
<ContextMenuItem onSelect={() => rename(target)}
>Rename</ContextMenuItem
>
<ContextMenuItem onSelect={() => goto(`/groups/edit/${target.id}`)}
>Edit</ContextMenuItem
>
<ContextMenuItem
onSelect={() =>
location.assign(oldUiUrl(`${location.origin}/groups`))}
>Open in old UI</ContextMenuItem
>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={(e) => {
if (!armed) {
e.preventDefault();
armed = true;
return;
}
run(() =>
api(`/v1/object-groups/${target.id}`, { method: "DELETE" })
);
}}
variant="destructive"
>{armed ? "Press again to delete" : "Delete"}</ContextMenuItem
>
{/if}
</ContextMenuContent>
</ContextMenu>
{/if}
</Box>
@@ -0,0 +1,5 @@
import { redirect } from "@sveltejs/kit";
export function load({ params }) {
redirect(307, `/groups/edit/${params.id}`);
}
+158
View File
@@ -0,0 +1,158 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js";
import type {
Bill,
ObjectGroup,
One,
Page,
PiggyBank,
} from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import { LINK } from "#lib/components/groups/object-groups.js";
import { Action } from "#lib/components/misc/action.svelte.js";
import Field from "#lib/components/misc/Field.svelte";
import Status from "#lib/components/misc/Status.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { Button } from "#lib/components/ui/button/index.js";
import { Input } from "#lib/components/ui/input/index.js";
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
const id = $derived(page.params.id ?? "");
const group = createQuery(() => ({
queryFn: () => api<One<ObjectGroup>>(`/v1/object-groups/${id}`),
queryKey: ["object-group", id],
}));
const bills = createQuery(() => ({
queryFn: () =>
api<Page<Bill>>(`/v1/object-groups/${id}/bills`, {
query: { limit: 1000 },
}),
queryKey: ["object-group", id, "bills"],
}));
const piggies = createQuery(() => ({
queryFn: () =>
api<Page<PiggyBank>>(`/v1/object-groups/${id}/piggy-banks`, {
query: { limit: 1000 },
}),
queryKey: ["object-group", id, "piggy-banks"],
}));
const current = $derived(group.data?.data.attributes.title ?? "");
let title = $state<string | null>(null);
const value = $derived(title ?? current);
const action = new Action();
async function save() {
const next = value.trim();
const ok = await action.run(() =>
api(`/v1/object-groups/${id}`, { body: { title: next }, method: "PUT" })
);
if (ok) {
await invalidate();
await goto("/groups");
}
}
async function remove() {
const ok = await action.run(() =>
api(`/v1/object-groups/${id}`, { method: "DELETE" })
);
if (ok) {
await goto("/groups");
await invalidate();
}
}
useKeys(() => [
{
group: "Form",
input: true,
keys: "mod+Enter",
label: "Update group",
run: save,
},
]);
</script>
<PageHeader
crumbs={[{ href: "/groups", label: "Groups" }, { label: current || id }]}
title={current ? `Edit group "${current}"` : "Edit group"}
/>
{#if group.error}
<p class="text-expense">{group.error.message}</p>
{:else if !group.data}
<p class="text-cladd-fg-soft">Loading group</p>
{:else}
<div class="grid items-start gap-3 lg:grid-cols-2">
<form
onsubmit={(e) => {
e.preventDefault();
save();
}}
>
<Box body="flex flex-col gap-3 p-4" title="Mandatory fields">
<Field for="group-title" label="Title" problem={action.field("title")}>
<Input
id="group-title"
oninput={(e) => (title = e.currentTarget.value)}
required
{value}
/>
</Field>
{#snippet footer()}
<Button disabled={action.busy} size="sm" type="submit">
Update group <span class="kbd">{prettyKeys("mod+Enter")[0]}</span>
</Button>
<Status {action} />
<ConfirmButton class="ms-auto" onconfirm={remove}
>Delete</ConfirmButton
>
{/snippet}
</Box>
</form>
<Box body="p-3" title="Members">
{@const billList = bills.data?.data ?? []}
{@const piggyList = piggies.data?.data ?? []}
{#if billList.length > 0}
<h3 class="mb-1 font-semibold text-[12px] text-cladd-fg-soft">
Subscriptions
</h3>
<ul class="mb-3 flex flex-col gap-0.5">
{#each billList as bill (bill.id)}
<li>
<a class={LINK} href="/subscriptions/show/{bill.id}"
>{bill.attributes.name}</a
>
</li>
{/each}
</ul>
{/if}
{#if piggyList.length > 0}
<h3 class="mb-1 font-semibold text-[12px] text-cladd-fg-soft">
Piggy banks
</h3>
<ul class="flex flex-col gap-0.5">
{#each piggyList as piggy (piggy.id)}
<li>
<a class={LINK} href="/piggy-banks/show/{piggy.id}"
>{piggy.attributes.name}</a
>
</li>
{/each}
</ul>
{/if}
{#if billList.length === 0 && piggyList.length === 0}
<p class="text-cladd-fg-soft">
{bills.isPending ? "Loading members" : "Nothing is in this group."}
</p>
{/if}
</Box>
</div>
{/if}
+499
View File
@@ -0,0 +1,499 @@
<script lang="ts">
import Minus from "@lucide/svelte/icons/minus";
import Plus from "@lucide/svelte/icons/plus";
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { invalidate, lists } from "#lib/api/lists.js";
import type { Item, PiggyBank } from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import { tones } from "#lib/components/charts/chart.js";
import Meter from "#lib/components/charts/Meter.svelte";
import {
byObjectGroup,
LINK,
sumByCurrency,
swapOrder,
} from "#lib/components/groups/object-groups.js";
import Totals from "#lib/components/groups/Totals.svelte";
import { errorText } from "#lib/components/misc/action.svelte.js";
import MoneyEditor from "#lib/components/piggy-banks/MoneyEditor.svelte";
import {
limit,
type Mode,
piggyMoney,
rooms,
} from "#lib/components/piggy-banks/piggy.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { buttonVariants } from "#lib/components/ui/button/button.svelte";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "#lib/components/ui/context-menu/index.js";
import { formatDate, formatMoney, percent, toNumber } from "#lib/format.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { oldUiUrl } from "#lib/spa-paths.js";
import { goto } from "$app/navigation";
const piggyQuery = lists.piggyBanks();
const piggies = createQuery(() => piggyQuery);
const assets = createQuery(() => lists.accounts("asset"));
const liabilities = createQuery(() => lists.accounts("liabilities"));
const all = $derived(piggies.data ?? []);
const sections = $derived(byObjectGroup(all));
const rows = $derived(sections.flatMap((s) => s.items));
const offsets = $derived(
sections.map((_, i) =>
sections.slice(0, i).reduce((n, s) => n + s.items.length, 0)
)
);
const room = $derived(
rooms(all, [...(assets.data ?? []), ...(liabilities.data ?? [])])
);
let editing = $state<{ id: string; mode: Mode } | null>(null);
let menuFor = $state<Item<PiggyBank> | null>(null);
let armed = $state(false);
let failure = $state("");
const sum = (items: Item<PiggyBank>[], field: keyof PiggyBank) =>
sumByCurrency(
items
.filter((p) => toNumber(p.attributes[field] as string) > 0)
.map((p) => piggyMoney(p.attributes, p.attributes[field]))
);
const canAdd = (p: Item<PiggyBank>) =>
(p.attributes.accounts ?? []).some(
(a) =>
limit(
p.attributes,
a.account_id ?? "",
"add",
room.get(a.account_id ?? "")
) > 0
);
const canRemove = (p: Item<PiggyBank>) =>
toNumber(p.attributes.current_amount) > 0;
function startMoney(p: Item<PiggyBank> | undefined, mode: Mode): void {
if (p) {
nav.cursor = rows.indexOf(p);
editing = { id: p.id, mode };
}
}
async function update(p: Item<PiggyBank>, body: Record<string, unknown>) {
failure = "";
try {
await api(`/v1/piggy-banks/${p.id}`, { body, method: "PUT" });
} catch (e) {
failure = errorText(e);
}
await invalidate();
}
function move(dir: 1 | -1): void {
const { current } = nav;
const section = sections.find((s) => current && s.items.includes(current));
if (!(current && section)) {
return;
}
const other = section.items[section.items.indexOf(current) + dir];
if (!other) {
return;
}
const order = swapOrder(piggyQuery.queryKey, current, other);
nav.move(nav.cursor + dir);
update(current, { order });
}
async function remove(p: Item<PiggyBank>) {
failure = "";
try {
await api(`/v1/piggy-banks/${p.id}`, { method: "DELETE" });
} catch (e) {
failure = errorText(e);
}
await invalidate();
}
const nav: ListNav<Item<PiggyBank>> = new ListNav<Item<PiggyBank>>({
edit: (p) => goto(`/piggy-banks/edit/${p.id}`),
extra: () => [
{
group: "Piggy banks",
keys: "+",
label: "Add money",
run: () => startMoney(nav.current, "add"),
},
{
group: "Piggy banks",
hidden: true,
keys: "=",
label: "Add money",
run: () => startMoney(nav.current, "add"),
},
{
group: "Piggy banks",
keys: "-",
label: "Remove money",
run: () => startMoney(nav.current, "remove"),
},
{
group: "Piggy banks",
keys: "alt+j",
label: "Move down in group",
run: () => move(1),
},
{
group: "Piggy banks",
keys: "alt+k",
label: "Move up in group",
run: () => move(-1),
},
{
group: "Piggy banks",
hidden: true,
keys: "alt+∆",
label: "Move down in group",
run: () => move(1),
},
{
group: "Piggy banks",
hidden: true,
keys: "alt+˚",
label: "Move up in group",
run: () => move(-1),
},
{
group: "Piggy banks",
keys: "c",
label: "New piggy bank",
run: () => goto("/piggy-banks/create"),
},
],
id: "piggies",
items: () => rows,
key: (p) => p.id,
open: (p) => goto(`/piggy-banks/show/${p.id}`),
});
nav.use();
const head = "px-3 py-1.5 font-semibold text-[12px] text-cladd-fg-soft";
const cell = "px-3 py-1 align-middle";
const wide = "hidden md:table-cell";
const COLUMNS = 9;
</script>
<PageHeader crumbs={[{ label: "Piggy banks" }]} title="Piggy banks">
{#snippet actions()}
<a class={buttonVariants({ size: "sm" })} href="/piggy-banks/create">
<Plus />Create new piggy bank<span class="kbd">c</span>
</a>
{/snippet}
</PageHeader>
<Box body="p-0" title="Piggy banks">
{#snippet tools()}
{#if failure}
<span class="text-[12px] text-expense">{failure}</span>
{/if}
{/snippet}
{#if piggies.isPending}
<p class="px-3 py-6 text-cladd-fg-soft">Loading piggy banks</p>
{:else if piggies.error}
<p class="px-3 py-6 text-expense">{piggies.error.message}</p>
{:else if rows.length === 0}
<p class="px-3 py-6 text-cladd-fg-soft">
No piggy banks yet.
<a class={LINK} href="/piggy-banks/create">Create a piggy bank</a>
</p>
{:else}
<ContextMenu onOpenChange={(open) => open || (armed = false)}>
<ContextMenuTrigger>
<div class="overflow-x-auto">
<table class="w-full text-[13px]">
<thead>
<tr class="border-cladd-outline border-b text-left">
<th class={head}>Piggy bank</th>
<th class="{head} text-right">Saved so far</th>
<th class={head} colspan="3"></th>
<th class="{head} {wide} text-right">Target amount</th>
<th class="{head} {wide} text-right">Left to save</th>
<th class="{head} hidden text-right lg:table-cell">
Suggested per month
</th>
<th class="{head} hidden lg:table-cell">Target date</th>
</tr>
</thead>
{#each sections as section, s (section.id)}
<tbody>
<tr
class="border-cladd-outline border-t bg-cladd-surface-next/60 font-semibold"
>
<td class={cell}>
{section.title}
<span class="ms-1 font-normal text-cladd-fg-softer"
>{section.items.length}</span
>
</td>
<td class="{cell} text-right">
<Totals
tone="income"
totals={sum(section.items, "current_amount")}
/>
</td>
<td colspan="3"></td>
<td class="{cell} {wide} text-right">
<Totals totals={sum(section.items, "target_amount")} />
</td>
<td class="{cell} {wide} text-right">
<Totals totals={sum(section.items, "left_to_save")} />
</td>
<td class="{cell} hidden text-right lg:table-cell">
<Totals totals={sum(section.items, "save_per_month")} />
</td>
<td class="hidden lg:table-cell"></td>
</tr>
{#each section.items as item, j (item.id)}
{@const i = offsets[s] + j}
{@const p = item.attributes}
{@const pct = p.percentage}
<tr
class="h-7 border-cladd-outline/60 border-t {nav.cursor ===
i
? "bg-row-cursor"
: "hover:bg-row-hover"}"
id={nav.rowId(i)}
onclick={(e) => nav.click(i, e)}
oncontextmenu={() => {
menuFor = item;
nav.cursor = i;
}}
>
<td class={cell}>
<a class={LINK} href="/piggy-banks/show/{item.id}"
>{p.name}</a
>
</td>
<td class="{cell} text-right">
<Amount
{...piggyMoney(p, p.current_amount)}
tone="income"
/>
</td>
<td class="w-8 py-1 ps-2 text-right">
<button
aria-label="Remove money from {p.name}"
class={buttonVariants({
size: "icon-xs",
variant: "ghost",
})}
disabled={!canRemove(item)}
onclick={() => startMoney(item, "remove")}
title="Remove money (-)"
type="button"
>
<Minus />
</button>
</td>
<td class="w-24 py-1 sm:w-40">
{#if pct !== null && pct !== undefined}
<div class="flex items-center gap-2">
<Meter
label={percent(pct / 100)}
max={toNumber(p.target_amount)}
segments={[
{
color: pct >= 100 ? tones.income : undefined,
value: toNumber(p.current_amount),
},
]}
/>
<span
class="num w-9 text-right text-[12px] text-cladd-fg-soft"
>{percent(pct / 100)}</span
>
</div>
{/if}
</td>
<td class="w-8 py-1 pe-2">
<button
aria-label="Add money to {p.name}"
class={buttonVariants({
size: "icon-xs",
variant: "ghost",
})}
disabled={!canAdd(item)}
onclick={() => startMoney(item, "add")}
title="Add money (+)"
type="button"
>
<Plus />
</button>
</td>
<td class="{cell} {wide} text-right">
{#if toNumber(p.target_amount) > 0}
<span class="num"
>{formatMoney(piggyMoney(p, p.target_amount))}</span
>
{/if}
</td>
<td class="{cell} {wide} text-right">
{#if toNumber(p.left_to_save) > 0}
<span class="num"
>{formatMoney(piggyMoney(p, p.left_to_save))}</span
>
{/if}
</td>
<td class="{cell} hidden text-right lg:table-cell">
{#if p.target_date && toNumber(p.save_per_month) > 0}
<span class="num"
>{formatMoney(piggyMoney(p, p.save_per_month))}</span
>
{/if}
</td>
<td class="{cell} num hidden lg:table-cell">
{formatDate(p.target_date)}
</td>
</tr>
{#if editing?.id === item.id}
<tr class="border-cladd-outline/60 border-t bg-row-hover">
<td class="px-3 py-2" colspan={COLUMNS}>
<MoneyEditor
mode={editing.mode}
onclose={() => (editing = null)}
piggy={item}
rooms={room}
/>
</td>
</tr>
{/if}
{/each}
</tbody>
{/each}
</table>
</div>
</ContextMenuTrigger>
<ContextMenuContent class="w-52">
{#if menuFor}
{@const target = menuFor}
<ContextMenuItem
onSelect={() => goto(`/piggy-banks/show/${target.id}`)}
>Show</ContextMenuItem
>
<ContextMenuItem
onSelect={() => goto(`/piggy-banks/edit/${target.id}`)}
>Edit</ContextMenuItem
>
<ContextMenuItem
disabled={!canAdd(target)}
onSelect={() => startMoney(target, "add")}
>Add money</ContextMenuItem
>
<ContextMenuItem
disabled={!canRemove(target)}
onSelect={() => startMoney(target, "remove")}
>Remove money</ContextMenuItem
>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={() =>
location.assign(
oldUiUrl(`${location.origin}/piggy-banks/show/${target.id}`)
)}
>Open in old UI</ContextMenuItem
>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={(e) => {
if (!armed) {
e.preventDefault();
armed = true;
return;
}
remove(target);
}}
variant="destructive"
>{armed ? "Press again to delete" : "Delete"}</ContextMenuItem
>
{/if}
</ContextMenuContent>
</ContextMenu>
{/if}
{#snippet footer()}
<a
class={buttonVariants({ size: "sm", variant: "outline" })}
href="/piggy-banks/create"
><Plus />Create new piggy bank</a
>
{/snippet}
</Box>
{#if room.size > 0}
<Box body="p-0" class="mt-3" title="Account status">
<div class="overflow-x-auto">
<table class="w-full text-[13px]">
<thead>
<tr class="border-cladd-outline border-b text-left">
<th class={head}>Account</th>
<th class="{head} {wide} text-right">Balance</th>
<th class="{head} text-right">Left for piggy banks</th>
<th class="{head} {wide} text-right">Sum of piggy banks</th>
<th class="{head} {wide} text-right">Saved so far</th>
<th class="{head} {wide} text-right">Left to save</th>
</tr>
</thead>
<tbody>
{#each [...room] as [id, r] (id)}
{@const linked = all.filter((p) =>
p.attributes.accounts?.some((a) => a.account_id === id)
)}
{@const a = r.account?.attributes}
{@const money = {
code: a?.currency_code,
decimals: a?.currency_decimal_places,
symbol: a?.currency_symbol,
}}
{@const targets = linked.reduce(
(t, p) => t + toNumber(p.attributes.target_amount),
0
)}
<tr class="h-7 border-cladd-outline/60 border-t hover:bg-row-hover">
<td class={cell}>
<a class={LINK} href="/accounts/show/{id}">{a?.name ?? id}</a>
</td>
<td class="{cell} {wide} text-right">
<Amount {...money} amount={r.balance} tone="auto" />
</td>
<td class="{cell} text-right">
<Amount {...money} amount={r.free} tone="auto" />
</td>
<td class="{cell} {wide} text-right">
<span class="num"
>{formatMoney({ ...money, amount: targets })}</span
>
</td>
<td class="{cell} {wide} text-right">
<Amount {...money} amount={r.earmarked} tone="income" />
</td>
<td class="{cell} {wide} text-right">
<span class="num"
>{formatMoney({
...money,
amount: Math.max(0, targets - r.earmarked),
})}</span
>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</Box>
{/if}
@@ -0,0 +1,5 @@
import { redirect } from "@sveltejs/kit";
export function load({ params }) {
redirect(307, `/piggy-banks/show/${params.id}?money=add`);
}
@@ -0,0 +1,11 @@
<script lang="ts">
import PiggyForm from "#lib/components/piggy-banks/PiggyForm.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
</script>
<PageHeader
crumbs={[{ href: "/piggy-banks", label: "Piggy banks" }, { label: "Create" }]}
title="Create a new piggy bank"
/>
<PiggyForm />
@@ -0,0 +1,5 @@
import { redirect } from "@sveltejs/kit";
export function load({ params }) {
redirect(307, `/piggy-banks/edit/${params.id}`);
}
@@ -0,0 +1,34 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import type { One, PiggyBank } from "#lib/api/types.js";
import PiggyForm from "#lib/components/piggy-banks/PiggyForm.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { page } from "$app/state";
const id = $derived(page.params.id ?? "");
const piggy = createQuery(() => ({
queryFn: () => api<One<PiggyBank>>(`/v1/piggy-banks/${id}`),
queryKey: ["piggy-bank", id],
}));
const name = $derived(piggy.data?.data.attributes.name ?? "");
</script>
<PageHeader
crumbs={[
{ href: "/piggy-banks", label: "Piggy banks" },
{ href: `/piggy-banks/show/${id}`, label: name || id },
{ label: "Edit" },
]}
title={name ? `Edit piggy bank "${name}"` : "Edit piggy bank"}
/>
{#if piggy.data}
{#key id}
<PiggyForm piggy={piggy.data.data} />
{/key}
{:else if piggy.error}
<p class="text-expense">{piggy.error.message}</p>
{:else}
<p class="text-cladd-fg-soft">Loading piggy bank</p>
{/if}
@@ -0,0 +1,5 @@
import { redirect } from "@sveltejs/kit";
export function load({ params }) {
redirect(307, `/piggy-banks/show/${params.id}?money=remove`);
}
@@ -0,0 +1,400 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { all, invalidate, lists } from "#lib/api/lists.js";
import type {
Item,
One,
PiggyBank,
PiggyBankEvent,
TransactionGroup,
} from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import { tones } from "#lib/components/charts/chart.js";
import LineChart from "#lib/components/charts/LineChart.svelte";
import Meter from "#lib/components/charts/Meter.svelte";
import { LINK } from "#lib/components/groups/object-groups.js";
import MoneyEditor from "#lib/components/piggy-banks/MoneyEditor.svelte";
import {
type Mode,
piggyMoney,
rooms,
} from "#lib/components/piggy-banks/piggy.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { buttonVariants } from "#lib/components/ui/button/button.svelte";
import { addDays, today } from "#lib/dates.js";
import {
formatDate,
formatDateTime,
formatMoney,
percent,
toNumber,
} from "#lib/format.js";
import { useKeys } from "#lib/keys.svelte.js";
import { oldUiUrl } from "#lib/spa-paths.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
const MIN_DAYS = 30;
const id = $derived(page.params.id ?? "");
const query = createQuery(() => ({
queryFn: () => api<One<PiggyBank>>(`/v1/piggy-banks/${id}`),
queryKey: ["piggy-bank", id],
}));
const events = createQuery(() => ({
queryFn: () => all<PiggyBankEvent>(`/v1/piggy-banks/${id}/events`),
queryKey: ["piggy-bank", id, "events"],
}));
const piggies = createQuery(() => lists.piggyBanks());
const assets = createQuery(() => lists.accounts("asset"));
const liabilities = createQuery(() => lists.accounts("liabilities"));
const item = $derived(query.data?.data);
const piggy = $derived(item?.attributes);
const room = $derived(
rooms(piggies.data ?? [], [
...(assets.data ?? []),
...(liabilities.data ?? []),
])
);
const mode = $derived(page.url.searchParams.get("money") as Mode | null);
const groupIds = $derived([
...new Set(
(events.data ?? [])
.map((e) => e.attributes.transaction_group_id ?? "")
.filter(Boolean)
),
]);
const linkedDates = createQuery(() => ({
enabled: groupIds.length > 0,
queryFn: async () =>
Object.fromEntries(
await Promise.all(
groupIds.map(async (group) => {
const one = await api<One<TransactionGroup>>(
`/v1/transactions/${group}`
);
return [group, one.data.attributes.transactions[0]?.date ?? ""];
})
)
),
queryKey: ["piggy-bank", id, "event-dates", groupIds],
}));
const when = (e: Item<PiggyBankEvent>) =>
linkedDates.data?.[e.attributes.transaction_group_id ?? ""] ||
e.attributes.created_at ||
"";
const history = $derived(
[...(events.data ?? [])].sort((a, b) => when(b).localeCompare(when(a)))
);
const points = $derived.by(() => {
const byDay = new Map<string, number>();
let running = 0;
for (const entry of [...history].reverse()) {
const day = when(entry).slice(0, 10);
const before = addDays(day, -1);
if (!byDay.has(before)) {
byDay.set(before, running);
}
running += toNumber(entry.attributes.amount);
byDay.set(day, running);
}
const [first = today()] = byDay.keys();
const start = piggy?.start_date?.slice(0, 10) ?? first;
const [from] = [start, first, addDays(today(), -MIN_DAYS)].sort();
byDay.set(from, 0);
byDay.set(today(), running);
return [...byDay]
.map(([x, y]) => ({ x, y }))
.sort((a, b) => a.x.localeCompare(b.x));
});
function setMode(next: Mode | null) {
const url = new URL(page.url.href);
if (next) {
url.searchParams.set("money", next);
} else {
url.searchParams.delete("money");
}
goto(url, { replace: true });
}
async function remove() {
await api(`/v1/piggy-banks/${id}`, { method: "DELETE" });
await goto("/piggy-banks");
await invalidate();
}
useKeys(() => [
{
group: "Piggy bank",
keys: "e",
label: "Edit piggy bank",
run: () => goto(`/piggy-banks/edit/${id}`),
},
{
group: "Piggy bank",
keys: "+",
label: "Add money",
run: () => setMode("add"),
},
{
group: "Piggy bank",
hidden: true,
keys: "=",
label: "Add money",
run: () => setMode("add"),
},
{
group: "Piggy bank",
keys: "-",
label: "Remove money",
run: () => setMode("remove"),
},
]);
const label = "px-3 py-1.5 align-top text-cladd-fg-soft";
const value = "px-3 py-1.5 text-right";
const row = "border-cladd-outline/60 border-b";
</script>
<PageHeader
crumbs={[
{ href: "/piggy-banks", label: "Piggy banks" },
{ label: piggy?.name ?? id },
]}
title={piggy?.name ?? "Piggy bank"}
>
{#snippet actions()}
<a
class={buttonVariants({ size: "sm", variant: "ghost" })}
data-sveltekit-reload
href={oldUiUrl(`${page.url.origin}/piggy-banks/show/${id}`)}
>Open in old UI</a
>
<a
class={buttonVariants({ size: "sm", variant: "outline" })}
href="/piggy-banks/edit/{id}"
>Edit <span class="kbd">e</span></a
>
<ConfirmButton onconfirm={remove}>Delete</ConfirmButton>
{/snippet}
</PageHeader>
{#if query.error}
<p class="text-expense">{query.error.message}</p>
{:else if !(piggy && item)}
<p class="text-cladd-fg-soft">Loading piggy bank</p>
{:else}
{@const target = toNumber(piggy.target_amount)}
<div class="grid items-start gap-3 lg:grid-cols-3">
<div class="flex flex-col gap-3 lg:col-span-2">
<Box title="Events">
{#if events.isPending}
<p class="text-cladd-fg-soft">Loading events</p>
{:else}
<LineChart
area
axisFormat={(v) => formatMoney(piggyMoney(piggy, v))}
height={260}
label="Saved over time"
series={[
{
color: tones.income,
format: (v) => formatMoney(piggyMoney(piggy, v)),
key: "saved",
label: "Saved so far",
points,
},
]}
toggle={false}
zero
/>
{/if}
</Box>
{#if piggy.notes}
<Box title="Notes">
<p class="whitespace-pre-wrap">{piggy.notes}</p>
</Box>
{/if}
</div>
<div class="flex flex-col gap-3">
<Box body="p-0" title="Details">
<table class="w-full text-[13px]">
<tbody>
<tr class={row}>
<td class={label}>Save on account(s)</td>
<td class={value}>
{#each piggy.accounts ?? [] as account, i (account.account_id)}
{#if i > 0}
,
{/if}
<a class={LINK} href="/accounts/show/{account.account_id}"
>{account.name}</a
>
{/each}
</td>
</tr>
<tr class={row}>
<td class={label}>Group</td>
<td class={value}>
{#if piggy.object_group_id}
<a class={LINK} href="/groups/edit/{piggy.object_group_id}"
>{piggy.object_group_title}</a
>
{:else}
<span class="text-cladd-fg-softer">No group</span>
{/if}
</td>
</tr>
<tr class={row}>
<td class={label}>Target amount</td>
<td class="{value} num">
{target > 0
? formatMoney(piggyMoney(piggy, piggy.target_amount))
: "No target"}
</td>
</tr>
{#if (piggy.accounts ?? []).length > 1}
{#each piggy.accounts ?? [] as account (account.account_id)}
<tr class={row}>
<td class={label}>Saved so far ({account.name})</td>
<td class={value}>
<Amount
{...piggyMoney(piggy, account.current_amount)}
tone="income"
/>
</td>
</tr>
{/each}
{/if}
<tr class={row}>
<td class={label}>Saved so far</td>
<td class={value}>
<Amount
{...piggyMoney(piggy, piggy.current_amount)}
tone="income"
/>
</td>
</tr>
{#if target > 0}
<tr class={row}>
<td class={label}>Left to save</td>
<td class="{value} num">
{formatMoney(piggyMoney(piggy, piggy.left_to_save))}
</td>
</tr>
{/if}
<tr class={row}>
<td class={label}>Start date</td>
<td class="{value} num">
{formatDate(piggy.start_date) || "No start date"}
</td>
</tr>
<tr class={row}>
<td class={label}>Target date</td>
<td class="{value} num">
{formatDate(piggy.target_date) || "No target date"}
</td>
</tr>
{#if piggy.target_date && toNumber(piggy.save_per_month) > 0}
<tr class={row}>
<td class={label}>Suggested monthly amount to save</td>
<td class="{value} num">
{formatMoney(piggyMoney(piggy, piggy.save_per_month))}
</td>
</tr>
{/if}
</tbody>
</table>
{#if target > 0 &&
piggy.percentage !== null &&
piggy.percentage !== undefined}
<div class="flex items-center gap-2 px-3 py-2.5">
<Meter
max={target}
segments={[
{ color: tones.income, value: toNumber(piggy.current_amount) },
]}
/>
<span class="num text-[12px] text-cladd-fg-soft"
>{percent(piggy.percentage / 100)}</span
>
</div>
{/if}
{#snippet footer()}
{#if mode}
<MoneyEditor
{mode}
onclose={() => setMode(null)}
piggy={item}
rooms={room}
/>
{:else}
<button
class={buttonVariants({ size: "sm", variant: "outline" })}
onclick={() => setMode("add")}
type="button"
>
Add money <span class="kbd">+</span>
</button>
<button
class={buttonVariants({ size: "sm", variant: "outline" })}
disabled={toNumber(piggy.current_amount) <= 0}
onclick={() => setMode("remove")}
type="button"
>
Remove money <span class="kbd">-</span>
</button>
{/if}
{/snippet}
</Box>
<Box body="p-0" title="Event history">
{#if history.length > 0}
<table class="w-full text-[13px]">
<tbody>
{#each history as entry (entry.id)}
{@const amount = toNumber(entry.attributes.amount)}
<tr
class="h-7 border-cladd-outline/60 border-b last:border-b-0"
>
<td class="px-3 py-1">
{#if entry.attributes.transaction_group_id}
<a
class={LINK}
href="/transactions/show/{entry.attributes
.transaction_group_id}"
>{formatDate(when(entry))}</a
>
{:else}
{formatDateTime(entry.attributes.created_at)}
{/if}
</td>
<td
class="px-3 py-1 text-right {amount < 0
? "text-expense"
: "text-income"}"
>
{amount < 0 ? "Removed" : "Added"}
<span class="num"
>{formatMoney(piggyMoney(piggy, Math.abs(amount)))}</span
>
</td>
</tr>
{/each}
</tbody>
</table>
{:else}
<p class="px-3 py-3 text-cladd-fg-soft">
{events.isPending ? "Loading events" : "No events yet."}
</p>
{/if}
</Box>
</div>
</div>
{/if}
+501
View File
@@ -0,0 +1,501 @@
<script lang="ts">
import Ban from "@lucide/svelte/icons/ban";
import Plus from "@lucide/svelte/icons/plus";
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { all, invalidate, lists } from "#lib/api/lists.js";
import type { BasicSummary, Bill, Item } from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import {
byObjectGroup,
LINK,
sumByCurrency,
swapOrder,
type Total,
} from "#lib/components/groups/object-groups.js";
import Totals from "#lib/components/groups/Totals.svelte";
import { errorText } from "#lib/components/misc/action.svelte.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import {
average,
billKey,
dueIn,
lastMonths,
money,
monthly,
nextExpected,
repeats,
status,
strip,
yearly,
} from "#lib/components/subscriptions/bills.js";
import PaidStatus from "#lib/components/subscriptions/PaidStatus.svelte";
import Strip from "#lib/components/subscriptions/Strip.svelte";
import { buttonVariants } from "#lib/components/ui/button/button.svelte";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "#lib/components/ui/context-menu/index.js";
import { today } from "#lib/dates.js";
import { formatDate } from "#lib/format.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { period } from "#lib/period.svelte.js";
import { oldUiUrl } from "#lib/spa-paths.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
const months = $derived(lastMonths(period.end));
const billsKey = $derived(["subscriptions", period.start, period.end]);
const bills = createQuery(() => ({
enabled: period.ready,
queryFn: () =>
all<Bill>("/v1/bills", { end: period.end, start: period.start }),
queryKey: billsKey,
}));
const history = createQuery(() => ({
enabled: period.ready,
queryFn: () =>
all<Bill>("/v1/bills", { end: period.end, start: `${months[0]}-01` }),
queryKey: ["subscriptions", "history", months[0], period.end],
}));
const summary = createQuery(() => ({
enabled: period.ready,
queryFn: () =>
api<BasicSummary>("/v1/summary/basic", {
query: { end: period.end, start: period.start },
}),
queryKey: ["subscriptions", "summary", period.start, period.end],
}));
const groups = createQuery(() => lists.objectGroups());
const hideInactive = $derived(
page.url.searchParams.get("inactive") === "hide"
);
const visible = $derived(
(bills.data ?? []).filter((b) => !hideInactive || b.attributes.active)
);
const sections = $derived(byObjectGroup(visible));
const rows = $derived(sections.flatMap((s) => s.items));
const offsets = $derived(
sections.map((_, i) =>
sections.slice(0, i).reduce((n, s) => n + s.items.length, 0)
)
);
const pastCells = $derived(
new Map((history.data ?? []).map((b) => [b.id, b.attributes]))
);
const activeOnly = (items: Item<Bill>[]) =>
items.filter((b) => b.attributes.active).map((b) => b.attributes);
const perMonth = (items: Item<Bill>[]) =>
sumByCurrency(activeOnly(items).map(monthly));
const perYear = (items: Item<Bill>[]) =>
sumByCurrency(activeOnly(items).map(yearly));
function pick(prefix: string): Total[] {
return Object.values(summary.data ?? {})
.filter((e) => e.key?.startsWith(prefix) && e.monetary_value)
.map((e) => ({
amount: Math.abs(e.monetary_value ?? 0),
code: e.currency_code ?? "",
decimals: e.currency_decimal_places ?? 2,
symbol: e.currency_symbol ?? "",
}));
}
const paid = $derived(pick("bills-paid-in-"));
const unpaid = $derived(pick("bills-unpaid-in-"));
const expected = $derived(
sumByCurrency(
activeOnly(bills.data ?? []).map((b) =>
money(
b,
average(b) *
((b.paid_dates?.length ?? 0) + dueIn(period.range, b).length)
)
)
).filter((t) => t.amount > 0)
);
let menuFor = $state<Item<Bill> | null>(null);
let armed = $state(false);
let failure = $state("");
const show = (b: Item<Bill>) => goto(`/subscriptions/show/${b.id}`);
const edit = (b: Item<Bill>) => goto(`/subscriptions/edit/${b.id}`);
function toggleInactive() {
const url = new URL(page.url.href);
if (hideInactive) {
url.searchParams.delete("inactive");
} else {
url.searchParams.set("inactive", "hide");
}
goto(url, { replace: true });
}
async function update(b: Item<Bill>, body: Record<string, unknown>) {
failure = "";
try {
await api(`/v1/bills/${b.id}`, { body, method: "PUT" });
} catch (e) {
failure = errorText(e);
}
await invalidate();
}
function move(dir: 1 | -1): void {
const { current } = nav;
const section = sections.find((s) => current && s.items.includes(current));
if (!(current && section)) {
return;
}
const other = section.items[section.items.indexOf(current) + dir];
if (!other) {
return;
}
const order = swapOrder(billsKey, current, other);
nav.move(nav.cursor + dir);
update(current, { order });
}
async function remove(b: Item<Bill>) {
failure = "";
try {
await api(`/v1/bills/${b.id}`, { method: "DELETE" });
} catch (e) {
failure = errorText(e);
}
await invalidate();
}
const nav: ListNav<Item<Bill>> = new ListNav<Item<Bill>>({
edit,
extra: () => [
{
group: "Subscriptions",
keys: "alt+j",
label: "Move down in group",
run: () => move(1),
},
{
group: "Subscriptions",
keys: "alt+k",
label: "Move up in group",
run: () => move(-1),
},
{
group: "Subscriptions",
hidden: true,
keys: "alt+∆",
label: "Move down in group",
run: () => move(1),
},
{
group: "Subscriptions",
hidden: true,
keys: "alt+˚",
label: "Move up in group",
run: () => move(-1),
},
{
group: "Subscriptions",
keys: "c",
label: "New subscription",
run: () => goto("/subscriptions/create"),
},
{
group: "Subscriptions",
keys: "i",
label: hideInactive ? "Show inactive" : "Hide inactive",
run: toggleInactive,
},
],
id: "bills",
items: () => rows,
key: billKey,
open: show,
});
nav.use();
const head = "px-3 py-1.5 font-semibold text-[12px] text-cladd-fg-soft";
const cell = "px-3 py-1 align-middle";
</script>
<PageHeader crumbs={[{ label: "Subscriptions" }]} title="Subscriptions">
{#snippet actions()}
<a class={buttonVariants({ size: "sm" })} href="/subscriptions/create">
<Plus />Create new subscription<span class="kbd max-sm:hidden">c</span>
</a>
{/snippet}
</PageHeader>
<div class="mb-3 grid gap-3 sm:grid-cols-3">
{#each [
{ label: "Expected this period", totals: expected },
{ label: "Paid", totals: paid },
{ label: "Still to pay", totals: unpaid },
] as stat (stat.label)}
<Box body="px-3 py-2" title={stat.label}>
<div class="text-[15px]">
<Totals
empty={summary.isPending ? "" : "Nothing"}
tone={stat.label === "Paid" ? "income" : "expense"}
totals={stat.totals}
/>
</div>
</Box>
{/each}
</div>
<Box body="p-0" title="Subscriptions">
{#snippet tools()}
{#if failure}
<span class="text-[12px] text-expense">{failure}</span>
{/if}
<span class="text-[12px] text-cladd-fg-soft">{period.label}</span>
<button
class={buttonVariants({ size: "xs", variant: "outline" })}
onclick={toggleInactive}
type="button"
>
{hideInactive ? "Show inactive" : "Hide inactive"}
<span class="kbd max-sm:hidden">i</span>
</button>
{/snippet}
{#if bills.isPending}
<p class="px-3 py-6 text-cladd-fg-soft">Loading subscriptions</p>
{:else if bills.error}
<p class="px-3 py-6 text-expense">{bills.error.message}</p>
{:else if rows.length === 0}
<p class="px-3 py-6 text-cladd-fg-soft">
No subscriptions yet.
<a class={LINK} href="/subscriptions/create">Create a subscription</a>
</p>
{:else}
<ContextMenu onOpenChange={(open) => open || (armed = false)}>
<ContextMenuTrigger>
<div class="overflow-x-auto">
<table class="w-full text-[13px]">
<thead>
<tr class="border-cladd-outline border-b text-left">
<th class={head}>Name</th>
<th class="{head} text-right">Amount</th>
<th class={head}>Paid this period</th>
<th class="{head} hidden md:table-cell">Next expected match</th>
<th class="{head} hidden md:table-cell">Repeats</th>
<th class="{head} hidden xl:table-cell">
<span class="inline-flex gap-[3px]">
{#each months as month (month)}
<span class="w-2.5 text-center text-[10px]"
>{formatDate(`${month}-01`, "month").slice(0, 1)}</span
>
{/each}
</span>
</th>
<th class="{head} hidden text-right lg:table-cell">Per year</th>
</tr>
</thead>
{#each sections as section, s (section.id)}
{@const monthTotals = perMonth(section.items)}
<tbody>
<tr
class="border-cladd-outline border-t bg-cladd-surface-next/60"
>
<td class="{cell} font-semibold">
{section.title}
<span class="ms-1 font-normal text-cladd-fg-softer"
>{section.items.length}</span
>
</td>
<td class="{cell} text-right font-semibold">
<Totals tone="expense" totals={monthTotals} />
</td>
<td class="{cell} text-[12px] text-cladd-fg-soft">
{monthTotals.length > 0 ? "a month" : ""}
</td>
<td class="hidden md:table-cell"></td>
<td class="hidden md:table-cell"></td>
<td class="hidden xl:table-cell"></td>
<td
class="{cell} hidden text-right font-semibold lg:table-cell"
>
<Totals tone="expense" totals={perYear(section.items)} />
</td>
</tr>
{#each section.items as item, j (item.id)}
{@const i = offsets[s] + j}
{@const bill = item.attributes}
{@const ends = bill.end_date?.slice(0, 10)}
<tr
class="h-7 border-cladd-outline/60 border-t {nav.cursor ===
i
? "bg-row-cursor"
: "hover:bg-row-hover"} {bill.active
? ""
: "text-cladd-fg-softer"}"
id={nav.rowId(i)}
onclick={(e) => nav.click(i, e)}
oncontextmenu={() => {
menuFor = item;
nav.cursor = i;
}}
>
<td class={cell}>
<a
class={bill.active ? LINK : "hover:underline"}
href="/subscriptions/show/{item.id}"
>
{#if !bill.active}
<Ban
aria-label="Inactive"
class="me-1 inline size-3.5 align-[-2px]"
/>
{/if}
{bill.name}
</a>
</td>
<td class="{cell} whitespace-nowrap text-right">
<Amount
amount={bill.amount_min}
code={bill.currency_code}
decimals={bill.currency_decimal_places}
symbol={bill.currency_symbol}
tone={bill.active ? "expense" : "neutral"}
/>
{#if bill.amount_max !== bill.amount_min}
<span class="text-cladd-fg-softer">–</span>
<Amount
amount={bill.amount_max}
decimals={bill.currency_decimal_places}
tone={bill.active ? "expense" : "neutral"}
/>
{/if}
</td>
<td class={cell}>
<PaidStatus state={status(bill, period.range)} />
</td>
<td class="{cell} num hidden md:table-cell">
{bill.active ? formatDate(nextExpected(bill)) : ""}
</td>
<td class="{cell} hidden whitespace-nowrap md:table-cell">
{repeats(bill)}
{#if ends}
<span
class={ends < today()
? "text-expense"
: "text-cladd-fg-soft"}
>, ends {formatDate(ends)}</span
>
{/if}
</td>
<td class="{cell} hidden xl:table-cell">
<Strip
cells={strip(pastCells.get(item.id) ?? bill, months)}
{months}
/>
</td>
<td class="{cell} hidden text-right lg:table-cell">
{#if bill.active}
<Amount {...yearly(bill)} tone="expense" />
{/if}
</td>
</tr>
{/each}
</tbody>
{/each}
{#if sections.length > 1}
<tfoot>
<tr class="border-cladd-outline border-t-2">
<td class="{cell} font-semibold">
Total, active subscriptions
</td>
<td class="{cell} text-right font-semibold">
<Totals tone="expense" totals={perMonth(rows)} />
</td>
<td class="{cell} text-[12px] text-cladd-fg-soft">a month</td>
<td class="hidden md:table-cell"></td>
<td class="hidden md:table-cell"></td>
<td class="hidden xl:table-cell"></td>
<td
class="{cell} hidden text-right font-semibold lg:table-cell"
>
<Totals tone="expense" totals={perYear(rows)} />
</td>
</tr>
</tfoot>
{/if}
</table>
</div>
</ContextMenuTrigger>
<ContextMenuContent class="w-56">
{#if menuFor}
{@const target = menuFor}
<ContextMenuItem onSelect={() => show(target)}>Show</ContextMenuItem>
<ContextMenuItem onSelect={() => edit(target)}>Edit</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>Move to group</ContextMenuSubTrigger>
<ContextMenuSubContent class="max-h-80 w-52">
{#each groups.data ?? [] as group (group.id)}
<ContextMenuItem
disabled={group.id === target.attributes.object_group_id}
onSelect={() =>
update(target, {
object_group_title: group.attributes.title,
})}
>{group.attributes.title}</ContextMenuItem
>
{/each}
<ContextMenuSeparator />
<ContextMenuItem
disabled={!target.attributes.object_group_id}
onSelect={() => update(target, { object_group_title: "" })}
>No group</ContextMenuItem
>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={() =>
location.assign(`/rules/create-from-bill/${target.id}`)}
>Create rule from subscription</ContextMenuItem
>
<ContextMenuItem
onSelect={() =>
location.assign(
oldUiUrl(`${location.origin}/subscriptions/show/${target.id}`)
)}
>Open in old UI</ContextMenuItem
>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={(e) => {
if (!armed) {
e.preventDefault();
armed = true;
return;
}
remove(target);
}}
variant="destructive"
>{armed ? "Press again to delete" : "Delete"}</ContextMenuItem
>
{/if}
</ContextMenuContent>
</ContextMenu>
{/if}
{#snippet footer()}
<a
class={buttonVariants({ size: "sm", variant: "outline" })}
href="/subscriptions/create"
><Plus />Create new subscription</a
>
{/snippet}
</Box>
@@ -0,0 +1,14 @@
<script lang="ts">
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import BillForm from "#lib/components/subscriptions/BillForm.svelte";
</script>
<PageHeader
crumbs={[
{ href: "/subscriptions", label: "Subscriptions" },
{ label: "Create" },
]}
title="Create a new subscription"
/>
<BillForm />
@@ -0,0 +1,5 @@
import { redirect } from "@sveltejs/kit";
export function load({ params }) {
redirect(307, `/subscriptions/edit/${params.id}`);
}
@@ -0,0 +1,34 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import type { Bill, One } from "#lib/api/types.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import BillForm from "#lib/components/subscriptions/BillForm.svelte";
import { page } from "$app/state";
const id = $derived(page.params.id ?? "");
const bill = createQuery(() => ({
queryFn: () => api<One<Bill>>(`/v1/bills/${id}`),
queryKey: ["bill", id],
}));
const name = $derived(bill.data?.data.attributes.name ?? "");
</script>
<PageHeader
crumbs={[
{ href: "/subscriptions", label: "Subscriptions" },
{ href: `/subscriptions/show/${id}`, label: name || id },
{ label: "Edit" },
]}
title={name ? `Edit subscription "${name}"` : "Edit subscription"}
/>
{#if bill.data}
{#key id}
<BillForm bill={bill.data.data} />
{/key}
{:else if bill.error}
<p class="text-expense">{bill.error.message}</p>
{:else}
<p class="text-cladd-fg-soft">Loading subscription</p>
{/if}
@@ -0,0 +1,353 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { all, invalidate } from "#lib/api/lists.js";
import type {
Bill,
One,
Page,
Rule,
TransactionGroup,
} from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import Columns from "#lib/components/charts/Columns.svelte";
import { seriesColor, tones } from "#lib/components/charts/chart.js";
import {
LINK,
sumByCurrency,
type Total,
} from "#lib/components/groups/object-groups.js";
import Totals from "#lib/components/groups/Totals.svelte";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import {
nextExpected,
repeats,
status,
} from "#lib/components/subscriptions/bills.js";
import PaidStatus from "#lib/components/subscriptions/PaidStatus.svelte";
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
import { buttonVariants } from "#lib/components/ui/button/button.svelte";
import { addMonths } from "#lib/dates.js";
import { formatDate, formatMoney, toNumber } from "#lib/format.js";
import { useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js";
import { oldUiUrl } from "#lib/spa-paths.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
const LEADING_DAY = /^\d+\s*/;
const id = $derived(page.params.id ?? "");
const query = createQuery(() => ({
enabled: period.ready,
queryFn: () =>
api<One<Bill>>(`/v1/bills/${id}`, {
query: { end: period.end, start: period.start },
}),
queryKey: ["bill", id, period.start, period.end],
}));
const rules = createQuery(() => ({
queryFn: () => api<Page<Rule>>(`/v1/bills/${id}/rules`),
queryKey: ["bill", id, "rules"],
}));
const transactions = createQuery(() => ({
queryFn: () => all<TransactionGroup>(`/v1/bills/${id}/transactions`),
queryKey: ["bill", id, "transactions"],
}));
const bill = $derived(query.data?.data.attributes);
const payments = $derived(
(transactions.data ?? []).flatMap((g) =>
g.attributes.transactions
.filter((t) => t.bill_id === id)
.map((t) => {
const foreign =
t.foreign_currency_code === bill?.currency_code &&
toNumber(t.foreign_amount) !== 0;
return {
amount: Math.abs(toNumber(foreign ? t.foreign_amount : t.amount)),
code: (foreign ? t.foreign_currency_code : t.currency_code) ?? "",
date: t.date.slice(0, 10),
decimals:
(foreign
? t.foreign_currency_decimal_places
: t.currency_decimal_places) ?? 2,
symbol:
(foreign ? t.foreign_currency_symbol : t.currency_symbol) ?? "",
};
})
)
);
type Payment = (typeof payments)[number];
function averages(list: Payment[]): Total[] {
return sumByCurrency(list).map((t) => ({
...t,
amount: t.amount / list.filter((p) => p.code === t.code).length,
}));
}
const year = $derived(period.start.slice(0, 4));
const yearAverage = $derived(
averages(payments.filter((p) => p.date.startsWith(year)))
);
const overallAverage = $derived(averages(payments));
const codes = $derived([...new Set(payments.map((p) => p.code))]);
const chartRows = $derived.by(() => {
if (payments.length === 0) {
return [];
}
const dates = payments.map((p) => p.date.slice(0, 7)).sort();
const months: string[] = [];
for (
let m = `${dates[0]}-01`;
m.slice(0, 7) <= (dates.at(-1) ?? "");
m = addMonths(m, 1)
) {
months.push(m.slice(0, 7));
}
const min = toNumber(bill?.amount_min);
const max = toNumber(bill?.amount_max);
return months.map((month) => {
const values = Object.fromEntries(
codes.map((code) => [
code,
payments
.filter((p) => p.code === code && p.date.startsWith(month))
.reduce((sum, p) => sum + p.amount, 0),
])
);
const own = values[bill?.currency_code ?? ""] ?? 0;
return {
color: own > 0 && (own < min || own > max) ? tones.warn : undefined,
key: month,
label: formatDate(`${month}-01`, "short").replace(LEADING_DAY, ""),
note: month.slice(0, 4),
values,
};
});
});
const chartKeys = $derived(
codes.map((code, i) => ({
color: seriesColor(i),
key: code,
label: `Paid in ${code}`,
}))
);
async function remove() {
await api(`/v1/bills/${id}`, { method: "DELETE" });
await goto("/subscriptions");
await invalidate();
}
useKeys(() => [
{
group: "Subscription",
keys: "E",
label: "Edit subscription",
run: () => goto(`/subscriptions/edit/${id}`),
},
]);
const label = "w-1/2 px-3 py-1.5 align-top text-cladd-fg-soft";
const value = "px-3 py-1.5";
</script>
<PageHeader
crumbs={[
{ href: "/subscriptions", label: "Subscriptions" },
{ label: bill?.name ?? id },
]}
title={bill?.name ?? "Subscription"}
>
{#snippet actions()}
<a
class={buttonVariants({ size: "sm", variant: "outline" })}
href="/subscriptions/edit/{id}"
>Edit <span class="kbd">E</span></a
>
{/snippet}
</PageHeader>
{#if query.error}
<p class="text-expense">{query.error.message}</p>
{:else if !bill}
<p class="text-cladd-fg-soft">Loading subscription</p>
{:else}
<div class="grid items-start gap-3 lg:grid-cols-2">
<Box body="p-0" title={bill.name ?? ""}>
<table class="w-full text-[13px]">
<tbody>
<tr class="border-cladd-outline/60 border-b">
<td class="{value} leading-6" colspan="2">
Matches transactions between
<Amount
amount={bill.amount_min}
decimals={bill.currency_decimal_places}
symbol={bill.currency_symbol}
tone="expense"
/>
and
<Amount
amount={bill.amount_max}
decimals={bill.currency_decimal_places}
symbol={bill.currency_symbol}
tone="expense"
/>. Repeats {repeats(bill)}.
</td>
</tr>
<tr class="border-cladd-outline/60 border-b">
<td class={label}>Subscription is active</td>
<td class={value}>{bill.active ? "Yes" : "No"}</td>
</tr>
<tr class="border-cladd-outline/60 border-b">
<td class={label}>Paid this period ({period.label})</td>
<td class={value}>
<PaidStatus state={status(bill, period.range)} />
</td>
</tr>
<tr class="border-cladd-outline/60 border-b">
<td class={label}>Next expected match</td>
<td class="{value} num">
{formatDate(nextExpected(bill)) || "unknown"}
</td>
</tr>
<tr class="border-cladd-outline/60 border-b">
<td class={label}>Group</td>
<td class={value}>
{#if bill.object_group_id}
<a class={LINK} href="/groups/edit/{bill.object_group_id}"
>{bill.object_group_title}</a
>
{:else}
<span class="text-cladd-fg-softer">No group</span>
{/if}
</td>
</tr>
{#if bill.end_date}
<tr class="border-cladd-outline/60 border-b">
<td class={label}>End date</td>
<td class="{value} num">{formatDate(bill.end_date)}</td>
</tr>
{/if}
{#if bill.extension_date}
<tr class="border-cladd-outline/60 border-b">
<td class={label}>Extension date</td>
<td class="{value} num">{formatDate(bill.extension_date)}</td>
</tr>
{/if}
<tr class="border-cladd-outline/60 border-b">
<td class={label}>Average subscription amount ({year})</td>
<td class="{value} text-right">
<Totals
empty={transactions.isPending ? "" : "No transactions"}
tone="expense"
totals={yearAverage}
/>
</td>
</tr>
<tr>
<td class={label}>Average subscription amount (overall)</td>
<td class="{value} text-right">
<Totals
empty={transactions.isPending ? "" : "No transactions"}
tone="expense"
totals={overallAverage}
/>
</td>
</tr>
</tbody>
</table>
{#snippet footer()}
<a
class={buttonVariants({ size: "sm", variant: "outline" })}
href="/subscriptions/edit/{id}"
>Edit</a
>
<a
class={buttonVariants({ size: "sm", variant: "ghost" })}
data-sveltekit-reload
href={oldUiUrl(`${page.url.origin}/subscriptions/show/${id}`)}
>Open in old UI</a
>
<ConfirmButton class="ms-auto" onconfirm={remove}>Delete</ConfirmButton>
{/snippet}
</Box>
<div class="flex flex-col gap-3">
<Box body="p-3" title="Rules related to this subscription">
{#if rules.data?.data.length}
<ul class="flex flex-col gap-1">
{#each rules.data.data as rule (rule.id)}
<li>
<a
class={LINK}
data-sveltekit-reload
href="/rules/edit/{rule.id}"
>{rule.attributes.title}</a
>
{#if !rule.attributes.active}
<span class="text-cladd-fg-softer">(inactive rule)</span>
{/if}
</li>
{/each}
</ul>
{:else if rules.isPending}
<p class="text-cladd-fg-soft">Loading rules</p>
{:else}
<p class="text-cladd-fg-soft">
No rule links transactions to this subscription.
</p>
{/if}
{#snippet footer()}
<a
class={buttonVariants({ size: "sm", variant: "outline" })}
data-sveltekit-reload
href="/rules/create-from-bill/{id}"
>Create rule from subscription</a
>
{/snippet}
</Box>
{#if bill.notes}
<Box title="Notes">
<p class="whitespace-pre-wrap">{bill.notes}</p>
</Box>
{/if}
</div>
</div>
<Box class="mt-3" title="Chart">
{#if chartRows.length > 0}
<Columns
format={(v) =>
formatMoney({
amount: v,
decimals: payments[0]?.decimals,
symbol: codes.length === 1 ? payments[0]?.symbol : null,
})}
height={220}
keys={chartKeys}
label="Paid per month"
rows={chartRows}
/>
{:else}
<p class="text-cladd-fg-soft">
{transactions.isPending
? "Loading transactions"
: "No transactions are linked to this subscription yet."}
</p>
{/if}
</Box>
<Box body="p-0" class="mt-3" title="Connected transactions">
<TransactionTable
empty="No transactions are linked to this subscription."
groups={transactions.data ?? []}
id="bill-transactions"
loading={transactions.isPending}
/>
</Box>
{/if}