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>
|
<span class="truncate text-[13px] text-cladd-fg-soft">{subtitle}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
{@render actions?.()}
|
{@render actions?.()}
|
||||||
<ol
|
<ol
|
||||||
aria-label="Breadcrumbs"
|
aria-label="Breadcrumbs"
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import { invalidate } from "#lib/api/lists.js";
|
||||||
|
import type { Bill, Item, One } from "#lib/api/types.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
|
||||||
|
import GroupInput from "#lib/components/groups/GroupInput.svelte";
|
||||||
|
import { amountString } from "#lib/components/inputs/parse.js";
|
||||||
|
import { Action } from "#lib/components/misc/action.svelte.js";
|
||||||
|
import Field from "#lib/components/misc/Field.svelte";
|
||||||
|
import Status from "#lib/components/misc/Status.svelte";
|
||||||
|
import { select, textarea } from "#lib/components/misc/styles.js";
|
||||||
|
import { Button } from "#lib/components/ui/button/index.js";
|
||||||
|
import { Input } from "#lib/components/ui/input/index.js";
|
||||||
|
import { today } from "#lib/dates.js";
|
||||||
|
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
|
||||||
|
import { session } from "#lib/session.svelte.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { FREQUENCIES, FREQUENCY_LABELS } from "./bills.js";
|
||||||
|
|
||||||
|
let { bill }: { bill?: Item<Bill> } = $props();
|
||||||
|
|
||||||
|
const initial = untrack(() => bill?.attributes);
|
||||||
|
const form = $state({
|
||||||
|
active: initial?.active ?? true,
|
||||||
|
amount_max: initial?.amount_max ?? "",
|
||||||
|
amount_min: initial?.amount_min ?? "",
|
||||||
|
currency_code: initial?.currency_code ?? session.primary?.code ?? "",
|
||||||
|
date: initial?.date?.slice(0, 10) ?? today(),
|
||||||
|
end_date: initial?.end_date?.slice(0, 10) ?? "",
|
||||||
|
extension_date: initial?.extension_date?.slice(0, 10) ?? "",
|
||||||
|
name: initial?.name ?? "",
|
||||||
|
notes: initial?.notes ?? "",
|
||||||
|
object_group_title: initial?.object_group_title ?? "",
|
||||||
|
repeat_freq: initial?.repeat_freq ?? "monthly",
|
||||||
|
skip: String(initial?.skip ?? 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
const action = new Action();
|
||||||
|
const currencies = $derived(
|
||||||
|
session.currencies.filter(
|
||||||
|
(c) => c.attributes.enabled || c.attributes.code === form.currency_code
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const decimals = $derived(
|
||||||
|
currencies.find((c) => c.attributes.code === form.currency_code)?.attributes
|
||||||
|
.decimal_places ?? 2
|
||||||
|
);
|
||||||
|
|
||||||
|
function body() {
|
||||||
|
const min = amountString(form.amount_min, decimals);
|
||||||
|
return {
|
||||||
|
active: form.active,
|
||||||
|
amount_max: amountString(form.amount_max, decimals) || min,
|
||||||
|
amount_min: min,
|
||||||
|
currency_code:
|
||||||
|
form.currency_code === initial?.currency_code
|
||||||
|
? undefined
|
||||||
|
: form.currency_code,
|
||||||
|
date: form.date,
|
||||||
|
end_date: form.end_date || undefined,
|
||||||
|
extension_date: form.extension_date || undefined,
|
||||||
|
name: form.name.trim(),
|
||||||
|
notes: form.notes.trim() || null,
|
||||||
|
object_group_title:
|
||||||
|
form.object_group_title.trim() || (bill ? "" : undefined),
|
||||||
|
repeat_freq: form.repeat_freq,
|
||||||
|
skip: Number(form.skip) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
let id = bill?.id;
|
||||||
|
const ok = await action.run(async () => {
|
||||||
|
const saved = await api<One<Bill>>(
|
||||||
|
bill ? `/v1/bills/${bill.id}` : "/v1/bills",
|
||||||
|
{ body: body(), method: bill ? "PUT" : "POST" }
|
||||||
|
);
|
||||||
|
({ id } = saved.data);
|
||||||
|
});
|
||||||
|
if (ok) {
|
||||||
|
await invalidate();
|
||||||
|
await goto(`/subscriptions/show/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (!bill) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ok = await action.run(() =>
|
||||||
|
api(`/v1/bills/${bill.id}`, { method: "DELETE" })
|
||||||
|
);
|
||||||
|
if (ok) {
|
||||||
|
await goto("/subscriptions");
|
||||||
|
await invalidate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useKeys(() => [
|
||||||
|
{
|
||||||
|
group: "Form",
|
||||||
|
input: true,
|
||||||
|
keys: "mod+Enter",
|
||||||
|
label: bill ? "Update subscription" : "Store new subscription",
|
||||||
|
run: save,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="flex flex-col gap-3"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
save();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="grid items-start gap-3 lg:grid-cols-2">
|
||||||
|
<Box body="flex flex-col gap-3 p-4" title="Mandatory fields">
|
||||||
|
<Field for="bill-name" label="Name" problem={action.field("name")}>
|
||||||
|
<Input id="bill-name" required bind:value={form.name} />
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
for="bill-currency"
|
||||||
|
label="Currency"
|
||||||
|
problem={action.field("currency_code") ?? action.field("currency_id")}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
class={select}
|
||||||
|
id="bill-currency"
|
||||||
|
bind:value={form.currency_code}
|
||||||
|
>
|
||||||
|
{#each currencies as currency (currency.id)}
|
||||||
|
<option value={currency.attributes.code}>
|
||||||
|
{currency.attributes.name}
|
||||||
|
({currency.attributes.symbol})
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
for="bill-min"
|
||||||
|
label="Minimum amount"
|
||||||
|
problem={action.field("amount_min")}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
class="num"
|
||||||
|
id="bill-min"
|
||||||
|
inputmode="decimal"
|
||||||
|
required
|
||||||
|
bind:value={form.amount_min}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
for="bill-max"
|
||||||
|
help="Leave empty when the amount is always the same."
|
||||||
|
label="Maximum amount"
|
||||||
|
problem={action.field("amount_max")}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
class="num"
|
||||||
|
id="bill-max"
|
||||||
|
inputmode="decimal"
|
||||||
|
bind:value={form.amount_max}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
for="bill-date"
|
||||||
|
help="The first date this subscription is expected."
|
||||||
|
label="Date"
|
||||||
|
problem={action.field("date")}
|
||||||
|
>
|
||||||
|
<Input id="bill-date" required type="date" bind:value={form.date} />
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
for="bill-repeat"
|
||||||
|
label="Repeats"
|
||||||
|
problem={action.field("repeat_freq")}
|
||||||
|
>
|
||||||
|
<select class={select} id="bill-repeat" bind:value={form.repeat_freq}>
|
||||||
|
{#each FREQUENCIES as frequency (frequency)}
|
||||||
|
<option value={frequency}>{FREQUENCY_LABELS[frequency]}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
for="bill-skip"
|
||||||
|
help="Skip 1 with monthly means every other month."
|
||||||
|
label="Skip"
|
||||||
|
problem={action.field("skip")}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
class="num w-24"
|
||||||
|
id="bill-skip"
|
||||||
|
max="31"
|
||||||
|
min="0"
|
||||||
|
type="number"
|
||||||
|
bind:value={form.skip}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Box>
|
||||||
|
<Box body="flex flex-col gap-3 p-4" title="Optional fields">
|
||||||
|
<Field
|
||||||
|
for="bill-end"
|
||||||
|
help="The subscription stops being expected after this date."
|
||||||
|
label="End date"
|
||||||
|
problem={action.field("end_date")}
|
||||||
|
>
|
||||||
|
<Input id="bill-end" type="date" bind:value={form.end_date} />
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
for="bill-extension"
|
||||||
|
help="The date to extend or cancel the subscription."
|
||||||
|
label="Extension date"
|
||||||
|
problem={action.field("extension_date")}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="bill-extension"
|
||||||
|
type="date"
|
||||||
|
bind:value={form.extension_date}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
for="bill-group"
|
||||||
|
label="Group"
|
||||||
|
problem={action.field("object_group_title")}
|
||||||
|
>
|
||||||
|
<GroupInput id="bill-group" bind:value={form.object_group_title} />
|
||||||
|
</Field>
|
||||||
|
<Field for="bill-notes" label="Notes" problem={action.field("notes")}>
|
||||||
|
<textarea
|
||||||
|
class={textarea}
|
||||||
|
id="bill-notes"
|
||||||
|
rows="4"
|
||||||
|
bind:value={form.notes}
|
||||||
|
></textarea>
|
||||||
|
</Field>
|
||||||
|
{#if bill}
|
||||||
|
<Field for="bill-active" label="Active">
|
||||||
|
<input
|
||||||
|
class="mt-2 size-4 accent-(--ff-brand)"
|
||||||
|
id="bill-active"
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={form.active}
|
||||||
|
>
|
||||||
|
</Field>
|
||||||
|
{/if}
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<Button disabled={action.busy} type="submit">
|
||||||
|
{bill ? "Update subscription" : "Store new subscription"}
|
||||||
|
<span class="kbd">{prettyKeys("mod+Enter")[0]}</span>
|
||||||
|
</Button>
|
||||||
|
<Status {action} />
|
||||||
|
{#if bill}
|
||||||
|
<ConfirmButton class="ms-auto" onconfirm={remove}>Delete</ConfirmButton>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { formatDate } from "#lib/format.js";
|
||||||
|
import { STATUS_TONE, type Status, statusText } from "./bills.js";
|
||||||
|
|
||||||
|
let { state }: { state: Status } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if state.kind === "paid"}
|
||||||
|
<span class="inline-flex flex-wrap gap-x-2">
|
||||||
|
{#each state.paid as paid (paid.transaction_journal_id)}
|
||||||
|
<a
|
||||||
|
class="text-income hover:underline"
|
||||||
|
href="/transactions/show/{paid.transaction_group_id}"
|
||||||
|
>✓ {formatDate(paid.date, "short")}</a
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class={STATUS_TONE[state.kind]}>{statusText(state)}</span>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Cell } from "./bills.js";
|
||||||
|
|
||||||
|
let { cells, months }: { cells: Cell[]; months: string[] } = $props();
|
||||||
|
|
||||||
|
const LOOK: Record<Cell, string> = {
|
||||||
|
due: "border border-warn",
|
||||||
|
idle: "border border-cladd-fg-softest",
|
||||||
|
missed: "border border-expense bg-expense/15",
|
||||||
|
off: "bg-cladd-fg-softest/25 scale-50",
|
||||||
|
paid: "bg-income",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TITLE: Record<Cell, string> = {
|
||||||
|
due: "expected, not paid yet",
|
||||||
|
idle: "expected",
|
||||||
|
missed: "expected, not paid",
|
||||||
|
off: "not expected",
|
||||||
|
paid: "paid",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="inline-flex gap-[3px] align-middle">
|
||||||
|
{#each cells as cell, i (months[i])}
|
||||||
|
<span
|
||||||
|
class="size-2.5 rounded-[2px] {LOOK[cell]}"
|
||||||
|
title="{months[i]}: {TITLE[cell]}"
|
||||||
|
></span>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
@@ -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