feat(transactions): the period as a local ledger with Excel-style filters
The list loads the whole period (or twelve months with Y) and filters it in the browser: a text line over descriptions, notes, accounts and amounts, dropdowns with counts per type, category, budget, account, currency, tag and subscription, group-by with subtotals per currency, pinned filters on 1 to 9. Possible duplicates show with a delete for the newer one, one account adds the balance column, s s sets a subscription and s . repeats the last mass action.
This commit is contained in:
1 parent
01c53d982d
commit
153a8cb191
9 files changed
+1693
-131
No files matched your search
@@ -3,10 +3,11 @@
|
||||
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||
import { tick, untrack } from "svelte";
|
||||
import { api } from "#lib/api/client.js";
|
||||
import { all } from "#lib/api/lists.js";
|
||||
import { all, invalidate } from "#lib/api/lists.js";
|
||||
import type { Page, TransactionGroup } from "#lib/api/types.js";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||
import FilterBar from "#lib/components/transactions/FilterBar.svelte";
|
||||
import Pager from "#lib/components/transactions/Pager.svelte";
|
||||
import PeriodSummary from "#lib/components/transactions/PeriodSummary.svelte";
|
||||
import Periods from "#lib/components/transactions/Periods.svelte";
|
||||
@@ -17,8 +18,25 @@
|
||||
import { useKeys } from "#lib/keys.svelte.js";
|
||||
import { period } from "#lib/period.svelte.js";
|
||||
import { session } from "#lib/session.svelte.js";
|
||||
import {
|
||||
accountIdOf,
|
||||
bySection,
|
||||
type Cluster,
|
||||
duplicates,
|
||||
FACETS,
|
||||
type FacetId,
|
||||
isFiltered,
|
||||
narrow,
|
||||
type Section,
|
||||
searchQuery,
|
||||
sectionOf,
|
||||
splitTest,
|
||||
yearRange,
|
||||
} from "#lib/transactions/ledger.js";
|
||||
import { LedgerView } from "#lib/transactions/ledger-view.svelte.js";
|
||||
import {
|
||||
createType,
|
||||
type Group,
|
||||
LIST_TITLE,
|
||||
listPath,
|
||||
listType,
|
||||
@@ -32,6 +50,9 @@
|
||||
transfer: "All transfers",
|
||||
withdrawal: "All expenses",
|
||||
};
|
||||
const WITHDRAWAL_ONLY = new Set<FacetId>(["budget", "bill"]);
|
||||
const TOOL =
|
||||
"inline-flex h-7 items-center gap-1.5 rounded-cladd-sm px-2 text-[12px] text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg";
|
||||
|
||||
let {
|
||||
param,
|
||||
@@ -49,13 +70,20 @@
|
||||
let input = $state<HTMLInputElement>();
|
||||
let lastRange = "";
|
||||
|
||||
const view = new LedgerView();
|
||||
const type = $derived(listType(param));
|
||||
const pageNo = $derived(
|
||||
Math.max(1, Number(page.url.searchParams.get("page")) || 1)
|
||||
);
|
||||
const range = $derived(
|
||||
everything ? null : { end: period.end, start: period.start }
|
||||
);
|
||||
const year = $derived(page.url.searchParams.get("span") === "year");
|
||||
const range = $derived.by(() => {
|
||||
if (everything) {
|
||||
return null;
|
||||
}
|
||||
return year
|
||||
? yearRange(period.end)
|
||||
: { end: period.end, start: period.start };
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (period.ready && start && end) {
|
||||
@@ -89,29 +117,16 @@
|
||||
});
|
||||
|
||||
const list = createQuery(() => ({
|
||||
enabled: everything || period.ready,
|
||||
enabled: everything,
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: () =>
|
||||
api<Page<TransactionGroup>>("/v1/transactions", {
|
||||
query: {
|
||||
end: range?.end,
|
||||
limit: session.pageSize,
|
||||
page: pageNo,
|
||||
start: range?.start,
|
||||
type,
|
||||
},
|
||||
query: { limit: session.pageSize, page: pageNo, type },
|
||||
}),
|
||||
queryKey: [
|
||||
"transactions",
|
||||
type,
|
||||
range?.start,
|
||||
range?.end,
|
||||
pageNo,
|
||||
session.pageSize,
|
||||
],
|
||||
queryKey: ["transactions", type, pageNo, session.pageSize],
|
||||
}));
|
||||
|
||||
const summary = createQuery(() => ({
|
||||
const ledger = createQuery(() => ({
|
||||
enabled: !everything && period.ready,
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: () =>
|
||||
@@ -120,16 +135,80 @@
|
||||
start: range?.start,
|
||||
type,
|
||||
}),
|
||||
queryKey: ["transactions-summary", type, range?.start, range?.end],
|
||||
queryKey: ["transactions-ledger", type, range?.start, range?.end],
|
||||
refetchOnWindowFocus: "always" as const,
|
||||
}));
|
||||
|
||||
const pagination = $derived(list.data?.meta.pagination);
|
||||
const groups = $derived(ledger.data ?? []);
|
||||
const filters = $derived(view.filters);
|
||||
const filtered = $derived(isFiltered(filters));
|
||||
const clusters = $derived(duplicates(groups, view.ignored));
|
||||
const clusterCount = $derived(new Set(clusters.values()).size);
|
||||
const keep = $derived(
|
||||
filters.dupes ? (group: Group) => clusters.has(group.id) : () => true
|
||||
);
|
||||
const matched = $derived(narrow(groups, splitTest(filters), keep));
|
||||
const section = $derived.by<((group: Group) => Section) | undefined>(() => {
|
||||
if (filters.dupes) {
|
||||
return (group) => clusterSection(clusters.get(group.id));
|
||||
}
|
||||
const by = filters.group;
|
||||
return by ? (group) => sectionOf(by, group) : undefined;
|
||||
});
|
||||
const ordered = $derived(
|
||||
section && filters.group !== "day" ? bySection(matched, section) : matched
|
||||
);
|
||||
const paged = $derived(!(filtered || section));
|
||||
const pages = $derived(
|
||||
paged ? Math.max(1, Math.ceil(matched.length / session.pageSize)) : 1
|
||||
);
|
||||
const visible = $derived(
|
||||
paged
|
||||
? ordered.slice(
|
||||
(pageNo - 1) * session.pageSize,
|
||||
pageNo * session.pageSize
|
||||
)
|
||||
: ordered
|
||||
);
|
||||
const facetIds = $derived(
|
||||
FACETS.map((facet) => facet.id).filter(
|
||||
(id) =>
|
||||
(id !== "kind" || type === "all") &&
|
||||
!(WITHDRAWAL_ONLY.has(id) && ["deposit", "transfer"].includes(type))
|
||||
)
|
||||
);
|
||||
const narrowedTo = $derived.by(() => {
|
||||
const accounts = filters.facets.account ?? [];
|
||||
return accounts.length === 1 ? accountIdOf(groups, accounts[0]) : undefined;
|
||||
});
|
||||
const subtitle = $derived(
|
||||
range
|
||||
? `${SUBTITLE[type]} between ${formatDate(range.start)} and ${formatDate(range.end)}`
|
||||
: SUBTITLE[type]
|
||||
);
|
||||
|
||||
function clusterSection(cluster: Cluster | undefined): Section {
|
||||
if (!cluster) {
|
||||
return { key: "", label: "" };
|
||||
}
|
||||
const [newest] = cluster.groups;
|
||||
return {
|
||||
actions: [
|
||||
{
|
||||
confirm: true,
|
||||
label: "Delete newer",
|
||||
run: async () => {
|
||||
await api(`/v1/transactions/${newest.id}`, { method: "DELETE" });
|
||||
await invalidate();
|
||||
},
|
||||
},
|
||||
{ label: "Not duplicates", run: () => view.ignore(cluster.id) },
|
||||
],
|
||||
key: cluster.id,
|
||||
label: cluster.label,
|
||||
};
|
||||
}
|
||||
|
||||
function pageHref(n: number) {
|
||||
const url = new URL(page.url.href);
|
||||
url.searchParams.set("page", String(n));
|
||||
@@ -140,20 +219,42 @@
|
||||
goto(`/search?search=${encodeURIComponent(text)}`);
|
||||
}
|
||||
|
||||
useKeys(() => [
|
||||
{
|
||||
group: "Global",
|
||||
keys: "/",
|
||||
label: "Search in this list",
|
||||
run: () => {
|
||||
if (!query && type !== "all") {
|
||||
query = `type:${type} `;
|
||||
}
|
||||
input?.focus();
|
||||
input?.setSelectionRange(query.length, query.length);
|
||||
},
|
||||
},
|
||||
]);
|
||||
function toggleYear() {
|
||||
const url = new URL(page.url.href);
|
||||
if (year) {
|
||||
url.searchParams.delete("span");
|
||||
} else {
|
||||
url.searchParams.set("span", "year");
|
||||
}
|
||||
url.searchParams.delete("page");
|
||||
goto(url, { replace: true, reset: false });
|
||||
}
|
||||
|
||||
useKeys(() =>
|
||||
everything
|
||||
? [
|
||||
{
|
||||
group: "Global",
|
||||
keys: "/",
|
||||
label: "Search in this list",
|
||||
run: () => {
|
||||
if (!query && type !== "all") {
|
||||
query = `type:${type} `;
|
||||
}
|
||||
input?.focus();
|
||||
input?.setSelectionRange(query.length, query.length);
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
group: "Filter",
|
||||
keys: "Y",
|
||||
label: year ? "Back to the period" : "Load the last 12 months",
|
||||
run: toggleYear,
|
||||
},
|
||||
]
|
||||
);
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
@@ -165,14 +266,16 @@
|
||||
title="Transactions"
|
||||
>
|
||||
{#snippet actions()}
|
||||
<QueryInput
|
||||
class="w-80 max-md:w-full"
|
||||
id="list-query"
|
||||
onsubmit={search}
|
||||
placeholder="Search, press / to start"
|
||||
bind:input
|
||||
bind:value={query}
|
||||
/>
|
||||
{#if everything}
|
||||
<QueryInput
|
||||
class="w-80 max-md:w-full"
|
||||
id="list-query"
|
||||
onsubmit={search}
|
||||
placeholder="Search, press / to start"
|
||||
bind:input
|
||||
bind:value={query}
|
||||
/>
|
||||
{/if}
|
||||
<Button
|
||||
color="green"
|
||||
href={`/transactions/create/${createType(type)}`}
|
||||
@@ -185,11 +288,7 @@
|
||||
</PageHeader>
|
||||
|
||||
{#if !everything}
|
||||
<PeriodSummary
|
||||
groups={summary.data ?? []}
|
||||
loading={summary.isPending}
|
||||
{type}
|
||||
/>
|
||||
<PeriodSummary groups={matched} loading={ledger.isPending} {type} />
|
||||
{/if}
|
||||
|
||||
<div
|
||||
@@ -197,23 +296,54 @@
|
||||
>
|
||||
<Box body="p-0" title={LIST_TITLE[type] ?? "Transactions"}>
|
||||
{#snippet tools()}
|
||||
{#if !everything}
|
||||
<button class={TOOL} onclick={toggleYear} type="button">
|
||||
{year ? "Back to the period" : "Load year"}
|
||||
<span class="kbd max-md:hidden">Y</span>
|
||||
</button>
|
||||
{/if}
|
||||
<span class="max-sm:hidden">
|
||||
<Pager
|
||||
href={pageHref}
|
||||
keys
|
||||
page={pageNo}
|
||||
pages={pagination?.total_pages ?? 1}
|
||||
/>
|
||||
{#if everything}
|
||||
<Pager
|
||||
href={pageHref}
|
||||
keys
|
||||
page={pageNo}
|
||||
pages={list.data?.meta.pagination?.total_pages ?? 1}
|
||||
/>
|
||||
{:else if paged}
|
||||
<Pager href={pageHref} keys page={pageNo} {pages} />
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
<TransactionTable
|
||||
empty={everything
|
||||
? "No transactions."
|
||||
: "No transactions in this period."}
|
||||
groups={list.data?.data ?? []}
|
||||
id="tx-list"
|
||||
loading={list.isFetching}
|
||||
/>
|
||||
{#if everything}
|
||||
<TransactionTable
|
||||
empty="No transactions."
|
||||
groups={list.data?.data ?? []}
|
||||
id="tx-list"
|
||||
loading={list.isFetching}
|
||||
/>
|
||||
{:else}
|
||||
<FilterBar
|
||||
duplicates={clusterCount}
|
||||
{groups}
|
||||
ids={facetIds}
|
||||
{keep}
|
||||
onsearch={() => search(searchQuery(filters, type, range))}
|
||||
shown={matched.length}
|
||||
total={groups.length}
|
||||
{view}
|
||||
/>
|
||||
<TransactionTable
|
||||
context={{ accountId: narrowedTo }}
|
||||
empty={filtered
|
||||
? "Nothing matches this filter."
|
||||
: "No transactions in this period."}
|
||||
groups={visible}
|
||||
id="tx-list"
|
||||
loading={ledger.isPending || ledger.isPlaceholderData}
|
||||
{section}
|
||||
/>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
{#if everything}
|
||||
<a class="text-brand hover:underline" href={listPath(type)}
|
||||
@@ -225,11 +355,15 @@
|
||||
>
|
||||
{/if}
|
||||
<span class="ml-auto">
|
||||
<Pager
|
||||
href={pageHref}
|
||||
page={pageNo}
|
||||
pages={pagination?.total_pages ?? 1}
|
||||
/>
|
||||
{#if everything}
|
||||
<Pager
|
||||
href={pageHref}
|
||||
page={pageNo}
|
||||
pages={list.data?.meta.pagination?.total_pages ?? 1}
|
||||
/>
|
||||
{:else if paged}
|
||||
<Pager href={pageHref} page={pageNo} {pages} />
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
</Box>
|
||||
|
||||
Reference in new issue
Block a user