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
@@ -128,7 +128,7 @@
|
|||||||
: "text-cladd-fg"}"
|
: "text-cladd-fg"}"
|
||||||
>
|
>
|
||||||
{#each row.need.totals as total, i (total.code)}
|
{#each row.need.totals as total, i (total.code)}
|
||||||
{i > 0 ? "+" : ""}<Money {...total} />
|
{i > 0 ? "+" : ""}<Money {...total} exact />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -10,27 +10,32 @@
|
|||||||
import { manualCards } from "#lib/subscriptions/manual.svelte.js";
|
import { manualCards } from "#lib/subscriptions/manual.svelte.js";
|
||||||
import type { Card } from "#lib/subscriptions/runway.js";
|
import type { Card } from "#lib/subscriptions/runway.js";
|
||||||
|
|
||||||
let { billId, cards }: { billId: string; cards: Card[] } = $props();
|
let { billIds, cards }: { billIds: string[]; cards: Card[] } = $props();
|
||||||
|
|
||||||
const chosen = $derived(manualCards.get(billId));
|
const chosen = $derived(billIds.map((id) => manualCards.get(id)));
|
||||||
|
const shared = $derived(chosen.every((id) => id === chosen[0]) && chosen[0]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ContextMenuSub>
|
<ContextMenuSub>
|
||||||
<ContextMenuSubTrigger>Paying card</ContextMenuSubTrigger>
|
<ContextMenuSubTrigger
|
||||||
|
>{billIds.length > 1
|
||||||
|
? `Paying card for ${billIds.length}`
|
||||||
|
: "Paying card"}</ContextMenuSubTrigger
|
||||||
|
>
|
||||||
<ContextMenuSubContent class="max-h-80 w-60">
|
<ContextMenuSubContent class="max-h-80 w-60">
|
||||||
{#each cards as card (card.id)}
|
{#each cards as card (card.id)}
|
||||||
<ContextMenuItem onSelect={() => manualCards.set(billId, card.id)}>
|
<ContextMenuItem onSelect={() => manualCards.set(billIds, card.id)}>
|
||||||
<span class="flex-1 truncate">{card.name}</span>
|
<span class="flex-1 truncate">{card.name}</span>
|
||||||
<span class="text-[11px] text-cladd-fg-softer">{card.code}</span>
|
<span class="text-[11px] text-cladd-fg-softer">{card.code}</span>
|
||||||
{#if chosen === card.id}
|
{#if shared === card.id}
|
||||||
<Check class="size-3.5" />
|
<Check class="size-3.5" />
|
||||||
{/if}
|
{/if}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
{/each}
|
{/each}
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
disabled={!chosen}
|
disabled={!chosen.some(Boolean)}
|
||||||
onSelect={() => manualCards.set(billId, null)}
|
onSelect={() => manualCards.set(billIds, null)}
|
||||||
>Card of the last payment</ContextMenuItem
|
>Card of the last payment</ContextMenuItem
|
||||||
>
|
>
|
||||||
</ContextMenuSubContent>
|
</ContextMenuSubContent>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
const AUTO = "\u0000";
|
const AUTO = "\u0000";
|
||||||
|
|
||||||
let { billId, cards }: { billId: string; cards: Card[] } = $props();
|
let { billIds, cards }: { billIds: string[]; cards: Card[] } = $props();
|
||||||
|
|
||||||
let text = $state("");
|
let text = $state("");
|
||||||
let input = $state<HTMLInputElement>();
|
let input = $state<HTMLInputElement>();
|
||||||
@@ -31,21 +31,23 @@
|
|||||||
input?.focus();
|
input?.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
const cancel = () => (manualCards.picking = "");
|
const cancel = () => (manualCards.picking = null);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SuggestInput
|
<SuggestInput
|
||||||
aria-label="Paying card"
|
aria-label="Paying card"
|
||||||
class="w-56"
|
class="w-56"
|
||||||
id="bill-card-{billId}"
|
id="bill-card-{billIds[0]}"
|
||||||
onaccept={(item) => {
|
onaccept={(item) => {
|
||||||
manualCards.set(billId, item.insert === AUTO ? null : item.insert);
|
manualCards.set(billIds, item.insert === AUTO ? null : item.insert);
|
||||||
return true;
|
return true;
|
||||||
}}
|
}}
|
||||||
onblur={cancel}
|
onblur={cancel}
|
||||||
oncancel={cancel}
|
oncancel={cancel}
|
||||||
onsubmit={cancel}
|
onsubmit={cancel}
|
||||||
placeholder="Paying card"
|
placeholder={billIds.length > 1
|
||||||
|
? `Paying card for ${billIds.length}`
|
||||||
|
: "Paying card"}
|
||||||
{suggestions}
|
{suggestions}
|
||||||
bind:input
|
bind:input
|
||||||
bind:value={text}
|
bind:value={text}
|
||||||
|
|||||||
@@ -1,23 +1,52 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { formatNumber, toNumber } from "#lib/format.js";
|
import { formatNumber, toNumber } from "#lib/format.js";
|
||||||
|
import { display } from "#lib/money/display.svelte.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
amount,
|
amount,
|
||||||
|
code,
|
||||||
symbol,
|
symbol,
|
||||||
decimals,
|
decimals,
|
||||||
approx = false,
|
approx = false,
|
||||||
|
converted = false,
|
||||||
|
exact = false,
|
||||||
class: className = "",
|
class: className = "",
|
||||||
}: {
|
}: {
|
||||||
amount: number | string | null | undefined;
|
amount: number | string | null | undefined;
|
||||||
|
code?: string | null;
|
||||||
symbol?: string | null;
|
symbol?: string | null;
|
||||||
decimals?: number | null;
|
decimals?: number | null;
|
||||||
approx?: boolean;
|
approx?: boolean;
|
||||||
|
converted?: boolean;
|
||||||
|
exact?: boolean;
|
||||||
class?: string;
|
class?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const value = $derived(Math.abs(toNumber(amount)));
|
||||||
|
const into = $derived(
|
||||||
|
exact || !code || code === display.code
|
||||||
|
? null
|
||||||
|
: display.convert(value, code)
|
||||||
|
);
|
||||||
|
const shown = $derived(
|
||||||
|
into === null
|
||||||
|
? { amount: value, decimals, symbol }
|
||||||
|
: { ...display.target, amount: into }
|
||||||
|
);
|
||||||
|
const mark = $derived.by(() => {
|
||||||
|
if (converted || into !== null) {
|
||||||
|
return "≈";
|
||||||
|
}
|
||||||
|
return approx ? "~" : "";
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span class="num {className}"
|
<span
|
||||||
|
class="num {className}"
|
||||||
|
title={into === null
|
||||||
|
? undefined
|
||||||
|
: `${symbol ?? ""}${formatNumber(value, decimals ?? 2)}`}
|
||||||
><span class="font-normal text-cladd-fg-softer"
|
><span class="font-normal text-cladd-fg-softer"
|
||||||
>{approx ? "~" : ""}{symbol ?? ""}</span
|
>{mark}{shown.symbol ?? ""}</span
|
||||||
>{formatNumber(Math.abs(toNumber(amount)), decimals ?? 2)}</span
|
>{formatNumber(shown.amount, shown.decimals ?? 2)}</span
|
||||||
>
|
>
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
import Calendar from "./Calendar.svelte";
|
import Calendar from "./Calendar.svelte";
|
||||||
import CardMenu from "./CardMenu.svelte";
|
import CardMenu from "./CardMenu.svelte";
|
||||||
import Money from "./Money.svelte";
|
import Money from "./Money.svelte";
|
||||||
|
import Sums from "./Sums.svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
needs,
|
needs,
|
||||||
@@ -69,8 +70,20 @@
|
|||||||
needs.flatMap((n) => n.dues).find((d) => d.bill.id === id)?.bill ?? null;
|
needs.flatMap((n) => n.dues).find((d) => d.bill.id === id)?.bill ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chosen(due: Due): string {
|
||||||
|
const card = byCurrency && due.card ? `${due.card.name}: ` : "";
|
||||||
|
if (due.manual) {
|
||||||
|
return `${card}set by you`;
|
||||||
|
}
|
||||||
|
if (!due.lastPaid) {
|
||||||
|
return "never paid";
|
||||||
|
}
|
||||||
|
const where = byCurrency ? "" : " here";
|
||||||
|
return `${card}last paid${where} ${formatDate(due.lastPaid, "short")}`;
|
||||||
|
}
|
||||||
|
|
||||||
const segment = (on: boolean) =>
|
const segment = (on: boolean) =>
|
||||||
buttonVariants({ size: "xs", variant: on ? "default" : "outline" });
|
buttonVariants({ size: "sm", variant: on ? "default" : "outline" });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="cladd-surface cladd-outline mb-3 rounded-cladd-lg">
|
<section class="cladd-surface cladd-outline mb-3 rounded-cladd-lg">
|
||||||
@@ -122,7 +135,7 @@
|
|||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger {oncontextmenu}>
|
<ContextMenuTrigger class="outline-none" {oncontextmenu}>
|
||||||
<div
|
<div
|
||||||
class="grid gap-3 p-3 [grid-template-columns:repeat(auto-fill,minmax(16rem,1fr))]"
|
class="grid gap-3 p-3 [grid-template-columns:repeat(auto-fill,minmax(16rem,1fr))]"
|
||||||
>
|
>
|
||||||
@@ -143,7 +156,7 @@
|
|||||||
<div
|
<div
|
||||||
class="font-semibold text-[26px] text-cladd-fg leading-tight"
|
class="font-semibold text-[26px] text-cladd-fg leading-tight"
|
||||||
>
|
>
|
||||||
<Money {...total} />
|
<Money {...total} exact />
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if need.short && need.currency}
|
{#if need.short && need.currency}
|
||||||
@@ -170,28 +183,33 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<ul class="mt-2 border-cladd-outline border-t pt-1 text-[12px]">
|
<ul class="mt-2 border-cladd-outline border-t pt-1 text-[12px]">
|
||||||
{#each need.dues as due, i (`${due.bill.id}:${due.date}:${i}`)}
|
{#each need.dues as due, i (`${due.bill.id}:${due.date}:${i}`)}
|
||||||
<li
|
<li class="py-0.5" data-bill={due.bill.id}>
|
||||||
class="flex h-6 items-center gap-2"
|
<div class="flex h-5 items-center gap-2">
|
||||||
data-bill={due.bill.id}
|
<span
|
||||||
>
|
class="num w-12 shrink-0 {due.overdue
|
||||||
<span
|
? "text-expense"
|
||||||
class="num w-12 shrink-0 {due.overdue
|
: "text-cladd-fg-soft"}"
|
||||||
? "text-expense"
|
>{due.overdue
|
||||||
: "text-cladd-fg-soft"}"
|
? "overdue"
|
||||||
>{due.overdue
|
: formatDate(due.date, "short")}</span
|
||||||
? "overdue"
|
>
|
||||||
: formatDate(due.date, "short")}</span
|
<a
|
||||||
|
class="min-w-0 flex-1 truncate text-cladd-fg hover:underline"
|
||||||
|
href="/subscriptions/show/{due.bill.id}"
|
||||||
|
>{due.bill.attributes.name}</a
|
||||||
|
>
|
||||||
|
<Money
|
||||||
|
{...due.pay}
|
||||||
|
approx={due.approx}
|
||||||
|
class="font-medium text-cladd-fg"
|
||||||
|
exact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="truncate ps-14 text-[11px] text-cladd-fg-softer leading-4"
|
||||||
>
|
>
|
||||||
<a
|
{chosen(due)}
|
||||||
class="min-w-0 flex-1 truncate text-cladd-fg hover:underline"
|
</div>
|
||||||
href="/subscriptions/show/{due.bill.id}"
|
|
||||||
>{due.bill.attributes.name}</a
|
|
||||||
>
|
|
||||||
<Money
|
|
||||||
{...due.pay}
|
|
||||||
approx={due.approx}
|
|
||||||
class="font-medium text-cladd-fg"
|
|
||||||
/>
|
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -211,7 +229,7 @@
|
|||||||
>Edit</ContextMenuItem
|
>Edit</ContextMenuItem
|
||||||
>
|
>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<CardMenu billId={target.id} {cards} />
|
<CardMenu billIds={[target.id]} {cards} />
|
||||||
{/if}
|
{/if}
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
@@ -225,10 +243,7 @@
|
|||||||
>Annual renewals in {RENEWAL_DAYS} days:</span
|
>Annual renewals in {RENEWAL_DAYS} days:</span
|
||||||
>
|
>
|
||||||
<span class="font-semibold text-cladd-fg"
|
<span class="font-semibold text-cladd-fg"
|
||||||
>{renewals.length},
|
>{renewals.length}, <Sums inline parts={renewalTotals} /></span
|
||||||
{#each renewalTotals as total, i (total.code)}
|
|
||||||
{i > 0 ? " + " : ""}<Money {...total} />
|
|
||||||
{/each}</span
|
|
||||||
>
|
>
|
||||||
{#each renewals as due (due.bill.id)}
|
{#each renewals as due (due.bill.id)}
|
||||||
<span class="text-cladd-fg-soft"
|
<span class="text-cladd-fg-soft"
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Money as Part } from "#lib/format.js";
|
||||||
|
import { display } from "#lib/money/display.svelte.js";
|
||||||
|
import Money from "./Money.svelte";
|
||||||
|
|
||||||
|
let { parts, inline = false }: { parts: Part[]; inline?: boolean } = $props();
|
||||||
|
|
||||||
|
const total = $derived(display.total(parts));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if total}
|
||||||
|
<Money {...total} converted={total.converted} />
|
||||||
|
{:else if inline}
|
||||||
|
{#each parts as part, i (part.code ?? i)}
|
||||||
|
{i > 0 ? " + " : ""}<Money {...part} />
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
{#each parts as part, i (part.code ?? i)}
|
||||||
|
<div><Money {...part} /></div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
@@ -1,39 +1,79 @@
|
|||||||
const PREFIX = "ffui.bill-card.";
|
import { errorText } from "#lib/components/misc/action.svelte.js";
|
||||||
|
import { session } from "#lib/session.svelte.js";
|
||||||
|
|
||||||
function read(): Record<string, string> {
|
const PREF = "ffui_bill_cards";
|
||||||
const ids: Record<string, string> = {};
|
const LEGACY = "ffui.bill-card.";
|
||||||
if (typeof localStorage === "undefined") {
|
|
||||||
return ids;
|
type Choices = Record<string, string>;
|
||||||
}
|
|
||||||
for (let i = 0; i < localStorage.length; i += 1) {
|
function stored(): Choices {
|
||||||
const key = localStorage.key(i);
|
const data = session.prefs[PREF];
|
||||||
if (key?.startsWith(PREFIX)) {
|
return data && typeof data === "object" && !Array.isArray(data)
|
||||||
ids[key.slice(PREFIX.length)] = localStorage.getItem(key) ?? "";
|
? (data as Choices)
|
||||||
}
|
: {};
|
||||||
}
|
}
|
||||||
return ids;
|
|
||||||
|
function legacyKeys(): string[] {
|
||||||
|
return Array.from(
|
||||||
|
{ length: localStorage.length },
|
||||||
|
(_, i) => localStorage.key(i) ?? ""
|
||||||
|
).filter((key) => key.startsWith(LEGACY));
|
||||||
}
|
}
|
||||||
|
|
||||||
class ManualCards {
|
class ManualCards {
|
||||||
ids = $state<Record<string, string>>(read());
|
pending = $state.raw<Choices | null>(null);
|
||||||
picking = $state("");
|
picking = $state<{ at: string; ids: string[] } | null>(null);
|
||||||
|
failure = $state("");
|
||||||
|
ids = $derived(this.pending ?? stored());
|
||||||
|
|
||||||
get(billId: string): string | null {
|
get(billId: string): string | null {
|
||||||
return this.ids[billId] || null;
|
return this.ids[billId] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
set(billId: string, cardId: string | null) {
|
async set(billIds: string[], cardId: string | null) {
|
||||||
if (cardId) {
|
const changes = Object.fromEntries(billIds.map((id) => [id, cardId ?? ""]));
|
||||||
localStorage.setItem(PREFIX + billId, cardId);
|
this.picking = null;
|
||||||
} else {
|
await this.save({ ...this.ids, ...changes });
|
||||||
localStorage.removeItem(PREFIX + billId);
|
}
|
||||||
|
|
||||||
|
async migrate() {
|
||||||
|
const keys = legacyKeys();
|
||||||
|
if (keys.length === 0) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
this.ids = Object.fromEntries(
|
const old = Object.fromEntries(
|
||||||
Object.entries({ ...this.ids, [billId]: cardId ?? "" }).filter(
|
keys.map((key) => [
|
||||||
([, id]) => id
|
key.slice(LEGACY.length),
|
||||||
)
|
localStorage.getItem(key) ?? "",
|
||||||
|
])
|
||||||
);
|
);
|
||||||
this.picking = "";
|
if (await this.save({ ...old, ...this.ids })) {
|
||||||
|
for (const key of keys) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async save(choices: Choices): Promise<boolean> {
|
||||||
|
const next = Object.fromEntries(
|
||||||
|
Object.entries(choices).filter(([, id]) => id)
|
||||||
|
);
|
||||||
|
this.pending = next;
|
||||||
|
this.failure = "";
|
||||||
|
try {
|
||||||
|
await session.setPreference(
|
||||||
|
PREF,
|
||||||
|
Object.keys(next).length > 0 ? next : false
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
this.failure = errorText(e);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (this.pending === next) {
|
||||||
|
this.pending = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export interface Due {
|
|||||||
bill: Item<Bill>;
|
bill: Item<Bill>;
|
||||||
card: Card | null;
|
card: Card | null;
|
||||||
date: string;
|
date: string;
|
||||||
|
lastPaid: string;
|
||||||
|
manual: boolean;
|
||||||
native: Sum;
|
native: Sum;
|
||||||
overdue: boolean;
|
overdue: boolean;
|
||||||
pay: Sum;
|
pay: Sum;
|
||||||
@@ -238,6 +240,8 @@ export function dues(
|
|||||||
bill,
|
bill,
|
||||||
card: who.card,
|
card: who.card,
|
||||||
date,
|
date,
|
||||||
|
lastPaid: who.last?.date ?? "",
|
||||||
|
manual: who.manual,
|
||||||
native,
|
native,
|
||||||
overdue,
|
overdue,
|
||||||
pay,
|
pay,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Ban from "@lucide/svelte/icons/ban";
|
import Ban from "@lucide/svelte/icons/ban";
|
||||||
import Pin from "@lucide/svelte/icons/pin";
|
|
||||||
import Plus from "@lucide/svelte/icons/plus";
|
import Plus from "@lucide/svelte/icons/plus";
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { onMount } from "svelte";
|
||||||
import { api } from "#lib/api/client.js";
|
import { api } from "#lib/api/client.js";
|
||||||
import { all, invalidate, lists } from "#lib/api/lists.js";
|
import { all, invalidate, lists } from "#lib/api/lists.js";
|
||||||
import type { Bill, Item } from "#lib/api/types.js";
|
import type { Bill, Item } from "#lib/api/types.js";
|
||||||
@@ -12,7 +12,6 @@
|
|||||||
LINK,
|
LINK,
|
||||||
sumByCurrency,
|
sumByCurrency,
|
||||||
swapOrder,
|
swapOrder,
|
||||||
type Total,
|
|
||||||
} from "#lib/components/groups/object-groups.js";
|
} from "#lib/components/groups/object-groups.js";
|
||||||
import { Arm, errorText } from "#lib/components/misc/action.svelte.js";
|
import { Arm, errorText } from "#lib/components/misc/action.svelte.js";
|
||||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
@@ -32,6 +31,7 @@
|
|||||||
import PaidStatus from "#lib/components/subscriptions/PaidStatus.svelte";
|
import PaidStatus from "#lib/components/subscriptions/PaidStatus.svelte";
|
||||||
import Runway from "#lib/components/subscriptions/Runway.svelte";
|
import Runway from "#lib/components/subscriptions/Runway.svelte";
|
||||||
import Strip from "#lib/components/subscriptions/Strip.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 { buttonVariants } from "#lib/components/ui/button/button.svelte";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
@@ -44,7 +44,8 @@
|
|||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "#lib/components/ui/context-menu/index.js";
|
} from "#lib/components/ui/context-menu/index.js";
|
||||||
import { addDays, today } from "#lib/dates.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 { ListNav } from "#lib/list-nav.svelte.js";
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import { oldUiUrl } from "#lib/spa-paths.js";
|
import { oldUiUrl } from "#lib/spa-paths.js";
|
||||||
@@ -54,7 +55,6 @@
|
|||||||
byBill,
|
byBill,
|
||||||
byCard,
|
byCard,
|
||||||
byCurrency,
|
byCurrency,
|
||||||
creep,
|
|
||||||
dues,
|
dues,
|
||||||
HORIZONS,
|
HORIZONS,
|
||||||
type Horizon,
|
type Horizon,
|
||||||
@@ -268,11 +268,21 @@
|
|||||||
say(outcome.errors.length ? `Failed: ${outcome.errors.join("; ")}` : "");
|
say(outcome.errors.length ? `Failed: ${outcome.errors.join("; ")}` : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
const pick = (b: Item<Bill> | undefined) => {
|
const activeIds = (items: Item<Bill>[]) =>
|
||||||
if (b?.attributes.active) {
|
items.filter((b) => b.attributes.active).map((b) => b.id);
|
||||||
manualCards.picking = 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>>({
|
const nav: ListNav<Item<Bill>> = new ListNav<Item<Bill>>({
|
||||||
edit,
|
edit,
|
||||||
@@ -293,12 +303,12 @@
|
|||||||
{
|
{
|
||||||
group: "Subscriptions",
|
group: "Subscriptions",
|
||||||
keys: "a",
|
keys: "a",
|
||||||
label: "Set paying card",
|
label: "Set paying card (row or selection)",
|
||||||
run: () => pick(nav.current),
|
run: () => pick(nav.targets),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: "Subscriptions",
|
group: "Subscriptions",
|
||||||
keys: "L",
|
keys: "y",
|
||||||
label: "Link payments found for this subscription",
|
label: "Link payments found for this subscription",
|
||||||
run: () => link(nav.current),
|
run: () => link(nav.current),
|
||||||
},
|
},
|
||||||
@@ -347,13 +357,21 @@
|
|||||||
items: () => rows,
|
items: () => rows,
|
||||||
key: billKey,
|
key: billKey,
|
||||||
open: show,
|
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) {
|
if (cursor) {
|
||||||
return "bg-row-cursor";
|
return "bg-row-cursor";
|
||||||
}
|
}
|
||||||
|
if (selected) {
|
||||||
|
return "bg-row-selected";
|
||||||
|
}
|
||||||
return stale ? "bg-warn/10 hover:bg-row-hover" : "hover:bg-row-hover";
|
return stale ? "bg-warn/10 hover:bg-row-hover" : "hover:bg-row-hover";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,22 +404,16 @@
|
|||||||
strip={stripNeeds}
|
strip={stripNeeds}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#snippet sums(
|
|
||||||
totals: Total[]
|
|
||||||
)}
|
|
||||||
{#each totals as total (total.code)}
|
|
||||||
<div><Money {...total} /></div>
|
|
||||||
{/each}
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<Box body="p-0" title="Subscriptions">
|
<Box body="p-0" title="Subscriptions">
|
||||||
{#snippet tools()}
|
{#snippet tools()}
|
||||||
{#if failure}
|
{#if failure || manualCards.failure}
|
||||||
<span class="text-[12px] text-expense">{failure}</span>
|
<span class="text-[12px] text-expense"
|
||||||
|
>{failure || manualCards.failure}</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="text-[12px] text-cladd-fg-soft">{period.label}</span>
|
<span class="text-[12px] text-cladd-fg-soft">{period.label}</span>
|
||||||
<button
|
<button
|
||||||
class={buttonVariants({ size: "xs", variant: "outline" })}
|
class={buttonVariants({ size: "sm", variant: "outline" })}
|
||||||
onclick={toggleInactive}
|
onclick={toggleInactive}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -420,14 +432,13 @@
|
|||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ContextMenu onOpenChange={(open) => open || (armed = false)}>
|
<ContextMenu onOpenChange={(open) => open || (armed = false)}>
|
||||||
<ContextMenuTrigger>
|
<ContextMenuTrigger class="block outline-none">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full text-[13px]">
|
<table class="w-full text-[13px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-cladd-outline border-b text-left">
|
<tr class="border-cladd-outline border-b text-left">
|
||||||
<th class={head}>Name</th>
|
<th class={head}>Name</th>
|
||||||
<th class="{head} text-right">Amount</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}>Paid this period</th>
|
||||||
<th class="{head} hidden md:table-cell">Next expected match</th>
|
<th class="{head} hidden md:table-cell">Next expected match</th>
|
||||||
<th class="{head} hidden md:table-cell">Card</th>
|
<th class="{head} hidden md:table-cell">Card</th>
|
||||||
@@ -459,9 +470,8 @@
|
|||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
<td class="{cell} text-right font-semibold text-cladd-fg">
|
<td class="{cell} text-right font-semibold text-cladd-fg">
|
||||||
{@render sums(monthTotals)}
|
<Sums parts={monthTotals} />
|
||||||
</td>
|
</td>
|
||||||
<td class="hidden lg:table-cell"></td>
|
|
||||||
<td class="{cell} text-[12px] text-cladd-fg-soft">
|
<td class="{cell} text-[12px] text-cladd-fg-soft">
|
||||||
{monthTotals.length > 0 ? "a month" : ""}
|
{monthTotals.length > 0 ? "a month" : ""}
|
||||||
</td>
|
</td>
|
||||||
@@ -470,9 +480,9 @@
|
|||||||
<td class="hidden 2xl:table-cell"></td>
|
<td class="hidden 2xl:table-cell"></td>
|
||||||
<td class="hidden 2xl:table-cell"></td>
|
<td class="hidden 2xl:table-cell"></td>
|
||||||
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{#each section.items as item, j (item.id)}
|
{#each section.items as item, j (item.id)}
|
||||||
@@ -481,13 +491,13 @@
|
|||||||
{@const ends = bill.end_date?.slice(0, 10)}
|
{@const ends = bill.end_date?.slice(0, 10)}
|
||||||
{@const last = lastOf(item)}
|
{@const last = lastOf(item)}
|
||||||
{@const who = payer(item)}
|
{@const who = payer(item)}
|
||||||
{@const change = creep(bill, payments.get(item.id) ?? [])}
|
|
||||||
{@const stale = staleSince(bill, last, day)}
|
{@const stale = staleSince(bill, last, day)}
|
||||||
{@const extra = found.get(item.id) ?? []}
|
{@const extra = found.get(item.id) ?? []}
|
||||||
{@const armedHere = arm.key?.endsWith(`:${item.id}`)}
|
{@const armedHere = arm.key?.endsWith(`:${item.id}`)}
|
||||||
<tr
|
<tr
|
||||||
class="h-7 border-cladd-outline/60 border-t {rowTone(
|
class="h-7 border-cladd-outline/60 border-t {rowTone(
|
||||||
nav.cursor === i,
|
nav.cursor === i,
|
||||||
|
nav.isSelected(item),
|
||||||
stale !== null
|
stale !== null
|
||||||
)} {bill.active ? "text-cladd-fg" : "text-cladd-fg-softer"}"
|
)} {bill.active ? "text-cladd-fg" : "text-cladd-fg-softer"}"
|
||||||
id={nav.rowId(i)}
|
id={nav.rowId(i)}
|
||||||
@@ -527,24 +537,11 @@
|
|||||||
2}
|
2}
|
||||||
approx={toNumber(bill.amount_max) !==
|
approx={toNumber(bill.amount_max) !==
|
||||||
toNumber(bill.amount_min)}
|
toNumber(bill.amount_min)}
|
||||||
|
code={bill.currency_code}
|
||||||
decimals={bill.currency_decimal_places}
|
decimals={bill.currency_decimal_places}
|
||||||
symbol={bill.currency_symbol ?? ""}
|
symbol={bill.currency_symbol ?? ""}
|
||||||
/>
|
/>
|
||||||
</td>
|
</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">
|
<td class="{cell} md:whitespace-nowrap">
|
||||||
{#if stale !== null}
|
{#if stale !== null}
|
||||||
<span class="text-warn"
|
<span class="text-warn"
|
||||||
@@ -565,18 +562,20 @@
|
|||||||
{bill.active ? formatDate(nextExpected(bill)) : ""}
|
{bill.active ? formatDate(nextExpected(bill)) : ""}
|
||||||
</td>
|
</td>
|
||||||
<td class="{cell} hidden whitespace-nowrap md:table-cell">
|
<td class="{cell} hidden whitespace-nowrap md:table-cell">
|
||||||
{#if manualCards.picking === item.id}
|
{#if manualCards.picking?.at === item.id}
|
||||||
<CardPicker billId={item.id} cards={cardList} />
|
<CardPicker
|
||||||
|
billIds={manualCards.picking.ids}
|
||||||
|
cards={cardList}
|
||||||
|
/>
|
||||||
{:else if bill.active}
|
{:else if bill.active}
|
||||||
{#if who.card}
|
{#if who.card}
|
||||||
<span class="text-cladd-fg-soft"
|
<span class="text-cladd-fg-soft"
|
||||||
>{who.card.name}</span
|
>{who.card.name}</span
|
||||||
>
|
>
|
||||||
{#if who.manual}
|
{#if who.manual}
|
||||||
<Pin
|
<span class="ms-1 text-[12px] text-cladd-fg-softer"
|
||||||
aria-label="Set by hand"
|
>set by you</span
|
||||||
class="ms-0.5 inline size-3 align-[-1px] text-cladd-fg-softer"
|
>
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-warn">card unknown</span>
|
<span class="text-warn">card unknown</span>
|
||||||
@@ -600,7 +599,9 @@
|
|||||||
{months}
|
{months}
|
||||||
/>
|
/>
|
||||||
</td>
|
</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}
|
{#if bill.active}
|
||||||
<Money {...yearly(bill)} />
|
<Money {...yearly(bill)} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -608,7 +609,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{#if extra.length > 0 || stale !== null || armedHere}
|
{#if extra.length > 0 || stale !== null || armedHere}
|
||||||
<tr class="border-0">
|
<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
|
<div
|
||||||
class="flex w-0 min-w-full flex-wrap items-center gap-x-3 gap-y-1 ps-4"
|
class="flex w-0 min-w-full flex-wrap items-center gap-x-3 gap-y-1 ps-4"
|
||||||
>
|
>
|
||||||
@@ -636,7 +637,7 @@
|
|||||||
? "it"
|
? "it"
|
||||||
: `all ${extra.length}`}<span
|
: `all ${extra.length}`}<span
|
||||||
class="kbd max-sm:hidden"
|
class="kbd max-sm:hidden"
|
||||||
>L</span
|
>y</span
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
{#if linking[item.id]}
|
{#if linking[item.id]}
|
||||||
@@ -692,18 +693,17 @@
|
|||||||
Total, active subscriptions
|
Total, active subscriptions
|
||||||
</td>
|
</td>
|
||||||
<td class="{cell} text-right font-semibold text-cladd-fg">
|
<td class="{cell} text-right font-semibold text-cladd-fg">
|
||||||
{@render sums(perMonth(rows))}
|
<Sums parts={perMonth(rows)} />
|
||||||
</td>
|
</td>
|
||||||
<td class="hidden lg:table-cell"></td>
|
|
||||||
<td class="{cell} text-[12px] text-cladd-fg-soft">a month</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 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="hidden 2xl:table-cell"></td>
|
<td class="hidden 2xl:table-cell"></td>
|
||||||
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
@@ -717,7 +717,12 @@
|
|||||||
<ContextMenuItem onSelect={() => show(target)}>Show</ContextMenuItem>
|
<ContextMenuItem onSelect={() => show(target)}>Show</ContextMenuItem>
|
||||||
<ContextMenuItem onSelect={() => edit(target)}>Edit</ContextMenuItem>
|
<ContextMenuItem onSelect={() => edit(target)}>Edit</ContextMenuItem>
|
||||||
{#if target.attributes.active}
|
{#if target.attributes.active}
|
||||||
<CardMenu billId={target.id} cards={cardList} />
|
<CardMenu
|
||||||
|
billIds={nav.isSelected(target)
|
||||||
|
? activeIds(nav.selection)
|
||||||
|
: [target.id]}
|
||||||
|
cards={cardList}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<ContextMenuSub>
|
<ContextMenuSub>
|
||||||
<ContextMenuSubTrigger>Move to group</ContextMenuSubTrigger>
|
<ContextMenuSubTrigger>Move to group</ContextMenuSubTrigger>
|
||||||
|
|||||||
@@ -30,10 +30,16 @@
|
|||||||
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
|
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
|
||||||
import { buttonVariants } from "#lib/components/ui/button/button.svelte";
|
import { buttonVariants } from "#lib/components/ui/button/button.svelte";
|
||||||
import { addMonths } from "#lib/dates.js";
|
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 { useKeys } from "#lib/keys.svelte.js";
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import { oldUiUrl } from "#lib/spa-paths.js";
|
import { oldUiUrl } from "#lib/spa-paths.js";
|
||||||
|
import { creep, toPayments } from "#lib/subscriptions/runway.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
|
|
||||||
@@ -82,6 +88,14 @@
|
|||||||
);
|
);
|
||||||
type Payment = (typeof payments)[number];
|
type Payment = (typeof payments)[number];
|
||||||
|
|
||||||
|
const change = $derived(
|
||||||
|
bill &&
|
||||||
|
creep(
|
||||||
|
bill,
|
||||||
|
toPayments(transactions.data ?? []).filter((p) => p.billId === id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
function averages(list: Payment[]): Total[] {
|
function averages(list: Payment[]): Total[] {
|
||||||
return sumByCurrency(list).map((t) => ({
|
return sumByCurrency(list).map((t) => ({
|
||||||
...t,
|
...t,
|
||||||
@@ -188,6 +202,7 @@
|
|||||||
<Amount
|
<Amount
|
||||||
amount={bill.amount_min}
|
amount={bill.amount_min}
|
||||||
decimals={bill.currency_decimal_places}
|
decimals={bill.currency_decimal_places}
|
||||||
|
exact
|
||||||
symbol={bill.currency_symbol}
|
symbol={bill.currency_symbol}
|
||||||
tone="expense"
|
tone="expense"
|
||||||
/>
|
/>
|
||||||
@@ -195,6 +210,7 @@
|
|||||||
<Amount
|
<Amount
|
||||||
amount={bill.amount_max}
|
amount={bill.amount_max}
|
||||||
decimals={bill.currency_decimal_places}
|
decimals={bill.currency_decimal_places}
|
||||||
|
exact
|
||||||
symbol={bill.currency_symbol}
|
symbol={bill.currency_symbol}
|
||||||
tone="expense"
|
tone="expense"
|
||||||
/>. Repeats {repeats(bill)}.
|
/>. Repeats {repeats(bill)}.
|
||||||
@@ -216,6 +232,24 @@
|
|||||||
{formatDate(nextExpected(bill)) || "unknown"}
|
{formatDate(nextExpected(bill)) || "unknown"}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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">
|
<tr class="border-cladd-outline/60 border-b">
|
||||||
<td class={label}>Group</td>
|
<td class={label}>Group</td>
|
||||||
<td class={value}>
|
<td class={value}>
|
||||||
|
|||||||
Reference in new issue
Block a user