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,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