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
+1658
-96
No files matched your search
@@ -2,32 +2,26 @@
|
|||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
import { invalidate, lists } from "#lib/api/lists.js";
|
import { invalidate, lists } from "#lib/api/lists.js";
|
||||||
import type { Split } from "#lib/api/types.js";
|
|
||||||
import { fuzzyFilter } from "#lib/fuzzy.js";
|
import { fuzzyFilter } from "#lib/fuzzy.js";
|
||||||
import {
|
import {
|
||||||
deleteTargets,
|
deleteTargets,
|
||||||
|
type MassAction,
|
||||||
|
MODES,
|
||||||
|
type Mode,
|
||||||
type Outcome,
|
type Outcome,
|
||||||
type Patch,
|
patchFor,
|
||||||
type Row,
|
type Row,
|
||||||
splitsOf,
|
splitsOf,
|
||||||
targetsOf,
|
targetsOf,
|
||||||
updateGroups,
|
updateGroups,
|
||||||
} from "#lib/transactions/mass.js";
|
} from "#lib/transactions/mass.js";
|
||||||
import type { Suggestion } from "#lib/transactions/query.js";
|
import type { Suggestion } from "#lib/transactions/query.js";
|
||||||
|
import { lastAction } from "#lib/transactions/repeat.svelte.js";
|
||||||
import SuggestInput from "./SuggestInput.svelte";
|
import SuggestInput from "./SuggestInput.svelte";
|
||||||
|
|
||||||
export type Mode = "category" | "budget" | "tag" | "untag";
|
|
||||||
|
|
||||||
const ARM_MS = 3000;
|
const ARM_MS = 3000;
|
||||||
const STATUS_MS = 8000;
|
const STATUS_MS = 8000;
|
||||||
const NONE = "\u0000";
|
const NONE = "\u0000";
|
||||||
|
|
||||||
const MODES: { mode: Mode; keys: string; label: string; verb: string }[] = [
|
|
||||||
{ keys: "s c", label: "Category", mode: "category", verb: "Set category" },
|
|
||||||
{ keys: "s b", label: "Budget", mode: "budget", verb: "Set budget" },
|
|
||||||
{ keys: "s t", label: "Add tag", mode: "tag", verb: "Add tag" },
|
|
||||||
{ keys: "s T", label: "Remove tag", mode: "untag", verb: "Remove tag" },
|
|
||||||
];
|
|
||||||
const VERB = Object.fromEntries(MODES.map((m) => [m.mode, m.verb]));
|
const VERB = Object.fromEntries(MODES.map((m) => [m.mode, m.verb]));
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -58,6 +52,10 @@
|
|||||||
...lists.tags(),
|
...lists.tags(),
|
||||||
enabled: mode === "tag",
|
enabled: mode === "tag",
|
||||||
}));
|
}));
|
||||||
|
const bills = createQuery(() => ({
|
||||||
|
...lists.bills(),
|
||||||
|
enabled: mode === "bill",
|
||||||
|
}));
|
||||||
|
|
||||||
const visible = $derived(
|
const visible = $derived(
|
||||||
selected > 0 || mode !== null || armed || status !== ""
|
selected > 0 || mode !== null || armed || status !== ""
|
||||||
@@ -80,6 +78,14 @@
|
|||||||
"(no budget)",
|
"(no budget)",
|
||||||
false,
|
false,
|
||||||
];
|
];
|
||||||
|
case "bill":
|
||||||
|
return [
|
||||||
|
(bills.data ?? [])
|
||||||
|
.filter((b) => b.attributes.active !== false)
|
||||||
|
.map((b) => b.attributes.name ?? ""),
|
||||||
|
"(no subscription)",
|
||||||
|
false,
|
||||||
|
];
|
||||||
case "tag":
|
case "tag":
|
||||||
return [(tags.data ?? []).map((t) => t.attributes.tag), null, true];
|
return [(tags.data ?? []).map((t) => t.attributes.tag), null, true];
|
||||||
case "untag":
|
case "untag":
|
||||||
@@ -146,28 +152,23 @@
|
|||||||
text = "";
|
text = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function patcher(current: Mode, value: string | null) {
|
function idOf(current: Mode, value: string | null): string | null {
|
||||||
const budgetId =
|
const items = current === "bill" ? bills.data : budgets.data;
|
||||||
(budgets.data ?? []).find((b) => b.attributes.name === value)?.id ?? null;
|
return items?.find((item) => item.attributes.name === value)?.id ?? null;
|
||||||
const patches: Record<Mode, (split: Split) => Patch | null> = {
|
}
|
||||||
budget: (split) =>
|
|
||||||
split.type !== "withdrawal" || (split.budget_id ?? null) === budgetId
|
async function run(action: MassAction, rows: Row[]) {
|
||||||
? null
|
const verb = VERB[action.mode];
|
||||||
: { budget_id: budgetId },
|
busy = true;
|
||||||
category: (split) =>
|
const outcome = await updateGroups(
|
||||||
(split.category_name ?? null) === value
|
targetsOf(rows),
|
||||||
? null
|
patchFor(action),
|
||||||
: { category_name: value },
|
(progress) =>
|
||||||
tag: (split) =>
|
say(`${verb}: updated ${progress.done} of ${progress.total}`)
|
||||||
!value || split.tags?.includes(value)
|
);
|
||||||
? null
|
await invalidate();
|
||||||
: { tags: [...(split.tags ?? []), value] },
|
busy = false;
|
||||||
untag: (split) =>
|
report("Updated", outcome);
|
||||||
value && split.tags?.includes(value)
|
|
||||||
? { tags: split.tags.filter((tag) => tag !== value) }
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
return patches[current];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apply(raw: string) {
|
async function apply(raw: string) {
|
||||||
@@ -178,17 +179,25 @@
|
|||||||
if (value === null && (mode === "tag" || mode === "untag")) {
|
if (value === null && (mode === "tag" || mode === "untag")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const verb = VERB[mode];
|
const byId = mode === "budget" || mode === "bill";
|
||||||
const patch = patcher(mode, value);
|
const id = byId ? idOf(mode, value) : null;
|
||||||
const rows = active;
|
if (byId && value !== null && id === null) {
|
||||||
|
say(`Nothing called “${value}”`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const action = { id, mode, value };
|
||||||
|
lastAction.current = action;
|
||||||
mode = null;
|
mode = null;
|
||||||
busy = true;
|
await run(action, active);
|
||||||
const outcome = await updateGroups(targetsOf(rows), patch, (progress) =>
|
}
|
||||||
say(`${verb}: updated ${progress.done} of ${progress.total}`)
|
|
||||||
);
|
export async function repeat(rows: Row[] = targets) {
|
||||||
await invalidate();
|
const action = lastAction.current;
|
||||||
busy = false;
|
if (!action || busy || rows.length === 0) {
|
||||||
report("Updated", outcome);
|
return;
|
||||||
|
}
|
||||||
|
active = rows;
|
||||||
|
await run(action, rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function remove(rows: Row[] = targets, confirmed = false) {
|
export async function remove(rows: Row[] = targets, confirmed = false) {
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChevronDown from "@lucide/svelte/icons/chevron-down";
|
||||||
|
import Funnel from "@lucide/svelte/icons/funnel";
|
||||||
|
import Search from "@lucide/svelte/icons/search";
|
||||||
|
import X from "@lucide/svelte/icons/x";
|
||||||
|
import { DropdownMenu } from "bits-ui";
|
||||||
|
import DropdownMenuCheckboxItem from "#lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte";
|
||||||
|
import DropdownMenuContent from "#lib/components/ui/dropdown-menu/dropdown-menu-content.svelte";
|
||||||
|
import DropdownMenuItem from "#lib/components/ui/dropdown-menu/dropdown-menu-item.svelte";
|
||||||
|
import DropdownMenuRadioGroup from "#lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte";
|
||||||
|
import DropdownMenuRadioItem from "#lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte";
|
||||||
|
import DropdownMenuSeparator from "#lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte";
|
||||||
|
import { fuzzyFilter } from "#lib/fuzzy.js";
|
||||||
|
import { useKeys } from "#lib/keys.svelte.js";
|
||||||
|
import {
|
||||||
|
type Count,
|
||||||
|
countFacets,
|
||||||
|
FACETS,
|
||||||
|
type FacetId,
|
||||||
|
GROUP_BY,
|
||||||
|
GROUP_LABEL,
|
||||||
|
type GroupBy,
|
||||||
|
isFiltered,
|
||||||
|
toggleValue,
|
||||||
|
valueLabel,
|
||||||
|
} from "#lib/transactions/ledger.js";
|
||||||
|
import type { LedgerView } from "#lib/transactions/ledger-view.svelte.js";
|
||||||
|
import type { Group } from "#lib/transactions/split.js";
|
||||||
|
|
||||||
|
interface Option extends Count {
|
||||||
|
facet: string;
|
||||||
|
id: FacetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOTE_MS = 4000;
|
||||||
|
const SUGGESTIONS = 6;
|
||||||
|
const LABEL = Object.fromEntries(FACETS.map((f) => [f.id, f.label]));
|
||||||
|
const TRIGGER =
|
||||||
|
"inline-flex h-7 shrink-0 items-center gap-1 rounded-cladd-sm px-2 text-[12px] hover:bg-row-hover hover:text-cladd-fg data-[state=open]:bg-row-hover";
|
||||||
|
const ITEM =
|
||||||
|
"min-h-7 gap-3 rounded-cladd-sm py-0.5 ps-9 pe-2 font-normal text-[13px]";
|
||||||
|
const LINK = "inline-flex items-center gap-1.5 hover:text-cladd-fg";
|
||||||
|
|
||||||
|
let {
|
||||||
|
view,
|
||||||
|
groups,
|
||||||
|
keep,
|
||||||
|
ids,
|
||||||
|
shown,
|
||||||
|
total,
|
||||||
|
duplicates,
|
||||||
|
onsearch,
|
||||||
|
}: {
|
||||||
|
view: LedgerView;
|
||||||
|
groups: Group[];
|
||||||
|
keep: (group: Group) => boolean;
|
||||||
|
ids: FacetId[];
|
||||||
|
shown: number;
|
||||||
|
total: number;
|
||||||
|
duplicates: number;
|
||||||
|
onsearch: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let input = $state<HTMLInputElement>();
|
||||||
|
let focused = $state(false);
|
||||||
|
let cursor = $state(-1);
|
||||||
|
let note = $state("");
|
||||||
|
let noteTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const filters = $derived(view.filters);
|
||||||
|
const typed = $derived(filters.text.trim());
|
||||||
|
const counts = $derived(countFacets(groups, filters, ids, keep));
|
||||||
|
const options = $derived.by<Option[]>(() => {
|
||||||
|
if (!(typed && focused)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const loose = countFacets(groups, { ...filters, text: "" }, ids, keep);
|
||||||
|
return [...loose].flatMap(([id, list]) =>
|
||||||
|
list
|
||||||
|
.filter((c) => c.count > 0 && !filters.facets[id]?.includes(c.key))
|
||||||
|
.map((c) => ({ ...c, facet: LABEL[id], id }))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const suggestions = $derived(
|
||||||
|
fuzzyFilter(options, typed, (o) => `${o.label} ${o.facet}`, SUGGESTIONS)
|
||||||
|
);
|
||||||
|
const open = $derived(focused && typed !== "");
|
||||||
|
const nextGroup = $derived(
|
||||||
|
[null, ...GROUP_BY][
|
||||||
|
(GROUP_BY.indexOf(filters.group as GroupBy) + 2) % (GROUP_BY.length + 1)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
function focus() {
|
||||||
|
input?.focus();
|
||||||
|
input?.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
function say(message: string) {
|
||||||
|
note = message;
|
||||||
|
clearTimeout(noteTimer);
|
||||||
|
noteTimer = setTimeout(() => (note = ""), NOTE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function choose(option: Option | undefined) {
|
||||||
|
if (!option) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
view.set({
|
||||||
|
...toggleValue(filters, option.id, option.key, true),
|
||||||
|
text: "",
|
||||||
|
});
|
||||||
|
input?.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onkeydown(event: KeyboardEvent) {
|
||||||
|
const count = suggestions.length;
|
||||||
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
const step = event.key === "ArrowDown" ? 1 : -1;
|
||||||
|
cursor = ((cursor + 1 + step + count + 1) % (count + 1)) - 1;
|
||||||
|
} else if (event.key === "Tab" && !event.shiftKey && count > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
choose(suggestions[Math.max(cursor, 0)]);
|
||||||
|
} else if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (cursor >= 0) {
|
||||||
|
choose(suggestions[cursor]);
|
||||||
|
} else if (typed) {
|
||||||
|
onsearch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summary(id: FacetId, keys: string[]) {
|
||||||
|
return keys.length === 1
|
||||||
|
? valueLabel(id, keys[0])
|
||||||
|
: `${keys.length} values`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGroup(value: string) {
|
||||||
|
view.set({
|
||||||
|
...filters,
|
||||||
|
group: GROUP_BY.includes(value as GroupBy) ? (value as GroupBy) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useKeys(() => [
|
||||||
|
{ group: "Filter", keys: "f", label: "Filter this list", run: focus },
|
||||||
|
{ group: "Filter", keys: "/", label: "Filter this list", run: focus },
|
||||||
|
{
|
||||||
|
group: "Filter",
|
||||||
|
keys: "F",
|
||||||
|
label: "Clear filters",
|
||||||
|
run: () => view.clear(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Filter",
|
||||||
|
keys: ";",
|
||||||
|
label: `Group by ${nextGroup ? GROUP_LABEL[nextGroup].toLowerCase() : "nothing"}`,
|
||||||
|
run: () => view.cycleGroup(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Filter",
|
||||||
|
keys: "P",
|
||||||
|
label: view.pinned >= 0 ? "Unpin this filter" : "Pin this filter",
|
||||||
|
run: () => say(view.togglePin()),
|
||||||
|
},
|
||||||
|
...view.pins.map((pin, index) => ({
|
||||||
|
group: "Pinned filters",
|
||||||
|
keys: String(index + 1),
|
||||||
|
label: `Filter: ${pin.label}`,
|
||||||
|
run: () => view.apply(pin),
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet facetMenu(
|
||||||
|
id: FacetId
|
||||||
|
)}
|
||||||
|
{@const values = counts.get(id) ?? []}
|
||||||
|
{@const selected = filters.facets[id] ?? []}
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
class="{TRIGGER} {selected.length
|
||||||
|
? "text-cladd-fg"
|
||||||
|
: "text-cladd-fg-soft"}"
|
||||||
|
>
|
||||||
|
{#if selected.length}
|
||||||
|
<Funnel class="size-3 text-brand" />
|
||||||
|
{LABEL[id]}:
|
||||||
|
<span class="max-w-40 truncate font-semibold"
|
||||||
|
>{summary(id, selected)}</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
{LABEL[id]}
|
||||||
|
{/if}
|
||||||
|
<ChevronDown class="size-3 opacity-60" />
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenuContent align="start" class="w-72 p-1">
|
||||||
|
{#if selected.length}
|
||||||
|
<DropdownMenuItem
|
||||||
|
class={ITEM}
|
||||||
|
onSelect={() =>
|
||||||
|
view.set({ ...filters, facets: { ...filters.facets, [id]: [] } })}
|
||||||
|
>
|
||||||
|
Clear this filter
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{/if}
|
||||||
|
{#each values as value (value.key)}
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
checked={selected.includes(value.key)}
|
||||||
|
class={ITEM}
|
||||||
|
closeOnSelect={false}
|
||||||
|
onCheckedChange={(on) =>
|
||||||
|
view.set(toggleValue(filters, id, value.key, on))}
|
||||||
|
textValue={value.label}
|
||||||
|
>
|
||||||
|
<span class="min-w-0 flex-1 truncate">{value.label}</span>
|
||||||
|
<span class="num text-[12px] text-cladd-fg-soft">{value.count}</span>
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
{:else}
|
||||||
|
<p class="px-2 py-1 text-cladd-fg-soft">Nothing to filter by</p>
|
||||||
|
{/each}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 border-cladd-outline border-b px-2 pt-2 pb-1.5">
|
||||||
|
<div class="flex flex-wrap items-center gap-1">
|
||||||
|
<div class="relative w-64 max-md:w-full">
|
||||||
|
<Search
|
||||||
|
class="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-cladd-fg-softer"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
aria-activedescendant={open ? `ledger-filter-${cursor}` : undefined}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-controls="ledger-filter-list"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label="Filter this list"
|
||||||
|
autocomplete="off"
|
||||||
|
class="cladd-cut cladd-focusable-input h-7 w-full rounded-cladd-md pr-2 pl-7 text-[13px] text-cladd-fg placeholder:text-cladd-fg-softer"
|
||||||
|
id="ledger-filter"
|
||||||
|
onblur={() => (focused = false)}
|
||||||
|
onfocus={() => (focused = true)}
|
||||||
|
oninput={(event) => {
|
||||||
|
cursor = -1;
|
||||||
|
view.type(event.currentTarget.value);
|
||||||
|
}}
|
||||||
|
{onkeydown}
|
||||||
|
placeholder="Filter: text, amount, >100"
|
||||||
|
role="combobox"
|
||||||
|
spellcheck="false"
|
||||||
|
value={view.text}
|
||||||
|
bind:this={input}
|
||||||
|
>
|
||||||
|
{#if open}
|
||||||
|
<div
|
||||||
|
class="cladd-surface cladd-outline absolute top-full left-0 z-30 mt-1 w-80 max-w-[calc(100vw-2rem)] rounded-cladd-md p-1 shadow-cladd-popover"
|
||||||
|
id="ledger-filter-list"
|
||||||
|
role="listbox"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-selected={cursor === -1}
|
||||||
|
class="flex h-7 cursor-pointer items-center gap-2 rounded-cladd-sm px-2 {cursor ===
|
||||||
|
-1
|
||||||
|
? "bg-row-cursor"
|
||||||
|
: "hover:bg-row-hover"}"
|
||||||
|
id="ledger-filter--1"
|
||||||
|
onmousedown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onsearch();
|
||||||
|
}}
|
||||||
|
role="option"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 flex-1 truncate"
|
||||||
|
>Search Firefly for “{typed}”</span
|
||||||
|
>
|
||||||
|
<span class="kbd">↵</span>
|
||||||
|
</div>
|
||||||
|
{#each suggestions as option, index (`${option.id}:${option.key}`)}
|
||||||
|
<div
|
||||||
|
aria-selected={index === cursor}
|
||||||
|
class="flex h-7 cursor-pointer items-center gap-2 rounded-cladd-sm px-2 {index ===
|
||||||
|
cursor
|
||||||
|
? "bg-row-cursor"
|
||||||
|
: "hover:bg-row-hover"}"
|
||||||
|
id={`ledger-filter-${index}`}
|
||||||
|
onmousedown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
choose(option);
|
||||||
|
}}
|
||||||
|
role="option"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<span class="w-22 shrink-0 text-[12px] text-cladd-fg-soft"
|
||||||
|
>{option.facet}</span
|
||||||
|
>
|
||||||
|
<span class="min-w-0 flex-1 truncate">{option.label}</span>
|
||||||
|
<span class="num text-[12px] text-cladd-fg-soft"
|
||||||
|
>{option.count}</span
|
||||||
|
>
|
||||||
|
{#if index === Math.max(cursor, 0)}
|
||||||
|
<span class="kbd">Tab</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex min-w-0 items-center max-md:-mx-2 max-md:w-[calc(100%+1rem)] max-md:overflow-x-auto max-md:px-1 md:flex-wrap"
|
||||||
|
>
|
||||||
|
{#each ids as id (id)}
|
||||||
|
{@render facetMenu(id)}
|
||||||
|
{/each}
|
||||||
|
{#if duplicates > 0 || filters.dupes}
|
||||||
|
<button
|
||||||
|
aria-pressed={filters.dupes}
|
||||||
|
class="{TRIGGER} {filters.dupes ? "text-cladd-fg" : "text-warn"}"
|
||||||
|
onclick={() => view.set({ ...filters, dupes: !filters.dupes })}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{#if filters.dupes}
|
||||||
|
<Funnel class="size-3 text-brand" />
|
||||||
|
{/if}
|
||||||
|
Possible duplicates
|
||||||
|
<span class="num font-semibold">{duplicates}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex min-h-6 flex-wrap items-center gap-x-3 gap-y-1 px-1 text-[12px] text-cladd-fg-soft"
|
||||||
|
>
|
||||||
|
{#each view.pins as pin, index (pin.query)}
|
||||||
|
{@const active = index === view.pinned}
|
||||||
|
<span class="inline-flex items-center gap-0.5">
|
||||||
|
<button
|
||||||
|
aria-label={`Filter: ${pin.label}`}
|
||||||
|
aria-pressed={active}
|
||||||
|
class="{LINK} {active ? "font-semibold text-cladd-fg" : ""}"
|
||||||
|
onclick={() => view.apply(pin)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="kbd max-md:hidden">{index + 1}</span>
|
||||||
|
{pin.label}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label={`Unpin ${pin.label}`}
|
||||||
|
class="grid size-4 place-items-center rounded text-cladd-fg-softer hover:bg-row-hover hover:text-cladd-fg"
|
||||||
|
onclick={() => view.unpin(pin)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X class="size-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
<span class="ml-auto flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
<span aria-live="polite">{note}</span>
|
||||||
|
<span class="flex items-center gap-0.5">
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
class="{TRIGGER} {filters.group
|
||||||
|
? "text-cladd-fg"
|
||||||
|
: "text-cladd-fg-soft"}"
|
||||||
|
>
|
||||||
|
Group by:
|
||||||
|
<span class="font-semibold"
|
||||||
|
>{filters.group ? GROUP_LABEL[filters.group] : "None"}</span
|
||||||
|
>
|
||||||
|
<ChevronDown class="size-3 opacity-60" />
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenuContent align="end" class="w-44 p-1">
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
onValueChange={setGroup}
|
||||||
|
value={filters.group ?? "none"}
|
||||||
|
>
|
||||||
|
<DropdownMenuRadioItem class={ITEM} value="none"
|
||||||
|
>None</DropdownMenuRadioItem
|
||||||
|
>
|
||||||
|
{#each GROUP_BY as value (value)}
|
||||||
|
<DropdownMenuRadioItem class={ITEM} {value}
|
||||||
|
>{GROUP_LABEL[value]}</DropdownMenuRadioItem
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
<span class="kbd max-md:hidden">;</span>
|
||||||
|
</span>
|
||||||
|
<span class="num text-cladd-fg" id="ledger-count">
|
||||||
|
{#if isFiltered(filters)}
|
||||||
|
<strong>{shown}</strong>
|
||||||
|
of {total}
|
||||||
|
{:else}
|
||||||
|
{total}
|
||||||
|
{total === 1 ? "transaction" : "transactions"}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if isFiltered(filters)}
|
||||||
|
<button class={LINK} onclick={() => view.clear()} type="button">
|
||||||
|
Clear <span class="kbd max-md:hidden">F</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class={LINK}
|
||||||
|
onclick={() => say(view.togglePin())}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{view.pinned >= 0 ? "Unpin" : "Pin"}
|
||||||
|
<span class="kbd max-md:hidden">P</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
import {
|
import {
|
||||||
type Group,
|
type Group,
|
||||||
type Signed,
|
type Signed,
|
||||||
signedAmount,
|
|
||||||
sumByCurrency,
|
sumByCurrency,
|
||||||
} from "#lib/transactions/split.js";
|
} from "#lib/transactions/split.js";
|
||||||
|
|
||||||
@@ -16,9 +15,10 @@
|
|||||||
["transfer", "Transferred"],
|
["transfer", "Transferred"],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
interface Entry extends Signed {
|
interface Entry {
|
||||||
href?: string;
|
href?: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
sums: Signed[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -36,25 +36,29 @@
|
|||||||
})).filter((row) => row.sums.length > 0)
|
})).filter((row) => row.sums.length > 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const size = (entry: Entry) =>
|
||||||
|
Math.max(...entry.sums.map((sum) => Math.abs(sum.amount)));
|
||||||
|
|
||||||
function aggregate(
|
function aggregate(
|
||||||
list: Split[],
|
list: Split[],
|
||||||
pick: (split: Split) => [string, string | undefined]
|
pick: (split: Split) => [string, string | undefined]
|
||||||
): { entries: Entry[]; more: number } {
|
): { entries: Entry[]; more: number } {
|
||||||
const map = new Map<string, Entry>();
|
const map = new Map<string, { href?: string; splits: Split[] }>();
|
||||||
for (const split of list) {
|
for (const split of list) {
|
||||||
const [name, href] = pick(split);
|
const [name, href] = pick(split);
|
||||||
const money = signedAmount(split);
|
const entry = map.get(name) ?? { href, splits: [] };
|
||||||
const key = `${name}|${money.code}`;
|
entry.splits.push(split);
|
||||||
const entry = map.get(key);
|
map.set(name, entry);
|
||||||
if (entry) {
|
|
||||||
entry.amount += money.amount;
|
|
||||||
} else {
|
|
||||||
map.set(key, { ...money, href, name });
|
|
||||||
}
|
}
|
||||||
}
|
const sorted = [...map]
|
||||||
const sorted = [...map.values()].sort(
|
.map(([name, entry]) => ({
|
||||||
|
href: entry.href,
|
||||||
|
name,
|
||||||
|
sums: sumByCurrency(entry.splits).sort(
|
||||||
(a, b) => Math.abs(b.amount) - Math.abs(a.amount)
|
(a, b) => Math.abs(b.amount) - Math.abs(a.amount)
|
||||||
);
|
),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => size(b) - size(a));
|
||||||
return { entries: sorted.slice(0, TOP), more: sorted.length - TOP };
|
return { entries: sorted.slice(0, TOP), more: sorted.length - TOP };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,8 +131,10 @@
|
|||||||
{#each blocks as block (block.title)}
|
{#each blocks as block (block.title)}
|
||||||
<Box body="py-1" class="max-md:hidden" title={block.title}>
|
<Box body="py-1" class="max-md:hidden" title={block.title}>
|
||||||
<ul>
|
<ul>
|
||||||
{#each block.entries as entry (`${entry.name}|${entry.code}`)}
|
{#each block.entries as entry (entry.name)}
|
||||||
<li class="flex h-6 items-center justify-between gap-3 px-3">
|
<li
|
||||||
|
class="flex min-h-6 items-start justify-between gap-3 px-3 py-0.5"
|
||||||
|
>
|
||||||
{#if entry.href}
|
{#if entry.href}
|
||||||
<a class="truncate hover:underline" href={entry.href}
|
<a class="truncate hover:underline" href={entry.href}
|
||||||
>{entry.name}</a
|
>{entry.name}</a
|
||||||
@@ -136,7 +142,11 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<span class="truncate text-cladd-fg-soft">{entry.name}</span>
|
<span class="truncate text-cladd-fg-soft">{entry.name}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{@render amount(entry)}
|
<span class="flex flex-col items-end">
|
||||||
|
{#each entry.sums as sum (sum.code)}
|
||||||
|
{@render amount(sum)}
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
{:else}
|
{:else}
|
||||||
<li class="px-3 py-1 text-cladd-fg-soft">
|
<li class="px-3 py-1 text-cladd-fg-soft">
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||||
import NotebookText from "@lucide/svelte/icons/notebook-text";
|
import NotebookText from "@lucide/svelte/icons/notebook-text";
|
||||||
import Paperclip from "@lucide/svelte/icons/paperclip";
|
import Paperclip from "@lucide/svelte/icons/paperclip";
|
||||||
|
import { untrack } from "svelte";
|
||||||
import type { Split } from "#lib/api/types.js";
|
import type { Split } from "#lib/api/types.js";
|
||||||
import Amount from "#lib/components/Amount.svelte";
|
import Amount from "#lib/components/Amount.svelte";
|
||||||
|
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
@@ -17,7 +19,14 @@
|
|||||||
import type { Binding } from "#lib/keys.svelte.js";
|
import type { Binding } from "#lib/keys.svelte.js";
|
||||||
import { ListNav } from "#lib/list-nav.svelte.js";
|
import { ListNav } from "#lib/list-nav.svelte.js";
|
||||||
import { oldUiUrl } from "#lib/spa-paths.js";
|
import { oldUiUrl } from "#lib/spa-paths.js";
|
||||||
import { buildRows, type Row } from "#lib/transactions/mass.js";
|
import { type Section, subtotals } from "#lib/transactions/ledger.js";
|
||||||
|
import {
|
||||||
|
actionLabel,
|
||||||
|
buildRows,
|
||||||
|
type Mode,
|
||||||
|
type Row,
|
||||||
|
} from "#lib/transactions/mass.js";
|
||||||
|
import { lastAction } from "#lib/transactions/repeat.svelte.js";
|
||||||
import {
|
import {
|
||||||
accountUrl,
|
accountUrl,
|
||||||
balanceAfter,
|
balanceAfter,
|
||||||
@@ -34,7 +43,7 @@
|
|||||||
} from "#lib/transactions/split.js";
|
} from "#lib/transactions/split.js";
|
||||||
import { ui } from "#lib/ui.svelte.js";
|
import { ui } from "#lib/ui.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import ActionBar, { type Mode } from "./ActionBar.svelte";
|
import ActionBar from "./ActionBar.svelte";
|
||||||
import TypeIcon from "./TypeIcon.svelte";
|
import TypeIcon from "./TypeIcon.svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -44,6 +53,7 @@
|
|||||||
loading = false,
|
loading = false,
|
||||||
id = "transactions",
|
id = "transactions",
|
||||||
empty = "No transactions.",
|
empty = "No transactions.",
|
||||||
|
section,
|
||||||
}: {
|
}: {
|
||||||
groups: Group[];
|
groups: Group[];
|
||||||
context?: { accountId?: string };
|
context?: { accountId?: string };
|
||||||
@@ -51,8 +61,15 @@
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
id?: string;
|
id?: string;
|
||||||
empty?: string;
|
empty?: string;
|
||||||
|
section?: (group: Group) => Section;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
interface Block extends Section {
|
||||||
|
groups: Group[];
|
||||||
|
keys: string[];
|
||||||
|
sums: Signed[];
|
||||||
|
}
|
||||||
|
|
||||||
let collapsed = $state(new Set<string>());
|
let collapsed = $state(new Set<string>());
|
||||||
let opened = $state(new Set<string>());
|
let opened = $state(new Set<string>());
|
||||||
let bar = $state<ActionBar>();
|
let bar = $state<ActionBar>();
|
||||||
@@ -65,6 +82,56 @@
|
|||||||
|
|
||||||
const accountId = $derived(context.accountId);
|
const accountId = $derived(context.accountId);
|
||||||
const rows = $derived(buildRows(groups, collapsed));
|
const rows = $derived(buildRows(groups, collapsed));
|
||||||
|
const blocks = $derived.by(() => {
|
||||||
|
const starts = new Map<number, Block>();
|
||||||
|
if (!section) {
|
||||||
|
return starts;
|
||||||
|
}
|
||||||
|
const byKey = new Map<string, Block>();
|
||||||
|
let previous: string | null = null;
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
const value = section(row.group);
|
||||||
|
let block = byKey.get(value.key);
|
||||||
|
if (!block) {
|
||||||
|
block = { ...value, groups: [], keys: [], sums: [] };
|
||||||
|
byKey.set(value.key, block);
|
||||||
|
}
|
||||||
|
if (row.kind !== "split") {
|
||||||
|
block.groups.push(row.group);
|
||||||
|
}
|
||||||
|
block.keys.push(row.key);
|
||||||
|
if (value.key !== previous) {
|
||||||
|
starts.set(index, block);
|
||||||
|
previous = value.key;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const block of byKey.values()) {
|
||||||
|
block.sums = subtotals(block.groups, context.accountId);
|
||||||
|
}
|
||||||
|
return starts;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const present = new Set(rows.map((row) => row.key));
|
||||||
|
untrack(() => {
|
||||||
|
const kept = [...nav.selected].filter((key) => present.has(key));
|
||||||
|
if (kept.length !== nav.selected.size) {
|
||||||
|
nav.selected = new Set(kept);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectKeys(keys: string[], on: boolean) {
|
||||||
|
const next = new Set(nav.selected);
|
||||||
|
for (const key of keys) {
|
||||||
|
if (on) {
|
||||||
|
next.add(key);
|
||||||
|
} else {
|
||||||
|
next.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nav.selected = next;
|
||||||
|
}
|
||||||
|
|
||||||
function toggled(set: Set<string>, key: string, on: boolean) {
|
function toggled(set: Set<string>, key: string, on: boolean) {
|
||||||
const next = new Set(set);
|
const next = new Set(set);
|
||||||
@@ -157,6 +224,7 @@
|
|||||||
list.push(
|
list.push(
|
||||||
action("category", "Set category", "s c"),
|
action("category", "Set category", "s c"),
|
||||||
action("budget", "Set budget", "s b"),
|
action("budget", "Set budget", "s b"),
|
||||||
|
action("bill", "Set subscription", "s s"),
|
||||||
action("tag", "Add tag", "s t"),
|
action("tag", "Add tag", "s t"),
|
||||||
action("untag", "Remove tag", "s T"),
|
action("untag", "Remove tag", "s T"),
|
||||||
{
|
{
|
||||||
@@ -167,6 +235,14 @@
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (selectable && lastAction.current) {
|
||||||
|
list.push({
|
||||||
|
group: "Selection",
|
||||||
|
keys: "s .",
|
||||||
|
label: `Repeat: ${actionLabel(lastAction.current)}`,
|
||||||
|
run: () => bar?.repeat(),
|
||||||
|
});
|
||||||
|
}
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,6 +478,77 @@
|
|||||||
</td>
|
</td>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet blockRow(
|
||||||
|
block: Block
|
||||||
|
)}
|
||||||
|
{@const all = block.keys.every((key) => nav.selected.has(key))}
|
||||||
|
<tr
|
||||||
|
class="border-cladd-outline border-t bg-cladd-surface-next [&>td]:px-2 [&>td]:py-1"
|
||||||
|
>
|
||||||
|
{#if selectable}
|
||||||
|
<td class="max-sm:hidden">
|
||||||
|
<input
|
||||||
|
aria-label={`Select ${block.label}`}
|
||||||
|
checked={all}
|
||||||
|
class={CHECKBOX}
|
||||||
|
onclick={(event) => {
|
||||||
|
event.currentTarget.blur();
|
||||||
|
selectKeys(block.keys, !all);
|
||||||
|
}}
|
||||||
|
type="checkbox"
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
<td class="max-sm:hidden"></td>
|
||||||
|
<td class="min-w-0">
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
{#if block.href}
|
||||||
|
<a class="truncate font-semibold hover:underline" href={block.href}
|
||||||
|
>{block.label}</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class="truncate font-semibold">{block.label}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="num shrink-0 text-[12px] text-cladd-fg-soft"
|
||||||
|
>{block.groups.length}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if block.actions?.length}
|
||||||
|
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
{#each block.actions as item (item.label)}
|
||||||
|
{#if item.confirm}
|
||||||
|
<ConfirmButton onconfirm={item.run}>{item.label}</ConfirmButton>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="inline-flex h-cladd-sm items-center rounded-cladd-sm px-2 text-[12px] text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg"
|
||||||
|
onclick={item.run}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="text-right font-semibold">
|
||||||
|
{#each block.sums as sum (`${sum.tone}|${sum.code}`)}
|
||||||
|
<div>{@render money(sum)}</div>
|
||||||
|
{/each}
|
||||||
|
</td>
|
||||||
|
<td class="max-lg:hidden"></td>
|
||||||
|
{#if accountId}
|
||||||
|
<td></td>
|
||||||
|
{/if}
|
||||||
|
<td></td>
|
||||||
|
<td class="max-md:hidden"></td>
|
||||||
|
<td class="max-md:hidden"></td>
|
||||||
|
<td class="max-lg:hidden"></td>
|
||||||
|
<td class="max-xl:hidden"></td>
|
||||||
|
<td class="max-2xl:hidden"></td>
|
||||||
|
</tr>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#snippet headCells(
|
{#snippet headCells(
|
||||||
row: Row
|
row: Row
|
||||||
)}
|
)}
|
||||||
@@ -534,6 +681,10 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class={loading && rows.length > 0 ? "opacity-60" : ""}>
|
<tbody class={loading && rows.length > 0 ? "opacity-60" : ""}>
|
||||||
{#each rows as row, index (row.key)}
|
{#each rows as row, index (row.key)}
|
||||||
|
{@const block = blocks.get(index)}
|
||||||
|
{#if block}
|
||||||
|
{@render blockRow(block)}
|
||||||
|
{/if}
|
||||||
<tr
|
<tr
|
||||||
class="align-top [&>td]:px-2 [&>td]:py-1 {rowClass(row, index)}"
|
class="align-top [&>td]:px-2 [&>td]:py-1 {rowClass(row, index)}"
|
||||||
data-index={index}
|
data-index={index}
|
||||||
@@ -597,6 +748,9 @@
|
|||||||
<ContextMenuItem onSelect={() => bar?.start("budget", menuRows)}>
|
<ContextMenuItem onSelect={() => bar?.start("budget", menuRows)}>
|
||||||
Set budget... <ContextMenuShortcut>s b</ContextMenuShortcut>
|
Set budget... <ContextMenuShortcut>s b</ContextMenuShortcut>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onSelect={() => bar?.start("bill", menuRows)}>
|
||||||
|
Set subscription... <ContextMenuShortcut>s s</ContextMenuShortcut>
|
||||||
|
</ContextMenuItem>
|
||||||
<ContextMenuItem onSelect={() => bar?.start("tag", menuRows)}>
|
<ContextMenuItem onSelect={() => bar?.start("tag", menuRows)}>
|
||||||
Add tag... <ContextMenuShortcut>s t</ContextMenuShortcut>
|
Add tag... <ContextMenuShortcut>s t</ContextMenuShortcut>
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { untrack } from "svelte";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import {
|
||||||
|
describe,
|
||||||
|
type Filters,
|
||||||
|
filterParams,
|
||||||
|
GROUP_BY,
|
||||||
|
isFiltered,
|
||||||
|
readFilters,
|
||||||
|
withFilters,
|
||||||
|
} from "./ledger.js";
|
||||||
|
|
||||||
|
const TYPING_MS = 300;
|
||||||
|
const MAX_PINS = 9;
|
||||||
|
const PINS_KEY = "ffui.ledger.pins";
|
||||||
|
const IGNORED_KEY = "ffui.ledger.notDuplicates";
|
||||||
|
|
||||||
|
export interface Pin {
|
||||||
|
label: string;
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PINS: Pin[] = [
|
||||||
|
{ label: "No budget", query: "budget=none" },
|
||||||
|
{ label: "No category", query: "category=none" },
|
||||||
|
{ label: "With notes", query: "notes=yes" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function stored<T>(key: string, fallback: T): T {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
return raw ? (JSON.parse(raw) as T) : fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinQuery = (filters: Filters) =>
|
||||||
|
filterParams({ ...filters, group: null }).toString();
|
||||||
|
|
||||||
|
export class LedgerView {
|
||||||
|
text = $state("");
|
||||||
|
pins = $state<Pin[]>(stored(PINS_KEY, DEFAULT_PINS));
|
||||||
|
ignored = $state(new Set<string>(stored<string[]>(IGNORED_KEY, [])));
|
||||||
|
private written = "";
|
||||||
|
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
url = $derived(readFilters(page.url.searchParams));
|
||||||
|
filters = $derived<Filters>({ ...this.url, text: this.text });
|
||||||
|
pinned = $derived(
|
||||||
|
this.pins.findIndex((pin) => pin.query === pinQuery(this.filters))
|
||||||
|
);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.text = this.url.text;
|
||||||
|
this.written = this.text;
|
||||||
|
$effect(() => {
|
||||||
|
const incoming = this.url.text;
|
||||||
|
untrack(() => {
|
||||||
|
if (incoming !== this.written) {
|
||||||
|
this.written = incoming;
|
||||||
|
this.text = incoming;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private go(filters: Filters) {
|
||||||
|
this.written = filters.text;
|
||||||
|
goto(withFilters(page.url, filters), { replace: true, reset: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
set(next: Filters) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.text = next.text;
|
||||||
|
this.go(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
type(value: string) {
|
||||||
|
this.text = value;
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = setTimeout(
|
||||||
|
() => this.go({ ...this.url, text: value }),
|
||||||
|
TYPING_MS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.set({ ...this.filters, dupes: false, facets: {}, text: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
cycleGroup() {
|
||||||
|
const order = [null, ...GROUP_BY];
|
||||||
|
const next = order[(order.indexOf(this.filters.group) + 1) % order.length];
|
||||||
|
this.set({ ...this.filters, group: next });
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(pin: Pin) {
|
||||||
|
const active = pin.query === pinQuery(this.filters);
|
||||||
|
const next = readFilters(new URLSearchParams(active ? "" : pin.query));
|
||||||
|
this.set({ ...next, group: this.filters.group });
|
||||||
|
}
|
||||||
|
|
||||||
|
private savePins(pins: Pin[]) {
|
||||||
|
this.pins = pins;
|
||||||
|
localStorage.setItem(PINS_KEY, JSON.stringify(pins));
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePin(): string {
|
||||||
|
if (this.pinned >= 0) {
|
||||||
|
const { label } = this.pins[this.pinned];
|
||||||
|
this.savePins(this.pins.filter((_, index) => index !== this.pinned));
|
||||||
|
return `Unpinned ${label}`;
|
||||||
|
}
|
||||||
|
if (!isFiltered(this.filters)) {
|
||||||
|
return "Filter first, then pin";
|
||||||
|
}
|
||||||
|
if (this.pins.length >= MAX_PINS) {
|
||||||
|
return `Up to ${MAX_PINS} pins`;
|
||||||
|
}
|
||||||
|
const label = describe({ ...this.filters, group: null });
|
||||||
|
this.savePins([...this.pins, { label, query: pinQuery(this.filters) }]);
|
||||||
|
return `Pinned as ${this.pins.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
unpin(pin: Pin) {
|
||||||
|
this.savePins(this.pins.filter((item) => item.query !== pin.query));
|
||||||
|
}
|
||||||
|
|
||||||
|
ignore(id: string) {
|
||||||
|
this.ignored = new Set([...this.ignored, id]);
|
||||||
|
localStorage.setItem(IGNORED_KEY, JSON.stringify([...this.ignored]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,617 @@
|
|||||||
|
import type { Split } from "#lib/api/types.js";
|
||||||
|
import { addDays, addMonths, type Range } from "#lib/dates.js";
|
||||||
|
import { formatDate, formatMoney } from "#lib/format.js";
|
||||||
|
import {
|
||||||
|
accountUrl,
|
||||||
|
type Group,
|
||||||
|
type Signed,
|
||||||
|
signedAmount,
|
||||||
|
TYPE_LABEL,
|
||||||
|
} from "./split.js";
|
||||||
|
|
||||||
|
export type FacetId =
|
||||||
|
| "kind"
|
||||||
|
| "category"
|
||||||
|
| "budget"
|
||||||
|
| "account"
|
||||||
|
| "currency"
|
||||||
|
| "tag"
|
||||||
|
| "bill"
|
||||||
|
| "notes"
|
||||||
|
| "foreign";
|
||||||
|
export type GroupBy = "day" | "destination" | "category" | "budget";
|
||||||
|
|
||||||
|
export interface Facet {
|
||||||
|
id: FacetId;
|
||||||
|
keys: (split: Split) => string[];
|
||||||
|
label: string;
|
||||||
|
names?: Record<string, string>;
|
||||||
|
none?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Count {
|
||||||
|
count: number;
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Filters {
|
||||||
|
dupes: boolean;
|
||||||
|
facets: Partial<Record<FacetId, string[]>>;
|
||||||
|
group: GroupBy | null;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Section {
|
||||||
|
actions?: {
|
||||||
|
confirm?: boolean;
|
||||||
|
label: string;
|
||||||
|
run: () => void | Promise<void>;
|
||||||
|
}[];
|
||||||
|
href?: string;
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Cluster {
|
||||||
|
groups: Group[];
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NONE = "none";
|
||||||
|
export const GROUP_BY: GroupBy[] = ["day", "destination", "category", "budget"];
|
||||||
|
export const GROUP_LABEL: Record<GroupBy, string> = {
|
||||||
|
budget: "Budget",
|
||||||
|
category: "Category",
|
||||||
|
day: "Day",
|
||||||
|
destination: "Destination",
|
||||||
|
};
|
||||||
|
|
||||||
|
const OWN = new Set([
|
||||||
|
"Asset account",
|
||||||
|
"Default account",
|
||||||
|
"Debt",
|
||||||
|
"Loan",
|
||||||
|
"Mortgage",
|
||||||
|
]);
|
||||||
|
const TONES = ["expense", "income", "transfer", "neutral"];
|
||||||
|
const WINDOW_MS = 48 * 3600 * 1000;
|
||||||
|
const HOUR_MS = 3600 * 1000;
|
||||||
|
const WHITESPACE = /\s+/;
|
||||||
|
const COMPARE = /^([<>])(\d+(?:[.,]\d+)?)$/;
|
||||||
|
const NEEDS_QUOTES = /[\s"]/;
|
||||||
|
const QUOTE = /"/g;
|
||||||
|
const PARENS = /\(([^)]*)\)/g;
|
||||||
|
const YES_NO = (yes: string, no: string) => ({ no, yes });
|
||||||
|
const flag = (on: boolean) => [on ? "yes" : "no"];
|
||||||
|
const named = (name: string | null | undefined, applies: boolean) => {
|
||||||
|
if (name) {
|
||||||
|
return [name];
|
||||||
|
}
|
||||||
|
return applies ? [NONE] : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FACETS: Facet[] = [
|
||||||
|
{
|
||||||
|
id: "kind",
|
||||||
|
keys: (s) => [s.type],
|
||||||
|
label: "Type",
|
||||||
|
names: TYPE_LABEL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "category",
|
||||||
|
keys: (s) => named(s.category_name, true),
|
||||||
|
label: "Category",
|
||||||
|
none: "(no category)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "budget",
|
||||||
|
keys: (s) => named(s.budget_name, s.type === "withdrawal"),
|
||||||
|
label: "Budget",
|
||||||
|
none: "(no budget)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "account",
|
||||||
|
keys: (s) =>
|
||||||
|
[
|
||||||
|
OWN.has(s.source_type ?? "") ? s.source_name : null,
|
||||||
|
OWN.has(s.destination_type ?? "") ? s.destination_name : null,
|
||||||
|
].filter((name): name is string => Boolean(name)),
|
||||||
|
label: "Account",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "currency",
|
||||||
|
keys: (s) =>
|
||||||
|
[s.currency_code, s.foreign_currency_code].filter(
|
||||||
|
(code): code is string => Boolean(code)
|
||||||
|
),
|
||||||
|
label: "Currency",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tag",
|
||||||
|
keys: (s) => (s.tags?.length ? s.tags : [NONE]),
|
||||||
|
label: "Tag",
|
||||||
|
none: "(no tags)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bill",
|
||||||
|
keys: (s) =>
|
||||||
|
named(s.bill_name ?? s.subscription_name, s.type === "withdrawal"),
|
||||||
|
label: "Subscription",
|
||||||
|
none: "(no subscription)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notes",
|
||||||
|
keys: (s) => flag(Boolean(s.notes?.trim())),
|
||||||
|
label: "Notes",
|
||||||
|
names: YES_NO("With notes", "Without notes"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "foreign",
|
||||||
|
keys: (s) => flag(Boolean(s.foreign_amount && s.foreign_currency_code)),
|
||||||
|
label: "Foreign",
|
||||||
|
names: YES_NO("With foreign amount", "Without foreign amount"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const FACET = Object.fromEntries(FACETS.map((f) => [f.id, f])) as Record<
|
||||||
|
FacetId,
|
||||||
|
Facet
|
||||||
|
>;
|
||||||
|
const PARAMS = ["q", "group", "dupes", "page", ...FACETS.map((f) => f.id)];
|
||||||
|
|
||||||
|
export function valueLabel(id: FacetId, key: string): string {
|
||||||
|
const facet = FACET[id];
|
||||||
|
if (key === NONE && facet.none) {
|
||||||
|
return facet.none;
|
||||||
|
}
|
||||||
|
return facet.names?.[key] ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readFilters(
|
||||||
|
params: Pick<URLSearchParams, "get" | "getAll">
|
||||||
|
): Filters {
|
||||||
|
const facets: Filters["facets"] = {};
|
||||||
|
for (const facet of FACETS) {
|
||||||
|
const values = params.getAll(facet.id).filter(Boolean);
|
||||||
|
if (values.length > 0) {
|
||||||
|
facets[facet.id] = [...new Set(values)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const group = params.get("group") as GroupBy;
|
||||||
|
return {
|
||||||
|
dupes: params.get("dupes") === "1",
|
||||||
|
facets,
|
||||||
|
group: GROUP_BY.includes(group) ? group : null,
|
||||||
|
text: params.get("q") ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterParams(filters: Filters): URLSearchParams {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const facet of FACETS) {
|
||||||
|
for (const value of [...(filters.facets[facet.id] ?? [])].sort()) {
|
||||||
|
params.append(facet.id, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filters.text.trim()) {
|
||||||
|
params.set("q", filters.text);
|
||||||
|
}
|
||||||
|
if (filters.group) {
|
||||||
|
params.set("group", filters.group);
|
||||||
|
}
|
||||||
|
if (filters.dupes) {
|
||||||
|
params.set("dupes", "1");
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withFilters(url: { href: string }, filters: Filters): URL {
|
||||||
|
const next = new URL(url.href);
|
||||||
|
for (const name of PARAMS) {
|
||||||
|
next.searchParams.delete(name);
|
||||||
|
}
|
||||||
|
for (const [name, value] of filterParams(filters)) {
|
||||||
|
next.searchParams.append(name, value);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFiltered(filters: Filters): boolean {
|
||||||
|
return (
|
||||||
|
filters.dupes ||
|
||||||
|
filters.text.trim() !== "" ||
|
||||||
|
Object.values(filters.facets).some((values) => values.length > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleValue(
|
||||||
|
filters: Filters,
|
||||||
|
id: FacetId,
|
||||||
|
key: string,
|
||||||
|
on?: boolean
|
||||||
|
): Filters {
|
||||||
|
const current = new Set(filters.facets[id] ?? []);
|
||||||
|
if (on ?? !current.has(key)) {
|
||||||
|
current.add(key);
|
||||||
|
} else {
|
||||||
|
current.delete(key);
|
||||||
|
}
|
||||||
|
return { ...filters, facets: { ...filters.facets, [id]: [...current] } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const haystacks = new WeakMap<Split, string>();
|
||||||
|
|
||||||
|
function plainAmount(
|
||||||
|
value: string | null | undefined,
|
||||||
|
decimals?: number | null
|
||||||
|
) {
|
||||||
|
return value ? Number(value).toFixed(decimals ?? 2) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function haystack(split: Split): string {
|
||||||
|
let text = haystacks.get(split);
|
||||||
|
if (text === undefined) {
|
||||||
|
text = [
|
||||||
|
split.description,
|
||||||
|
split.notes,
|
||||||
|
split.source_name,
|
||||||
|
split.destination_name,
|
||||||
|
split.category_name,
|
||||||
|
split.budget_name,
|
||||||
|
split.bill_name,
|
||||||
|
split.currency_code,
|
||||||
|
split.foreign_currency_code,
|
||||||
|
plainAmount(split.amount, split.currency_decimal_places),
|
||||||
|
plainAmount(split.foreign_amount, split.foreign_currency_decimal_places),
|
||||||
|
...(split.tags ?? []),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
.toLowerCase();
|
||||||
|
haystacks.set(split, text);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wordTest(word: string): (split: Split) => boolean {
|
||||||
|
const match = COMPARE.exec(word);
|
||||||
|
if (!match) {
|
||||||
|
return (split) => haystack(split).includes(word);
|
||||||
|
}
|
||||||
|
const limit = Number(match[2].replace(",", "."));
|
||||||
|
const size = (split: Split) => Math.abs(Number(split.amount));
|
||||||
|
return match[1] === ">"
|
||||||
|
? (split) => size(split) > limit
|
||||||
|
: (split) => size(split) < limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitTest(
|
||||||
|
filters: Filters,
|
||||||
|
skip?: FacetId
|
||||||
|
): (split: Split) => boolean {
|
||||||
|
const words = filters.text
|
||||||
|
.toLowerCase()
|
||||||
|
.split(WHITESPACE)
|
||||||
|
.filter((word) => word && !word.includes(":"))
|
||||||
|
.map(wordTest);
|
||||||
|
const active = FACETS.filter(
|
||||||
|
(facet) => facet.id !== skip && filters.facets[facet.id]?.length
|
||||||
|
).map((facet) => ({ facet, wanted: new Set(filters.facets[facet.id]) }));
|
||||||
|
return (split) =>
|
||||||
|
active.every(({ facet, wanted }) =>
|
||||||
|
facet.keys(split).some((key) => wanted.has(key))
|
||||||
|
) && words.every((test) => test(split));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function narrow(
|
||||||
|
groups: Group[],
|
||||||
|
test: (split: Split) => boolean,
|
||||||
|
keep: (group: Group) => boolean = () => true
|
||||||
|
): Group[] {
|
||||||
|
const out: Group[] = [];
|
||||||
|
for (const group of groups) {
|
||||||
|
if (!keep(group)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const splits = group.attributes.transactions;
|
||||||
|
const kept = splits.filter(test);
|
||||||
|
if (kept.length === splits.length) {
|
||||||
|
out.push(group);
|
||||||
|
} else if (kept.length > 0) {
|
||||||
|
out.push({
|
||||||
|
...group,
|
||||||
|
attributes: { ...group.attributes, transactions: kept },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countFacets(
|
||||||
|
groups: Group[],
|
||||||
|
filters: Filters,
|
||||||
|
ids: FacetId[],
|
||||||
|
keep: (group: Group) => boolean
|
||||||
|
): Map<FacetId, Count[]> {
|
||||||
|
const result = new Map<FacetId, Count[]>();
|
||||||
|
for (const id of ids) {
|
||||||
|
const facet = FACET[id];
|
||||||
|
const test = splitTest(filters, id);
|
||||||
|
const found = new Map<string, Set<string>>(
|
||||||
|
(filters.facets[id] ?? []).map((key) => [key, new Set()])
|
||||||
|
);
|
||||||
|
for (const group of groups) {
|
||||||
|
if (!keep(group)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const split of group.attributes.transactions) {
|
||||||
|
if (!test(split)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const key of facet.keys(split)) {
|
||||||
|
const seen = found.get(key) ?? new Set<string>();
|
||||||
|
seen.add(group.id);
|
||||||
|
found.set(key, seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const counts = [...found].map(([key, seen]) => ({
|
||||||
|
count: seen.size,
|
||||||
|
key,
|
||||||
|
label: valueLabel(id, key),
|
||||||
|
}));
|
||||||
|
counts.sort(
|
||||||
|
(a, b) =>
|
||||||
|
Number(b.key === NONE) - Number(a.key === NONE) ||
|
||||||
|
b.count - a.count ||
|
||||||
|
a.label.localeCompare(b.label)
|
||||||
|
);
|
||||||
|
result.set(id, counts);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sectionOf(by: GroupBy, group: Group): Section {
|
||||||
|
const [split] = group.attributes.transactions;
|
||||||
|
switch (by) {
|
||||||
|
case "day": {
|
||||||
|
const day = split?.date?.slice(0, 10) ?? "";
|
||||||
|
return { key: day, label: formatDate(day, "day") };
|
||||||
|
}
|
||||||
|
case "destination":
|
||||||
|
return {
|
||||||
|
href: accountUrl(split?.destination_id),
|
||||||
|
key: `account:${split?.destination_id ?? ""}`,
|
||||||
|
label: split?.destination_name ?? "(cash)",
|
||||||
|
};
|
||||||
|
case "category":
|
||||||
|
return {
|
||||||
|
href: split?.category_id
|
||||||
|
? `/categories/show/${split.category_id}`
|
||||||
|
: undefined,
|
||||||
|
key: split?.category_name ?? NONE,
|
||||||
|
label: split?.category_name ?? "(no category)",
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
href: split?.budget_id ? `/budgets/show/${split.budget_id}` : undefined,
|
||||||
|
key: split?.budget_name ?? NONE,
|
||||||
|
label: split?.budget_name ?? "(no budget)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bySection(
|
||||||
|
groups: Group[],
|
||||||
|
section: (group: Group) => Section
|
||||||
|
): Group[] {
|
||||||
|
const buckets = new Map<string, Group[]>();
|
||||||
|
for (const group of groups) {
|
||||||
|
const { key } = section(group);
|
||||||
|
const list = buckets.get(key) ?? [];
|
||||||
|
list.push(group);
|
||||||
|
buckets.set(key, list);
|
||||||
|
}
|
||||||
|
return [...buckets]
|
||||||
|
.sort(
|
||||||
|
([a, left], [b, right]) =>
|
||||||
|
Number(b === NONE) - Number(a === NONE) || right.length - left.length
|
||||||
|
)
|
||||||
|
.flatMap(([, list]) => list);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subtotals(groups: Group[], accountId?: string): Signed[] {
|
||||||
|
const sums = new Map<string, Signed>();
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const split of group.attributes.transactions) {
|
||||||
|
const money = signedAmount(split, accountId);
|
||||||
|
const key = `${money.tone}|${money.code}`;
|
||||||
|
const sum = sums.get(key);
|
||||||
|
if (sum) {
|
||||||
|
sum.amount += money.amount;
|
||||||
|
} else {
|
||||||
|
sums.set(key, { ...money });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...sums.values()].sort(
|
||||||
|
(a, b) =>
|
||||||
|
TONES.indexOf(a.tone) - TONES.indexOf(b.tone) ||
|
||||||
|
(a.code ?? "").localeCompare(b.code ?? "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newestFirst(a: Group, b: Group) {
|
||||||
|
const dateA = a.attributes.transactions[0]?.date ?? "";
|
||||||
|
const dateB = b.attributes.transactions[0]?.date ?? "";
|
||||||
|
return dateB.localeCompare(dateA) || Number(b.id) - Number(a.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spanLabel(ms: number): string {
|
||||||
|
const hours = Math.max(1, Math.round(ms / HOUR_MS));
|
||||||
|
if (hours < 24) {
|
||||||
|
return `${hours} h`;
|
||||||
|
}
|
||||||
|
const days = Math.round(hours / 24);
|
||||||
|
return days === 1 ? "1 day" : `${days} days`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clusterOf(
|
||||||
|
run: { group: Group; time: number }[],
|
||||||
|
split: Split
|
||||||
|
): Cluster {
|
||||||
|
const groups = [...new Set(run.map((item) => item.group))].sort(newestFirst);
|
||||||
|
const span = (run.at(-1)?.time ?? 0) - (run[0]?.time ?? 0);
|
||||||
|
const money = formatMoney({
|
||||||
|
amount: split.amount,
|
||||||
|
code: split.currency_code,
|
||||||
|
decimals: split.currency_decimal_places,
|
||||||
|
symbol: split.currency_symbol,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
groups,
|
||||||
|
id: groups
|
||||||
|
.map((group) => group.id)
|
||||||
|
.sort()
|
||||||
|
.join("+"),
|
||||||
|
label: `${split.destination_name ?? "(cash)"} · ${money} · ${spanLabel(span)} apart`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function duplicates(
|
||||||
|
groups: Group[],
|
||||||
|
ignored: Set<string>
|
||||||
|
): Map<string, Cluster> {
|
||||||
|
const buckets = new Map<
|
||||||
|
string,
|
||||||
|
{ group: Group; split: Split; time: number }[]
|
||||||
|
>();
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const split of group.attributes.transactions) {
|
||||||
|
if (!split.destination_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = `${split.type}|${split.destination_id}|${split.currency_code}|${Number(split.amount)}`;
|
||||||
|
const list = buckets.get(key) ?? [];
|
||||||
|
list.push({ group, split, time: Date.parse(split.date) });
|
||||||
|
buckets.set(key, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const found = new Map<string, Cluster>();
|
||||||
|
const flush = (run: { group: Group; split: Split; time: number }[]) => {
|
||||||
|
if (new Set(run.map((item) => item.group.id)).size < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cluster = clusterOf(run, run[0].split);
|
||||||
|
if (ignored.has(cluster.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const group of cluster.groups) {
|
||||||
|
found.set(group.id, cluster);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const list of buckets.values()) {
|
||||||
|
list.sort((a, b) => a.time - b.time);
|
||||||
|
let run: typeof list = [];
|
||||||
|
for (const item of list) {
|
||||||
|
const last = run.at(-1);
|
||||||
|
if (last && item.time - last.time > WINDOW_MS) {
|
||||||
|
flush(run);
|
||||||
|
run = [];
|
||||||
|
}
|
||||||
|
run.push(item);
|
||||||
|
}
|
||||||
|
flush(run);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function accountIdOf(groups: Group[], name: string): string | undefined {
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const split of group.attributes.transactions) {
|
||||||
|
if (split.source_name === name && OWN.has(split.source_type ?? "")) {
|
||||||
|
return split.source_id ?? undefined;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
split.destination_name === name &&
|
||||||
|
OWN.has(split.destination_type ?? "")
|
||||||
|
) {
|
||||||
|
return split.destination_id ?? undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yearRange(end: string): Range {
|
||||||
|
return { end, start: addDays(addMonths(end, -12), 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const quote = (value: string) =>
|
||||||
|
NEEDS_QUOTES.test(value) ? `"${value.replace(QUOTE, '\\"')}"` : value;
|
||||||
|
|
||||||
|
const OPERATOR: Partial<Record<FacetId, [string, string]>> = {
|
||||||
|
account: ["account_is", ""],
|
||||||
|
bill: ["bill_is", "has_no_bill:true"],
|
||||||
|
budget: ["budget_is", "has_no_budget:true"],
|
||||||
|
category: ["category_is", "has_no_category:true"],
|
||||||
|
currency: ["currency_is", ""],
|
||||||
|
kind: ["type", ""],
|
||||||
|
tag: ["tag_is", "has_no_tag:true"],
|
||||||
|
};
|
||||||
|
const FLAG_OPERATOR: Record<string, Record<string, string>> = {
|
||||||
|
foreign: { no: "-has_foreign_amount:true", yes: "has_foreign_amount:true" },
|
||||||
|
notes: { no: "no_notes:true", yes: "has_any_notes:true" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function searchQuery(
|
||||||
|
filters: Filters,
|
||||||
|
type: string,
|
||||||
|
range: Range | null
|
||||||
|
): string {
|
||||||
|
const parts = [filters.text.trim()];
|
||||||
|
if (type !== "all") {
|
||||||
|
parts.push(`type:${type}`);
|
||||||
|
}
|
||||||
|
for (const [id, values] of Object.entries(filters.facets)) {
|
||||||
|
if (values?.length !== 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [value] = values;
|
||||||
|
const flagged = FLAG_OPERATOR[id]?.[value];
|
||||||
|
const [operator, none] = OPERATOR[id as FacetId] ?? ["", ""];
|
||||||
|
if (flagged) {
|
||||||
|
parts.push(flagged);
|
||||||
|
} else if (value === NONE && none) {
|
||||||
|
parts.push(none);
|
||||||
|
} else if (operator) {
|
||||||
|
parts.push(`${operator}:${quote(value)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (range) {
|
||||||
|
parts.push(`date_after:${range.start}`, `date_before:${range.end}`);
|
||||||
|
}
|
||||||
|
return parts.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describe(filters: Filters): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const facet of FACETS) {
|
||||||
|
const values = filters.facets[facet.id] ?? [];
|
||||||
|
if (values.length > 0) {
|
||||||
|
parts.push(values.map((key) => valueLabel(facet.id, key)).join(" or "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filters.text.trim()) {
|
||||||
|
parts.push(`“${filters.text.trim()}”`);
|
||||||
|
}
|
||||||
|
if (filters.dupes) {
|
||||||
|
parts.push("Duplicates");
|
||||||
|
}
|
||||||
|
if (filters.group) {
|
||||||
|
parts.push(`by ${GROUP_LABEL[filters.group].toLowerCase()}`);
|
||||||
|
}
|
||||||
|
const label = parts.join(", ").replace(PARENS, "$1");
|
||||||
|
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||||
|
}
|
||||||
@@ -4,6 +4,86 @@ import { type Group, groupTitle, journalId } from "./split.js";
|
|||||||
|
|
||||||
export type Patch = Record<string, unknown>;
|
export type Patch = Record<string, unknown>;
|
||||||
export type Targets = Map<string, Set<string> | null>;
|
export type Targets = Map<string, Set<string> | null>;
|
||||||
|
export type Mode = "category" | "budget" | "bill" | "tag" | "untag";
|
||||||
|
|
||||||
|
export interface MassAction {
|
||||||
|
id: string | null;
|
||||||
|
mode: Mode;
|
||||||
|
value: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MODES: {
|
||||||
|
keys: string;
|
||||||
|
label: string;
|
||||||
|
mode: Mode;
|
||||||
|
none?: string;
|
||||||
|
verb: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
keys: "s c",
|
||||||
|
label: "Category",
|
||||||
|
mode: "category",
|
||||||
|
none: "(no category)",
|
||||||
|
verb: "Set category",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keys: "s b",
|
||||||
|
label: "Budget",
|
||||||
|
mode: "budget",
|
||||||
|
none: "(no budget)",
|
||||||
|
verb: "Set budget",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keys: "s s",
|
||||||
|
label: "Subscription",
|
||||||
|
mode: "bill",
|
||||||
|
none: "(no subscription)",
|
||||||
|
verb: "Set subscription",
|
||||||
|
},
|
||||||
|
{ keys: "s t", label: "Add tag", mode: "tag", verb: "Add tag" },
|
||||||
|
{ keys: "s T", label: "Remove tag", mode: "untag", verb: "Remove tag" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MODE = Object.fromEntries(MODES.map((m) => [m.mode, m]));
|
||||||
|
|
||||||
|
export function actionLabel(action: MassAction): string {
|
||||||
|
const { verb, none } = MODE[action.mode];
|
||||||
|
return `${verb} “${action.value ?? none}”`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patchFor({
|
||||||
|
mode,
|
||||||
|
value,
|
||||||
|
id,
|
||||||
|
}: MassAction): (split: Split) => Patch | null {
|
||||||
|
switch (mode) {
|
||||||
|
case "budget":
|
||||||
|
return (split) =>
|
||||||
|
split.type !== "withdrawal" || (split.budget_id ?? null) === id
|
||||||
|
? null
|
||||||
|
: { budget_id: id };
|
||||||
|
case "bill":
|
||||||
|
return (split) =>
|
||||||
|
split.type !== "withdrawal" || (split.bill_id ?? null) === id
|
||||||
|
? null
|
||||||
|
: { bill_id: id };
|
||||||
|
case "category":
|
||||||
|
return (split) =>
|
||||||
|
(split.category_name ?? null) === value
|
||||||
|
? null
|
||||||
|
: { category_name: value };
|
||||||
|
case "tag":
|
||||||
|
return (split) =>
|
||||||
|
!value || split.tags?.includes(value)
|
||||||
|
? null
|
||||||
|
: { tags: [...(split.tags ?? []), value] };
|
||||||
|
default:
|
||||||
|
return (split) =>
|
||||||
|
value && split.tags?.includes(value)
|
||||||
|
? { tags: split.tags.filter((tag) => tag !== value) }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface Row {
|
export interface Row {
|
||||||
count: number;
|
count: number;
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { MassAction } from "./mass.js";
|
||||||
|
|
||||||
|
export const lastAction = $state<{ current: MassAction | null }>({
|
||||||
|
current: null,
|
||||||
|
});
|
||||||
@@ -3,10 +3,11 @@
|
|||||||
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||||
import { tick, untrack } from "svelte";
|
import { tick, untrack } from "svelte";
|
||||||
import { api } from "#lib/api/client.js";
|
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 type { Page, TransactionGroup } from "#lib/api/types.js";
|
||||||
import Box from "#lib/components/Box.svelte";
|
import Box from "#lib/components/Box.svelte";
|
||||||
import PageHeader from "#lib/components/shell/PageHeader.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 Pager from "#lib/components/transactions/Pager.svelte";
|
||||||
import PeriodSummary from "#lib/components/transactions/PeriodSummary.svelte";
|
import PeriodSummary from "#lib/components/transactions/PeriodSummary.svelte";
|
||||||
import Periods from "#lib/components/transactions/Periods.svelte";
|
import Periods from "#lib/components/transactions/Periods.svelte";
|
||||||
@@ -17,8 +18,25 @@
|
|||||||
import { useKeys } from "#lib/keys.svelte.js";
|
import { useKeys } from "#lib/keys.svelte.js";
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import { session } from "#lib/session.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 {
|
import {
|
||||||
createType,
|
createType,
|
||||||
|
type Group,
|
||||||
LIST_TITLE,
|
LIST_TITLE,
|
||||||
listPath,
|
listPath,
|
||||||
listType,
|
listType,
|
||||||
@@ -32,6 +50,9 @@
|
|||||||
transfer: "All transfers",
|
transfer: "All transfers",
|
||||||
withdrawal: "All expenses",
|
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 {
|
let {
|
||||||
param,
|
param,
|
||||||
@@ -49,13 +70,20 @@
|
|||||||
let input = $state<HTMLInputElement>();
|
let input = $state<HTMLInputElement>();
|
||||||
let lastRange = "";
|
let lastRange = "";
|
||||||
|
|
||||||
|
const view = new LedgerView();
|
||||||
const type = $derived(listType(param));
|
const type = $derived(listType(param));
|
||||||
const pageNo = $derived(
|
const pageNo = $derived(
|
||||||
Math.max(1, Number(page.url.searchParams.get("page")) || 1)
|
Math.max(1, Number(page.url.searchParams.get("page")) || 1)
|
||||||
);
|
);
|
||||||
const range = $derived(
|
const year = $derived(page.url.searchParams.get("span") === "year");
|
||||||
everything ? null : { end: period.end, start: period.start }
|
const range = $derived.by(() => {
|
||||||
);
|
if (everything) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return year
|
||||||
|
? yearRange(period.end)
|
||||||
|
: { end: period.end, start: period.start };
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (period.ready && start && end) {
|
if (period.ready && start && end) {
|
||||||
@@ -89,29 +117,16 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const list = createQuery(() => ({
|
const list = createQuery(() => ({
|
||||||
enabled: everything || period.ready,
|
enabled: everything,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api<Page<TransactionGroup>>("/v1/transactions", {
|
api<Page<TransactionGroup>>("/v1/transactions", {
|
||||||
query: {
|
query: { limit: session.pageSize, page: pageNo, type },
|
||||||
end: range?.end,
|
|
||||||
limit: session.pageSize,
|
|
||||||
page: pageNo,
|
|
||||||
start: range?.start,
|
|
||||||
type,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
queryKey: [
|
queryKey: ["transactions", type, pageNo, session.pageSize],
|
||||||
"transactions",
|
|
||||||
type,
|
|
||||||
range?.start,
|
|
||||||
range?.end,
|
|
||||||
pageNo,
|
|
||||||
session.pageSize,
|
|
||||||
],
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const summary = createQuery(() => ({
|
const ledger = createQuery(() => ({
|
||||||
enabled: !everything && period.ready,
|
enabled: !everything && period.ready,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -120,16 +135,80 @@
|
|||||||
start: range?.start,
|
start: range?.start,
|
||||||
type,
|
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(
|
const subtitle = $derived(
|
||||||
range
|
range
|
||||||
? `${SUBTITLE[type]} between ${formatDate(range.start)} and ${formatDate(range.end)}`
|
? `${SUBTITLE[type]} between ${formatDate(range.start)} and ${formatDate(range.end)}`
|
||||||
: SUBTITLE[type]
|
: 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) {
|
function pageHref(n: number) {
|
||||||
const url = new URL(page.url.href);
|
const url = new URL(page.url.href);
|
||||||
url.searchParams.set("page", String(n));
|
url.searchParams.set("page", String(n));
|
||||||
@@ -140,7 +219,20 @@
|
|||||||
goto(`/search?search=${encodeURIComponent(text)}`);
|
goto(`/search?search=${encodeURIComponent(text)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
useKeys(() => [
|
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",
|
group: "Global",
|
||||||
keys: "/",
|
keys: "/",
|
||||||
@@ -153,7 +245,16 @@
|
|||||||
input?.setSelectionRange(query.length, query.length);
|
input?.setSelectionRange(query.length, query.length);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
group: "Filter",
|
||||||
|
keys: "Y",
|
||||||
|
label: year ? "Back to the period" : "Load the last 12 months",
|
||||||
|
run: toggleYear,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -165,6 +266,7 @@
|
|||||||
title="Transactions"
|
title="Transactions"
|
||||||
>
|
>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
|
{#if everything}
|
||||||
<QueryInput
|
<QueryInput
|
||||||
class="w-80 max-md:w-full"
|
class="w-80 max-md:w-full"
|
||||||
id="list-query"
|
id="list-query"
|
||||||
@@ -173,6 +275,7 @@
|
|||||||
bind:input
|
bind:input
|
||||||
bind:value={query}
|
bind:value={query}
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
<Button
|
<Button
|
||||||
color="green"
|
color="green"
|
||||||
href={`/transactions/create/${createType(type)}`}
|
href={`/transactions/create/${createType(type)}`}
|
||||||
@@ -185,11 +288,7 @@
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{#if !everything}
|
{#if !everything}
|
||||||
<PeriodSummary
|
<PeriodSummary groups={matched} loading={ledger.isPending} {type} />
|
||||||
groups={summary.data ?? []}
|
|
||||||
loading={summary.isPending}
|
|
||||||
{type}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -197,23 +296,54 @@
|
|||||||
>
|
>
|
||||||
<Box body="p-0" title={LIST_TITLE[type] ?? "Transactions"}>
|
<Box body="p-0" title={LIST_TITLE[type] ?? "Transactions"}>
|
||||||
{#snippet tools()}
|
{#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">
|
<span class="max-sm:hidden">
|
||||||
|
{#if everything}
|
||||||
<Pager
|
<Pager
|
||||||
href={pageHref}
|
href={pageHref}
|
||||||
keys
|
keys
|
||||||
page={pageNo}
|
page={pageNo}
|
||||||
pages={pagination?.total_pages ?? 1}
|
pages={list.data?.meta.pagination?.total_pages ?? 1}
|
||||||
/>
|
/>
|
||||||
|
{:else if paged}
|
||||||
|
<Pager href={pageHref} keys page={pageNo} {pages} />
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
{#if everything}
|
||||||
<TransactionTable
|
<TransactionTable
|
||||||
empty={everything
|
empty="No transactions."
|
||||||
? "No transactions."
|
|
||||||
: "No transactions in this period."}
|
|
||||||
groups={list.data?.data ?? []}
|
groups={list.data?.data ?? []}
|
||||||
id="tx-list"
|
id="tx-list"
|
||||||
loading={list.isFetching}
|
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()}
|
{#snippet footer()}
|
||||||
{#if everything}
|
{#if everything}
|
||||||
<a class="text-brand hover:underline" href={listPath(type)}
|
<a class="text-brand hover:underline" href={listPath(type)}
|
||||||
@@ -225,11 +355,15 @@
|
|||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="ml-auto">
|
<span class="ml-auto">
|
||||||
|
{#if everything}
|
||||||
<Pager
|
<Pager
|
||||||
href={pageHref}
|
href={pageHref}
|
||||||
page={pageNo}
|
page={pageNo}
|
||||||
pages={pagination?.total_pages ?? 1}
|
pages={list.data?.meta.pagination?.total_pages ?? 1}
|
||||||
/>
|
/>
|
||||||
|
{:else if paged}
|
||||||
|
<Pager href={pageHref} page={pageNo} {pages} />
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
Reference in new issue
Block a user