feat(subscriptions,piggy-banks,groups): grouped subscriptions with yearly cost, inline piggy money, object groups
This commit is contained in:
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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
<span class="truncate text-[13px] text-cladd-fg-soft">{subtitle}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
{@render actions?.()}
|
||||
<ol
|
||||
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>
|
||||
@@ -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;
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
Reference in new issue
Block a user