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,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>
|
||||
Reference in new issue
Block a user