feat(budgets,categories,tags): in-place limits with pace, category shares, tag tree and clean-up
This commit is contained in:
1 parent
4a693a83c9
commit
89bbf56062
54 files changed
+5135
-1
No files matched your search
@@ -0,0 +1,205 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import { invalidate } from "#lib/api/lists.js";
|
||||||
|
import type { Budget, Item, One } from "#lib/api/types.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
|
||||||
|
import Field from "#lib/components/categories/Field.svelte";
|
||||||
|
import {
|
||||||
|
fieldErrors,
|
||||||
|
SELECT_CLASS,
|
||||||
|
} from "#lib/components/categories/form.js";
|
||||||
|
import { Button } from "#lib/components/ui/button/index.js";
|
||||||
|
import { Input } from "#lib/components/ui/input/index.js";
|
||||||
|
import { Textarea } from "#lib/components/ui/textarea/index.js";
|
||||||
|
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
|
||||||
|
import { session } from "#lib/session.svelte.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
|
const TYPES = [
|
||||||
|
{ label: "No auto-budget", value: "none" },
|
||||||
|
{ label: "Set a fixed amount every period", value: "reset" },
|
||||||
|
{ label: "Add an amount every period", value: "rollover" },
|
||||||
|
{
|
||||||
|
label: "Add an amount every period and correct for overspending",
|
||||||
|
value: "adjusted",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const PERIODS = [
|
||||||
|
{ label: "Daily", value: "daily" },
|
||||||
|
{ label: "Weekly", value: "weekly" },
|
||||||
|
{ label: "Monthly", value: "monthly" },
|
||||||
|
{ label: "Quarterly", value: "quarterly" },
|
||||||
|
{ label: "Every half year", value: "half_year" },
|
||||||
|
{ label: "Yearly", value: "yearly" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let { budget }: { budget?: Item<Budget> } = $props();
|
||||||
|
|
||||||
|
const initial = untrack(() => budget)?.attributes;
|
||||||
|
let name = $state(initial?.name ?? "");
|
||||||
|
let active = $state(initial?.active ?? true);
|
||||||
|
let autoType = $state<string>(initial?.auto_budget_type ?? "none");
|
||||||
|
let currency = $state(
|
||||||
|
initial?.currency_code ?? session.primary?.code ?? "EUR"
|
||||||
|
);
|
||||||
|
let autoAmount = $state(initial?.auto_budget_amount ?? "");
|
||||||
|
let autoPeriod = $state<string>(initial?.auto_budget_period ?? "monthly");
|
||||||
|
let notes = $state(initial?.notes ?? "");
|
||||||
|
let errors = $state<Record<string, string>>({});
|
||||||
|
let busy = $state(false);
|
||||||
|
let armed = $state(false);
|
||||||
|
|
||||||
|
const enabled = $derived(
|
||||||
|
session.currencies.filter((c) => c.attributes.enabled)
|
||||||
|
);
|
||||||
|
|
||||||
|
async function save(event?: Event) {
|
||||||
|
event?.preventDefault();
|
||||||
|
if (busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
errors = {};
|
||||||
|
const auto =
|
||||||
|
autoType === "none"
|
||||||
|
? { auto_budget_type: "none" }
|
||||||
|
: {
|
||||||
|
auto_budget_amount: autoAmount,
|
||||||
|
auto_budget_currency_code: currency,
|
||||||
|
auto_budget_period: autoPeriod,
|
||||||
|
auto_budget_type: autoType,
|
||||||
|
};
|
||||||
|
const body = budget
|
||||||
|
? { active, name, notes: notes || null, ...auto }
|
||||||
|
: { name, ...auto };
|
||||||
|
try {
|
||||||
|
const saved = await api<One<Budget>>(
|
||||||
|
budget ? `/v1/budgets/${budget.id}` : "/v1/budgets",
|
||||||
|
{ body, method: budget ? "PUT" : "POST" }
|
||||||
|
);
|
||||||
|
await invalidate();
|
||||||
|
await goto(`/budgets/show/${saved.data.id}`);
|
||||||
|
} catch (failure) {
|
||||||
|
errors = fieldErrors(failure);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (!budget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await api(`/v1/budgets/${budget.id}`, { method: "DELETE" });
|
||||||
|
await invalidate();
|
||||||
|
await goto("/budgets");
|
||||||
|
}
|
||||||
|
|
||||||
|
useKeys(() => [
|
||||||
|
{
|
||||||
|
group: "Form",
|
||||||
|
input: true,
|
||||||
|
keys: "mod+Enter",
|
||||||
|
label: budget ? "Update budget" : "Store new budget",
|
||||||
|
run: () => save(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form class="flex flex-col gap-4" onsubmit={save}>
|
||||||
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Box title="Mandatory fields">
|
||||||
|
<Field id="name" issue={errors.name} label="Name">
|
||||||
|
<Input autofocus id="name" required bind:value={name} />
|
||||||
|
</Field>
|
||||||
|
{#if budget}
|
||||||
|
<Field id="active" issue={errors.active} label="Active">
|
||||||
|
<input
|
||||||
|
class="mt-1.5 size-4 accent-brand"
|
||||||
|
id="active"
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={active}
|
||||||
|
>
|
||||||
|
</Field>
|
||||||
|
{/if}
|
||||||
|
</Box>
|
||||||
|
<Box title="Optional fields">
|
||||||
|
<Field id="auto-type" issue={errors.auto_budget_type} label="Auto-budget">
|
||||||
|
<select class={SELECT_CLASS} id="auto-type" bind:value={autoType}>
|
||||||
|
{#each TYPES as type (type.value)}
|
||||||
|
<option value={type.value}>{type.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
{#if autoType !== "none"}
|
||||||
|
<Field
|
||||||
|
id="currency"
|
||||||
|
issue={errors.auto_budget_currency_code ?? errors.currency_code}
|
||||||
|
label="Currency"
|
||||||
|
>
|
||||||
|
<select class={SELECT_CLASS} id="currency" bind:value={currency}>
|
||||||
|
{#each enabled as cur (cur.id)}
|
||||||
|
<option value={cur.attributes.code}>
|
||||||
|
{cur.attributes.name}
|
||||||
|
({cur.attributes.symbol})
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
id="auto-amount"
|
||||||
|
issue={errors.auto_budget_amount}
|
||||||
|
label="Auto-budget amount"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
class="num"
|
||||||
|
id="auto-amount"
|
||||||
|
inputmode="decimal"
|
||||||
|
bind:value={autoAmount}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
id="auto-period"
|
||||||
|
issue={errors.auto_budget_period}
|
||||||
|
label="Auto-budget period"
|
||||||
|
>
|
||||||
|
<select class={SELECT_CLASS} id="auto-period" bind:value={autoPeriod}>
|
||||||
|
{#each PERIODS as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
{/if}
|
||||||
|
{#if budget}
|
||||||
|
<Field id="notes" issue={errors.notes} label="Notes">
|
||||||
|
<Textarea id="notes" rows={4} bind:value={notes} />
|
||||||
|
</Field>
|
||||||
|
{/if}
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Button disabled={busy} type="submit">
|
||||||
|
{budget ? "Update budget" : "Store new budget"}
|
||||||
|
</Button>
|
||||||
|
<span class="flex gap-1 text-[12px] text-cladd-fg-soft">
|
||||||
|
{#each prettyKeys("mod+Enter") as key (key)}
|
||||||
|
<span class="kbd">{key}</span>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
{#if busy}
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft">Saving</span>
|
||||||
|
{:else if errors.form}
|
||||||
|
<span class="text-[12px] text-expense">{errors.form}</span>
|
||||||
|
{/if}
|
||||||
|
{#if budget}
|
||||||
|
<span class="ml-auto text-[12px] text-cladd-fg-soft">
|
||||||
|
{#if armed}
|
||||||
|
Its transactions stay, without a budget.
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<ConfirmButton onconfirm={remove} bind:armed>Delete budget</ConfirmButton>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import { ApiError } from "#lib/api/client.js";
|
||||||
|
import { invalidate } from "#lib/api/lists.js";
|
||||||
|
import type { Range } from "#lib/dates.js";
|
||||||
|
import { formatMoney } from "#lib/format.js";
|
||||||
|
import { type Line, parseAmount, saveLimit } from "./data.js";
|
||||||
|
|
||||||
|
export type Move = 0 | 1 | -1;
|
||||||
|
|
||||||
|
let {
|
||||||
|
line,
|
||||||
|
budgetId,
|
||||||
|
range,
|
||||||
|
editing,
|
||||||
|
trap = true,
|
||||||
|
onedit,
|
||||||
|
ondone,
|
||||||
|
}: {
|
||||||
|
line: Line;
|
||||||
|
budgetId: string;
|
||||||
|
range: Range;
|
||||||
|
editing: boolean;
|
||||||
|
trap?: boolean;
|
||||||
|
onedit: () => void;
|
||||||
|
ondone: (move: Move) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let value = $state("");
|
||||||
|
let busy = $state(false);
|
||||||
|
let problem = $state("");
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (editing) {
|
||||||
|
untrack(() => {
|
||||||
|
value = line.amount ? String(line.amount) : "";
|
||||||
|
problem = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function focus(input: HTMLInputElement) {
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
function message(failure: unknown) {
|
||||||
|
if (failure instanceof ApiError) {
|
||||||
|
return Object.values(failure.errors).flat()[0] ?? failure.message;
|
||||||
|
}
|
||||||
|
return failure instanceof Error ? failure.message : String(failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commit(move: Move) {
|
||||||
|
if (busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const amount = parseAmount(value);
|
||||||
|
if (amount === null) {
|
||||||
|
problem = "Type a positive number";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (amount === (line.amount ?? 0)) {
|
||||||
|
ondone(move);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
problem = "";
|
||||||
|
try {
|
||||||
|
await saveLimit(budgetId, line, amount, range);
|
||||||
|
await invalidate();
|
||||||
|
ondone(move);
|
||||||
|
} catch (failure) {
|
||||||
|
problem = message(failure);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onkeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
commit(0);
|
||||||
|
} else if (event.key === "Tab" && trap) {
|
||||||
|
event.preventDefault();
|
||||||
|
commit(event.shiftKey ? -1 : 1);
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
ondone(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onblur(event: FocusEvent) {
|
||||||
|
const next = event.relatedTarget;
|
||||||
|
if (
|
||||||
|
!editing ||
|
||||||
|
busy ||
|
||||||
|
(next instanceof Element && next.closest("[data-limit-editor]"))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const amount = parseAmount(value);
|
||||||
|
if (amount === (line.amount ?? 0)) {
|
||||||
|
ondone(0);
|
||||||
|
} else {
|
||||||
|
commit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if editing}
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<span class="num text-cladd-fg-softer">{line.cur.symbol}</span>
|
||||||
|
<input
|
||||||
|
aria-label="Budgeted amount in {line.cur.code}"
|
||||||
|
class="num cladd-cut cladd-focusable-input h-6 w-28 rounded-cladd-sm px-1.5 text-right text-[13px] text-cladd-fg"
|
||||||
|
disabled={busy}
|
||||||
|
inputmode="decimal"
|
||||||
|
{onblur}
|
||||||
|
{onkeydown}
|
||||||
|
placeholder="0"
|
||||||
|
bind:value
|
||||||
|
{@attach focus}
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="num border-cladd-fg-softest border-b border-dashed leading-tight hover:border-brand hover:text-brand {line.amount
|
||||||
|
? "text-cladd-fg"
|
||||||
|
: "text-cladd-fg-softer"}"
|
||||||
|
onclick={onedit}
|
||||||
|
title="Set the budgeted amount (e)"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{line.amount
|
||||||
|
? formatMoney({
|
||||||
|
amount: line.amount,
|
||||||
|
decimals: line.cur.decimals,
|
||||||
|
symbol: line.cur.symbol,
|
||||||
|
})
|
||||||
|
: `${line.cur.symbol} set amount`}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if busy}
|
||||||
|
<span class="text-[11px] text-cladd-fg-soft">Saving</span>
|
||||||
|
{:else if problem}
|
||||||
|
<span class="text-[11px] text-expense">{problem}</span>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
value,
|
||||||
|
max,
|
||||||
|
tick = null,
|
||||||
|
class: className = "",
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
max: number;
|
||||||
|
tick?: number | null;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const ratio = $derived(max > 0 ? value / max : 0);
|
||||||
|
const over = $derived(ratio > 1);
|
||||||
|
const fill = $derived(over ? 1 / ratio : Math.min(1, ratio));
|
||||||
|
const ahead = $derived(tick !== null && !over && ratio > tick);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="relative h-2 overflow-hidden rounded-full bg-cladd-surface-next {className}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute inset-y-0 left-0 rounded-full {over || ahead
|
||||||
|
? "bg-warn"
|
||||||
|
: "bg-brand"}"
|
||||||
|
style:width="{fill * 100}%"
|
||||||
|
></div>
|
||||||
|
{#if over}
|
||||||
|
<div
|
||||||
|
class="absolute inset-y-0 right-0 bg-expense"
|
||||||
|
style:left="{fill * 100}%"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
{#if tick !== null}
|
||||||
|
<div
|
||||||
|
class="absolute -inset-y-px w-0.5 bg-cladd-fg"
|
||||||
|
style:left="calc({Math.min(1, tick) * 100}% - 1px)"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { all } from "#lib/api/lists.js";
|
||||||
|
import type { TransactionGroup } from "#lib/api/types.js";
|
||||||
|
import PeriodList from "#lib/components/categories/PeriodList.svelte";
|
||||||
|
import { windowOf } from "#lib/components/categories/sums.js";
|
||||||
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
|
import { formatDate } from "#lib/format.js";
|
||||||
|
import { period } from "#lib/period.svelte.js";
|
||||||
|
import { periodPath, usePeriodPath } from "./period-path.svelte.js";
|
||||||
|
|
||||||
|
const BASE = "/budgets/list/no-budget";
|
||||||
|
|
||||||
|
let { everything = false }: { everything?: boolean } = $props();
|
||||||
|
|
||||||
|
usePeriodPath(() => BASE);
|
||||||
|
|
||||||
|
const span = $derived(windowOf(period.end, period.view));
|
||||||
|
const query = createQuery(() => ({
|
||||||
|
enabled: period.ready,
|
||||||
|
queryFn: () =>
|
||||||
|
all<TransactionGroup>(
|
||||||
|
"/v1/budgets/transactions-without-budget",
|
||||||
|
everything
|
||||||
|
? { type: "withdrawal" }
|
||||||
|
: { end: span.end, start: span.start, type: "withdrawal" }
|
||||||
|
),
|
||||||
|
queryKey: everything
|
||||||
|
? ["no-budget", "all"]
|
||||||
|
: ["no-budget", span.start, span.end],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const title = $derived(
|
||||||
|
everything
|
||||||
|
? "All transactions without a budget"
|
||||||
|
: `Transactions without a budget between ${formatDate(period.start)} and ${formatDate(period.end)}`
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[
|
||||||
|
{ href: "/budgets", label: "Budgets" },
|
||||||
|
{ label: "Expenses without a budget" },
|
||||||
|
]}
|
||||||
|
title="Expenses without a budget"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PeriodList
|
||||||
|
fields={["spent"]}
|
||||||
|
groups={query.data ?? []}
|
||||||
|
href={(range) => periodPath(BASE, range.start, range.end)}
|
||||||
|
keep={(split) => !split.budget_id && split.type === "withdrawal"}
|
||||||
|
loading={query.isPending}
|
||||||
|
sidebar={!everything}
|
||||||
|
{title}
|
||||||
|
>
|
||||||
|
{#snippet links()}
|
||||||
|
{#if everything}
|
||||||
|
<a class="text-brand hover:underline" href={BASE}
|
||||||
|
>Show the current period and overview</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<a class="text-brand hover:underline" href="{BASE}/all"
|
||||||
|
>Show all transactions without grouping them by date</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</PeriodList>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
|
||||||
|
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||||
|
import { isDynamic, type Range, shiftPeriod } from "#lib/dates.js";
|
||||||
|
import { period, periodLabel } from "#lib/period.svelte.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { periodPath } from "./period-path.svelte.js";
|
||||||
|
|
||||||
|
const COUNT = 12;
|
||||||
|
|
||||||
|
let { base }: { base: string } = $props();
|
||||||
|
|
||||||
|
const view = $derived(isDynamic(period.view) ? "1M" : period.view);
|
||||||
|
|
||||||
|
function walk(dir: 1 | -1): Range[] {
|
||||||
|
const out: Range[] = [];
|
||||||
|
let { range } = period;
|
||||||
|
for (let i = 0; i < COUNT; i += 1) {
|
||||||
|
range = shiftPeriod(range, view, dir);
|
||||||
|
out.push(range);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = $derived(walk(-1));
|
||||||
|
const after = $derived(walk(1));
|
||||||
|
const href = (range: Range) => periodPath(base, range.start, range.end);
|
||||||
|
|
||||||
|
function pick(event: Event) {
|
||||||
|
const select = event.currentTarget as HTMLSelectElement;
|
||||||
|
if (select.value) {
|
||||||
|
goto(select.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
aria-label="Period"
|
||||||
|
class="grid items-center gap-3 pb-3 sm:grid-cols-[1fr_auto_1fr]"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
aria-label="Earlier periods"
|
||||||
|
class="cladd-cut h-7 max-w-44 rounded-cladd-sm px-2 text-[12px] text-cladd-fg-soft max-sm:hidden"
|
||||||
|
onchange={pick}
|
||||||
|
>
|
||||||
|
<option value="">Select date..</option>
|
||||||
|
{#each before as range (range.start)}
|
||||||
|
<option value={href(range)}>{periodLabel(range, view)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<div class="flex items-center gap-1 justify-self-center">
|
||||||
|
<a
|
||||||
|
aria-label="Previous period"
|
||||||
|
class="grid size-7 place-items-center rounded-cladd-sm text-cladd-fg-soft hover:bg-cladd-surface-next hover:text-cladd-fg"
|
||||||
|
href={href(before[0])}
|
||||||
|
title="Previous period ["
|
||||||
|
>
|
||||||
|
<ChevronLeft class="size-4" />
|
||||||
|
</a>
|
||||||
|
<span class="min-w-40 text-center font-semibold text-[14px] text-cladd-fg"
|
||||||
|
>{period.label}</span
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
aria-label="Next period"
|
||||||
|
class="grid size-7 place-items-center rounded-cladd-sm text-cladd-fg-soft hover:bg-cladd-surface-next hover:text-cladd-fg"
|
||||||
|
href={href(after[0])}
|
||||||
|
title="Next period ]"
|
||||||
|
>
|
||||||
|
<ChevronRight class="size-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
aria-label="Later periods"
|
||||||
|
class="cladd-cut h-7 max-w-44 justify-self-end rounded-cladd-sm px-2 text-[12px] text-cladd-fg-soft max-sm:hidden"
|
||||||
|
onchange={pick}
|
||||||
|
>
|
||||||
|
<option value="">Select date..</option>
|
||||||
|
{#each after as range (range.start)}
|
||||||
|
<option value={href(range)}>{periodLabel(range, view)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</nav>
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import { amountIn, type Sum } from "#lib/components/categories/sums.js";
|
||||||
|
import { tones } from "#lib/components/charts/chart.js";
|
||||||
|
import Meter from "#lib/components/charts/Meter.svelte";
|
||||||
|
import { formatMoney, formatRange, percent } from "#lib/format.js";
|
||||||
|
import type { Pool } from "./data.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
pool,
|
||||||
|
total,
|
||||||
|
noBudget,
|
||||||
|
noBudgetCount,
|
||||||
|
noBudgetHref,
|
||||||
|
}: {
|
||||||
|
pool: Pool;
|
||||||
|
total: Sum[];
|
||||||
|
noBudget: Sum[];
|
||||||
|
noBudgetCount: number;
|
||||||
|
noBudgetHref: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let explain = $state(false);
|
||||||
|
|
||||||
|
const money = (amount: number) =>
|
||||||
|
formatMoney({
|
||||||
|
amount,
|
||||||
|
decimals: pool.cur.decimals,
|
||||||
|
symbol: pool.cur.symbol,
|
||||||
|
});
|
||||||
|
|
||||||
|
const spent = $derived(-pool.spent);
|
||||||
|
const left = $derived(pool.budgeted - spent);
|
||||||
|
const allSpent = $derived(-amountIn(total, pool.cur.code));
|
||||||
|
const outside = $derived(-amountIn(noBudget, pool.cur.code));
|
||||||
|
const available = $derived(pool.available ?? 0);
|
||||||
|
const planned = $derived(pool.available !== null || pool.budgeted > 0);
|
||||||
|
|
||||||
|
const budgetedSegments = $derived(
|
||||||
|
pool.budgeted > available && available > 0
|
||||||
|
? [
|
||||||
|
{ color: tones.transfer, value: available },
|
||||||
|
{ color: tones.expense, value: pool.budgeted - available },
|
||||||
|
]
|
||||||
|
: [{ color: tones.transfer, value: pool.budgeted }]
|
||||||
|
);
|
||||||
|
const spentSegments = $derived(
|
||||||
|
spent > pool.budgeted && pool.budgeted > 0
|
||||||
|
? [
|
||||||
|
{ color: tones.warn, value: pool.budgeted },
|
||||||
|
{ color: tones.expense, value: spent - pool.budgeted },
|
||||||
|
]
|
||||||
|
: [{ color: "var(--ff-brand)", value: spent }]
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
body="flex flex-col gap-3 p-3"
|
||||||
|
title="Total available budget in {pool.cur.code}"
|
||||||
|
>
|
||||||
|
{#snippet tools()}
|
||||||
|
{#if pool.availableRange}
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft"
|
||||||
|
>{formatRange(pool.availableRange.start, pool.availableRange.end)}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
{#if !planned}
|
||||||
|
<p class="text-[13px] text-cladd-fg-soft">
|
||||||
|
Nothing budgeted in {pool.cur.code} for this period. Budgets spent
|
||||||
|
<span class="num text-expense">{money(spent)}</span>
|
||||||
|
in {pool.cur.code}.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-[auto_auto_minmax(0,1fr)] items-center gap-x-4 gap-y-2 text-[13px]"
|
||||||
|
>
|
||||||
|
<span class="text-cladd-fg-soft">Available</span>
|
||||||
|
<button
|
||||||
|
class="num justify-self-end border-cladd-fg-softest border-b border-dashed text-right font-semibold text-cladd-fg"
|
||||||
|
onclick={() => (explain = !explain)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{pool.available === null ? "not set" : money(available)}
|
||||||
|
</button>
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft">
|
||||||
|
{#if explain}
|
||||||
|
Firefly works this out from the amounts budgeted below. It can't be
|
||||||
|
edited here or in the API.
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="text-cladd-fg-soft">Budgeted</span>
|
||||||
|
<span class="num justify-self-end font-semibold text-cladd-fg"
|
||||||
|
>{money(pool.budgeted)}</span
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
<div class="min-w-16 flex-1 basis-24">
|
||||||
|
<Meter max={available || pool.budgeted} segments={budgetedSegments} />
|
||||||
|
</div>
|
||||||
|
{#if available > 0}
|
||||||
|
<span class="num shrink-0 text-[12px] text-cladd-fg-soft"
|
||||||
|
>{percent(pool.budgeted / available)}
|
||||||
|
of available</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-cladd-fg-soft">Spent</span>
|
||||||
|
<span class="num justify-self-end font-semibold text-expense"
|
||||||
|
>{money(spent)}</span
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
<div class="min-w-16 flex-1 basis-24">
|
||||||
|
<Meter max={pool.budgeted || spent} segments={spentSegments} />
|
||||||
|
</div>
|
||||||
|
{#if pool.budgeted > 0}
|
||||||
|
<span
|
||||||
|
class="num shrink-0 text-[12px] {left < 0
|
||||||
|
? "text-expense"
|
||||||
|
: "text-cladd-fg-soft"}"
|
||||||
|
>{left < 0
|
||||||
|
? `${money(-left)} overspent`
|
||||||
|
: `${money(left)} left`}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if allSpent > 0}
|
||||||
|
<p
|
||||||
|
class="mt-auto border-cladd-outline border-t pt-2 text-[12px] text-cladd-fg-soft"
|
||||||
|
>
|
||||||
|
All expenses in {pool.cur.code}:
|
||||||
|
<span class="num text-cladd-fg">{money(allSpent)}</span>.
|
||||||
|
{#if outside > 0}
|
||||||
|
<a class="text-brand hover:underline" href={noBudgetHref}
|
||||||
|
><span class="num">{money(outside)}</span>
|
||||||
|
of it has no budget{noBudgetCount
|
||||||
|
? ` (${noBudgetCount} transactions in all currencies)`
|
||||||
|
: ""}</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</Box>
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
<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 {
|
||||||
|
Budget,
|
||||||
|
BudgetLimit,
|
||||||
|
One,
|
||||||
|
TransactionGroup,
|
||||||
|
} from "#lib/api/types.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
|
||||||
|
import BreakdownBox from "#lib/components/categories/BreakdownBox.svelte";
|
||||||
|
import CurrencyTabs from "#lib/components/categories/CurrencyTabs.svelte";
|
||||||
|
import GroupsBox from "#lib/components/categories/GroupsBox.svelte";
|
||||||
|
import {
|
||||||
|
breakdown,
|
||||||
|
curOfSplit,
|
||||||
|
dayOf,
|
||||||
|
firstDay,
|
||||||
|
fromEntries,
|
||||||
|
inRange,
|
||||||
|
periodRanges,
|
||||||
|
rows as splitRows,
|
||||||
|
stepLabel,
|
||||||
|
} from "#lib/components/categories/sums.js";
|
||||||
|
import { tones, wash } from "#lib/components/charts/chart.js";
|
||||||
|
import LineChart from "#lib/components/charts/LineChart.svelte";
|
||||||
|
import Meter from "#lib/components/charts/Meter.svelte";
|
||||||
|
import StackedBars from "#lib/components/charts/StackedBars.svelte";
|
||||||
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
|
import { Button } from "#lib/components/ui/button/index.js";
|
||||||
|
import { addDays, contains, today } from "#lib/dates.js";
|
||||||
|
import { formatMoney, formatRange, toNumber } from "#lib/format.js";
|
||||||
|
import { useKeys } from "#lib/keys.svelte.js";
|
||||||
|
import { period } from "#lib/period.svelte.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import { limitRange } from "./data.js";
|
||||||
|
|
||||||
|
let { id, limitId }: { id: string; limitId?: string } = $props();
|
||||||
|
|
||||||
|
const budget = createQuery(() => ({
|
||||||
|
queryFn: () => api<One<Budget>>(`/v1/budgets/${id}`),
|
||||||
|
queryKey: ["budget", id],
|
||||||
|
}));
|
||||||
|
const limits = createQuery(() => ({
|
||||||
|
queryFn: () => all<BudgetLimit>(`/v1/budgets/${id}/limits`),
|
||||||
|
queryKey: ["budget-limits", id],
|
||||||
|
}));
|
||||||
|
const groups = createQuery(() => ({
|
||||||
|
queryFn: () => all<TransactionGroup>(`/v1/budgets/${id}/transactions`),
|
||||||
|
queryKey: ["budget-transactions", id],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const name = $derived(budget.data?.data.attributes.name ?? "");
|
||||||
|
const sortedLimits = $derived(
|
||||||
|
[...(limits.data ?? [])].sort((a, b) =>
|
||||||
|
(b.attributes.start ?? "").localeCompare(a.attributes.start ?? "")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const limit = $derived(sortedLimits.find((l) => l.id === limitId));
|
||||||
|
const range = $derived(limit ? limitRange(limit) : null);
|
||||||
|
|
||||||
|
const allRows = $derived(
|
||||||
|
splitRows(groups.data ?? [], (s) => s.budget_id === id)
|
||||||
|
);
|
||||||
|
const scoped = $derived(
|
||||||
|
limit && range
|
||||||
|
? inRange(allRows, range).filter(
|
||||||
|
(r) => r.split.currency_code === limit.attributes.currency_code
|
||||||
|
)
|
||||||
|
: allRows
|
||||||
|
);
|
||||||
|
const spentRows = $derived(
|
||||||
|
scoped.filter((r) => r.split.type === "withdrawal")
|
||||||
|
);
|
||||||
|
const codes = $derived(
|
||||||
|
[...new Set(spentRows.map((r) => r.split.currency_code ?? ""))].filter(
|
||||||
|
Boolean
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const code = $derived(
|
||||||
|
limit?.attributes.currency_code ??
|
||||||
|
(codes.includes(page.url.searchParams.get("currency") ?? "")
|
||||||
|
? page.url.searchParams.get("currency")
|
||||||
|
: codes[0]) ??
|
||||||
|
""
|
||||||
|
);
|
||||||
|
const inCur = $derived(
|
||||||
|
spentRows.filter((r) => r.split.currency_code === code)
|
||||||
|
);
|
||||||
|
const cur = $derived(
|
||||||
|
inCur[0]
|
||||||
|
? curOfSplit(inCur[0].split)
|
||||||
|
: {
|
||||||
|
code,
|
||||||
|
decimals: limit?.attributes.currency_decimal_places ?? 2,
|
||||||
|
symbol: limit?.attributes.currency_symbol ?? code,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const fmt = (value: number) =>
|
||||||
|
formatMoney({ amount: value, decimals: cur.decimals, symbol: cur.symbol });
|
||||||
|
|
||||||
|
const visibleGroups = $derived.by(() => {
|
||||||
|
if (!range) {
|
||||||
|
return groups.data ?? [];
|
||||||
|
}
|
||||||
|
const ids = new Set(scoped.map((r) => r.group.id));
|
||||||
|
return (groups.data ?? []).filter((g) => ids.has(g.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const view = $derived(period.view);
|
||||||
|
const history = $derived.by(() => {
|
||||||
|
const first = firstDay(inCur) ?? period.start;
|
||||||
|
const end = period.end > first ? period.end : today();
|
||||||
|
const ranges = periodRanges(end, view, 400)
|
||||||
|
.filter((r) => r.end >= first)
|
||||||
|
.reverse();
|
||||||
|
return ranges.map((r) => {
|
||||||
|
const spent = inRange(inCur, r).reduce(
|
||||||
|
(sum, row) => sum + toNumber(row.split.amount),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const own = sortedLimits.find(
|
||||||
|
(l) =>
|
||||||
|
l.attributes.currency_code === code &&
|
||||||
|
contains(r, l.attributes.start ?? "")
|
||||||
|
);
|
||||||
|
const amount = own ? toNumber(own.attributes.amount) : 0;
|
||||||
|
return {
|
||||||
|
format: fmt,
|
||||||
|
href: own ? `/budgets/show/${id}/${own.id}` : undefined,
|
||||||
|
key: r.start,
|
||||||
|
label: stepLabel(r, view),
|
||||||
|
values: amount
|
||||||
|
? {
|
||||||
|
left: Math.max(0, amount - spent),
|
||||||
|
over: Math.max(0, spent - amount),
|
||||||
|
spent: Math.min(spent, amount),
|
||||||
|
}
|
||||||
|
: { left: 0, over: 0, spent },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const historyKeys = [
|
||||||
|
{ color: "var(--ff-brand)", key: "spent", label: "Spent" },
|
||||||
|
{ color: wash("var(--ff-brand)", 28), key: "left", label: "Left" },
|
||||||
|
{ color: tones.expense, key: "over", label: "Overspent" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const burn = $derived.by(() => {
|
||||||
|
if (!(limit && range)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const amount = toNumber(limit.attributes.amount);
|
||||||
|
const last = range.end < today() ? range.end : today();
|
||||||
|
const byDay = new Map<string, number>();
|
||||||
|
for (const row of inCur) {
|
||||||
|
byDay.set(
|
||||||
|
dayOf(row),
|
||||||
|
(byDay.get(dayOf(row)) ?? 0) + toNumber(row.split.amount)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const points: { x: string; y: number }[] = [];
|
||||||
|
let left = amount;
|
||||||
|
for (let d = range.start; d <= last; d = addDays(d, 1)) {
|
||||||
|
left -= byDay.get(d) ?? 0;
|
||||||
|
points.push({ x: d, y: left });
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
area: true,
|
||||||
|
format: fmt,
|
||||||
|
key: "left",
|
||||||
|
label: "Left",
|
||||||
|
points,
|
||||||
|
strong: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: "var(--cladd-fg-softer)",
|
||||||
|
format: fmt,
|
||||||
|
key: "pace",
|
||||||
|
label: "Even pace",
|
||||||
|
points: [
|
||||||
|
{ x: range.start, y: amount },
|
||||||
|
{ x: range.end, y: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const byCategory = $derived(
|
||||||
|
breakdown(inCur, (s) => ({
|
||||||
|
href: s.category_id ? `/categories/show/${s.category_id}` : undefined,
|
||||||
|
label: s.category_name ?? "(no category)",
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const bySource = $derived(
|
||||||
|
breakdown(inCur, (s) => ({
|
||||||
|
href: s.source_id ? `/accounts/show/${s.source_id}` : undefined,
|
||||||
|
label: s.source_name ?? "",
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const byDestination = $derived(
|
||||||
|
breakdown(inCur, (s) => ({
|
||||||
|
href: s.destination_id ? `/accounts/show/${s.destination_id}` : undefined,
|
||||||
|
label: s.destination_name ?? "",
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
await api(`/v1/budgets/${id}`, { method: "DELETE" });
|
||||||
|
await invalidate();
|
||||||
|
await goto("/budgets");
|
||||||
|
}
|
||||||
|
|
||||||
|
useKeys(() => [
|
||||||
|
{
|
||||||
|
group: "Budget",
|
||||||
|
keys: "E",
|
||||||
|
label: "Edit budget",
|
||||||
|
run: () => goto(`/budgets/edit/${id}`),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ href: "/budgets", label: "Budgets" }, { label: name || "Budget" }]}
|
||||||
|
subtitle={limit && range
|
||||||
|
? `${formatRange(range.start, range.end)} in ${limit.attributes.currency_code}`
|
||||||
|
: undefined}
|
||||||
|
title={name || "Budget"}
|
||||||
|
>
|
||||||
|
{#snippet actions()}
|
||||||
|
<Button
|
||||||
|
href="/budgets/edit/{id}"
|
||||||
|
size="sm"
|
||||||
|
title="Edit budget (E)"
|
||||||
|
variant="outline"
|
||||||
|
>Edit</Button
|
||||||
|
>
|
||||||
|
<ConfirmButton onconfirm={remove}>Delete</ConfirmButton>
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
{#if limit && range}
|
||||||
|
<Box
|
||||||
|
title="Chart for all transactions for budget "{name}" between {formatRange(
|
||||||
|
range.start,
|
||||||
|
range.end
|
||||||
|
)} in {cur.code}"
|
||||||
|
>
|
||||||
|
{#if burn[0]?.points.length}
|
||||||
|
<LineChart
|
||||||
|
label="Amount left in this budget"
|
||||||
|
marker={contains(range, today()) ? today() : undefined}
|
||||||
|
series={burn}
|
||||||
|
zero
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<p class="text-[12px] text-cladd-fg-soft">Nothing spent yet.</p>
|
||||||
|
{/if}
|
||||||
|
</Box>
|
||||||
|
{:else}
|
||||||
|
<Box title="Chart of all transactions for budget "{name}"">
|
||||||
|
{#snippet tools()}
|
||||||
|
<CurrencyTabs {codes} current={code} />
|
||||||
|
{/snippet}
|
||||||
|
{#if history.length > 0}
|
||||||
|
<StackedBars
|
||||||
|
format={fmt}
|
||||||
|
keys={historyKeys}
|
||||||
|
label="Spent per period against the budgeted amount"
|
||||||
|
rows={history}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<p class="text-[12px] text-cladd-fg-soft">No expenses yet.</p>
|
||||||
|
{/if}
|
||||||
|
</Box>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-3">
|
||||||
|
<BreakdownBox items={byCategory} title="Expenses by category" />
|
||||||
|
<BreakdownBox items={bySource} title="Expenses by asset account" />
|
||||||
|
<BreakdownBox
|
||||||
|
items={byDestination}
|
||||||
|
title="Expenses by destination account"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid items-start gap-4 2xl:grid-cols-[minmax(0,1fr)_16rem]">
|
||||||
|
<GroupsBox
|
||||||
|
groups={visibleGroups}
|
||||||
|
loading={groups.isPending}
|
||||||
|
title="Transactions"
|
||||||
|
>
|
||||||
|
{#snippet links()}
|
||||||
|
{#if limit}
|
||||||
|
<a class="text-brand hover:underline" href="/budgets/show/{id}"
|
||||||
|
>Show everything</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</GroupsBox>
|
||||||
|
<nav
|
||||||
|
aria-label="Budgeted amounts"
|
||||||
|
class="grid grid-cols-[repeat(auto-fill,minmax(12rem,1fr))] gap-2 2xl:grid-cols-1"
|
||||||
|
>
|
||||||
|
{#each sortedLimits as item (item.id)}
|
||||||
|
{@const r = limitRange(item)}
|
||||||
|
{@const amount = toNumber(item.attributes.amount)}
|
||||||
|
{@const spent = -(
|
||||||
|
fromEntries(item.attributes.spent).find(
|
||||||
|
(s) => s.code === item.attributes.currency_code
|
||||||
|
)?.amount ?? 0
|
||||||
|
)}
|
||||||
|
{@const money = (value: number) =>
|
||||||
|
formatMoney({
|
||||||
|
amount: value,
|
||||||
|
decimals: item.attributes.currency_decimal_places,
|
||||||
|
symbol: item.attributes.currency_symbol,
|
||||||
|
})}
|
||||||
|
<a
|
||||||
|
aria-current={item.id === limitId ? "page" : undefined}
|
||||||
|
class="cladd-surface cladd-outline flex flex-col gap-1 rounded-cladd-md px-3 py-2 text-[12px] {item.id ===
|
||||||
|
limitId
|
||||||
|
? "bg-row-cursor outline-2 outline-brand"
|
||||||
|
: "hover:bg-row-hover"}"
|
||||||
|
href="/budgets/show/{id}/{item.id}"
|
||||||
|
>
|
||||||
|
<span class="font-semibold text-cladd-fg"
|
||||||
|
>{formatRange(r.start, r.end)}</span
|
||||||
|
>
|
||||||
|
<span class="flex justify-between gap-2">
|
||||||
|
<span class="num text-expense">{money(spent)}</span>
|
||||||
|
<span class="num text-cladd-fg-soft">of {money(amount)}</span>
|
||||||
|
</span>
|
||||||
|
<Meter
|
||||||
|
height={6}
|
||||||
|
max={amount}
|
||||||
|
segments={spent > amount
|
||||||
|
? [
|
||||||
|
{ color: tones.warn, value: amount },
|
||||||
|
{ color: tones.expense, value: spent - amount },
|
||||||
|
]
|
||||||
|
: [{ color: "var(--ff-brand)", value: spent }]}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import { all } from "#lib/api/lists.js";
|
||||||
|
import type {
|
||||||
|
AvailableBudget,
|
||||||
|
Budget,
|
||||||
|
BudgetLimit,
|
||||||
|
Currency,
|
||||||
|
InsightTotalEntry,
|
||||||
|
Item,
|
||||||
|
Page,
|
||||||
|
TransactionGroup,
|
||||||
|
} from "#lib/api/types.js";
|
||||||
|
import {
|
||||||
|
add,
|
||||||
|
type Cur,
|
||||||
|
fromEntries,
|
||||||
|
list,
|
||||||
|
type Sum,
|
||||||
|
type Sums,
|
||||||
|
} from "#lib/components/categories/sums.js";
|
||||||
|
import { contains, daysBetween, type Range, today } from "#lib/dates.js";
|
||||||
|
import { toNumber } from "#lib/format.js";
|
||||||
|
|
||||||
|
export interface Line {
|
||||||
|
amount: number | null;
|
||||||
|
cur: Cur & { id: string };
|
||||||
|
key: string;
|
||||||
|
limit?: Item<BudgetLimit>;
|
||||||
|
range: Range;
|
||||||
|
spent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BudgetRow {
|
||||||
|
budget: Item<Budget>;
|
||||||
|
lines: Line[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pool {
|
||||||
|
available: number | null;
|
||||||
|
availableRange: Range | null;
|
||||||
|
budgeted: number;
|
||||||
|
cur: Cur;
|
||||||
|
spent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pace {
|
||||||
|
expected: number;
|
||||||
|
fraction: number;
|
||||||
|
over: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BudgetPage {
|
||||||
|
available: Item<AvailableBudget>[];
|
||||||
|
budgets: Item<Budget>[];
|
||||||
|
limits: Item<BudgetLimit>[];
|
||||||
|
noBudget: Sum[];
|
||||||
|
noBudgetCount: number;
|
||||||
|
total: Sum[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const day = (value: string | undefined) => (value ?? "").slice(0, 10);
|
||||||
|
|
||||||
|
export const limitRange = (limit: Item<BudgetLimit>): Range => ({
|
||||||
|
end: day(limit.attributes.end),
|
||||||
|
start: day(limit.attributes.start),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function curOf(
|
||||||
|
currencies: Item<Currency>[],
|
||||||
|
code: string | undefined
|
||||||
|
): Cur & { id: string } {
|
||||||
|
const found = currencies.find((c) => c.attributes.code === code);
|
||||||
|
return {
|
||||||
|
code: code ?? "",
|
||||||
|
decimals: found?.attributes.decimal_places ?? 2,
|
||||||
|
id: found?.id ?? "",
|
||||||
|
symbol: found?.attributes.symbol ?? code ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function insightSums(
|
||||||
|
entries: InsightTotalEntry[],
|
||||||
|
currencies: Item<Currency>[]
|
||||||
|
): Sum[] {
|
||||||
|
const sums: Sums = {};
|
||||||
|
for (const entry of entries) {
|
||||||
|
add(
|
||||||
|
sums,
|
||||||
|
curOf(currencies, entry.currency_code),
|
||||||
|
toNumber(entry.difference)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return list(sums);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadBudgetPage(
|
||||||
|
range: Range,
|
||||||
|
currencies: Item<Currency>[]
|
||||||
|
): Promise<BudgetPage> {
|
||||||
|
const query = { end: range.end, start: range.start };
|
||||||
|
const [budgets, limits, available, total, noBudget, noBudgetPage] =
|
||||||
|
await Promise.all([
|
||||||
|
all<Budget>("/v1/budgets", query),
|
||||||
|
all<BudgetLimit>("/v1/budget-limits", query),
|
||||||
|
all<AvailableBudget>("/v1/available-budgets", query),
|
||||||
|
api<InsightTotalEntry[]>("/v1/insight/expense/total", { query }),
|
||||||
|
api<InsightTotalEntry[]>("/v1/insight/expense/no-budget", { query }),
|
||||||
|
api<Page<TransactionGroup>>("/v1/budgets/transactions-without-budget", {
|
||||||
|
query: { ...query, limit: 1, type: "withdrawal" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
available,
|
||||||
|
budgets,
|
||||||
|
limits,
|
||||||
|
noBudget: insightSums(noBudget, currencies),
|
||||||
|
noBudgetCount: noBudgetPage.meta.pagination?.total ?? 0,
|
||||||
|
total: insightSums(total, currencies),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const byOrder = (a: Item<Budget>, b: Item<Budget>) =>
|
||||||
|
(a.attributes.order ?? 0) - (b.attributes.order ?? 0) ||
|
||||||
|
a.attributes.name.localeCompare(b.attributes.name);
|
||||||
|
|
||||||
|
export function buildRows(
|
||||||
|
data: BudgetPage,
|
||||||
|
range: Range,
|
||||||
|
currencies: Item<Currency>[],
|
||||||
|
primary: string
|
||||||
|
): BudgetRow[] {
|
||||||
|
return data.budgets
|
||||||
|
.filter((budget) => budget.attributes.active)
|
||||||
|
.sort(byOrder)
|
||||||
|
.map((budget) => {
|
||||||
|
const spent = fromEntries(budget.attributes.spent);
|
||||||
|
const lines: Line[] = data.limits
|
||||||
|
.filter((limit) => limit.attributes.budget_id === budget.id)
|
||||||
|
.map((limit) => {
|
||||||
|
const code = limit.attributes.currency_code;
|
||||||
|
const own = fromEntries(limit.attributes.spent);
|
||||||
|
return {
|
||||||
|
amount: toNumber(limit.attributes.amount),
|
||||||
|
cur: curOf(currencies, code),
|
||||||
|
key: `${budget.id}:${limit.id}`,
|
||||||
|
limit,
|
||||||
|
range: limitRange(limit),
|
||||||
|
spent: own.find((s) => s.code === code)?.amount ?? 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
for (const sum of spent) {
|
||||||
|
if (!lines.some((line) => line.cur.code === sum.code)) {
|
||||||
|
lines.push({
|
||||||
|
amount: null,
|
||||||
|
cur: curOf(currencies, sum.code),
|
||||||
|
key: `${budget.id}:${sum.code}`,
|
||||||
|
range,
|
||||||
|
spent: sum.amount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lines.length === 0) {
|
||||||
|
lines.push({
|
||||||
|
amount: null,
|
||||||
|
cur: curOf(currencies, primary),
|
||||||
|
key: `${budget.id}:${primary}`,
|
||||||
|
range,
|
||||||
|
spent: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { budget, lines };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPools(
|
||||||
|
data: BudgetPage,
|
||||||
|
rows: BudgetRow[],
|
||||||
|
currencies: Item<Currency>[],
|
||||||
|
primary: string
|
||||||
|
): Pool[] {
|
||||||
|
const pools = new Map<string, Pool>();
|
||||||
|
const pool = (code: string) => {
|
||||||
|
let found = pools.get(code);
|
||||||
|
if (!found) {
|
||||||
|
found = {
|
||||||
|
available: null,
|
||||||
|
availableRange: null,
|
||||||
|
budgeted: 0,
|
||||||
|
cur: curOf(currencies, code),
|
||||||
|
spent: 0,
|
||||||
|
};
|
||||||
|
pools.set(code, found);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
};
|
||||||
|
for (const item of data.available) {
|
||||||
|
const target = pool(item.attributes.currency_code ?? primary);
|
||||||
|
target.available =
|
||||||
|
(target.available ?? 0) + toNumber(item.attributes.amount);
|
||||||
|
target.availableRange = {
|
||||||
|
end: day(item.attributes.end),
|
||||||
|
start: day(item.attributes.start),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const line of row.lines) {
|
||||||
|
const target = pool(line.cur.code);
|
||||||
|
target.budgeted += line.amount ?? 0;
|
||||||
|
target.spent += line.spent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = [...pools.values()].filter(
|
||||||
|
(p) => p.available !== null || p.budgeted !== 0 || p.spent !== 0
|
||||||
|
);
|
||||||
|
return result.length > 0 ? result : [pool(primary)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pace(line: Line, now = today()): Pace | null {
|
||||||
|
if (!(line.amount && contains(line.range, now))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const total = daysBetween(line.range.start, line.range.end) + 1;
|
||||||
|
const elapsed = daysBetween(line.range.start, now) + 1;
|
||||||
|
const expected = (line.amount * elapsed) / total;
|
||||||
|
return { expected, fraction: elapsed / total, over: -line.spent - expected };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function perDay(line: Line, now = today()) {
|
||||||
|
const total = daysBetween(line.range.start, line.range.end) + 1;
|
||||||
|
const current = contains(line.range, now);
|
||||||
|
const passed = current ? daysBetween(line.range.start, now) + 1 : total;
|
||||||
|
const left = current ? daysBetween(now, line.range.end) + 1 : total;
|
||||||
|
return {
|
||||||
|
left: line.amount === null ? null : (line.amount + line.spent) / left,
|
||||||
|
past: line.range.end < now,
|
||||||
|
spent: line.spent / passed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPACES = /\s/g;
|
||||||
|
|
||||||
|
export const parseAmount = (raw: string): number | null => {
|
||||||
|
const clean = raw.replace(SPACES, "").replace(",", ".");
|
||||||
|
if (clean === "") {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const value = Number(clean);
|
||||||
|
return Number.isFinite(value) && value >= 0 ? value : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function saveLimit(
|
||||||
|
budgetId: string,
|
||||||
|
line: Line,
|
||||||
|
amount: number,
|
||||||
|
range: Range
|
||||||
|
) {
|
||||||
|
const { limit } = line;
|
||||||
|
if (limit && amount === 0) {
|
||||||
|
await api(`/v1/budgets/${budgetId}/limits/${limit.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (limit) {
|
||||||
|
await api(`/v1/budgets/${budgetId}/limits/${limit.id}`, {
|
||||||
|
body: { amount: String(amount) },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (amount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await api(`/v1/budgets/${budgetId}/limits`, {
|
||||||
|
body: {
|
||||||
|
amount: String(amount),
|
||||||
|
currency_code: line.cur.code,
|
||||||
|
end: range.end,
|
||||||
|
start: range.start,
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { untrack } from "svelte";
|
||||||
|
import { period } from "#lib/period.svelte.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
export function periodPath(base: string, start: string, end: string) {
|
||||||
|
return `${base}/${start}/${end}?start=${start}&end=${end}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePeriodPath(base: () => string) {
|
||||||
|
$effect(() => {
|
||||||
|
const { start, end } = page.params;
|
||||||
|
if (period.ready && start && end && start <= end) {
|
||||||
|
untrack(() => period.set({ end, start }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
if (!period.ready) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { start, end } = period;
|
||||||
|
untrack(() => {
|
||||||
|
const { start: from, end: to } = page.params;
|
||||||
|
if (from && to && (from !== start || to !== end)) {
|
||||||
|
const target = base();
|
||||||
|
setTimeout(() => {
|
||||||
|
if (page.url.pathname.startsWith(`${target}/${from}`)) {
|
||||||
|
goto(periodPath(target, start, end), { replace: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import BarChart from "#lib/components/charts/BarChart.svelte";
|
||||||
|
import { formatMoney } from "#lib/format.js";
|
||||||
|
import type { Breakdown } from "./sums.js";
|
||||||
|
|
||||||
|
const TOP = 8;
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
color = "var(--ff-expense)",
|
||||||
|
}: { title: string; items: Breakdown[]; color?: string } = $props();
|
||||||
|
|
||||||
|
function bars(item: Breakdown) {
|
||||||
|
const top = item.bars.slice(0, TOP);
|
||||||
|
const rest = item.bars.slice(TOP).reduce((sum, bar) => sum + bar.amount, 0);
|
||||||
|
const format = (value: number) =>
|
||||||
|
formatMoney({
|
||||||
|
amount: value,
|
||||||
|
decimals: item.cur.decimals,
|
||||||
|
symbol: item.cur.symbol,
|
||||||
|
});
|
||||||
|
const list = top.map((bar) => ({
|
||||||
|
format,
|
||||||
|
href: bar.href,
|
||||||
|
key: bar.label,
|
||||||
|
label: bar.label,
|
||||||
|
value: bar.amount,
|
||||||
|
}));
|
||||||
|
if (rest > 0) {
|
||||||
|
list.push({
|
||||||
|
format,
|
||||||
|
href: undefined,
|
||||||
|
key: "\u0000other",
|
||||||
|
label: `${item.bars.length - TOP} more`,
|
||||||
|
value: rest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Box {title}>
|
||||||
|
{#if items.length === 0}
|
||||||
|
<p class="text-[12px] text-cladd-fg-soft">Nothing in this range.</p>
|
||||||
|
{/if}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{#each items as item (item.cur.code)}
|
||||||
|
{#if items.length > 1}
|
||||||
|
<h3 class="font-medium text-[12px] text-cladd-fg-soft">
|
||||||
|
{item.cur.code}
|
||||||
|
</h3>
|
||||||
|
{/if}
|
||||||
|
<BarChart
|
||||||
|
bars={bars(item)}
|
||||||
|
{color}
|
||||||
|
label={title}
|
||||||
|
labelWidth="minmax(5rem, 10rem)"
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import { invalidate } from "#lib/api/lists.js";
|
||||||
|
import type { Category, Item, One } from "#lib/api/types.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
|
||||||
|
import { Button } from "#lib/components/ui/button/index.js";
|
||||||
|
import { Input } from "#lib/components/ui/input/index.js";
|
||||||
|
import { Textarea } from "#lib/components/ui/textarea/index.js";
|
||||||
|
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import Field from "./Field.svelte";
|
||||||
|
import { fieldErrors } from "./form.js";
|
||||||
|
|
||||||
|
let { category }: { category?: Item<Category> } = $props();
|
||||||
|
|
||||||
|
const initial = untrack(() => category)?.attributes;
|
||||||
|
let name = $state(initial?.name ?? "");
|
||||||
|
let notes = $state(initial?.notes ?? "");
|
||||||
|
let errors = $state<Record<string, string>>({});
|
||||||
|
let busy = $state(false);
|
||||||
|
let armed = $state(false);
|
||||||
|
|
||||||
|
async function save(event?: Event) {
|
||||||
|
event?.preventDefault();
|
||||||
|
if (busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
errors = {};
|
||||||
|
try {
|
||||||
|
const saved = await api<One<Category>>(
|
||||||
|
category ? `/v1/categories/${category.id}` : "/v1/categories",
|
||||||
|
{
|
||||||
|
body: { name, notes: notes || null },
|
||||||
|
method: category ? "PUT" : "POST",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await invalidate();
|
||||||
|
await goto(`/categories/show/${saved.data.id}`);
|
||||||
|
} catch (failure) {
|
||||||
|
errors = fieldErrors(failure);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (!category) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await api(`/v1/categories/${category.id}`, { method: "DELETE" });
|
||||||
|
await invalidate();
|
||||||
|
await goto("/categories");
|
||||||
|
}
|
||||||
|
|
||||||
|
useKeys(() => [
|
||||||
|
{
|
||||||
|
group: "Form",
|
||||||
|
input: true,
|
||||||
|
keys: "mod+Enter",
|
||||||
|
label: category ? "Update category" : "Store new category",
|
||||||
|
run: () => save(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form class="flex flex-col gap-4" onsubmit={save}>
|
||||||
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Box title="Mandatory fields">
|
||||||
|
<Field id="name" issue={errors.name} label="Name">
|
||||||
|
<Input autofocus id="name" required bind:value={name} />
|
||||||
|
</Field>
|
||||||
|
</Box>
|
||||||
|
<Box title="Optional fields">
|
||||||
|
<Field id="notes" issue={errors.notes} label="Notes">
|
||||||
|
<Textarea id="notes" rows={4} bind:value={notes} />
|
||||||
|
</Field>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Button disabled={busy} type="submit">
|
||||||
|
{category ? "Update category" : "Store new category"}
|
||||||
|
</Button>
|
||||||
|
<span class="flex gap-1 text-[12px] text-cladd-fg-soft">
|
||||||
|
{#each prettyKeys("mod+Enter") as key (key)}
|
||||||
|
<span class="kbd">{key}</span>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
{#if busy}
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft">Saving</span>
|
||||||
|
{:else if errors.form}
|
||||||
|
<span class="text-[12px] text-expense">{errors.form}</span>
|
||||||
|
{/if}
|
||||||
|
{#if category}
|
||||||
|
<span class="ml-auto text-[12px] text-cladd-fg-soft">
|
||||||
|
{#if armed}
|
||||||
|
Its transactions stay, without a category.
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<ConfirmButton onconfirm={remove} bind:armed
|
||||||
|
>Delete category</ConfirmButton
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
let { codes, current }: { codes: string[]; current: string } = $props();
|
||||||
|
|
||||||
|
function href(code: string) {
|
||||||
|
const url = new URL(page.url.href);
|
||||||
|
url.searchParams.set("currency", code);
|
||||||
|
return url.pathname + url.search;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if codes.length > 1}
|
||||||
|
<nav aria-label="Currency" class="flex gap-0.5 text-[12px]">
|
||||||
|
{#each codes as code (code)}
|
||||||
|
<a
|
||||||
|
aria-current={code === current ? "true" : undefined}
|
||||||
|
class="num rounded-cladd-sm px-1.5 py-0.5 {code === current
|
||||||
|
? "bg-row-selected font-semibold text-cladd-fg"
|
||||||
|
: "text-cladd-fg-soft hover:bg-row-hover"}"
|
||||||
|
data-sveltekit-noscroll
|
||||||
|
data-sveltekit-replacestate
|
||||||
|
href={href(code)}
|
||||||
|
>{code}</a
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
issue,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
id: string;
|
||||||
|
issue?: string;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid items-start gap-x-4 gap-y-1 py-1.5 sm:grid-cols-[10rem_minmax(0,1fr)]"
|
||||||
|
>
|
||||||
|
<label class="pt-1 text-[13px] text-cladd-fg-soft" for={id}>{label}</label>
|
||||||
|
<div class="flex min-w-0 flex-col gap-1">
|
||||||
|
{@render children()}
|
||||||
|
{#if issue}
|
||||||
|
<span class="text-[12px] text-expense">{issue}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import Pager from "#lib/components/transactions/Pager.svelte";
|
||||||
|
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
|
||||||
|
import { session } from "#lib/session.svelte.js";
|
||||||
|
import type { Group } from "#lib/transactions/split.js";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
let {
|
||||||
|
groups,
|
||||||
|
title,
|
||||||
|
loading = false,
|
||||||
|
empty = "No transactions.",
|
||||||
|
links,
|
||||||
|
}: {
|
||||||
|
groups: Group[];
|
||||||
|
title: string;
|
||||||
|
loading?: boolean;
|
||||||
|
empty?: string;
|
||||||
|
links?: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const size = $derived(session.pageSize);
|
||||||
|
const pages = $derived(Math.max(1, Math.ceil(groups.length / size)));
|
||||||
|
const current = $derived(
|
||||||
|
Math.min(pages, Math.max(1, Number(page.url.searchParams.get("page")) || 1))
|
||||||
|
);
|
||||||
|
const slice = $derived(groups.slice((current - 1) * size, current * size));
|
||||||
|
|
||||||
|
function href(n: number) {
|
||||||
|
const url = new URL(page.url.href);
|
||||||
|
url.searchParams.set("page", String(n));
|
||||||
|
return url.pathname + url.search;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Box body="p-0" {title}>
|
||||||
|
{#snippet tools()}
|
||||||
|
<span class="num text-[12px] text-cladd-fg-soft">{groups.length}</span>
|
||||||
|
{/snippet}
|
||||||
|
<TransactionTable {empty} groups={slice} {loading} />
|
||||||
|
{#snippet footer()}
|
||||||
|
{@render links?.()}
|
||||||
|
<div class="ml-auto"><Pager {href} keys page={current} {pages} /></div>
|
||||||
|
{/snippet}
|
||||||
|
</Box>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from "#lib/components/ui/context-menu/index.js";
|
||||||
|
import { prettyKeys } from "#lib/keys.svelte.js";
|
||||||
|
|
||||||
|
export interface MenuItem {
|
||||||
|
keys?: string;
|
||||||
|
label: string;
|
||||||
|
run: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
items,
|
||||||
|
ondelete,
|
||||||
|
deleteLabel = "Delete",
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
items: () => MenuItem[];
|
||||||
|
ondelete?: () => void | Promise<void>;
|
||||||
|
deleteLabel?: string;
|
||||||
|
children: Snippet<[Record<string, unknown>]>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let armed = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ContextMenu onOpenChange={(open) => !open && (armed = false)}>
|
||||||
|
<ContextMenuTrigger>
|
||||||
|
{#snippet child({
|
||||||
|
props,
|
||||||
|
})}
|
||||||
|
{@render children(props)}
|
||||||
|
{/snippet}
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent class="min-w-52">
|
||||||
|
{#each items() as item (item.label)}
|
||||||
|
<ContextMenuItem onSelect={item.run}>
|
||||||
|
{item.label}
|
||||||
|
{#if item.keys}
|
||||||
|
<ContextMenuShortcut
|
||||||
|
>{prettyKeys(item.keys).join(" ")}</ContextMenuShortcut
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</ContextMenuItem>
|
||||||
|
{/each}
|
||||||
|
{#if ondelete}
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
onSelect={(event) => {
|
||||||
|
if (!armed) {
|
||||||
|
event.preventDefault();
|
||||||
|
armed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ondelete();
|
||||||
|
}}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
{armed ? "Press again to delete" : deleteLabel}
|
||||||
|
</ContextMenuItem>
|
||||||
|
{/if}
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Amount, { type Tone } from "#lib/components/Amount.svelte";
|
||||||
|
import type { Sum } from "./sums.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
sums,
|
||||||
|
tone = "auto",
|
||||||
|
empty = "",
|
||||||
|
class: className = "",
|
||||||
|
}: { sums: Sum[]; tone?: Tone; empty?: string; class?: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if sums.length === 0}
|
||||||
|
{#if empty}
|
||||||
|
<span class="num text-cladd-fg-softer {className}">{empty}</span>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<span class="inline-flex flex-wrap justify-end gap-x-2.5 {className}">
|
||||||
|
{#each sums as sum (sum.code)}
|
||||||
|
<Amount
|
||||||
|
amount={sum.amount}
|
||||||
|
code={sum.code}
|
||||||
|
decimals={sum.decimals}
|
||||||
|
symbol={sum.symbol}
|
||||||
|
{tone}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import {
|
||||||
|
periodPath,
|
||||||
|
usePeriodPath,
|
||||||
|
} from "#lib/components/budgets/period-path.svelte.js";
|
||||||
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
|
import { formatDate } from "#lib/format.js";
|
||||||
|
import { period } from "#lib/period.svelte.js";
|
||||||
|
import { searchAll } from "./data.js";
|
||||||
|
import PeriodList from "./PeriodList.svelte";
|
||||||
|
import { windowOf } from "./sums.js";
|
||||||
|
|
||||||
|
const BASE = "/categories/list/no-category";
|
||||||
|
|
||||||
|
let { everything = false }: { everything?: boolean } = $props();
|
||||||
|
|
||||||
|
usePeriodPath(() => BASE);
|
||||||
|
|
||||||
|
const span = $derived(windowOf(period.end, period.view));
|
||||||
|
const query = createQuery(() => ({
|
||||||
|
enabled: period.ready,
|
||||||
|
queryFn: () =>
|
||||||
|
searchAll(
|
||||||
|
everything
|
||||||
|
? "has_no_category:true"
|
||||||
|
: `has_no_category:true date_after:${span.start} date_before:${span.end}`
|
||||||
|
),
|
||||||
|
queryKey: everything
|
||||||
|
? ["no-category", "all"]
|
||||||
|
: ["no-category", span.start, span.end],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const title = $derived(
|
||||||
|
everything
|
||||||
|
? "All transactions without a category"
|
||||||
|
: `Without category between ${formatDate(period.start)} and ${formatDate(period.end)}`
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[
|
||||||
|
{ href: "/categories", label: "Categories" },
|
||||||
|
{ label: "Without a category" },
|
||||||
|
]}
|
||||||
|
title="Transactions without a category"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PeriodList
|
||||||
|
groups={query.data ?? []}
|
||||||
|
href={(range) => periodPath(BASE, range.start, range.end)}
|
||||||
|
keep={(split) => !split.category_id}
|
||||||
|
loading={query.isPending}
|
||||||
|
sidebar={!everything}
|
||||||
|
{title}
|
||||||
|
>
|
||||||
|
{#snippet links()}
|
||||||
|
{#if everything}
|
||||||
|
<a class="text-brand hover:underline" href={BASE}
|
||||||
|
>Show the current period and overview</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<a class="text-brand hover:underline" href="{BASE}/all"
|
||||||
|
>Show all transactions without grouping them by date</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</PeriodList>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import type { Split } from "#lib/api/types.js";
|
||||||
|
import type { Range } from "#lib/dates.js";
|
||||||
|
import { period } from "#lib/period.svelte.js";
|
||||||
|
import type { Group } from "#lib/transactions/split.js";
|
||||||
|
import GroupsBox from "./GroupsBox.svelte";
|
||||||
|
import PeriodSidebar, { type Field } from "./PeriodSidebar.svelte";
|
||||||
|
import { byPeriod, periodRanges, rows, SIDEBAR_PERIODS } from "./sums.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
groups,
|
||||||
|
keep,
|
||||||
|
title,
|
||||||
|
href,
|
||||||
|
sidebar = true,
|
||||||
|
loading = false,
|
||||||
|
fields,
|
||||||
|
links,
|
||||||
|
}: {
|
||||||
|
groups: Group[];
|
||||||
|
keep: (split: Split) => boolean;
|
||||||
|
title: string;
|
||||||
|
href: (range: Range) => string;
|
||||||
|
sidebar?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
fields?: Field[];
|
||||||
|
links?: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const own = $derived(rows(groups, keep));
|
||||||
|
const visible = $derived.by(() => {
|
||||||
|
if (!sidebar) {
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
const ids = new Set(
|
||||||
|
own
|
||||||
|
.filter((r) => {
|
||||||
|
const day = r.split.date.slice(0, 10);
|
||||||
|
return day >= period.start && day <= period.end;
|
||||||
|
})
|
||||||
|
.map((r) => r.group.id)
|
||||||
|
);
|
||||||
|
return groups.filter((g) => ids.has(g.id));
|
||||||
|
});
|
||||||
|
const periods = $derived(
|
||||||
|
byPeriod(own, periodRanges(period.end, period.view, SIDEBAR_PERIODS))
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid items-start gap-4 {sidebar
|
||||||
|
? "2xl:grid-cols-[minmax(0,1fr)_15rem]"
|
||||||
|
: ""}"
|
||||||
|
>
|
||||||
|
<GroupsBox groups={visible} {links} {loading} {title} />
|
||||||
|
{#if sidebar}
|
||||||
|
<PeriodSidebar {fields} {href} {periods} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Range } from "#lib/dates.js";
|
||||||
|
import { period, periodLabel } from "#lib/period.svelte.js";
|
||||||
|
import MoneyList from "./MoneyList.svelte";
|
||||||
|
import { list, type PeriodTotals } from "./sums.js";
|
||||||
|
|
||||||
|
export type Field = "spent" | "earned" | "transferred";
|
||||||
|
|
||||||
|
const LABELS: Record<Field, string> = {
|
||||||
|
earned: "Earned",
|
||||||
|
spent: "Spent",
|
||||||
|
transferred: "Transferred",
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
periods,
|
||||||
|
href,
|
||||||
|
fields = ["spent", "earned", "transferred"],
|
||||||
|
}: {
|
||||||
|
periods: PeriodTotals[];
|
||||||
|
href: (range: Range) => string;
|
||||||
|
fields?: Field[];
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
aria-label="Periods"
|
||||||
|
class="grid grid-cols-[repeat(auto-fill,minmax(12rem,1fr))] gap-2 2xl:grid-cols-1"
|
||||||
|
>
|
||||||
|
{#each periods as item (item.range.start)}
|
||||||
|
{@const current =
|
||||||
|
item.range.start === period.start && item.range.end === period.end}
|
||||||
|
<a
|
||||||
|
aria-current={current ? "page" : undefined}
|
||||||
|
class="cladd-surface cladd-outline flex flex-col gap-0.5 rounded-cladd-md px-3 py-2 text-[12px] {current
|
||||||
|
? "bg-row-cursor outline-2 outline-brand"
|
||||||
|
: "hover:bg-row-hover"}"
|
||||||
|
href={href(item.range)}
|
||||||
|
>
|
||||||
|
<span class="font-semibold text-cladd-fg"
|
||||||
|
>{periodLabel(item.range, period.view)}</span
|
||||||
|
>
|
||||||
|
{#if item.totals.count === 0}
|
||||||
|
<span class="text-cladd-fg-softer">No transactions</span>
|
||||||
|
{:else}
|
||||||
|
<span class="flex justify-between gap-2">
|
||||||
|
<span class="text-cladd-fg-soft">Transactions</span>
|
||||||
|
<span class="num text-cladd-fg">{item.totals.count}</span>
|
||||||
|
</span>
|
||||||
|
{#each fields as field (field)}
|
||||||
|
{@const sums = list(item.totals[field])}
|
||||||
|
{#if sums.length > 0}
|
||||||
|
<span class="flex justify-between gap-2">
|
||||||
|
<span class="text-cladd-fg-soft">{LABELS[field]}</span>
|
||||||
|
<MoneyList
|
||||||
|
{sums}
|
||||||
|
tone={field === "transferred" ? "transfer" : "auto"}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<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 { Category, One, TransactionGroup } from "#lib/api/types.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import {
|
||||||
|
periodPath,
|
||||||
|
usePeriodPath,
|
||||||
|
} from "#lib/components/budgets/period-path.svelte.js";
|
||||||
|
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
|
||||||
|
import StackedBars from "#lib/components/charts/StackedBars.svelte";
|
||||||
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
|
import { Button } from "#lib/components/ui/button/index.js";
|
||||||
|
import { formatDate, formatMoney } from "#lib/format.js";
|
||||||
|
import { useKeys } from "#lib/keys.svelte.js";
|
||||||
|
import { period } from "#lib/period.svelte.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import CurrencyTabs from "./CurrencyTabs.svelte";
|
||||||
|
import { currencies, FLOW_KEYS, flowRows } from "./flow.js";
|
||||||
|
import PeriodList from "./PeriodList.svelte";
|
||||||
|
import {
|
||||||
|
firstDay,
|
||||||
|
periodRanges,
|
||||||
|
rows as splitRows,
|
||||||
|
stepLabel,
|
||||||
|
stepRanges,
|
||||||
|
} from "./sums.js";
|
||||||
|
|
||||||
|
let { id, everything = false }: { id: string; everything?: boolean } =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
const base = $derived(`/categories/show/${id}`);
|
||||||
|
usePeriodPath(() => base);
|
||||||
|
|
||||||
|
const category = createQuery(() => ({
|
||||||
|
queryFn: () => api<One<Category>>(`/v1/categories/${id}`),
|
||||||
|
queryKey: ["category", id],
|
||||||
|
}));
|
||||||
|
const groups = createQuery(() => ({
|
||||||
|
queryFn: () => all<TransactionGroup>(`/v1/categories/${id}/transactions`),
|
||||||
|
queryKey: ["category-transactions", id],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const name = $derived(category.data?.data.attributes.name ?? "");
|
||||||
|
const own = $derived(
|
||||||
|
splitRows(groups.data ?? [], (s) => s.category_id === id)
|
||||||
|
);
|
||||||
|
const curs = $derived(currencies(own));
|
||||||
|
const cur = $derived(
|
||||||
|
curs.find((c) => c.code === page.url.searchParams.get("currency")) ??
|
||||||
|
curs[0]
|
||||||
|
);
|
||||||
|
const fmt = $derived((value: number) =>
|
||||||
|
formatMoney({ amount: value, decimals: cur?.decimals, symbol: cur?.symbol })
|
||||||
|
);
|
||||||
|
|
||||||
|
const periodRows = $derived(
|
||||||
|
cur
|
||||||
|
? flowRows(own, stepRanges(period.range), cur, (r) =>
|
||||||
|
r.start === r.end
|
||||||
|
? String(Number(r.start.slice(8)))
|
||||||
|
: formatDate(r.start, "short")
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
const historyRows = $derived.by(() => {
|
||||||
|
if (!cur) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const first = firstDay(own) ?? period.start;
|
||||||
|
const ranges = periodRanges(period.end, period.view, 400)
|
||||||
|
.filter((r) => r.end >= first)
|
||||||
|
.reverse();
|
||||||
|
return flowRows(
|
||||||
|
own,
|
||||||
|
ranges,
|
||||||
|
cur,
|
||||||
|
(r) => stepLabel(r, period.view),
|
||||||
|
(r) => periodPath(base, r.start, r.end)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
await api(`/v1/categories/${id}`, { method: "DELETE" });
|
||||||
|
await invalidate();
|
||||||
|
await goto("/categories");
|
||||||
|
}
|
||||||
|
|
||||||
|
useKeys(() => [
|
||||||
|
{
|
||||||
|
group: "Category",
|
||||||
|
keys: "E",
|
||||||
|
label: "Edit category",
|
||||||
|
run: () => goto(`/categories/edit/${id}`),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[
|
||||||
|
{ href: "/categories", label: "Categories" },
|
||||||
|
{ label: name || "Category" },
|
||||||
|
]}
|
||||||
|
subtitle={everything
|
||||||
|
? "All transactions"
|
||||||
|
: `Between ${formatDate(period.start)} and ${formatDate(period.end)}`}
|
||||||
|
title={name || "Category"}
|
||||||
|
>
|
||||||
|
{#snippet actions()}
|
||||||
|
<Button
|
||||||
|
href="/categories/edit/{id}"
|
||||||
|
size="sm"
|
||||||
|
title="Edit category (E)"
|
||||||
|
variant="outline"
|
||||||
|
>Edit</Button
|
||||||
|
>
|
||||||
|
<ConfirmButton onconfirm={remove}>Delete</ConfirmButton>
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="grid gap-4 {everything ? "" : "lg:grid-cols-2"}">
|
||||||
|
{#if !everything}
|
||||||
|
<Box
|
||||||
|
title="Chart for all transactions for category "{name}" between {formatDate(
|
||||||
|
period.start
|
||||||
|
)} and {formatDate(period.end)}"
|
||||||
|
>
|
||||||
|
{#snippet tools()}
|
||||||
|
<CurrencyTabs
|
||||||
|
codes={curs.map((c) => c.code)}
|
||||||
|
current={cur?.code ?? ""}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
<StackedBars
|
||||||
|
format={fmt}
|
||||||
|
height={220}
|
||||||
|
keys={FLOW_KEYS}
|
||||||
|
label="Spent and earned in this period"
|
||||||
|
rows={periodRows}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/if}
|
||||||
|
<Box title="Chart for all transactions for category "{name}"">
|
||||||
|
{#snippet tools()}
|
||||||
|
{#if everything}
|
||||||
|
<CurrencyTabs
|
||||||
|
codes={curs.map((c) => c.code)}
|
||||||
|
current={cur?.code ?? ""}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
<StackedBars
|
||||||
|
format={fmt}
|
||||||
|
height={220}
|
||||||
|
keys={FLOW_KEYS}
|
||||||
|
label="Spent and earned per period"
|
||||||
|
rows={historyRows}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PeriodList
|
||||||
|
groups={groups.data ?? []}
|
||||||
|
href={(r) => periodPath(base, r.start, r.end)}
|
||||||
|
keep={(s) => s.category_id === id}
|
||||||
|
loading={groups.isPending}
|
||||||
|
sidebar={!everything}
|
||||||
|
title="Transactions"
|
||||||
|
>
|
||||||
|
{#snippet links()}
|
||||||
|
{#if everything}
|
||||||
|
<a class="text-brand hover:underline" href={base}
|
||||||
|
>Show the current period and overview</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<a class="text-brand hover:underline" href="{base}/all"
|
||||||
|
>Show all transactions without grouping them by date</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</PeriodList>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import { all } from "#lib/api/lists.js";
|
||||||
|
import type {
|
||||||
|
Category,
|
||||||
|
Currency,
|
||||||
|
InsightTotalEntry,
|
||||||
|
Item,
|
||||||
|
Page,
|
||||||
|
TransactionGroup,
|
||||||
|
} from "#lib/api/types.js";
|
||||||
|
import type { Range } from "#lib/dates.js";
|
||||||
|
import { toNumber } from "#lib/format.js";
|
||||||
|
import { add, fromEntries, list, type Sum, type Sums } from "./sums.js";
|
||||||
|
|
||||||
|
export interface CategoryRow {
|
||||||
|
earned: Sum[];
|
||||||
|
item: Item<Category>;
|
||||||
|
spent: Sum[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryPage {
|
||||||
|
earned: Sum[];
|
||||||
|
rows: CategoryRow[];
|
||||||
|
spent: Sum[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function insightSums(
|
||||||
|
entries: InsightTotalEntry[],
|
||||||
|
currencies: Item<Currency>[]
|
||||||
|
): Sum[] {
|
||||||
|
const sums: Sums = {};
|
||||||
|
for (const entry of entries) {
|
||||||
|
const cur = currencies.find(
|
||||||
|
(c) => c.attributes.code === entry.currency_code
|
||||||
|
)?.attributes;
|
||||||
|
add(
|
||||||
|
sums,
|
||||||
|
{
|
||||||
|
code: entry.currency_code ?? "",
|
||||||
|
decimals: cur?.decimal_places ?? 2,
|
||||||
|
symbol: cur?.symbol ?? entry.currency_code ?? "",
|
||||||
|
},
|
||||||
|
toNumber(entry.difference)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return list(sums);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCategoryPage(
|
||||||
|
range: Range,
|
||||||
|
currencies: Item<Currency>[]
|
||||||
|
): Promise<CategoryPage> {
|
||||||
|
const query = { end: range.end, start: range.start };
|
||||||
|
const [items, spent, earned] = await Promise.all([
|
||||||
|
all<Category>("/v1/categories", query),
|
||||||
|
api<InsightTotalEntry[]>("/v1/insight/expense/no-category", { query }),
|
||||||
|
api<InsightTotalEntry[]>("/v1/insight/income/no-category", { query }),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
earned: insightSums(earned, currencies),
|
||||||
|
rows: items.map((item) => ({
|
||||||
|
earned: fromEntries(item.attributes.earned),
|
||||||
|
item,
|
||||||
|
spent: fromEntries(item.attributes.spent),
|
||||||
|
})),
|
||||||
|
spent: insightSums(spent, currencies),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEARCH_LIMIT = 500;
|
||||||
|
|
||||||
|
export async function searchAll(query: string) {
|
||||||
|
const first = await api<Page<TransactionGroup>>("/v1/search/transactions", {
|
||||||
|
query: { limit: SEARCH_LIMIT, page: 1, query },
|
||||||
|
});
|
||||||
|
const pages = first.meta.pagination?.total_pages ?? 1;
|
||||||
|
const rest = await Promise.all(
|
||||||
|
Array.from({ length: Math.max(0, pages - 1) }, (_, i) =>
|
||||||
|
api<Page<TransactionGroup>>("/v1/search/transactions", {
|
||||||
|
query: { limit: SEARCH_LIMIT, page: i + 2, query },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const seen = new Map<string, Item<TransactionGroup>>();
|
||||||
|
for (const group of [first, ...rest].flatMap((page) => page.data)) {
|
||||||
|
seen.set(group.id, group);
|
||||||
|
}
|
||||||
|
return [...seen.values()];
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
type StackKey,
|
||||||
|
type StackRow,
|
||||||
|
tones,
|
||||||
|
} from "#lib/components/charts/chart.js";
|
||||||
|
import type { Range } from "#lib/dates.js";
|
||||||
|
import { formatMoney, toNumber } from "#lib/format.js";
|
||||||
|
import { type Cur, curOfSplit, inRange, type Row } from "./sums.js";
|
||||||
|
|
||||||
|
export const FLOW_KEYS: StackKey[] = [
|
||||||
|
{ color: tones.income, key: "earned", label: "Earned" },
|
||||||
|
{ color: tones.expense, key: "spent", label: "Spent" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function currencies(items: Row[]): Cur[] {
|
||||||
|
const found = new Map<string, Cur>();
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const { split } of items) {
|
||||||
|
const cur = curOfSplit(split);
|
||||||
|
found.set(cur.code, cur);
|
||||||
|
counts.set(cur.code, (counts.get(cur.code) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return [...found.values()].sort(
|
||||||
|
(a, b) => (counts.get(b.code) ?? 0) - (counts.get(a.code) ?? 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flowRows(
|
||||||
|
items: Row[],
|
||||||
|
ranges: Range[],
|
||||||
|
cur: Cur,
|
||||||
|
label: (range: Range) => string,
|
||||||
|
href?: (range: Range) => string
|
||||||
|
): StackRow[] {
|
||||||
|
const own = items.filter((row) => row.split.currency_code === cur.code);
|
||||||
|
const format = (value: number) =>
|
||||||
|
formatMoney({ amount: value, decimals: cur.decimals, symbol: cur.symbol });
|
||||||
|
return ranges.map((range) => {
|
||||||
|
let spent = 0;
|
||||||
|
let earned = 0;
|
||||||
|
for (const { split } of inRange(own, range)) {
|
||||||
|
if (split.type === "withdrawal") {
|
||||||
|
spent -= toNumber(split.amount);
|
||||||
|
} else if (split.type === "deposit") {
|
||||||
|
earned += toNumber(split.amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
format,
|
||||||
|
href: href?.(range),
|
||||||
|
key: range.start,
|
||||||
|
label: label(range),
|
||||||
|
values: { earned, spent },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { ApiError } from "#lib/api/client.js";
|
||||||
|
|
||||||
|
export const SELECT_CLASS =
|
||||||
|
"cladd-cut cladd-hoverable cladd-focusable-input h-cladd-md w-full rounded-cladd-md px-2 text-cladd-xs font-medium text-cladd-fg";
|
||||||
|
|
||||||
|
export function fieldErrors(failure: unknown): Record<string, string> {
|
||||||
|
if (!(failure instanceof ApiError)) {
|
||||||
|
return {
|
||||||
|
form: failure instanceof Error ? failure.message : String(failure),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(failure.errors)) {
|
||||||
|
errors[key] = value[0] ?? "";
|
||||||
|
}
|
||||||
|
if (Object.keys(errors).length === 0) {
|
||||||
|
errors.form = failure.message;
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import type { Item, Split, TransactionGroup } from "#lib/api/types.js";
|
||||||
|
import {
|
||||||
|
daysBetween,
|
||||||
|
isDynamic,
|
||||||
|
periodOf,
|
||||||
|
type Range,
|
||||||
|
shiftPeriod,
|
||||||
|
type ViewRange,
|
||||||
|
} from "#lib/dates.js";
|
||||||
|
import { currentLocale, formatDate, toNumber } from "#lib/format.js";
|
||||||
|
|
||||||
|
export interface Cur {
|
||||||
|
code: string;
|
||||||
|
decimals: number;
|
||||||
|
symbol: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Sum extends Cur {
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Sums = Record<string, Sum>;
|
||||||
|
|
||||||
|
export interface Entry {
|
||||||
|
currency_code?: string;
|
||||||
|
currency_decimal_places?: number;
|
||||||
|
currency_symbol?: string;
|
||||||
|
sum?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Row {
|
||||||
|
group: Item<TransactionGroup>;
|
||||||
|
split: Split;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Totals {
|
||||||
|
count: number;
|
||||||
|
earned: Sums;
|
||||||
|
spent: Sums;
|
||||||
|
transferred: Sums;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeriodTotals {
|
||||||
|
range: Range;
|
||||||
|
totals: Totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Bar {
|
||||||
|
amount: number;
|
||||||
|
href?: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Breakdown {
|
||||||
|
bars: Bar[];
|
||||||
|
cur: Cur;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const curOfSplit = (split: Split): Cur => ({
|
||||||
|
code: split.currency_code ?? "",
|
||||||
|
decimals: split.currency_decimal_places ?? 2,
|
||||||
|
symbol: split.currency_symbol ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const curOfEntry = (entry: Entry): Cur => ({
|
||||||
|
code: entry.currency_code ?? "",
|
||||||
|
decimals: entry.currency_decimal_places ?? 2,
|
||||||
|
symbol: entry.currency_symbol ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function add(sums: Sums, cur: Cur, value: number) {
|
||||||
|
const current = sums[cur.code] ?? { ...cur, amount: 0 };
|
||||||
|
current.amount += value;
|
||||||
|
sums[cur.code] = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const list = (sums: Sums): Sum[] =>
|
||||||
|
Object.values(sums)
|
||||||
|
.filter((sum) => Math.abs(sum.amount) > 1e-9)
|
||||||
|
.sort((a, b) => Math.abs(b.amount) - Math.abs(a.amount));
|
||||||
|
|
||||||
|
export function fromEntries(entries: Entry[] | null | undefined): Sum[] {
|
||||||
|
const sums: Sums = {};
|
||||||
|
for (const entry of entries ?? []) {
|
||||||
|
add(sums, curOfEntry(entry), toNumber(entry.sum));
|
||||||
|
}
|
||||||
|
return list(sums);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const amountIn = (sums: Sum[], code: string) =>
|
||||||
|
sums.find((sum) => sum.code === code)?.amount ?? 0;
|
||||||
|
|
||||||
|
export function rows(
|
||||||
|
groups: Item<TransactionGroup>[],
|
||||||
|
keep: (split: Split) => boolean = () => true
|
||||||
|
): Row[] {
|
||||||
|
return groups.flatMap((group) =>
|
||||||
|
group.attributes.transactions
|
||||||
|
.filter(keep)
|
||||||
|
.map((split) => ({ group, split }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dayOf = (row: Row) => row.split.date.slice(0, 10);
|
||||||
|
|
||||||
|
export function totals(items: Row[]): Totals {
|
||||||
|
const result: Totals = { count: 0, earned: {}, spent: {}, transferred: {} };
|
||||||
|
const groups = new Set<string>();
|
||||||
|
for (const { group, split } of items) {
|
||||||
|
groups.add(group.id);
|
||||||
|
const amount = toNumber(split.amount);
|
||||||
|
const cur = curOfSplit(split);
|
||||||
|
if (split.type === "withdrawal") {
|
||||||
|
add(result.spent, cur, -amount);
|
||||||
|
} else if (split.type === "deposit") {
|
||||||
|
add(result.earned, cur, amount);
|
||||||
|
} else if (split.type === "transfer") {
|
||||||
|
add(result.transferred, cur, amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.count = groups.size;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inRange(items: Row[], range: Range): Row[] {
|
||||||
|
return items.filter((row) => {
|
||||||
|
const day = dayOf(row);
|
||||||
|
return day >= range.start && day <= range.end;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function periodRanges(
|
||||||
|
end: string,
|
||||||
|
view: ViewRange,
|
||||||
|
count: number
|
||||||
|
): Range[] {
|
||||||
|
const fixed = isDynamic(view) ? "1M" : view;
|
||||||
|
let range = periodOf(end, fixed);
|
||||||
|
const out = [range];
|
||||||
|
for (let i = 1; i < count; i += 1) {
|
||||||
|
range = shiftPeriod(range, fixed, -1);
|
||||||
|
out.push(range);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function byPeriod(items: Row[], ranges: Range[]): PeriodTotals[] {
|
||||||
|
return ranges.map((range) => ({
|
||||||
|
range,
|
||||||
|
totals: totals(inRange(items, range)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAILY_MAX = 31;
|
||||||
|
const WEEKLY_MAX = 120;
|
||||||
|
const MONTHLY_MAX = 1100;
|
||||||
|
|
||||||
|
export function stepRanges(range: Range): Range[] {
|
||||||
|
const days = daysBetween(range.start, range.end) + 1;
|
||||||
|
let view: ViewRange = "1Y";
|
||||||
|
if (days <= DAILY_MAX) {
|
||||||
|
view = "1D";
|
||||||
|
} else if (days <= WEEKLY_MAX) {
|
||||||
|
view = "1W";
|
||||||
|
} else if (days <= MONTHLY_MAX) {
|
||||||
|
view = "1M";
|
||||||
|
}
|
||||||
|
const out: Range[] = [];
|
||||||
|
let step = periodOf(range.start, view);
|
||||||
|
while (step.start <= range.end) {
|
||||||
|
out.push(step);
|
||||||
|
step = shiftPeriod(step, view, 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firstDay(items: Row[]): string | null {
|
||||||
|
let first: string | null = null;
|
||||||
|
for (const row of items) {
|
||||||
|
const day = dayOf(row);
|
||||||
|
if (!first || day < first) {
|
||||||
|
first = day;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function breakdown(
|
||||||
|
items: Row[],
|
||||||
|
pick: (split: Split) => { href?: string; label: string }
|
||||||
|
): Breakdown[] {
|
||||||
|
const byCur = new Map<
|
||||||
|
string,
|
||||||
|
{ cur: Cur; bars: Map<string, Bar>; total: number }
|
||||||
|
>();
|
||||||
|
for (const { split } of items) {
|
||||||
|
const cur = curOfSplit(split);
|
||||||
|
const amount = toNumber(split.amount);
|
||||||
|
const entry = byCur.get(cur.code) ?? { bars: new Map(), cur, total: 0 };
|
||||||
|
const { href, label } = pick(split);
|
||||||
|
const bar = entry.bars.get(label) ?? { amount: 0, href, label };
|
||||||
|
bar.amount += amount;
|
||||||
|
entry.bars.set(label, bar);
|
||||||
|
entry.total += amount;
|
||||||
|
byCur.set(cur.code, entry);
|
||||||
|
}
|
||||||
|
return [...byCur.values()]
|
||||||
|
.map(({ cur, bars, total }) => ({
|
||||||
|
bars: [...bars.values()].sort((a, b) => b.amount - a.amount),
|
||||||
|
cur,
|
||||||
|
total,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.bars.length - a.bars.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stepLabel(range: Range, view: ViewRange): string {
|
||||||
|
const fixed = isDynamic(view) ? "1M" : view;
|
||||||
|
const month = Number(range.start.slice(5, 7));
|
||||||
|
const year = range.start.slice(0, 4);
|
||||||
|
switch (fixed) {
|
||||||
|
case "1M":
|
||||||
|
return new Intl.DateTimeFormat(currentLocale(), {
|
||||||
|
month: "short",
|
||||||
|
year: "2-digit",
|
||||||
|
}).format(new Date(Number(year), month - 1, 1));
|
||||||
|
case "3M":
|
||||||
|
return `Q${Math.ceil(month / 3)} ${year}`;
|
||||||
|
case "6M":
|
||||||
|
return `H${month <= 6 ? 1 : 2} ${year}`;
|
||||||
|
case "1Y":
|
||||||
|
return year;
|
||||||
|
default:
|
||||||
|
return formatDate(range.start, "short");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SIDEBAR_PERIODS = 12;
|
||||||
|
|
||||||
|
export function windowOf(end: string, view: ViewRange): Range {
|
||||||
|
const ranges = periodRanges(end, view, SIDEBAR_PERIODS);
|
||||||
|
return { end, start: ranges.at(-1)?.start ?? end };
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
export function setParam(name: string, value: string) {
|
||||||
|
const url = new URL(page.url.href);
|
||||||
|
if (value) {
|
||||||
|
url.searchParams.set(name, value);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete(name);
|
||||||
|
}
|
||||||
|
url.searchParams.delete("page");
|
||||||
|
return goto(url, { replace: true, reset: false, state: page.state });
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invalidate } from "#lib/api/lists.js";
|
||||||
|
import type { Item, Tag } from "#lib/api/types.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import { SELECT_CLASS } from "#lib/components/categories/form.js";
|
||||||
|
import { Button } from "#lib/components/ui/button/index.js";
|
||||||
|
import { mergeTag, sequence } from "./actions.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
clusters,
|
||||||
|
counts,
|
||||||
|
}: { clusters: Item<Tag>[][]; counts: Map<string, number> } = $props();
|
||||||
|
|
||||||
|
let targets = $state<Record<string, string>>({});
|
||||||
|
let status = $state<Record<string, string>>({});
|
||||||
|
let busy = $state(false);
|
||||||
|
|
||||||
|
const keyOf = (cluster: Item<Tag>[]) => cluster.map((t) => t.id).join(",");
|
||||||
|
|
||||||
|
function best(cluster: Item<Tag>[]) {
|
||||||
|
return [...cluster].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(counts.get(b.id) ?? 0) - (counts.get(a.id) ?? 0) ||
|
||||||
|
Number(a.attributes.tag !== a.attributes.tag.toLowerCase()) -
|
||||||
|
Number(b.attributes.tag !== b.attributes.tag.toLowerCase())
|
||||||
|
)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetOf = (cluster: Item<Tag>[]) =>
|
||||||
|
targets[keyOf(cluster)] ?? best(cluster).id;
|
||||||
|
|
||||||
|
async function merge(cluster: Item<Tag>[]) {
|
||||||
|
const key = keyOf(cluster);
|
||||||
|
const into = cluster.find((t) => t.id === targetOf(cluster));
|
||||||
|
if (!into || busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
const errors: string[] = [];
|
||||||
|
await sequence(
|
||||||
|
cluster.filter((t) => t.id !== into.id),
|
||||||
|
async (tag) => {
|
||||||
|
status[key] = `Merging ${tag.attributes.tag}`;
|
||||||
|
const outcome = await mergeTag(tag, into.attributes.tag, (o) => {
|
||||||
|
status[key] =
|
||||||
|
`Merging ${tag.attributes.tag}: ${o.done} of ${o.total}`;
|
||||||
|
}).catch((failure: unknown) => ({ errors: [String(failure)] }));
|
||||||
|
errors.push(...outcome.errors);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
status[key] =
|
||||||
|
errors.length > 0 ? `${errors.length} failed: ${errors[0]}` : "Merged";
|
||||||
|
busy = false;
|
||||||
|
await invalidate();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Box body="p-0" title="Possible duplicates">
|
||||||
|
{#if clusters.length === 0}
|
||||||
|
<p class="px-3 py-2 text-[12px] text-cladd-fg-soft">No tags look alike.</p>
|
||||||
|
{/if}
|
||||||
|
<ul class="divide-y divide-cladd-outline text-[13px]">
|
||||||
|
{#each clusters as cluster (keyOf(cluster))}
|
||||||
|
{@const key = keyOf(cluster)}
|
||||||
|
{@const target = targetOf(cluster)}
|
||||||
|
<li class="flex flex-wrap items-center gap-x-4 gap-y-1 px-3 py-2">
|
||||||
|
<span class="flex flex-wrap gap-x-3">
|
||||||
|
{#each cluster as tag (tag.id)}
|
||||||
|
<a class="hover:underline" href="/tags/show/{tag.id}">
|
||||||
|
<span class={tag.id === target ? "font-semibold" : ""}
|
||||||
|
>{tag.attributes.tag}</span
|
||||||
|
>
|
||||||
|
<span class="num text-[12px] text-cladd-fg-soft"
|
||||||
|
>{counts.get(tag.id) ?? ""}</span
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
<span class="ml-auto flex items-center gap-2">
|
||||||
|
<label class="text-[12px] text-cladd-fg-soft" for="into-{key}"
|
||||||
|
>Keep</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
class="{SELECT_CLASS} h-7 w-48"
|
||||||
|
id="into-{key}"
|
||||||
|
onchange={(event) => (targets[key] = event.currentTarget.value)}
|
||||||
|
value={target}
|
||||||
|
>
|
||||||
|
{#each cluster as tag (tag.id)}
|
||||||
|
<option value={tag.id}>{tag.attributes.tag}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
disabled={busy}
|
||||||
|
onclick={() => merge(cluster)}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Merge {cluster.length - 1} into it
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
{#if status[key]}
|
||||||
|
<span
|
||||||
|
class="basis-full text-right text-[12px] {status[key].includes(
|
||||||
|
"failed"
|
||||||
|
)
|
||||||
|
? "text-expense"
|
||||||
|
: "text-cladd-fg-soft"}"
|
||||||
|
>{status[key]}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</Box>
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invalidate } from "#lib/api/lists.js";
|
||||||
|
import type { Item, Tag } from "#lib/api/types.js";
|
||||||
|
import { fuzzyFilter } from "#lib/fuzzy.js";
|
||||||
|
import type { Outcome } from "#lib/transactions/mass.js";
|
||||||
|
import { mergeTag } from "./actions.js";
|
||||||
|
|
||||||
|
const SUGGESTIONS = 6;
|
||||||
|
|
||||||
|
let {
|
||||||
|
tag,
|
||||||
|
tags,
|
||||||
|
ondone,
|
||||||
|
}: { tag: Item<Tag>; tags: Item<Tag>[]; ondone: () => void } = $props();
|
||||||
|
|
||||||
|
let value = $state("");
|
||||||
|
let cursor = $state(0);
|
||||||
|
let busy = $state(false);
|
||||||
|
let outcome = $state<Outcome | null>(null);
|
||||||
|
let problem = $state("");
|
||||||
|
|
||||||
|
const options = $derived(
|
||||||
|
fuzzyFilter(
|
||||||
|
tags.filter((t) => t.id !== tag.id),
|
||||||
|
value,
|
||||||
|
(t) => t.attributes.tag,
|
||||||
|
SUGGESTIONS
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
function focus(input: HTMLInputElement) {
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(into: string) {
|
||||||
|
const target = into.trim();
|
||||||
|
if (!target || busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
problem = "";
|
||||||
|
try {
|
||||||
|
const result = await mergeTag(tag, target, (next) => (outcome = next));
|
||||||
|
await invalidate();
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
problem = `${result.errors.length} failed: ${result.errors[0]}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ondone();
|
||||||
|
} catch (failure) {
|
||||||
|
problem = failure instanceof Error ? failure.message : String(failure);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onkeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
cursor = Math.min(options.length - 1, cursor + 1);
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
cursor = Math.max(0, cursor - 1);
|
||||||
|
} else if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
run(options[cursor]?.attributes.tag ?? value);
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
ondone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative flex flex-wrap items-center gap-2 py-0.5">
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft">Merge into</span>
|
||||||
|
<input
|
||||||
|
aria-label="Merge {tag.attributes.tag} into"
|
||||||
|
class="cladd-cut cladd-focusable-input h-6 w-60 rounded-cladd-sm px-1.5 text-[13px]"
|
||||||
|
disabled={busy}
|
||||||
|
oninput={() => (cursor = 0)}
|
||||||
|
{onkeydown}
|
||||||
|
placeholder="tag name"
|
||||||
|
bind:value
|
||||||
|
{@attach focus}
|
||||||
|
>
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft">
|
||||||
|
{#if busy && outcome}
|
||||||
|
Moving {outcome.done} of {outcome.total} transactions
|
||||||
|
{:else if busy}
|
||||||
|
Finding transactions
|
||||||
|
{:else}
|
||||||
|
<span class="kbd">↵</span>
|
||||||
|
merge <span class="kbd">Esc</span> cancel
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if problem}
|
||||||
|
<span class="basis-full text-[12px] text-expense">{problem}</span>
|
||||||
|
{/if}
|
||||||
|
{#if !busy && options.length > 0}
|
||||||
|
<div
|
||||||
|
class="cladd-surface cladd-outline cladd-level-1 absolute top-full left-[4.6rem] z-20 mt-1 w-60 rounded-cladd-md p-1 shadow-cladd-popover"
|
||||||
|
role="listbox"
|
||||||
|
>
|
||||||
|
{#each options as option, i (option.id)}
|
||||||
|
<div
|
||||||
|
aria-selected={i === cursor}
|
||||||
|
class="cursor-default truncate rounded-cladd-sm px-2 py-1 text-[13px] {i ===
|
||||||
|
cursor
|
||||||
|
? "bg-row-cursor"
|
||||||
|
: "hover:bg-row-hover"}"
|
||||||
|
onmousedown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
run(option.attributes.tag);
|
||||||
|
}}
|
||||||
|
role="option"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
{option.attributes.tag}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import { ApiError } from "#lib/api/client.js";
|
||||||
|
import { invalidate } from "#lib/api/lists.js";
|
||||||
|
import type { Item, Tag } from "#lib/api/types.js";
|
||||||
|
import { renameTag } from "./actions.js";
|
||||||
|
import { descendants, type TagNode } from "./tree.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
node,
|
||||||
|
tags,
|
||||||
|
ondone,
|
||||||
|
}: { node: TagNode; tags: Item<Tag>[]; ondone: () => void } = $props();
|
||||||
|
|
||||||
|
let value = $state(untrack(() => node.path));
|
||||||
|
let deep = $state(true);
|
||||||
|
let busy = $state(false);
|
||||||
|
let problem = $state("");
|
||||||
|
|
||||||
|
const below = $derived(descendants(node).filter((child) => child.tag));
|
||||||
|
|
||||||
|
function focus(input: HTMLInputElement) {
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const next = value.trim();
|
||||||
|
if (!next || next === node.path) {
|
||||||
|
ondone();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const taken = tags.find(
|
||||||
|
(t) =>
|
||||||
|
t.id !== node.tag?.id &&
|
||||||
|
t.attributes.tag.toLowerCase() === next.toLowerCase()
|
||||||
|
);
|
||||||
|
if (taken) {
|
||||||
|
problem = `"${taken.attributes.tag}" already exists. To join them, use merge (m).`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
problem = "";
|
||||||
|
try {
|
||||||
|
if (node.tag) {
|
||||||
|
await renameTag(node.tag, next);
|
||||||
|
}
|
||||||
|
if (deep || !node.tag) {
|
||||||
|
await Promise.all(
|
||||||
|
below.map((child) =>
|
||||||
|
child.tag
|
||||||
|
? renameTag(child.tag, next + child.path.slice(node.path.length))
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await invalidate();
|
||||||
|
ondone();
|
||||||
|
} catch (failure) {
|
||||||
|
const text =
|
||||||
|
failure instanceof ApiError
|
||||||
|
? (Object.values(failure.errors).flat()[0] ?? failure.message)
|
||||||
|
: String(failure);
|
||||||
|
problem = `${text} To join it with an existing tag, use merge (m).`;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onkeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
save();
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
ondone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2 py-0.5">
|
||||||
|
<input
|
||||||
|
aria-label="New name for {node.path}"
|
||||||
|
class="cladd-cut cladd-focusable-input h-6 w-72 rounded-cladd-sm px-1.5 text-[13px]"
|
||||||
|
disabled={busy}
|
||||||
|
{onkeydown}
|
||||||
|
bind:value
|
||||||
|
{@attach focus}
|
||||||
|
>
|
||||||
|
{#if node.tag && below.length > 0}
|
||||||
|
<label class="flex items-center gap-1 text-[12px] text-cladd-fg-soft">
|
||||||
|
<input class="accent-brand" type="checkbox" bind:checked={deep}>
|
||||||
|
also rename {below.length} sub-tags
|
||||||
|
</label>
|
||||||
|
{:else if !node.tag}
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft"
|
||||||
|
>renames {below.length} sub-tags</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft">
|
||||||
|
{#if busy}
|
||||||
|
Renaming
|
||||||
|
{:else}
|
||||||
|
<span class="kbd">↵</span>
|
||||||
|
save <span class="kbd">Esc</span> cancel
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if problem}
|
||||||
|
<span class="basis-full text-[12px] text-expense">{problem}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
<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 { One, Tag, TransactionGroup } from "#lib/api/types.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import {
|
||||||
|
periodPath,
|
||||||
|
usePeriodPath,
|
||||||
|
} from "#lib/components/budgets/period-path.svelte.js";
|
||||||
|
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
|
||||||
|
import CurrencyTabs from "#lib/components/categories/CurrencyTabs.svelte";
|
||||||
|
import {
|
||||||
|
currencies,
|
||||||
|
FLOW_KEYS,
|
||||||
|
flowRows,
|
||||||
|
} from "#lib/components/categories/flow.js";
|
||||||
|
import MoneyList from "#lib/components/categories/MoneyList.svelte";
|
||||||
|
import PeriodList from "#lib/components/categories/PeriodList.svelte";
|
||||||
|
import {
|
||||||
|
firstDay,
|
||||||
|
inRange,
|
||||||
|
list,
|
||||||
|
periodRanges,
|
||||||
|
rows as splitRows,
|
||||||
|
stepLabel,
|
||||||
|
totals,
|
||||||
|
} from "#lib/components/categories/sums.js";
|
||||||
|
import StackedBars from "#lib/components/charts/StackedBars.svelte";
|
||||||
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
|
import { Button } from "#lib/components/ui/button/index.js";
|
||||||
|
import { formatDate, formatMoney } from "#lib/format.js";
|
||||||
|
import { useKeys } from "#lib/keys.svelte.js";
|
||||||
|
import { period } from "#lib/period.svelte.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
const ID = /^\d+$/;
|
||||||
|
|
||||||
|
let { param, everything = false }: { param: string; everything?: boolean } =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
const tags = createQuery(() => lists.tags());
|
||||||
|
const id = $derived(
|
||||||
|
ID.test(param)
|
||||||
|
? param
|
||||||
|
: tags.data?.find((t) => t.attributes.tag === param)?.id
|
||||||
|
);
|
||||||
|
const base = $derived(`/tags/show/${id ?? encodeURIComponent(param)}`);
|
||||||
|
usePeriodPath(() => base);
|
||||||
|
|
||||||
|
const tag = createQuery(() => ({
|
||||||
|
enabled: Boolean(id),
|
||||||
|
queryFn: () => api<One<Tag>>(`/v1/tags/${id}`),
|
||||||
|
queryKey: ["tag", id],
|
||||||
|
}));
|
||||||
|
const groups = createQuery(() => ({
|
||||||
|
enabled: Boolean(id),
|
||||||
|
queryFn: () =>
|
||||||
|
all<TransactionGroup>(`/v1/tags/${id}/transactions`, { type: "all" }),
|
||||||
|
queryKey: ["tag-transactions", id],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const attributes = $derived(tag.data?.data.attributes);
|
||||||
|
const name = $derived(attributes?.tag ?? param);
|
||||||
|
const keep = $derived((s: { tags?: string[] | null }) =>
|
||||||
|
(s.tags ?? []).includes(name)
|
||||||
|
);
|
||||||
|
const own = $derived(splitRows(groups.data ?? [], keep));
|
||||||
|
const scoped = $derived(everything ? own : inRange(own, period.range));
|
||||||
|
const sums = $derived(totals(scoped));
|
||||||
|
const net = $derived.by(() => {
|
||||||
|
const result = { ...sums.earned };
|
||||||
|
for (const [code, sum] of Object.entries(sums.spent)) {
|
||||||
|
result[code] = {
|
||||||
|
...sum,
|
||||||
|
amount: (result[code]?.amount ?? 0) + sum.amount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return list(result);
|
||||||
|
});
|
||||||
|
const children = $derived(
|
||||||
|
(tags.data ?? []).filter((t) => t.attributes.tag.startsWith(`${name}/`))
|
||||||
|
);
|
||||||
|
const parentPath = $derived(
|
||||||
|
name.includes("/") ? name.slice(0, name.lastIndexOf("/")) : ""
|
||||||
|
);
|
||||||
|
const parent = $derived(
|
||||||
|
tags.data?.find((t) => t.attributes.tag === parentPath)
|
||||||
|
);
|
||||||
|
|
||||||
|
const curs = $derived(currencies(own));
|
||||||
|
const cur = $derived(
|
||||||
|
curs.find((c) => c.code === page.url.searchParams.get("currency")) ??
|
||||||
|
curs[0]
|
||||||
|
);
|
||||||
|
const fmt = $derived((value: number) =>
|
||||||
|
formatMoney({ amount: value, decimals: cur?.decimals, symbol: cur?.symbol })
|
||||||
|
);
|
||||||
|
const history = $derived.by(() => {
|
||||||
|
if (!cur) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const first = firstDay(own) ?? period.start;
|
||||||
|
const ranges = periodRanges(period.end, period.view, 400)
|
||||||
|
.filter((r) => r.end >= first)
|
||||||
|
.reverse();
|
||||||
|
return flowRows(
|
||||||
|
own,
|
||||||
|
ranges,
|
||||||
|
cur,
|
||||||
|
(r) => stepLabel(r, period.view),
|
||||||
|
(r) => periodPath(base, r.start, r.end)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await api(`/v1/tags/${id}`, { method: "DELETE" });
|
||||||
|
await invalidate();
|
||||||
|
await goto("/tags");
|
||||||
|
}
|
||||||
|
|
||||||
|
useKeys(() => [
|
||||||
|
{
|
||||||
|
group: "Tag",
|
||||||
|
keys: "E",
|
||||||
|
label: "Edit tag",
|
||||||
|
run: () => id && goto(`/tags/edit/${id}`),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[
|
||||||
|
{ href: "/tags", label: "Tags" },
|
||||||
|
...(parent
|
||||||
|
? [{ href: `/tags/show/${parent.id}`, label: parent.attributes.tag }]
|
||||||
|
: []),
|
||||||
|
{ label: name },
|
||||||
|
]}
|
||||||
|
subtitle={everything
|
||||||
|
? "All transactions"
|
||||||
|
: `Between ${formatDate(period.start)} and ${formatDate(period.end)}`}
|
||||||
|
title={name}
|
||||||
|
>
|
||||||
|
{#snippet actions()}
|
||||||
|
<Button
|
||||||
|
href="/tags/edit/{id}"
|
||||||
|
size="sm"
|
||||||
|
title="Edit tag (E)"
|
||||||
|
variant="outline"
|
||||||
|
>Edit</Button
|
||||||
|
>
|
||||||
|
<ConfirmButton onconfirm={remove}>Delete</ConfirmButton>
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{#if tag.isError}
|
||||||
|
<p class="pb-3 text-expense">{tag.error.message}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="grid items-start gap-4 lg:grid-cols-2">
|
||||||
|
<Box body="p-0" title="Meta data">
|
||||||
|
<table class="w-full text-[13px]">
|
||||||
|
<tbody>
|
||||||
|
{#if attributes?.description}
|
||||||
|
<tr class="border-cladd-outline border-b">
|
||||||
|
<th
|
||||||
|
class="w-1/2 px-3 py-1.5 text-left font-normal text-cladd-fg-soft"
|
||||||
|
>
|
||||||
|
Description
|
||||||
|
</th>
|
||||||
|
<td class="px-3 py-1.5">{attributes.description}</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{#if attributes?.date}
|
||||||
|
<tr class="border-cladd-outline border-b">
|
||||||
|
<th class="px-3 py-1.5 text-left font-normal text-cladd-fg-soft">
|
||||||
|
Date
|
||||||
|
</th>
|
||||||
|
<td class="num px-3 py-1.5">{formatDate(attributes.date)}</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{#each [
|
||||||
|
{ label: "Sum (excluding transfers)", sums: net },
|
||||||
|
{ label: "Sum of withdrawals", sums: list(sums.spent) },
|
||||||
|
{ label: "Sum of deposits", sums: list(sums.earned) },
|
||||||
|
{ label: "Sum of transfers", sums: list(sums.transferred) },
|
||||||
|
] as row (row.label)}
|
||||||
|
{#if row.sums.length > 0}
|
||||||
|
<tr class="border-cladd-outline border-b">
|
||||||
|
<th
|
||||||
|
class="px-3 py-1.5 text-left font-normal text-cladd-fg-soft"
|
||||||
|
>
|
||||||
|
{row.label}
|
||||||
|
</th>
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
<MoneyList
|
||||||
|
class="!justify-start"
|
||||||
|
sums={row.sums}
|
||||||
|
tone={row.label === "Sum of transfers"
|
||||||
|
? "transfer"
|
||||||
|
: "auto"}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{#if children.length > 0}
|
||||||
|
<tr class="border-cladd-outline border-b">
|
||||||
|
<th class="px-3 py-1.5 text-left font-normal text-cladd-fg-soft">
|
||||||
|
Sub-tags
|
||||||
|
</th>
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
{#each children as child, i (child.id)}
|
||||||
|
<a
|
||||||
|
class="text-brand hover:underline"
|
||||||
|
href="/tags/show/{child.id}"
|
||||||
|
>{child.attributes.tag.slice(name.length + 1)}</a
|
||||||
|
>{i < children.length - 1 ? ", " : ""}
|
||||||
|
{/each}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{#if scoped.length === 0 && !groups.isPending}
|
||||||
|
<tr>
|
||||||
|
<td class="px-3 py-1.5 text-cladd-fg-soft" colspan="2">
|
||||||
|
No transactions in this range.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Box>
|
||||||
|
<Box title="Chart for all transactions for tag "{name}"">
|
||||||
|
{#snippet tools()}
|
||||||
|
<CurrencyTabs
|
||||||
|
codes={curs.map((c) => c.code)}
|
||||||
|
current={cur?.code ?? ""}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
{#if history.length > 0}
|
||||||
|
<StackedBars
|
||||||
|
format={fmt}
|
||||||
|
height={200}
|
||||||
|
keys={FLOW_KEYS}
|
||||||
|
label="Spent and earned per period"
|
||||||
|
rows={history}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<p class="text-[12px] text-cladd-fg-soft">No transactions yet.</p>
|
||||||
|
{/if}
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PeriodList
|
||||||
|
groups={groups.data ?? []}
|
||||||
|
href={(r) => periodPath(base, r.start, r.end)}
|
||||||
|
{keep}
|
||||||
|
loading={groups.isPending}
|
||||||
|
sidebar={!everything}
|
||||||
|
title="Transactions"
|
||||||
|
>
|
||||||
|
{#snippet links()}
|
||||||
|
{#if everything}
|
||||||
|
<a class="text-brand hover:underline" href={base}
|
||||||
|
>Show the current period and overview</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<a class="text-brand hover:underline" href="{base}/all"
|
||||||
|
>Show all transactions without grouping them by date</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</PeriodList>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import { invalidate } from "#lib/api/lists.js";
|
||||||
|
import type { Item, One, Tag } from "#lib/api/types.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
|
||||||
|
import Field from "#lib/components/categories/Field.svelte";
|
||||||
|
import { fieldErrors } from "#lib/components/categories/form.js";
|
||||||
|
import { Button } from "#lib/components/ui/button/index.js";
|
||||||
|
import { Input } from "#lib/components/ui/input/index.js";
|
||||||
|
import { Textarea } from "#lib/components/ui/textarea/index.js";
|
||||||
|
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
|
let { tag }: { tag?: Item<Tag> } = $props();
|
||||||
|
|
||||||
|
const initial = untrack(() => tag)?.attributes;
|
||||||
|
let name = $state(initial?.tag ?? "");
|
||||||
|
let date = $state(initial?.date ?? "");
|
||||||
|
let description = $state(initial?.description ?? "");
|
||||||
|
let errors = $state<Record<string, string>>({});
|
||||||
|
let busy = $state(false);
|
||||||
|
let armed = $state(false);
|
||||||
|
|
||||||
|
async function save(event?: Event) {
|
||||||
|
event?.preventDefault();
|
||||||
|
if (busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
errors = {};
|
||||||
|
try {
|
||||||
|
const saved = await api<One<Tag>>(
|
||||||
|
tag ? `/v1/tags/${tag.id}` : "/v1/tags",
|
||||||
|
{
|
||||||
|
body: {
|
||||||
|
date: date || null,
|
||||||
|
description: description || null,
|
||||||
|
tag: name,
|
||||||
|
},
|
||||||
|
method: tag ? "PUT" : "POST",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await invalidate();
|
||||||
|
await goto(`/tags/show/${saved.data.id}`);
|
||||||
|
} catch (failure) {
|
||||||
|
errors = fieldErrors(failure);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (!tag) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await api(`/v1/tags/${tag.id}`, { method: "DELETE" });
|
||||||
|
await invalidate();
|
||||||
|
await goto("/tags");
|
||||||
|
}
|
||||||
|
|
||||||
|
useKeys(() => [
|
||||||
|
{
|
||||||
|
group: "Form",
|
||||||
|
input: true,
|
||||||
|
keys: "mod+Enter",
|
||||||
|
label: tag ? "Update tag" : "Store new tag",
|
||||||
|
run: () => save(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form class="flex flex-col gap-4" onsubmit={save}>
|
||||||
|
<div class="grid gap-4 lg:grid-cols-[5fr_7fr]">
|
||||||
|
<Box title="Mandatory fields">
|
||||||
|
<Field id="tag" issue={errors.tag} label="Tag">
|
||||||
|
<Input autofocus id="tag" required bind:value={name} />
|
||||||
|
</Field>
|
||||||
|
</Box>
|
||||||
|
<Box title="Optional fields">
|
||||||
|
<Field id="date" issue={errors.date} label="Date">
|
||||||
|
<Input class="num w-44" id="date" type="date" bind:value={date} />
|
||||||
|
</Field>
|
||||||
|
<Field id="description" issue={errors.description} label="Description">
|
||||||
|
<Textarea id="description" rows={3} bind:value={description} />
|
||||||
|
</Field>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Button disabled={busy} type="submit">
|
||||||
|
{tag ? "Update tag" : "Store new tag"}
|
||||||
|
</Button>
|
||||||
|
<span class="flex gap-1 text-[12px] text-cladd-fg-soft">
|
||||||
|
{#each prettyKeys("mod+Enter") as key (key)}
|
||||||
|
<span class="kbd">{key}</span>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
{#if busy}
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft">Saving</span>
|
||||||
|
{:else if errors.form}
|
||||||
|
<span class="text-[12px] text-expense">{errors.form}</span>
|
||||||
|
{/if}
|
||||||
|
{#if tag}
|
||||||
|
<span class="ml-auto text-[12px] text-cladd-fg-soft">
|
||||||
|
{#if armed}
|
||||||
|
Its transactions stay, without this tag.
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<ConfirmButton onconfirm={remove} bind:armed>Delete tag</ConfirmButton>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import { all } from "#lib/api/lists.js";
|
||||||
|
import type { Item, Page, Tag, TransactionGroup } from "#lib/api/types.js";
|
||||||
|
import {
|
||||||
|
type Outcome,
|
||||||
|
type Targets,
|
||||||
|
updateGroups,
|
||||||
|
} from "#lib/transactions/mass.js";
|
||||||
|
|
||||||
|
const lower = (tag: Item<Tag>) => tag.attributes.tag.toLowerCase();
|
||||||
|
|
||||||
|
const tagged = (groups: Item<TransactionGroup>[], name: string) =>
|
||||||
|
groups.filter((group) =>
|
||||||
|
group.attributes.transactions.some((split) => split.tags?.includes(name))
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function renameTag(tag: Item<Tag>, next: string) {
|
||||||
|
await api(`/v1/tags/${tag.id}`, { body: { tag: next }, method: "PUT" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTag(tag: Item<Tag>) {
|
||||||
|
await api(`/v1/tags/${tag.id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mergeTag(
|
||||||
|
from: Item<Tag>,
|
||||||
|
into: string,
|
||||||
|
progress: (outcome: Outcome) => void
|
||||||
|
): Promise<Outcome> {
|
||||||
|
let name = from.attributes.tag;
|
||||||
|
if (name === into) {
|
||||||
|
return { done: 0, errors: [], total: 0 };
|
||||||
|
}
|
||||||
|
if (name.toLowerCase() === into.toLowerCase()) {
|
||||||
|
name = `${name} (merging into ${into})`;
|
||||||
|
await renameTag(from, name);
|
||||||
|
}
|
||||||
|
const groups = tagged(
|
||||||
|
await all<TransactionGroup>(`/v1/tags/${from.id}/transactions`, {
|
||||||
|
type: "all",
|
||||||
|
}),
|
||||||
|
name
|
||||||
|
);
|
||||||
|
const targets: Targets = new Map(groups.map((group) => [group.id, null]));
|
||||||
|
const outcome = await updateGroups(
|
||||||
|
targets,
|
||||||
|
(split) => {
|
||||||
|
const tags = split.tags ?? [];
|
||||||
|
if (!tags.includes(name)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tags: [...new Set([...tags.filter((tag) => tag !== name), into])],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
progress
|
||||||
|
);
|
||||||
|
if (outcome.errors.length === 0) {
|
||||||
|
await deleteTag(from);
|
||||||
|
}
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function usage(tags: Item<Tag>[]): Promise<Map<string, number>> {
|
||||||
|
const keys = tags.map(lower);
|
||||||
|
const clash = new Set(keys.filter((key, i) => keys.indexOf(key) !== i));
|
||||||
|
const plain = tags.filter((tag) => !clash.has(lower(tag)));
|
||||||
|
const pages = await Promise.all(
|
||||||
|
plain.map((tag) =>
|
||||||
|
api<Page<TransactionGroup>>(`/v1/tags/${tag.id}/transactions`, {
|
||||||
|
query: { limit: 1, type: "all" },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const counts = new Map(
|
||||||
|
plain.map((tag, i) => [tag.id, pages[i].meta.pagination?.total ?? 0])
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
[...clash].map(async (key) => {
|
||||||
|
const variants = tags.filter((tag) => lower(tag) === key);
|
||||||
|
const groups = await all<TransactionGroup>(
|
||||||
|
`/v1/tags/${variants[0].id}/transactions`,
|
||||||
|
{ type: "all" }
|
||||||
|
);
|
||||||
|
for (const variant of variants) {
|
||||||
|
counts.set(variant.id, tagged(groups, variant.attributes.tag).length);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sequence<T>(items: T[], run: (item: T) => Promise<void>) {
|
||||||
|
return items.reduce<Promise<void>>(
|
||||||
|
(previous, item) => previous.then(() => run(item)),
|
||||||
|
Promise.resolve()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import type { Item, Tag } from "#lib/api/types.js";
|
||||||
|
import {
|
||||||
|
add,
|
||||||
|
curOfSplit,
|
||||||
|
type Row,
|
||||||
|
type Sums,
|
||||||
|
} from "#lib/components/categories/sums.js";
|
||||||
|
import { toNumber } from "#lib/format.js";
|
||||||
|
|
||||||
|
export interface TagNode {
|
||||||
|
children: TagNode[];
|
||||||
|
depth: number;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
tag?: Item<Tag>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Stats {
|
||||||
|
count: number;
|
||||||
|
earned: Sums;
|
||||||
|
spent: Sums;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEPARATOR = "/";
|
||||||
|
const TOKEN = /[-_/.\s]+/;
|
||||||
|
const DIACRITICS = /\p{Diacritic}/gu;
|
||||||
|
const NOT_WORD = /[^\p{L}\p{N}/]+/gu;
|
||||||
|
const NUMERIC = /^\d+$/;
|
||||||
|
const SHORT_TOKEN = 2;
|
||||||
|
const NEAR_ONE = 5;
|
||||||
|
const NEAR_TWO = 10;
|
||||||
|
|
||||||
|
const byName = (a: TagNode, b: TagNode) =>
|
||||||
|
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) ||
|
||||||
|
a.name.localeCompare(b.name);
|
||||||
|
|
||||||
|
export function buildTree(tags: Item<Tag>[]): TagNode[] {
|
||||||
|
const nodes = new Map<string, TagNode>();
|
||||||
|
const roots: TagNode[] = [];
|
||||||
|
const ensure = (path: string): TagNode => {
|
||||||
|
const found = nodes.get(path);
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
const cut = path.lastIndexOf(SEPARATOR);
|
||||||
|
const node: TagNode = {
|
||||||
|
children: [],
|
||||||
|
depth: 0,
|
||||||
|
name: cut < 0 ? path : path.slice(cut + 1),
|
||||||
|
path,
|
||||||
|
};
|
||||||
|
nodes.set(path, node);
|
||||||
|
if (cut > 0) {
|
||||||
|
const parent = ensure(path.slice(0, cut));
|
||||||
|
node.depth = parent.depth + 1;
|
||||||
|
parent.children.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
for (const tag of tags) {
|
||||||
|
ensure(tag.attributes.tag).tag = tag;
|
||||||
|
}
|
||||||
|
const sortDeep = (list: TagNode[]) => {
|
||||||
|
list.sort(byName);
|
||||||
|
for (const node of list) {
|
||||||
|
sortDeep(node.children);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
sortDeep(roots);
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flatten(roots: TagNode[]): TagNode[] {
|
||||||
|
return roots.flatMap((node) => [node, ...flatten(node.children)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flatNodes(tags: Item<Tag>[]): TagNode[] {
|
||||||
|
return tags
|
||||||
|
.map((tag) => ({
|
||||||
|
children: [],
|
||||||
|
depth: 0,
|
||||||
|
name: tag.attributes.tag,
|
||||||
|
path: tag.attributes.tag,
|
||||||
|
tag,
|
||||||
|
}))
|
||||||
|
.sort(byName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function descendants(node: TagNode): TagNode[] {
|
||||||
|
return flatten(node.children);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyStats = (): Stats => ({ count: 0, earned: {}, spent: {} });
|
||||||
|
|
||||||
|
function pathsOf(tags: string[], rollup: boolean): Set<string> {
|
||||||
|
const paths = new Set<string>();
|
||||||
|
for (const tag of tags) {
|
||||||
|
paths.add(tag);
|
||||||
|
let cut = rollup ? tag.lastIndexOf(SEPARATOR) : -1;
|
||||||
|
while (cut > 0) {
|
||||||
|
paths.add(tag.slice(0, cut));
|
||||||
|
cut = tag.lastIndexOf(SEPARATOR, cut - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tagStats(items: Row[], rollup: boolean): Map<string, Stats> {
|
||||||
|
const stats = new Map<string, Stats>();
|
||||||
|
const groups = new Map<string, Set<string>>();
|
||||||
|
for (const { group, split } of items) {
|
||||||
|
const paths = pathsOf(split.tags ?? [], rollup);
|
||||||
|
const amount = toNumber(split.amount);
|
||||||
|
const cur = curOfSplit(split);
|
||||||
|
for (const path of paths) {
|
||||||
|
const entry = stats.get(path) ?? emptyStats();
|
||||||
|
const seen = groups.get(path) ?? new Set<string>();
|
||||||
|
seen.add(group.id);
|
||||||
|
groups.set(path, seen);
|
||||||
|
entry.count = seen.size;
|
||||||
|
if (split.type === "withdrawal") {
|
||||||
|
add(entry.spent, cur, -amount);
|
||||||
|
} else if (split.type === "deposit") {
|
||||||
|
add(entry.earned, cur, amount);
|
||||||
|
}
|
||||||
|
stats.set(path, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normal = (value: string) =>
|
||||||
|
value.normalize("NFKD").replace(DIACRITICS, "").toLowerCase();
|
||||||
|
|
||||||
|
const squash = (value: string) => normal(value).replace(NOT_WORD, "");
|
||||||
|
|
||||||
|
function distance(a: string, b: string): number {
|
||||||
|
const rows = a.length + 1;
|
||||||
|
const cols = b.length + 1;
|
||||||
|
const d: number[][] = Array.from({ length: rows }, (_, row) =>
|
||||||
|
Array.from(
|
||||||
|
{ length: cols },
|
||||||
|
(__, col) => Math.max(row, col) * (row * col === 0 ? 1 : 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
for (let i = 1; i < rows; i += 1) {
|
||||||
|
for (let j = 1; j < cols; j += 1) {
|
||||||
|
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||||
|
d[i][j] = Math.min(
|
||||||
|
d[i - 1][j] + 1,
|
||||||
|
d[i][j - 1] + 1,
|
||||||
|
d[i - 1][j - 1] + cost
|
||||||
|
);
|
||||||
|
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
||||||
|
d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d[a.length][b.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function variantOnly(a: string, b: string): boolean {
|
||||||
|
const ta = normal(a).split(TOKEN);
|
||||||
|
const tb = normal(b).split(TOKEN);
|
||||||
|
if (ta.length !== tb.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const diff = ta.flatMap((token, i) =>
|
||||||
|
token === tb[i] ? [] : [[token, tb[i]]]
|
||||||
|
);
|
||||||
|
if (diff.length !== 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const [x, y] = diff[0];
|
||||||
|
return (
|
||||||
|
(NUMERIC.test(x) && NUMERIC.test(y)) ||
|
||||||
|
x.length <= SHORT_TOKEN ||
|
||||||
|
y.length <= SHORT_TOKEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function near(a: string, b: string): boolean {
|
||||||
|
const x = squash(a);
|
||||||
|
const y = squash(b);
|
||||||
|
if (x === y) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const length = Math.min(x.length, y.length);
|
||||||
|
if (length < NEAR_ONE || variantOnly(a, b)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const allowed = length >= NEAR_TWO ? 2 : 1;
|
||||||
|
return Math.abs(x.length - y.length) <= allowed && distance(x, y) <= allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function duplicates(tags: Item<Tag>[]): Item<Tag>[][] {
|
||||||
|
const parent = tags.map((_, i) => i);
|
||||||
|
const find = (i: number): number => {
|
||||||
|
let root = i;
|
||||||
|
while (parent[root] !== root) {
|
||||||
|
root = parent[root];
|
||||||
|
}
|
||||||
|
parent[i] = root;
|
||||||
|
return root;
|
||||||
|
};
|
||||||
|
for (let i = 0; i < tags.length; i += 1) {
|
||||||
|
for (let j = i + 1; j < tags.length; j += 1) {
|
||||||
|
if (near(tags[i].attributes.tag, tags[j].attributes.tag)) {
|
||||||
|
parent[find(j)] = find(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const clusters = new Map<number, Item<Tag>[]>();
|
||||||
|
tags.forEach((tag, i) => {
|
||||||
|
const root = find(i);
|
||||||
|
clusters.set(root, [...(clusters.get(root) ?? []), tag]);
|
||||||
|
});
|
||||||
|
return [...clusters.values()].filter((cluster) => cluster.length > 1);
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
<svelte:window onkeydown={keys.handle} />
|
<svelte:window onkeydown={keys.handle} />
|
||||||
|
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
{#if session.ready}
|
{#if session.ready && period.ready}
|
||||||
<Shell>
|
<Shell>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
{#snippet quick()}
|
{#snippet quick()}
|
||||||
|
|||||||
@@ -0,0 +1,508 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Plus from "@lucide/svelte/icons/plus";
|
||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import { invalidate } from "#lib/api/lists.js";
|
||||||
|
import Amount from "#lib/components/Amount.svelte";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import {
|
||||||
|
type BudgetRow,
|
||||||
|
buildPools,
|
||||||
|
buildRows,
|
||||||
|
type Line,
|
||||||
|
loadBudgetPage,
|
||||||
|
pace,
|
||||||
|
perDay,
|
||||||
|
} from "#lib/components/budgets/data.js";
|
||||||
|
import LimitCell, {
|
||||||
|
type Move,
|
||||||
|
} from "#lib/components/budgets/LimitCell.svelte";
|
||||||
|
import MeterBar from "#lib/components/budgets/MeterBar.svelte";
|
||||||
|
import PeriodNav from "#lib/components/budgets/PeriodNav.svelte";
|
||||||
|
import PoolBox from "#lib/components/budgets/PoolBox.svelte";
|
||||||
|
import {
|
||||||
|
periodPath,
|
||||||
|
usePeriodPath,
|
||||||
|
} from "#lib/components/budgets/period-path.svelte.js";
|
||||||
|
import ListMenu from "#lib/components/categories/ListMenu.svelte";
|
||||||
|
import MoneyList from "#lib/components/categories/MoneyList.svelte";
|
||||||
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
|
import { Button } from "#lib/components/ui/button/index.js";
|
||||||
|
import { formatMoney, formatRange } from "#lib/format.js";
|
||||||
|
import type { Binding } from "#lib/keys.svelte.js";
|
||||||
|
import { ListNav } from "#lib/list-nav.svelte.js";
|
||||||
|
import { period } from "#lib/period.svelte.js";
|
||||||
|
import { session } from "#lib/session.svelte.js";
|
||||||
|
import { oldUiUrl } from "#lib/spa-paths.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
|
const PERIOD_WORDS: Record<string, string> = {
|
||||||
|
daily: "a day",
|
||||||
|
half_year: "every half year",
|
||||||
|
monthly: "a month",
|
||||||
|
quarterly: "a quarter",
|
||||||
|
weekly: "a week",
|
||||||
|
yearly: "a year",
|
||||||
|
};
|
||||||
|
|
||||||
|
usePeriodPath(() => "/budgets");
|
||||||
|
|
||||||
|
const query = createQuery(() => ({
|
||||||
|
enabled: period.ready,
|
||||||
|
queryFn: () => loadBudgetPage(period.range, session.currencies),
|
||||||
|
queryKey: ["budgets-page", period.start, period.end],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const primary = $derived(session.primary?.code ?? "EUR");
|
||||||
|
const rows = $derived(
|
||||||
|
query.data
|
||||||
|
? buildRows(query.data, period.range, session.currencies, primary)
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
const pools = $derived(
|
||||||
|
query.data ? buildPools(query.data, rows, session.currencies, primary) : []
|
||||||
|
);
|
||||||
|
const inactive = $derived(
|
||||||
|
query.data?.budgets.filter((b) => !b.attributes.active) ?? []
|
||||||
|
);
|
||||||
|
const noBudgetHref = $derived(
|
||||||
|
periodPath("/budgets/list/no-budget", period.start, period.end)
|
||||||
|
);
|
||||||
|
const enabled = $derived(
|
||||||
|
session.currencies.filter((c) => c.attributes.enabled)
|
||||||
|
);
|
||||||
|
|
||||||
|
let editKey = $state<string | null>(null);
|
||||||
|
let adding = $state<{ budgetId: string; code: string } | null>(null);
|
||||||
|
let newName = $state("");
|
||||||
|
let createError = $state("");
|
||||||
|
let creating = $state(false);
|
||||||
|
let createInput = $state<HTMLInputElement>();
|
||||||
|
|
||||||
|
function showHref(row: BudgetRow) {
|
||||||
|
const limits = row.lines.filter((line) => line.limit);
|
||||||
|
return limits.length === 1
|
||||||
|
? `/budgets/show/${row.budget.id}/${limits[0].limit?.id}`
|
||||||
|
: `/budgets/show/${row.budget.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAdding(row: BudgetRow) {
|
||||||
|
const used = new Set(row.lines.map((line) => line.cur.code));
|
||||||
|
const free = enabled.find((c) => !used.has(c.attributes.code));
|
||||||
|
if (free) {
|
||||||
|
editKey = null;
|
||||||
|
adding = { budgetId: row.budget.id, code: free.attributes.code };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addingLine(row: BudgetRow): Line | null {
|
||||||
|
if (adding?.budgetId !== row.budget.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const cur = enabled.find((c) => c.attributes.code === adding?.code);
|
||||||
|
return {
|
||||||
|
amount: null,
|
||||||
|
cur: {
|
||||||
|
code: adding.code,
|
||||||
|
decimals: cur?.attributes.decimal_places ?? 2,
|
||||||
|
id: cur?.id ?? "",
|
||||||
|
symbol: cur?.attributes.symbol ?? adding.code,
|
||||||
|
},
|
||||||
|
key: `${row.budget.id}:new`,
|
||||||
|
range: period.range,
|
||||||
|
spent: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function done(index: number, move: Move) {
|
||||||
|
adding = null;
|
||||||
|
const next = rows[index + move];
|
||||||
|
if (move === 0 || !next) {
|
||||||
|
editKey = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nav.move(index + move);
|
||||||
|
editKey = next.lines[0].key;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(row: BudgetRow) {
|
||||||
|
await api(`/v1/budgets/${row.budget.id}`, { method: "DELETE" });
|
||||||
|
await invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const name = newName.trim();
|
||||||
|
if (!name || creating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
creating = true;
|
||||||
|
createError = "";
|
||||||
|
try {
|
||||||
|
await api("/v1/budgets", { body: { name }, method: "POST" });
|
||||||
|
newName = "";
|
||||||
|
await invalidate();
|
||||||
|
} catch (failure) {
|
||||||
|
createError = failure instanceof Error ? failure.message : "Failed";
|
||||||
|
} finally {
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nav: ListNav<BudgetRow> = new ListNav<BudgetRow>({
|
||||||
|
edit: (row) => {
|
||||||
|
adding = null;
|
||||||
|
editKey = row.lines[0].key;
|
||||||
|
},
|
||||||
|
extra: (): Binding[] => [
|
||||||
|
{
|
||||||
|
group: "Budgets",
|
||||||
|
keys: "E",
|
||||||
|
label: "Edit budget",
|
||||||
|
run: () =>
|
||||||
|
nav.current && goto(`/budgets/edit/${nav.current.budget.id}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Budgets",
|
||||||
|
keys: "a",
|
||||||
|
label: "Budget an amount in another currency",
|
||||||
|
run: () => nav.current && startAdding(nav.current),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Budgets",
|
||||||
|
keys: "c",
|
||||||
|
label: "New budget",
|
||||||
|
run: () => createInput?.focus(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Budgets",
|
||||||
|
keys: "w",
|
||||||
|
label: "Expenses without a budget",
|
||||||
|
run: () => goto(noBudgetHref),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: "budgets",
|
||||||
|
items: () => rows,
|
||||||
|
key: (row) => row.budget.id,
|
||||||
|
open: (row) => goto(showHref(row)),
|
||||||
|
});
|
||||||
|
nav.use();
|
||||||
|
|
||||||
|
const money = (line: Line, amount: number) =>
|
||||||
|
formatMoney({
|
||||||
|
amount,
|
||||||
|
decimals: line.cur.decimals,
|
||||||
|
symbol: line.cur.symbol,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader crumbs={[{ label: "Budgets" }]} title="Budgets">
|
||||||
|
{#snippet actions()}
|
||||||
|
<Button href="/budgets/create" size="sm" variant="outline">
|
||||||
|
<Plus />New budget
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<PeriodNav base="/budgets" />
|
||||||
|
|
||||||
|
{#if query.isError}
|
||||||
|
<p class="text-expense">Could not load budgets: {query.error.message}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid gap-4 {pools.length > 1 ? "xl:grid-cols-2" : ""}">
|
||||||
|
{#each pools as pool (pool.cur.code)}
|
||||||
|
<PoolBox
|
||||||
|
noBudget={query.data?.noBudget ?? []}
|
||||||
|
noBudgetCount={query.data?.noBudgetCount ?? 0}
|
||||||
|
{noBudgetHref}
|
||||||
|
{pool}
|
||||||
|
total={query.data?.total ?? []}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Box body="p-0" class="mt-4" title="Budgets">
|
||||||
|
{#snippet tools()}
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft"
|
||||||
|
><span class="kbd">e</span>
|
||||||
|
set amount
|
||||||
|
<span class="kbd">Tab</span>
|
||||||
|
next</span
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-[13px] sm:min-w-[46rem]">
|
||||||
|
<thead>
|
||||||
|
<tr
|
||||||
|
class="h-8 border-cladd-outline border-b text-left text-[12px] text-cladd-fg-soft"
|
||||||
|
>
|
||||||
|
<th class="px-3 font-medium">Budget</th>
|
||||||
|
<th class="px-3 text-right font-medium">Budgeted</th>
|
||||||
|
<th class="px-3 text-right font-medium">Spent</th>
|
||||||
|
<th class="px-3 text-right font-medium max-sm:hidden">Left</th>
|
||||||
|
<th class="w-[30%] px-3 font-medium max-sm:hidden">Progress</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<ListMenu
|
||||||
|
deleteLabel="Delete budget"
|
||||||
|
items={() => [
|
||||||
|
{
|
||||||
|
keys: "Enter",
|
||||||
|
label: "Show",
|
||||||
|
run: () => nav.current && goto(showHref(nav.current)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keys: "E",
|
||||||
|
label: "Edit budget",
|
||||||
|
run: () =>
|
||||||
|
nav.current && goto(`/budgets/edit/${nav.current.budget.id}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keys: "e",
|
||||||
|
label: "Set budgeted amount",
|
||||||
|
run: () => nav.current && (editKey = nav.current.lines[0].key),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keys: "a",
|
||||||
|
label: "Budget in another currency",
|
||||||
|
run: () => nav.current && startAdding(nav.current),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Open in old UI",
|
||||||
|
run: () =>
|
||||||
|
nav.current &&
|
||||||
|
location.assign(
|
||||||
|
oldUiUrl(
|
||||||
|
`${location.origin}/budgets/show/${nav.current.budget.id}`
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
ondelete={() => nav.current && remove(nav.current)}
|
||||||
|
>
|
||||||
|
{#snippet children(
|
||||||
|
props
|
||||||
|
)}
|
||||||
|
<tbody {...props}>
|
||||||
|
{#each rows as row, i (row.budget.id)}
|
||||||
|
{@const extra = addingLine(row)}
|
||||||
|
{@const lines = extra ? [...row.lines, extra] : row.lines}
|
||||||
|
{#each lines as line, j (line.key)}
|
||||||
|
{@const p = pace(line)}
|
||||||
|
{@const day = perDay(line)}
|
||||||
|
<tr
|
||||||
|
class="h-8 border-cladd-outline {j === lines.length - 1
|
||||||
|
? "border-b"
|
||||||
|
: ""} {nav.cursor === i
|
||||||
|
? "bg-row-cursor"
|
||||||
|
: "hover:bg-row-hover"}"
|
||||||
|
id={j === 0 ? nav.rowId(i) : undefined}
|
||||||
|
onclick={(event) => nav.click(i, event)}
|
||||||
|
oncontextmenu={() => (nav.cursor = i)}
|
||||||
|
>
|
||||||
|
{#if j === 0}
|
||||||
|
<td class="px-3 align-top" rowspan={lines.length}>
|
||||||
|
<div
|
||||||
|
class="flex min-h-8 items-center gap-2 sm:whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="text-cladd-fg hover:underline"
|
||||||
|
href={showHref(row)}
|
||||||
|
>{row.budget.attributes.name}</a
|
||||||
|
>
|
||||||
|
{#if row.budget.attributes.auto_budget_type &&
|
||||||
|
row.budget.attributes.auto_budget_amount}
|
||||||
|
<span
|
||||||
|
class="text-[12px] text-cladd-fg-soft max-sm:hidden"
|
||||||
|
>
|
||||||
|
auto
|
||||||
|
<span class="num"
|
||||||
|
>{formatMoney({
|
||||||
|
amount:
|
||||||
|
row.budget.attributes.auto_budget_amount,
|
||||||
|
decimals:
|
||||||
|
row.budget.attributes.currency_decimal_places,
|
||||||
|
symbol: row.budget.attributes.currency_symbol,
|
||||||
|
})}</span
|
||||||
|
>
|
||||||
|
{PERIOD_WORDS[
|
||||||
|
row.budget.attributes.auto_budget_period ?? ""
|
||||||
|
] ?? ""}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if !extra && row.lines.length < enabled.length}
|
||||||
|
<button
|
||||||
|
class="ml-auto text-[11px] text-cladd-fg-softer hover:text-brand max-sm:hidden"
|
||||||
|
onclick={() => startAdding(row)}
|
||||||
|
title="Budget an amount in another currency (a)"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
+ currency
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
<td class="px-3 text-right">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-end gap-1.5"
|
||||||
|
data-limit-editor={line === extra ? "" : undefined}
|
||||||
|
>
|
||||||
|
{#if line === extra}
|
||||||
|
<select
|
||||||
|
aria-label="Currency"
|
||||||
|
class="cladd-cut h-6 rounded-cladd-sm px-1 text-[12px]"
|
||||||
|
onchange={(event) =>
|
||||||
|
adding &&
|
||||||
|
(adding = {
|
||||||
|
...adding,
|
||||||
|
code: event.currentTarget.value,
|
||||||
|
})}
|
||||||
|
value={adding?.code}
|
||||||
|
>
|
||||||
|
{#each enabled.filter(
|
||||||
|
(c) =>
|
||||||
|
c.attributes.code === adding?.code ||
|
||||||
|
!row.lines.some(
|
||||||
|
(l) => l.cur.code === c.attributes.code
|
||||||
|
)
|
||||||
|
) as cur (cur.id)}
|
||||||
|
<option value={cur.attributes.code}>
|
||||||
|
{cur.attributes.code}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
{#if line.limit &&
|
||||||
|
(line.range.start !== period.start ||
|
||||||
|
line.range.end !== period.end)}
|
||||||
|
<span class="text-[11px] text-cladd-fg-softer"
|
||||||
|
>{formatRange(line.range.start, line.range.end)}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<LimitCell
|
||||||
|
budgetId={row.budget.id}
|
||||||
|
editing={line === extra || editKey === line.key}
|
||||||
|
{line}
|
||||||
|
ondone={(move) => done(i, move)}
|
||||||
|
onedit={() => {
|
||||||
|
nav.cursor = i;
|
||||||
|
adding = null;
|
||||||
|
editKey = line.key;
|
||||||
|
}}
|
||||||
|
range={period.range}
|
||||||
|
trap={line !== extra}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 text-right">
|
||||||
|
{#if line !== extra}
|
||||||
|
<Amount
|
||||||
|
amount={line.spent}
|
||||||
|
decimals={line.cur.decimals}
|
||||||
|
symbol={line.cur.symbol}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-3 text-right max-sm:hidden">
|
||||||
|
{#if line.amount !== null}
|
||||||
|
<span
|
||||||
|
class="num {line.amount + line.spent < 0
|
||||||
|
? "text-expense"
|
||||||
|
: "text-cladd-fg"}"
|
||||||
|
>{money(line, line.amount + line.spent)}</span
|
||||||
|
>
|
||||||
|
{#if !day.past && day.left !== null && day.left > 0}
|
||||||
|
<span class="num ml-1 text-[11px] text-cladd-fg-soft"
|
||||||
|
>{money(line, day.left)}/day</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 max-sm:hidden">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
{#if line.amount}
|
||||||
|
<MeterBar
|
||||||
|
class="w-28 shrink-0"
|
||||||
|
max={line.amount}
|
||||||
|
tick={p?.fraction ?? null}
|
||||||
|
value={-line.spent}
|
||||||
|
/>
|
||||||
|
{#if -line.spent > line.amount}
|
||||||
|
<span class="text-[12px] text-expense"
|
||||||
|
>Overspent</span
|
||||||
|
>
|
||||||
|
{:else if p && p.over > 0}
|
||||||
|
<span class="num text-[12px] text-warn"
|
||||||
|
>{money(line, p.over)}
|
||||||
|
over pace</span
|
||||||
|
>
|
||||||
|
{:else if p}
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft"
|
||||||
|
>On track</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{:else if line.spent !== 0}
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft"
|
||||||
|
>No amount set</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
<tr class="border-cladd-outline border-t bg-cladd-surface-next/40">
|
||||||
|
<td class="px-3 py-1.5 sm:whitespace-nowrap">
|
||||||
|
<a class="text-brand italic hover:underline" href={noBudgetHref}
|
||||||
|
>Expenses without a budget</a
|
||||||
|
>
|
||||||
|
{#if query.data?.noBudgetCount}
|
||||||
|
<span class="ml-1.5 text-[12px] text-cladd-fg-soft"
|
||||||
|
>{query.data.noBudgetCount}
|
||||||
|
transactions</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
<td class="px-3 py-1.5 text-right">
|
||||||
|
<MoneyList sums={query.data?.noBudget ?? []} />
|
||||||
|
</td>
|
||||||
|
<td class="max-sm:hidden" colspan="2"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
{/snippet}
|
||||||
|
</ListMenu>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{#snippet footer()}
|
||||||
|
<form class="flex flex-1 items-center gap-2" onsubmit={create}>
|
||||||
|
<input
|
||||||
|
aria-label="New budget name"
|
||||||
|
class="cladd-cut cladd-focusable-input h-7 w-64 min-w-0 flex-1 rounded-cladd-sm px-2 text-[13px] sm:flex-none"
|
||||||
|
placeholder="New budget name (c)"
|
||||||
|
bind:this={createInput}
|
||||||
|
bind:value={newName}
|
||||||
|
>
|
||||||
|
<Button disabled={creating || !newName.trim()} size="sm" type="submit"
|
||||||
|
>Create</Button
|
||||||
|
>
|
||||||
|
{#if createError}
|
||||||
|
<span class="text-expense">{createError}</span>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
{/snippet}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{#if inactive.length > 0}
|
||||||
|
<Box class="mt-4" title="Inactive budgets">
|
||||||
|
<p class="text-[13px]">
|
||||||
|
{#each inactive as budget, i (budget.id)}
|
||||||
|
<a class="text-brand hover:underline" href="/budgets/show/{budget.id}"
|
||||||
|
>{budget.attributes.name}</a
|
||||||
|
>{i < inactive.length - 1 ? ", " : ""}
|
||||||
|
{/each}
|
||||||
|
</p>
|
||||||
|
</Box>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BudgetForm from "#lib/components/budgets/BudgetForm.svelte";
|
||||||
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ href: "/budgets", label: "Budgets" }, { label: "Create" }]}
|
||||||
|
title="Create a new budget"
|
||||||
|
/>
|
||||||
|
<BudgetForm />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export function load({ params }: { params: { id: string } }) {
|
||||||
|
redirect(307, `/budgets/edit/${params.id}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import type { Budget, One } from "#lib/api/types.js";
|
||||||
|
import BudgetForm from "#lib/components/budgets/BudgetForm.svelte";
|
||||||
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
const id = $derived(page.params.id ?? "");
|
||||||
|
const query = createQuery(() => ({
|
||||||
|
queryFn: () => api<One<Budget>>(`/v1/budgets/${id}`),
|
||||||
|
queryKey: ["budget", id],
|
||||||
|
}));
|
||||||
|
const name = $derived(query.data?.data.attributes.name ?? "");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[
|
||||||
|
{ href: "/budgets", label: "Budgets" },
|
||||||
|
{ href: `/budgets/show/${id}`, label: name || "Budget" },
|
||||||
|
{ label: "Edit" },
|
||||||
|
]}
|
||||||
|
title={name ? `Edit budget "${name}"` : "Edit budget"}
|
||||||
|
/>
|
||||||
|
{#if query.data}
|
||||||
|
{#key query.data.data.id}
|
||||||
|
<BudgetForm budget={query.data.data} />
|
||||||
|
{/key}
|
||||||
|
{:else if query.isError}
|
||||||
|
<p class="text-expense">{query.error.message}</p>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import NoBudget from "#lib/components/budgets/NoBudget.svelte";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NoBudget />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import NoBudget from "#lib/components/budgets/NoBudget.svelte";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NoBudget everything />
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ShowBudget from "#lib/components/budgets/ShowBudget.svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#key page.params.id}
|
||||||
|
<ShowBudget id={page.params.id ?? ""} limitId={page.params.limit} />
|
||||||
|
{/key}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ArrowDown from "@lucide/svelte/icons/arrow-down";
|
||||||
|
import Plus from "@lucide/svelte/icons/plus";
|
||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import { invalidate } from "#lib/api/lists.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import { periodPath } from "#lib/components/budgets/period-path.svelte.js";
|
||||||
|
import {
|
||||||
|
type CategoryRow,
|
||||||
|
loadCategoryPage,
|
||||||
|
} from "#lib/components/categories/data.js";
|
||||||
|
import ListMenu from "#lib/components/categories/ListMenu.svelte";
|
||||||
|
import MoneyList from "#lib/components/categories/MoneyList.svelte";
|
||||||
|
import { amountIn } from "#lib/components/categories/sums.js";
|
||||||
|
import { setParam } from "#lib/components/categories/url.js";
|
||||||
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
|
import { Button } from "#lib/components/ui/button/index.js";
|
||||||
|
import { formatDate, percent } from "#lib/format.js";
|
||||||
|
import { fuzzyFilter } from "#lib/fuzzy.js";
|
||||||
|
import type { Binding } from "#lib/keys.svelte.js";
|
||||||
|
import { ListNav } from "#lib/list-nav.svelte.js";
|
||||||
|
import { period } from "#lib/period.svelte.js";
|
||||||
|
import { session } from "#lib/session.svelte.js";
|
||||||
|
import { oldUiUrl } from "#lib/spa-paths.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
type Sort = "name" | "spent" | "earned" | "activity";
|
||||||
|
|
||||||
|
const SORTS: { key: Sort; label: string }[] = [
|
||||||
|
{ key: "name", label: "Name" },
|
||||||
|
{ key: "spent", label: "Spent" },
|
||||||
|
{ key: "earned", label: "Earned" },
|
||||||
|
{ key: "activity", label: "Last activity" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const query = createQuery(() => ({
|
||||||
|
enabled: period.ready,
|
||||||
|
queryFn: () => loadCategoryPage(period.range, session.currencies),
|
||||||
|
queryKey: ["categories-page", period.start, period.end],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sort = $derived<Sort>(
|
||||||
|
(SORTS.find((s) => s.key === page.url.searchParams.get("sort"))?.key ??
|
||||||
|
"name") as Sort
|
||||||
|
);
|
||||||
|
const filter = $derived(page.url.searchParams.get("q") ?? "");
|
||||||
|
const primary = $derived(session.primary?.code ?? "");
|
||||||
|
|
||||||
|
const main = $derived.by(() => {
|
||||||
|
const codes = new Set(
|
||||||
|
(query.data?.rows ?? []).flatMap((r) => r.spent.map((s) => s.code))
|
||||||
|
);
|
||||||
|
return codes.has(primary) ? primary : ([...codes][0] ?? primary);
|
||||||
|
});
|
||||||
|
const totalSpent = $derived(
|
||||||
|
-(
|
||||||
|
(query.data?.rows ?? []).reduce(
|
||||||
|
(sum, r) => sum + amountIn(r.spent, main),
|
||||||
|
0
|
||||||
|
) + amountIn(query.data?.spent ?? [], main)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const share = (spent: number) => (totalSpent > 0 ? -spent / totalSpent : 0);
|
||||||
|
|
||||||
|
const rows = $derived.by(() => {
|
||||||
|
const source = query.data?.rows ?? [];
|
||||||
|
const matched = filter
|
||||||
|
? fuzzyFilter(
|
||||||
|
source,
|
||||||
|
filter,
|
||||||
|
(r) => r.item.attributes.name,
|
||||||
|
source.length
|
||||||
|
)
|
||||||
|
: source;
|
||||||
|
const sorted = [...matched];
|
||||||
|
const name = (r: CategoryRow) => r.item.attributes.name;
|
||||||
|
if (sort === "name") {
|
||||||
|
sorted.sort((a, b) => name(a).localeCompare(name(b)));
|
||||||
|
} else if (sort === "spent") {
|
||||||
|
sorted.sort((a, b) => amountIn(a.spent, main) - amountIn(b.spent, main));
|
||||||
|
} else if (sort === "earned") {
|
||||||
|
sorted.sort(
|
||||||
|
(a, b) => amountIn(b.earned, main) - amountIn(a.earned, main)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
sorted.sort((a, b) =>
|
||||||
|
(b.item.attributes.last_activity ?? "").localeCompare(
|
||||||
|
a.item.attributes.last_activity ?? ""
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
});
|
||||||
|
|
||||||
|
const noCategoryHref = $derived(
|
||||||
|
periodPath("/categories/list/no-category", period.start, period.end)
|
||||||
|
);
|
||||||
|
|
||||||
|
let filterInput = $state<HTMLInputElement>();
|
||||||
|
let createInput = $state<HTMLInputElement>();
|
||||||
|
let newName = $state("");
|
||||||
|
let creating = $state(false);
|
||||||
|
let createError = $state("");
|
||||||
|
|
||||||
|
const showHref = (row: CategoryRow) =>
|
||||||
|
periodPath(`/categories/show/${row.item.id}`, period.start, period.end);
|
||||||
|
|
||||||
|
async function remove(row: CategoryRow) {
|
||||||
|
await api(`/v1/categories/${row.item.id}`, { method: "DELETE" });
|
||||||
|
await invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const name = newName.trim();
|
||||||
|
if (!name || creating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
creating = true;
|
||||||
|
createError = "";
|
||||||
|
try {
|
||||||
|
await api("/v1/categories", { body: { name }, method: "POST" });
|
||||||
|
newName = "";
|
||||||
|
await invalidate();
|
||||||
|
} catch (failure) {
|
||||||
|
createError = failure instanceof Error ? failure.message : "Failed";
|
||||||
|
} finally {
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nav: ListNav<CategoryRow> = new ListNav<CategoryRow>({
|
||||||
|
edit: (row) => goto(`/categories/edit/${row.item.id}`),
|
||||||
|
extra: (): Binding[] => [
|
||||||
|
{
|
||||||
|
group: "Categories",
|
||||||
|
keys: "/",
|
||||||
|
label: "Filter categories",
|
||||||
|
run: () => filterInput?.focus(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Categories",
|
||||||
|
keys: "c",
|
||||||
|
label: "New category",
|
||||||
|
run: () => createInput?.focus(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Categories",
|
||||||
|
keys: "w",
|
||||||
|
label: "Transactions without a category",
|
||||||
|
run: () => goto(noCategoryHref),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: "categories",
|
||||||
|
items: () => rows,
|
||||||
|
key: (row) => row.item.id,
|
||||||
|
open: (row) => goto(showHref(row)),
|
||||||
|
});
|
||||||
|
nav.use();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader crumbs={[{ label: "Categories" }]} title="Categories">
|
||||||
|
{#snippet actions()}
|
||||||
|
<Button href="/categories/create" size="sm" variant="outline">
|
||||||
|
<Plus />New category
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{#if query.isError}
|
||||||
|
<p class="text-expense">Could not load categories: {query.error.message}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Box body="p-0" title="Categories">
|
||||||
|
{#snippet tools()}
|
||||||
|
<input
|
||||||
|
aria-label="Filter categories"
|
||||||
|
class="cladd-cut cladd-focusable-input h-7 w-48 rounded-cladd-sm px-2 text-[12px]"
|
||||||
|
oninput={(event) => setParam("q", event.currentTarget.value)}
|
||||||
|
onkeydown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.currentTarget.blur();
|
||||||
|
nav.move(0);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Filter (/)"
|
||||||
|
value={filter}
|
||||||
|
bind:this={filterInput}
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full min-w-[40rem] text-[13px]">
|
||||||
|
<thead>
|
||||||
|
<tr
|
||||||
|
class="h-8 border-cladd-outline border-b text-left text-[12px] text-cladd-fg-soft"
|
||||||
|
>
|
||||||
|
{#each SORTS as column (column.key)}
|
||||||
|
<th
|
||||||
|
aria-sort={sort === column.key ? "ascending" : undefined}
|
||||||
|
class="px-3 font-medium {column.key === "name"
|
||||||
|
? ""
|
||||||
|
: "text-right"} {column.key === "activity"
|
||||||
|
? "hidden md:table-cell"
|
||||||
|
: ""}"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-1 hover:text-cladd-fg {sort ===
|
||||||
|
column.key
|
||||||
|
? "text-cladd-fg"
|
||||||
|
: ""}"
|
||||||
|
onclick={() =>
|
||||||
|
setParam("sort", column.key === "name" ? "" : column.key)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{column.label}
|
||||||
|
{#if sort === column.key}
|
||||||
|
<ArrowDown class="size-3" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
{#if column.key === "earned"}
|
||||||
|
<th class="w-[22%] px-3 font-medium">Share of {main} spending</th>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<ListMenu
|
||||||
|
deleteLabel="Delete category"
|
||||||
|
items={() => [
|
||||||
|
{
|
||||||
|
keys: "Enter",
|
||||||
|
label: "Show",
|
||||||
|
run: () => nav.current && goto(showHref(nav.current)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keys: "e",
|
||||||
|
label: "Edit category",
|
||||||
|
run: () =>
|
||||||
|
nav.current && goto(`/categories/edit/${nav.current.item.id}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Open in old UI",
|
||||||
|
run: () =>
|
||||||
|
nav.current &&
|
||||||
|
location.assign(
|
||||||
|
oldUiUrl(
|
||||||
|
`${location.origin}/categories/show/${nav.current.item.id}`
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
ondelete={() => nav.current && remove(nav.current)}
|
||||||
|
>
|
||||||
|
{#snippet children(
|
||||||
|
props
|
||||||
|
)}
|
||||||
|
<tbody {...props}>
|
||||||
|
<tr class="h-7 border-cladd-outline border-b">
|
||||||
|
<td class="px-3">
|
||||||
|
<a
|
||||||
|
class="text-brand italic hover:underline"
|
||||||
|
href={noCategoryHref}
|
||||||
|
>Without a category</a
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 text-right">
|
||||||
|
<MoneyList sums={query.data?.spent ?? []} />
|
||||||
|
</td>
|
||||||
|
<td class="px-3 text-right">
|
||||||
|
<MoneyList sums={query.data?.earned ?? []} />
|
||||||
|
</td>
|
||||||
|
<td class="px-3">
|
||||||
|
{@render bar(amountIn(query.data?.spent ?? [], main))}
|
||||||
|
</td>
|
||||||
|
<td class="hidden md:table-cell"></td>
|
||||||
|
</tr>
|
||||||
|
{#each rows as row, i (row.item.id)}
|
||||||
|
<tr
|
||||||
|
class="h-7 border-cladd-outline border-b last:border-b-0 {nav.cursor ===
|
||||||
|
i
|
||||||
|
? "bg-row-cursor"
|
||||||
|
: "hover:bg-row-hover"}"
|
||||||
|
id={nav.rowId(i)}
|
||||||
|
onclick={(event) => nav.click(i, event)}
|
||||||
|
oncontextmenu={() => (nav.cursor = i)}
|
||||||
|
>
|
||||||
|
<td class="px-3">
|
||||||
|
<a class="text-cladd-fg hover:underline" href={showHref(row)}
|
||||||
|
>{row.item.attributes.name}</a
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 text-right">
|
||||||
|
<MoneyList sums={row.spent} />
|
||||||
|
</td>
|
||||||
|
<td class="px-3 text-right">
|
||||||
|
<MoneyList sums={row.earned} />
|
||||||
|
</td>
|
||||||
|
<td class="px-3">{@render bar(amountIn(row.spent, main))}</td>
|
||||||
|
<td
|
||||||
|
class="num hidden px-3 text-right text-[12px] text-cladd-fg-soft md:table-cell"
|
||||||
|
>
|
||||||
|
{row.item.attributes.last_activity
|
||||||
|
? formatDate(row.item.attributes.last_activity)
|
||||||
|
: "never"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
{#if !query.isPending}
|
||||||
|
<tr>
|
||||||
|
<td class="px-3 py-3 text-cladd-fg-soft" colspan="5">
|
||||||
|
{filter ? "No category matches." : "No categories yet."}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
{/snippet}
|
||||||
|
</ListMenu>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{#snippet footer()}
|
||||||
|
<form class="flex flex-1 items-center gap-2" onsubmit={create}>
|
||||||
|
<input
|
||||||
|
aria-label="New category name"
|
||||||
|
class="cladd-cut cladd-focusable-input h-7 w-64 rounded-cladd-sm px-2 text-[13px]"
|
||||||
|
placeholder="New category name (c)"
|
||||||
|
bind:this={createInput}
|
||||||
|
bind:value={newName}
|
||||||
|
>
|
||||||
|
<Button disabled={creating || !newName.trim()} size="sm" type="submit"
|
||||||
|
>Create</Button
|
||||||
|
>
|
||||||
|
{#if createError}
|
||||||
|
<span class="text-expense">{createError}</span>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
{/snippet}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{#snippet bar(
|
||||||
|
spent: number
|
||||||
|
)}
|
||||||
|
{@const value = share(spent)}
|
||||||
|
{#if value > 0}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
class="h-2 flex-1 overflow-hidden rounded-full bg-cladd-surface-next"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-expense/70"
|
||||||
|
style:width="{value * 100}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="num w-10 text-right text-[12px] text-cladd-fg-soft"
|
||||||
|
>{percent(value)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CategoryForm from "#lib/components/categories/CategoryForm.svelte";
|
||||||
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ href: "/categories", label: "Categories" }, { label: "Create" }]}
|
||||||
|
title="Create a new category"
|
||||||
|
/>
|
||||||
|
<CategoryForm />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export function load({ params }: { params: { id: string } }) {
|
||||||
|
redirect(307, `/categories/edit/${params.id}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import type { Category, One } from "#lib/api/types.js";
|
||||||
|
import CategoryForm from "#lib/components/categories/CategoryForm.svelte";
|
||||||
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
const id = $derived(page.params.id ?? "");
|
||||||
|
const query = createQuery(() => ({
|
||||||
|
queryFn: () => api<One<Category>>(`/v1/categories/${id}`),
|
||||||
|
queryKey: ["category", id],
|
||||||
|
}));
|
||||||
|
const name = $derived(query.data?.data.attributes.name ?? "");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[
|
||||||
|
{ href: "/categories", label: "Categories" },
|
||||||
|
{ href: `/categories/show/${id}`, label: name || "Category" },
|
||||||
|
{ label: "Edit" },
|
||||||
|
]}
|
||||||
|
title={name ? `Edit category "${name}"` : "Edit category"}
|
||||||
|
/>
|
||||||
|
{#if query.data}
|
||||||
|
{#key query.data.data.id}
|
||||||
|
<CategoryForm category={query.data.data} />
|
||||||
|
{/key}
|
||||||
|
{:else if query.isError}
|
||||||
|
<p class="text-expense">{query.error.message}</p>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import NoCategory from "#lib/components/categories/NoCategory.svelte";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NoCategory />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import NoCategory from "#lib/components/categories/NoCategory.svelte";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NoCategory everything />
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ShowCategory from "#lib/components/categories/ShowCategory.svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#key page.params.id}
|
||||||
|
<ShowCategory id={page.params.id ?? ""} />
|
||||||
|
{/key}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ShowCategory from "#lib/components/categories/ShowCategory.svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#key page.params.id}
|
||||||
|
<ShowCategory everything id={page.params.id ?? ""} />
|
||||||
|
{/key}
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
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 { TransactionGroup } from "#lib/api/types.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import { periodPath } from "#lib/components/budgets/period-path.svelte.js";
|
||||||
|
import ListMenu from "#lib/components/categories/ListMenu.svelte";
|
||||||
|
import MoneyList from "#lib/components/categories/MoneyList.svelte";
|
||||||
|
import { list, rows as splitRows } from "#lib/components/categories/sums.js";
|
||||||
|
import { setParam } from "#lib/components/categories/url.js";
|
||||||
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
|
import { deleteTag, usage } from "#lib/components/tags/actions.js";
|
||||||
|
import Duplicates from "#lib/components/tags/Duplicates.svelte";
|
||||||
|
import MergeField from "#lib/components/tags/MergeField.svelte";
|
||||||
|
import RenameField from "#lib/components/tags/RenameField.svelte";
|
||||||
|
import {
|
||||||
|
buildTree,
|
||||||
|
duplicates,
|
||||||
|
flatNodes,
|
||||||
|
flatten,
|
||||||
|
type TagNode,
|
||||||
|
tagStats,
|
||||||
|
} from "#lib/components/tags/tree.js";
|
||||||
|
import { Button } from "#lib/components/ui/button/index.js";
|
||||||
|
import { formatDate } from "#lib/format.js";
|
||||||
|
import { fuzzyFilter } from "#lib/fuzzy.js";
|
||||||
|
import type { Binding } 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";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
const ARM_MS = 3000;
|
||||||
|
|
||||||
|
const tags = createQuery(() => lists.tags());
|
||||||
|
const groups = createQuery(() => ({
|
||||||
|
enabled: period.ready,
|
||||||
|
queryFn: () =>
|
||||||
|
all<TransactionGroup>("/v1/transactions", {
|
||||||
|
end: period.end,
|
||||||
|
start: period.start,
|
||||||
|
type: "all",
|
||||||
|
}),
|
||||||
|
queryKey: ["tags-period", period.start, period.end],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const flat = $derived(page.url.searchParams.get("view") === "flat");
|
||||||
|
const cleanup = $derived(page.url.searchParams.get("mode") === "cleanup");
|
||||||
|
const filter = $derived(page.url.searchParams.get("q") ?? "");
|
||||||
|
|
||||||
|
const counts = createQuery(() => ({
|
||||||
|
enabled: cleanup && Boolean(tags.data),
|
||||||
|
queryFn: () => usage(tags.data ?? []),
|
||||||
|
queryKey: ["tags-usage", (tags.data ?? []).map((t) => t.id).join(",")],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const items = $derived(splitRows(groups.data ?? []));
|
||||||
|
const stats = $derived(tagStats(items, !(flat || filter)));
|
||||||
|
const nodes = $derived.by(() => {
|
||||||
|
const source = tags.data ?? [];
|
||||||
|
if (filter) {
|
||||||
|
return flatNodes(
|
||||||
|
fuzzyFilter(source, filter, (t) => t.attributes.tag, source.length)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return flat ? flatNodes(source) : flatten(buildTree(source));
|
||||||
|
});
|
||||||
|
const clusters = $derived(cleanup ? duplicates(tags.data ?? []) : []);
|
||||||
|
|
||||||
|
let editing = $state<{ path: string; mode: "rename" | "merge" } | null>(null);
|
||||||
|
let armed = $state<string | null>(null);
|
||||||
|
let armTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let rowStatus = $state<Record<string, string>>({});
|
||||||
|
let filterInput = $state<HTMLInputElement>();
|
||||||
|
let createInput = $state<HTMLInputElement>();
|
||||||
|
let newTag = $state("");
|
||||||
|
let creating = $state(false);
|
||||||
|
let createError = $state("");
|
||||||
|
|
||||||
|
const showHref = (node: TagNode) =>
|
||||||
|
node.tag
|
||||||
|
? periodPath(`/tags/show/${node.tag.id}`, period.start, period.end)
|
||||||
|
: `/search?search=${encodeURIComponent(`tag_starts:"${node.path}/"`)}`;
|
||||||
|
|
||||||
|
function rename(node: TagNode | undefined) {
|
||||||
|
if (node) {
|
||||||
|
armed = null;
|
||||||
|
editing = { mode: "rename", path: node.path };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function merge(node: TagNode | undefined) {
|
||||||
|
if (node?.tag) {
|
||||||
|
armed = null;
|
||||||
|
editing = { mode: "merge", path: node.path };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(node: TagNode) {
|
||||||
|
if (!node.tag) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rowStatus[node.path] = "Deleting";
|
||||||
|
try {
|
||||||
|
await deleteTag(node.tag);
|
||||||
|
await invalidate();
|
||||||
|
} catch (failure) {
|
||||||
|
rowStatus[node.path] =
|
||||||
|
failure instanceof Error ? failure.message : String(failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pressDelete(node: TagNode | undefined) {
|
||||||
|
if (!node?.tag) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(armTimer);
|
||||||
|
if (armed === node.path) {
|
||||||
|
armed = null;
|
||||||
|
remove(node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
armed = node.path;
|
||||||
|
armTimer = setTimeout(() => (armed = null), ARM_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const tag = newTag.trim();
|
||||||
|
if (!tag || creating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
creating = true;
|
||||||
|
createError = "";
|
||||||
|
try {
|
||||||
|
await api("/v1/tags", { body: { tag }, method: "POST" });
|
||||||
|
newTag = "";
|
||||||
|
await invalidate();
|
||||||
|
} catch (failure) {
|
||||||
|
createError = failure instanceof Error ? failure.message : "Failed";
|
||||||
|
} finally {
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nav: ListNav<TagNode> = new ListNav<TagNode>({
|
||||||
|
edit: (node) => node.tag && goto(`/tags/edit/${node.tag.id}`),
|
||||||
|
extra: (): Binding[] => [
|
||||||
|
{
|
||||||
|
group: "Tags",
|
||||||
|
keys: "r",
|
||||||
|
label: "Rename tag",
|
||||||
|
run: () => rename(nav.current),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Tags",
|
||||||
|
keys: "m",
|
||||||
|
label: "Merge tag into another",
|
||||||
|
run: () => merge(nav.current),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Tags",
|
||||||
|
keys: "d d",
|
||||||
|
label: "Delete tag (twice)",
|
||||||
|
run: () => pressDelete(nav.current),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Tags",
|
||||||
|
keys: "/",
|
||||||
|
label: "Filter tags",
|
||||||
|
run: () => filterInput?.focus(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Tags",
|
||||||
|
keys: "c",
|
||||||
|
label: "New tag",
|
||||||
|
run: () => createInput?.focus(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Tags",
|
||||||
|
keys: "v",
|
||||||
|
label: "Tree or flat view",
|
||||||
|
run: () => setParam("view", flat ? "" : "flat"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Tags",
|
||||||
|
keys: "C",
|
||||||
|
label: "Clean-up mode",
|
||||||
|
run: () => setParam("mode", cleanup ? "" : "cleanup"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: "tags",
|
||||||
|
items: () => nodes,
|
||||||
|
key: (node) => node.path,
|
||||||
|
open: (node) => goto(showHref(node)),
|
||||||
|
});
|
||||||
|
nav.use();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader crumbs={[{ label: "Tags" }]} title="Tags">
|
||||||
|
{#snippet actions()}
|
||||||
|
<Button href="/tags/create" size="sm" variant="outline">
|
||||||
|
<Plus />Create a tag
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{#if tags.isError}
|
||||||
|
<p class="text-expense">Could not load tags: {tags.error.message}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
{#if cleanup}
|
||||||
|
<Duplicates {clusters} counts={counts.data ?? new Map()} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Box body="p-0" title="Tags">
|
||||||
|
{#snippet tools()}
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
{#if !(flat || filter)}
|
||||||
|
<span class="hidden text-[12px] text-cladd-fg-soft md:inline"
|
||||||
|
>parent rows include sub-tags</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
class="flex rounded-cladd-sm bg-cladd-surface-next p-0.5 text-[12px]"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-pressed={!flat}
|
||||||
|
class="rounded-cladd-xs px-2 py-0.5 {flat
|
||||||
|
? "text-cladd-fg-soft"
|
||||||
|
: "cladd-surface font-semibold"}"
|
||||||
|
onclick={() => setParam("view", "")}
|
||||||
|
title="Tree view (v)"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Tree
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-pressed={flat}
|
||||||
|
class="rounded-cladd-xs px-2 py-0.5 {flat
|
||||||
|
? "cladd-surface font-semibold"
|
||||||
|
: "text-cladd-fg-soft"}"
|
||||||
|
onclick={() => setParam("view", "flat")}
|
||||||
|
title="Flat view (v)"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Flat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-pressed={cleanup}
|
||||||
|
class="rounded-cladd-sm px-2 py-1 text-[12px] {cleanup
|
||||||
|
? "bg-row-selected font-semibold"
|
||||||
|
: "text-cladd-fg-soft hover:bg-row-hover"}"
|
||||||
|
onclick={() => setParam("mode", cleanup ? "" : "cleanup")}
|
||||||
|
title="Clean-up mode (C)"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Clean up
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
aria-label="Filter tags"
|
||||||
|
class="cladd-cut cladd-focusable-input h-7 w-32 rounded-cladd-sm px-2 text-[12px] sm:w-44"
|
||||||
|
oninput={(event) => setParam("q", event.currentTarget.value)}
|
||||||
|
onkeydown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.currentTarget.blur();
|
||||||
|
nav.move(0);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Filter (/)"
|
||||||
|
value={filter}
|
||||||
|
bind:this={filterInput}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full min-w-[44rem] text-[13px]">
|
||||||
|
<thead>
|
||||||
|
<tr
|
||||||
|
class="h-8 border-cladd-outline border-b text-left text-[12px] text-cladd-fg-soft"
|
||||||
|
>
|
||||||
|
<th class="px-3 font-medium">Tag</th>
|
||||||
|
<th class="px-3 text-right font-medium">Transactions</th>
|
||||||
|
<th class="px-3 text-right font-medium">Spent</th>
|
||||||
|
<th class="px-3 text-right font-medium">Earned</th>
|
||||||
|
{#if cleanup}
|
||||||
|
<th class="px-3 text-right font-medium">
|
||||||
|
{counts.isFetching ? "Counting" : "All time"}
|
||||||
|
</th>
|
||||||
|
{/if}
|
||||||
|
<th class="hidden px-3 font-medium md:table-cell">Date</th>
|
||||||
|
<th class="hidden px-3 font-medium lg:table-cell">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<ListMenu
|
||||||
|
deleteLabel="Delete tag"
|
||||||
|
items={() => [
|
||||||
|
{
|
||||||
|
keys: "Enter",
|
||||||
|
label: "Show",
|
||||||
|
run: () => nav.current && goto(showHref(nav.current)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keys: "e",
|
||||||
|
label: "Edit tag",
|
||||||
|
run: () =>
|
||||||
|
nav.current?.tag && goto(`/tags/edit/${nav.current.tag.id}`),
|
||||||
|
},
|
||||||
|
{ keys: "r", label: "Rename", run: () => rename(nav.current) },
|
||||||
|
{
|
||||||
|
keys: "m",
|
||||||
|
label: "Merge into another tag",
|
||||||
|
run: () => merge(nav.current),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Open in old UI",
|
||||||
|
run: () =>
|
||||||
|
nav.current?.tag &&
|
||||||
|
location.assign(
|
||||||
|
oldUiUrl(`${location.origin}/tags/show/${nav.current.tag.id}`)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
ondelete={() => nav.current && remove(nav.current)}
|
||||||
|
>
|
||||||
|
{#snippet children(
|
||||||
|
props
|
||||||
|
)}
|
||||||
|
<tbody {...props}>
|
||||||
|
{#each nodes as node, i (node.path)}
|
||||||
|
{@const stat = stats.get(node.path)}
|
||||||
|
{@const total = node.tag
|
||||||
|
? counts.data?.get(node.tag.id)
|
||||||
|
: undefined}
|
||||||
|
<tr
|
||||||
|
class="h-7 border-cladd-outline border-b last:border-b-0 {nav.cursor ===
|
||||||
|
i
|
||||||
|
? "bg-row-cursor"
|
||||||
|
: "hover:bg-row-hover"}"
|
||||||
|
id={nav.rowId(i)}
|
||||||
|
onclick={(event) => nav.click(i, event)}
|
||||||
|
oncontextmenu={() => (nav.cursor = i)}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
class="px-3"
|
||||||
|
style:padding-left="{0.75 + node.depth * 1.25}rem"
|
||||||
|
>
|
||||||
|
{#if editing?.path === node.path &&
|
||||||
|
editing.mode === "rename"}
|
||||||
|
<RenameField
|
||||||
|
{node}
|
||||||
|
ondone={() => (editing = null)}
|
||||||
|
tags={tags.data ?? []}
|
||||||
|
/>
|
||||||
|
{:else if editing?.path === node.path &&
|
||||||
|
editing.mode === "merge" &&
|
||||||
|
node.tag}
|
||||||
|
<MergeField
|
||||||
|
ondone={() => (editing = null)}
|
||||||
|
tag={node.tag}
|
||||||
|
tags={tags.data ?? []}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
class="hover:underline {node.tag
|
||||||
|
? "text-cladd-fg"
|
||||||
|
: "text-cladd-fg-soft italic"}"
|
||||||
|
href={showHref(node)}
|
||||||
|
>
|
||||||
|
{node.name}
|
||||||
|
</a>
|
||||||
|
{#if !node.tag}
|
||||||
|
<span class="ml-1 text-[11px] text-cladd-fg-softer"
|
||||||
|
>no tag of its own</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if armed === node.path}
|
||||||
|
<span class="ml-2 text-[12px] text-expense"
|
||||||
|
>Press d d again to delete</span
|
||||||
|
>
|
||||||
|
{:else if rowStatus[node.path]}
|
||||||
|
<span class="ml-2 text-[12px] text-cladd-fg-soft"
|
||||||
|
>{rowStatus[node.path]}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="num px-3 text-right {node.children.length > 0 &&
|
||||||
|
!flat
|
||||||
|
? "font-semibold"
|
||||||
|
: ""}"
|
||||||
|
>
|
||||||
|
{stat?.count || ""}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 text-right">
|
||||||
|
<MoneyList sums={list(stat?.spent ?? {})} />
|
||||||
|
</td>
|
||||||
|
<td class="px-3 text-right">
|
||||||
|
<MoneyList sums={list(stat?.earned ?? {})} />
|
||||||
|
</td>
|
||||||
|
{#if cleanup}
|
||||||
|
<td class="num px-3 text-right">
|
||||||
|
{#if total === 0}
|
||||||
|
<span class="text-warn">unused</span>
|
||||||
|
{:else}
|
||||||
|
{total ?? ""}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
<td
|
||||||
|
class="num hidden px-3 text-[12px] text-cladd-fg-soft md:table-cell"
|
||||||
|
>
|
||||||
|
{node.tag?.attributes.date
|
||||||
|
? formatDate(node.tag.attributes.date)
|
||||||
|
: ""}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="hidden max-w-80 truncate px-3 text-[12px] text-cladd-fg-soft lg:table-cell"
|
||||||
|
>
|
||||||
|
{node.tag?.attributes.description ?? ""}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
{#if !tags.isPending}
|
||||||
|
<tr>
|
||||||
|
<td class="px-3 py-3 text-cladd-fg-soft" colspan="7">
|
||||||
|
{filter ? "No tag matches." : "No tags yet."}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
{/snippet}
|
||||||
|
</ListMenu>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{#snippet footer()}
|
||||||
|
<form class="flex flex-1 flex-wrap items-center gap-2" onsubmit={create}>
|
||||||
|
<input
|
||||||
|
aria-label="New tag"
|
||||||
|
class="cladd-cut cladd-focusable-input h-7 w-64 rounded-cladd-sm px-2 text-[13px]"
|
||||||
|
placeholder="New tag, parent/child works (c)"
|
||||||
|
bind:this={createInput}
|
||||||
|
bind:value={newTag}
|
||||||
|
>
|
||||||
|
<Button disabled={creating || !newTag.trim()} size="sm" type="submit"
|
||||||
|
>Create</Button
|
||||||
|
>
|
||||||
|
{#if createError}
|
||||||
|
<span class="text-expense">{createError}</span>
|
||||||
|
{/if}
|
||||||
|
<span
|
||||||
|
class="ml-auto flex flex-wrap gap-x-3 text-[12px] text-cladd-fg-soft"
|
||||||
|
>
|
||||||
|
<span><span class="kbd">r</span> rename</span>
|
||||||
|
<span><span class="kbd">m</span> merge</span>
|
||||||
|
<span
|
||||||
|
><span class="kbd">d</span><span class="kbd">d</span>
|
||||||
|
delete</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</form>
|
||||||
|
{/snippet}
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
|
import TagForm from "#lib/components/tags/TagForm.svelte";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ href: "/tags", label: "Tags" }, { label: "Create" }]}
|
||||||
|
title="Create a tag"
|
||||||
|
/>
|
||||||
|
<TagForm />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export function load({ params }: { params: { tag: string } }) {
|
||||||
|
redirect(307, `/tags/edit/${encodeURIComponent(params.tag)}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import type { One, Tag } from "#lib/api/types.js";
|
||||||
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
|
import TagForm from "#lib/components/tags/TagForm.svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
const id = $derived(page.params.tag ?? "");
|
||||||
|
const query = createQuery(() => ({
|
||||||
|
queryFn: () => api<One<Tag>>(`/v1/tags/${encodeURIComponent(id)}`),
|
||||||
|
queryKey: ["tag", id],
|
||||||
|
}));
|
||||||
|
const name = $derived(query.data?.data.attributes.tag ?? "");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[
|
||||||
|
{ href: "/tags", label: "Tags" },
|
||||||
|
{ href: `/tags/show/${query.data?.data.id ?? id}`, label: name || "Tag" },
|
||||||
|
{ label: "Edit" },
|
||||||
|
]}
|
||||||
|
title={name ? `Edit tag "${name}"` : "Edit tag"}
|
||||||
|
/>
|
||||||
|
{#if query.data}
|
||||||
|
{#key query.data.data.id}
|
||||||
|
<TagForm tag={query.data.data} />
|
||||||
|
{/key}
|
||||||
|
{:else if query.isError}
|
||||||
|
<p class="text-expense">{query.error.message}</p>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ShowTag from "#lib/components/tags/ShowTag.svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#key page.params.tag}
|
||||||
|
<ShowTag param={page.params.tag ?? ""} />
|
||||||
|
{/key}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ShowTag from "#lib/components/tags/ShowTag.svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#key page.params.tag}
|
||||||
|
<ShowTag everything param={page.params.tag ?? ""} />
|
||||||
|
{/key}
|
||||||
Reference in new issue
Block a user