feat(subscriptions): paying card set for several at once and kept in Firefly, quieter table
Each runway line says whether its card came from the last payment or was set by hand; x selects bills and a sets the card for all of them, stored in the Firefly preference ffui_bill_cards so every device sees it. Price change moves to the subscription page, per year turns grey, group sums follow the one-currency mode, key chips fit their buttons and the panels no longer show a focus ring. Linking found payments is y.
This commit is contained in:
1 parent
54895b9305
commit
c610aa58fa
10 files changed
+285
-130
No files matched your search
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import Ban from "@lucide/svelte/icons/ban";
|
||||
import Pin from "@lucide/svelte/icons/pin";
|
||||
import Plus from "@lucide/svelte/icons/plus";
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { onMount } from "svelte";
|
||||
import { api } from "#lib/api/client.js";
|
||||
import { all, invalidate, lists } from "#lib/api/lists.js";
|
||||
import type { Bill, Item } from "#lib/api/types.js";
|
||||
@@ -12,7 +12,6 @@
|
||||
LINK,
|
||||
sumByCurrency,
|
||||
swapOrder,
|
||||
type Total,
|
||||
} from "#lib/components/groups/object-groups.js";
|
||||
import { Arm, errorText } from "#lib/components/misc/action.svelte.js";
|
||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||
@@ -32,6 +31,7 @@
|
||||
import PaidStatus from "#lib/components/subscriptions/PaidStatus.svelte";
|
||||
import Runway from "#lib/components/subscriptions/Runway.svelte";
|
||||
import Strip from "#lib/components/subscriptions/Strip.svelte";
|
||||
import Sums from "#lib/components/subscriptions/Sums.svelte";
|
||||
import { buttonVariants } from "#lib/components/ui/button/button.svelte";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -44,7 +44,8 @@
|
||||
ContextMenuTrigger,
|
||||
} from "#lib/components/ui/context-menu/index.js";
|
||||
import { addDays, today } from "#lib/dates.js";
|
||||
import { formatDate, formatNumber, toNumber } from "#lib/format.js";
|
||||
import { formatDate, toNumber } from "#lib/format.js";
|
||||
import { useKeys } from "#lib/keys.svelte.js";
|
||||
import { ListNav } from "#lib/list-nav.svelte.js";
|
||||
import { period } from "#lib/period.svelte.js";
|
||||
import { oldUiUrl } from "#lib/spa-paths.js";
|
||||
@@ -54,7 +55,6 @@
|
||||
byBill,
|
||||
byCard,
|
||||
byCurrency,
|
||||
creep,
|
||||
dues,
|
||||
HORIZONS,
|
||||
type Horizon,
|
||||
@@ -268,11 +268,21 @@
|
||||
say(outcome.errors.length ? `Failed: ${outcome.errors.join("; ")}` : "");
|
||||
}
|
||||
|
||||
const pick = (b: Item<Bill> | undefined) => {
|
||||
if (b?.attributes.active) {
|
||||
manualCards.picking = b.id;
|
||||
const activeIds = (items: Item<Bill>[]) =>
|
||||
items.filter((b) => b.attributes.active).map((b) => b.id);
|
||||
|
||||
function pick(targets: Item<Bill>[]) {
|
||||
const ids = activeIds(targets);
|
||||
const here = nav.current?.id ?? "";
|
||||
const at = ids.includes(here) ? here : ids[0];
|
||||
if (at) {
|
||||
manualCards.picking = { at, ids };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
manualCards.migrate();
|
||||
});
|
||||
|
||||
const nav: ListNav<Item<Bill>> = new ListNav<Item<Bill>>({
|
||||
edit,
|
||||
@@ -293,12 +303,12 @@
|
||||
{
|
||||
group: "Subscriptions",
|
||||
keys: "a",
|
||||
label: "Set paying card",
|
||||
run: () => pick(nav.current),
|
||||
label: "Set paying card (row or selection)",
|
||||
run: () => pick(nav.targets),
|
||||
},
|
||||
{
|
||||
group: "Subscriptions",
|
||||
keys: "L",
|
||||
keys: "y",
|
||||
label: "Link payments found for this subscription",
|
||||
run: () => link(nav.current),
|
||||
},
|
||||
@@ -347,13 +357,21 @@
|
||||
items: () => rows,
|
||||
key: billKey,
|
||||
open: show,
|
||||
selectable: true,
|
||||
});
|
||||
nav.use();
|
||||
useKeys(() =>
|
||||
nav
|
||||
.bindings()
|
||||
.filter((b) => b.group !== "Selection" || !["J", "K"].includes(b.keys))
|
||||
);
|
||||
|
||||
function rowTone(cursor: boolean, stale: boolean) {
|
||||
function rowTone(cursor: boolean, selected: boolean, stale: boolean) {
|
||||
if (cursor) {
|
||||
return "bg-row-cursor";
|
||||
}
|
||||
if (selected) {
|
||||
return "bg-row-selected";
|
||||
}
|
||||
return stale ? "bg-warn/10 hover:bg-row-hover" : "hover:bg-row-hover";
|
||||
}
|
||||
|
||||
@@ -386,22 +404,16 @@
|
||||
strip={stripNeeds}
|
||||
/>
|
||||
|
||||
{#snippet sums(
|
||||
totals: Total[]
|
||||
)}
|
||||
{#each totals as total (total.code)}
|
||||
<div><Money {...total} /></div>
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
<Box body="p-0" title="Subscriptions">
|
||||
{#snippet tools()}
|
||||
{#if failure}
|
||||
<span class="text-[12px] text-expense">{failure}</span>
|
||||
{#if failure || manualCards.failure}
|
||||
<span class="text-[12px] text-expense"
|
||||
>{failure || manualCards.failure}</span
|
||||
>
|
||||
{/if}
|
||||
<span class="text-[12px] text-cladd-fg-soft">{period.label}</span>
|
||||
<button
|
||||
class={buttonVariants({ size: "xs", variant: "outline" })}
|
||||
class={buttonVariants({ size: "sm", variant: "outline" })}
|
||||
onclick={toggleInactive}
|
||||
type="button"
|
||||
>
|
||||
@@ -420,14 +432,13 @@
|
||||
</p>
|
||||
{:else}
|
||||
<ContextMenu onOpenChange={(open) => open || (armed = false)}>
|
||||
<ContextMenuTrigger>
|
||||
<ContextMenuTrigger class="block outline-none">
|
||||
<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} hidden lg:table-cell">Price change</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">Card</th>
|
||||
@@ -459,9 +470,8 @@
|
||||
>
|
||||
</td>
|
||||
<td class="{cell} text-right font-semibold text-cladd-fg">
|
||||
{@render sums(monthTotals)}
|
||||
<Sums parts={monthTotals} />
|
||||
</td>
|
||||
<td class="hidden lg:table-cell"></td>
|
||||
<td class="{cell} text-[12px] text-cladd-fg-soft">
|
||||
{monthTotals.length > 0 ? "a month" : ""}
|
||||
</td>
|
||||
@@ -470,9 +480,9 @@
|
||||
<td class="hidden 2xl:table-cell"></td>
|
||||
<td class="hidden 2xl:table-cell"></td>
|
||||
<td
|
||||
class="{cell} hidden text-right font-semibold text-cladd-fg lg:table-cell"
|
||||
class="{cell} hidden text-right font-semibold text-cladd-fg-soft lg:table-cell"
|
||||
>
|
||||
{@render sums(perYear(section.items))}
|
||||
<Sums parts={perYear(section.items)} />
|
||||
</td>
|
||||
</tr>
|
||||
{#each section.items as item, j (item.id)}
|
||||
@@ -481,13 +491,13 @@
|
||||
{@const ends = bill.end_date?.slice(0, 10)}
|
||||
{@const last = lastOf(item)}
|
||||
{@const who = payer(item)}
|
||||
{@const change = creep(bill, payments.get(item.id) ?? [])}
|
||||
{@const stale = staleSince(bill, last, day)}
|
||||
{@const extra = found.get(item.id) ?? []}
|
||||
{@const armedHere = arm.key?.endsWith(`:${item.id}`)}
|
||||
<tr
|
||||
class="h-7 border-cladd-outline/60 border-t {rowTone(
|
||||
nav.cursor === i,
|
||||
nav.isSelected(item),
|
||||
stale !== null
|
||||
)} {bill.active ? "text-cladd-fg" : "text-cladd-fg-softer"}"
|
||||
id={nav.rowId(i)}
|
||||
@@ -527,24 +537,11 @@
|
||||
2}
|
||||
approx={toNumber(bill.amount_max) !==
|
||||
toNumber(bill.amount_min)}
|
||||
code={bill.currency_code}
|
||||
decimals={bill.currency_decimal_places}
|
||||
symbol={bill.currency_symbol ?? ""}
|
||||
/>
|
||||
</td>
|
||||
<td class="{cell} hidden whitespace-nowrap lg:table-cell">
|
||||
{#if change}
|
||||
<span
|
||||
class="num {change.to > change.from
|
||||
? "text-expense"
|
||||
: "text-income"}"
|
||||
>{formatNumber(change.from, change.decimals)}
|
||||
→ {formatNumber(change.to, change.decimals)}
|
||||
<span class="text-cladd-fg-soft"
|
||||
>since {formatDate(change.since, "short")}</span
|
||||
></span
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="{cell} md:whitespace-nowrap">
|
||||
{#if stale !== null}
|
||||
<span class="text-warn"
|
||||
@@ -565,18 +562,20 @@
|
||||
{bill.active ? formatDate(nextExpected(bill)) : ""}
|
||||
</td>
|
||||
<td class="{cell} hidden whitespace-nowrap md:table-cell">
|
||||
{#if manualCards.picking === item.id}
|
||||
<CardPicker billId={item.id} cards={cardList} />
|
||||
{#if manualCards.picking?.at === item.id}
|
||||
<CardPicker
|
||||
billIds={manualCards.picking.ids}
|
||||
cards={cardList}
|
||||
/>
|
||||
{:else if bill.active}
|
||||
{#if who.card}
|
||||
<span class="text-cladd-fg-soft"
|
||||
>{who.card.name}</span
|
||||
>
|
||||
{#if who.manual}
|
||||
<Pin
|
||||
aria-label="Set by hand"
|
||||
class="ms-0.5 inline size-3 align-[-1px] text-cladd-fg-softer"
|
||||
/>
|
||||
<span class="ms-1 text-[12px] text-cladd-fg-softer"
|
||||
>set by you</span
|
||||
>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="text-warn">card unknown</span>
|
||||
@@ -600,7 +599,9 @@
|
||||
{months}
|
||||
/>
|
||||
</td>
|
||||
<td class="{cell} hidden text-right lg:table-cell">
|
||||
<td
|
||||
class="{cell} hidden text-right text-cladd-fg-soft lg:table-cell"
|
||||
>
|
||||
{#if bill.active}
|
||||
<Money {...yearly(bill)} />
|
||||
{/if}
|
||||
@@ -608,7 +609,7 @@
|
||||
</tr>
|
||||
{#if extra.length > 0 || stale !== null || armedHere}
|
||||
<tr class="border-0">
|
||||
<td class="px-3 pb-1.5 text-[12px]" colspan="9">
|
||||
<td class="px-3 pb-1.5 text-[12px]" colspan="8">
|
||||
<div
|
||||
class="flex w-0 min-w-full flex-wrap items-center gap-x-3 gap-y-1 ps-4"
|
||||
>
|
||||
@@ -636,7 +637,7 @@
|
||||
? "it"
|
||||
: `all ${extra.length}`}<span
|
||||
class="kbd max-sm:hidden"
|
||||
>L</span
|
||||
>y</span
|
||||
>
|
||||
</button>
|
||||
{#if linking[item.id]}
|
||||
@@ -692,18 +693,17 @@
|
||||
Total, active subscriptions
|
||||
</td>
|
||||
<td class="{cell} text-right font-semibold text-cladd-fg">
|
||||
{@render sums(perMonth(rows))}
|
||||
<Sums parts={perMonth(rows)} />
|
||||
</td>
|
||||
<td class="hidden lg:table-cell"></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 2xl:table-cell"></td>
|
||||
<td class="hidden 2xl:table-cell"></td>
|
||||
<td
|
||||
class="{cell} hidden text-right font-semibold text-cladd-fg lg:table-cell"
|
||||
class="{cell} hidden text-right font-semibold text-cladd-fg-soft lg:table-cell"
|
||||
>
|
||||
{@render sums(perYear(rows))}
|
||||
<Sums parts={perYear(rows)} />
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
@@ -717,7 +717,12 @@
|
||||
<ContextMenuItem onSelect={() => show(target)}>Show</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => edit(target)}>Edit</ContextMenuItem>
|
||||
{#if target.attributes.active}
|
||||
<CardMenu billId={target.id} cards={cardList} />
|
||||
<CardMenu
|
||||
billIds={nav.isSelected(target)
|
||||
? activeIds(nav.selection)
|
||||
: [target.id]}
|
||||
cards={cardList}
|
||||
/>
|
||||
{/if}
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>Move to group</ContextMenuSubTrigger>
|
||||
|
||||
@@ -30,10 +30,16 @@
|
||||
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 {
|
||||
formatDate,
|
||||
formatMoney,
|
||||
formatNumber,
|
||||
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 { creep, toPayments } from "#lib/subscriptions/runway.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
|
||||
@@ -82,6 +88,14 @@
|
||||
);
|
||||
type Payment = (typeof payments)[number];
|
||||
|
||||
const change = $derived(
|
||||
bill &&
|
||||
creep(
|
||||
bill,
|
||||
toPayments(transactions.data ?? []).filter((p) => p.billId === id)
|
||||
)
|
||||
);
|
||||
|
||||
function averages(list: Payment[]): Total[] {
|
||||
return sumByCurrency(list).map((t) => ({
|
||||
...t,
|
||||
@@ -188,6 +202,7 @@
|
||||
<Amount
|
||||
amount={bill.amount_min}
|
||||
decimals={bill.currency_decimal_places}
|
||||
exact
|
||||
symbol={bill.currency_symbol}
|
||||
tone="expense"
|
||||
/>
|
||||
@@ -195,6 +210,7 @@
|
||||
<Amount
|
||||
amount={bill.amount_max}
|
||||
decimals={bill.currency_decimal_places}
|
||||
exact
|
||||
symbol={bill.currency_symbol}
|
||||
tone="expense"
|
||||
/>. Repeats {repeats(bill)}.
|
||||
@@ -216,6 +232,24 @@
|
||||
{formatDate(nextExpected(bill)) || "unknown"}
|
||||
</td>
|
||||
</tr>
|
||||
{#if change}
|
||||
<tr class="border-cladd-outline/60 border-b">
|
||||
<td class={label}>Price change</td>
|
||||
<td class={value}>
|
||||
<span
|
||||
class="num {change.to > change.from
|
||||
? "text-expense"
|
||||
: "text-income"}"
|
||||
>{formatNumber(change.from, change.decimals)}
|
||||
→ {formatNumber(change.to, change.decimals)}
|
||||
{change.code}</span
|
||||
>
|
||||
<span class="text-cladd-fg-soft"
|
||||
>since {formatDate(change.since, "short")}</span
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
<tr class="border-cladd-outline/60 border-b">
|
||||
<td class={label}>Group</td>
|
||||
<td class={value}>
|
||||
|
||||
Reference in new issue
Block a user