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
@@ -61,7 +61,7 @@
|
||||
<svelte:window onkeydown={keys.handle} />
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{#if session.ready}
|
||||
{#if session.ready && period.ready}
|
||||
<Shell>
|
||||
{@render children()}
|
||||
{#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