From 772047c0a17f02e9fc115c300ad5e5deca8c6a5f Mon Sep 17 00:00:00 2001 From: h Date: Fri, 16 Oct 2026 03:33:54 +0000 Subject: [PATCH] feat(transactions,search): dense split-aware tables, mass edit, query-language search and show page --- .../components/transactions/ActionBar.svelte | 293 +++++++ src/lib/components/transactions/Pager.svelte | 93 +++ .../transactions/PeriodSummary.svelte | 154 ++++ .../components/transactions/QueryInput.svelte | 150 ++++ .../transactions/SuggestInput.svelte | 149 ++++ .../transactions/TransactionTable.svelte | 595 ++++++++++++++ .../components/transactions/TypeIcon.svelte | 27 + src/lib/format.ts | 15 +- src/lib/transactions/mass.ts | 162 ++++ src/lib/transactions/query.ts | 312 +++++++ src/lib/transactions/split.ts | 151 ++++ src/routes/search/+page.svelte | 195 +++++ .../[type=txtype]/ListPage.svelte | 229 ++++++ .../[[start=date]]/[[end=date]]/+page.svelte | 10 + .../[type=txtype]/all/+page.svelte | 6 + .../transactions/show/[id=id]/+page.svelte | 761 ++++++++++++++++++ 16 files changed, 3300 insertions(+), 2 deletions(-) create mode 100644 src/lib/components/transactions/ActionBar.svelte create mode 100644 src/lib/components/transactions/Pager.svelte create mode 100644 src/lib/components/transactions/PeriodSummary.svelte create mode 100644 src/lib/components/transactions/QueryInput.svelte create mode 100644 src/lib/components/transactions/SuggestInput.svelte create mode 100644 src/lib/components/transactions/TransactionTable.svelte create mode 100644 src/lib/components/transactions/TypeIcon.svelte create mode 100644 src/lib/transactions/mass.ts create mode 100644 src/lib/transactions/query.ts create mode 100644 src/lib/transactions/split.ts create mode 100644 src/routes/search/+page.svelte create mode 100644 src/routes/transactions/[type=txtype]/ListPage.svelte create mode 100644 src/routes/transactions/[type=txtype]/[[start=date]]/[[end=date]]/+page.svelte create mode 100644 src/routes/transactions/[type=txtype]/all/+page.svelte create mode 100644 src/routes/transactions/show/[id=id]/+page.svelte diff --git a/src/lib/components/transactions/ActionBar.svelte b/src/lib/components/transactions/ActionBar.svelte new file mode 100644 index 0000000..12655af --- /dev/null +++ b/src/lib/components/transactions/ActionBar.svelte @@ -0,0 +1,293 @@ + + +{#if visible} + +{/if} diff --git a/src/lib/components/transactions/Pager.svelte b/src/lib/components/transactions/Pager.svelte new file mode 100644 index 0000000..496c447 --- /dev/null +++ b/src/lib/components/transactions/Pager.svelte @@ -0,0 +1,93 @@ + + +{#if pages > 1} + +{/if} diff --git a/src/lib/components/transactions/PeriodSummary.svelte b/src/lib/components/transactions/PeriodSummary.svelte new file mode 100644 index 0000000..924e395 --- /dev/null +++ b/src/lib/components/transactions/PeriodSummary.svelte @@ -0,0 +1,154 @@ + + +{#snippet amount( + value: Signed +)} + +{/snippet} + +
+ + {#if loading && groups.length === 0} +

Loading...

+ {:else} +
+
Transactions
+
{groups.length}
+ {#each totals as row (row.label)} +
{row.label}
+
+ {#each row.sums as sum (sum.code)} + {@render amount(sum)} + {/each} +
+ {/each} +
+ {/if} +
+ {#each blocks as block (block.title)} + +
    + {#each block.entries as entry (`${entry.name}|${entry.code}`)} +
  • + {#if entry.href} + {entry.name} + {:else} + {entry.name} + {/if} + {@render amount(entry)} +
  • + {:else} +
  • + {loading ? "Loading..." : "Nothing in this period."} +
  • + {/each} + {#if block.more > 0} +
  • + and {block.more} more +
  • + {/if} +
+
+ {/each} +
diff --git a/src/lib/components/transactions/QueryInput.svelte b/src/lib/components/transactions/QueryInput.svelte new file mode 100644 index 0000000..ca3a1cc --- /dev/null +++ b/src/lib/components/transactions/QueryInput.svelte @@ -0,0 +1,150 @@ + + + (used = true)} + onsubmit={() => onsubmit(value.trim())} + {placeholder} + {suggestions} + bind:caret + bind:input + bind:value +/> diff --git a/src/lib/components/transactions/SuggestInput.svelte b/src/lib/components/transactions/SuggestInput.svelte new file mode 100644 index 0000000..f99fcdb --- /dev/null +++ b/src/lib/components/transactions/SuggestInput.svelte @@ -0,0 +1,149 @@ + + +
+ { + focused = false; + onblur?.(event); + }} + onclick={sync} + onfocus={(event) => { + focused = true; + sync(); + onfocus?.(event); + }} + oninput={() => { + dismissed = false; + sync(); + }} + {onkeydown} + onkeyup={sync} + role="combobox" + spellcheck="false" + bind:this={input} + bind:value + > + {#if shown} +
+ {#each suggestions as item, index (item.id)} +
{ + event.preventDefault(); + accept(index); + }} + role="option" + tabindex="-1" + > + {item.label} + {#if item.hint} + {item.hint} + {/if} +
+ {/each} +
+ {/if} +
diff --git a/src/lib/components/transactions/TransactionTable.svelte b/src/lib/components/transactions/TransactionTable.svelte new file mode 100644 index 0000000..6b4537e --- /dev/null +++ b/src/lib/components/transactions/TransactionTable.svelte @@ -0,0 +1,595 @@ + + +{#snippet day( + value: string | undefined +)} + {formatDate(value?.slice(0, 10), "short")} + {formatDate(value?.slice(0, 10))} +{/snippet} + +{#snippet tagLinks( + tags: string[] | null | undefined +)} + {#each tags ?? [] as tag (tag)} + {tag} + {/each} +{/snippet} + +{#snippet money( + value: Signed +)} + +{/snippet} + +{#snippet plain( + value: Money | null +)} + {#if value} + {formatMoney(value)} + {/if} +{/snippet} + +{#snippet account( + accountIdValue: string | null | undefined, + name: string | null | undefined, + type: string | null | undefined +)} + {#if isCash(type)} + (cash) + {:else if accountIdValue} + {name} + {/if} +{/snippet} + +{#snippet splitCells( + row: Row, + split: Split +)} + {@const amount = signedAmount(split, accountId)} + {@const foreign = signedForeign(split, accountId)} + {@const wide = opened.has(row.key)} + +
+ {#if split.reconciled} + + {/if} + {#if split.has_attachments} + + {/if} + {split.description} + {#if split.tags?.length} + {@render tagLinks(split.tags)} + {/if} +
+ {#if split.notes} +
+ + {split.notes} +
+ {/if} + + +
+ {@render money(amount)} + {#if foreign} + ({formatMoney(foreign)}) + {/if} +
+ + {#if accountId} + {@render plain(balanceAfter(split, accountId))} + {/if} + + {#if row.kind !== "split"} + {@render day(split.date)} + {/if} + + + {@render account(split.source_id, split.source_name, split.source_type)} + + + {@render account( + split.destination_id, + split.destination_name, + split.destination_type + )} + + + {#if split.category_id} + {split.category_name} + {/if} + + + {#if split.budget_id} + {split.budget_name} + {/if} + + + {@render tagLinks(split.tags)} + +{/snippet} + +{#snippet headCells( + row: Row +)} + {@const splits = row.group.attributes.transactions} + {@const folded = collapsed.has(row.group.id)} + {@const source = common(splits, (s) => s.source_id)} + {@const destination = common(splits, (s) => s.destination_id)} + {@const sums = sumByCurrency(splits, accountId)} + +
+ + {row.group.attributes.group_title || splits[0]?.description} + {row.count} + splits +
+ + +
+ {#each sums as sum, i (sum.code)} + {@render money(sum)} + {#if i < sums.length - 1} + , + {/if} + {/each} +
+ + {#if accountId} + + {/if} + + {@render day(splits[0]?.date)} + + + {#if source} + {@render account(source, splits[0].source_name, splits[0].source_type)} + {/if} + + + {#if destination} + {@render account( + destination, + splits[0].destination_name, + splits[0].destination_type + )} + {/if} + + + + +{/snippet} + +
+
+ nav.clear()} + selected={nav.selected.size} + targets={nav.targets} + bind:this={bar} + /> +
+ + + + + + {#if selectable} + + {/if} + + + + {#if accountId} + + {/if} + + + + + + + + + 0 ? "opacity-60" : ""}> + {#each rows as row, index (row.key)} + nav.click(index, event)} + ondblclick={() => open(row)} + > + {#if selectable} + + {/if} + + {#if row.split} + {@render splitCells(row, row.split)} + {:else} + {@render headCells(row)} + {/if} + + {/each} + +
+ 0 && nav.selected.size === rows.length} + class={CHECKBOX} + onclick={(event) => { + event.currentTarget.blur(); + nav.selectAll(); + }} + type="checkbox" + > + TypeDescriptionAmountBalanceDateSource accountDestination accountCategoryBudgetTags
+ { + event.stopPropagation(); + event.currentTarget.blur(); + nav.toggle(row); + }} + type="checkbox" + > + + {#if row.kind !== "split"} + + {/if} +
+ {#if rows.length === 0} +

+ {loading ? "Loading transactions..." : empty} +

+ {/if} +
+ + {#if menuRows.length > 0} + {@const first = menuRows[0]} + open(first)}> + Open ↵ + + goto(editUrl(first.group.id))}> + Edit e + + goto(cloneUrl(first.group))}> + Clone c + + {#if selectable} + + bar?.start("category", menuRows)}> + Set category... s c + + bar?.start("budget", menuRows)}> + Set budget... s b + + bar?.start("tag", menuRows)}> + Add tag... s t + + bar?.start("untag", menuRows)}> + Remove tag... s T + + + { + if (menuArmed) { + bar?.remove(menuRows, true); + return; + } + event.preventDefault(); + menuArmed = true; + }} + variant="destructive" + > + {menuArmed ? "Press again to delete" : "Delete"} + {menuRows.length > 1 ? `${menuRows.length} transactions` : ""} + d + + {/if} + + oldUi(first)}> + Open in old UI + + {:else} + Right-click a row + {/if} + +
+
diff --git a/src/lib/components/transactions/TypeIcon.svelte b/src/lib/components/transactions/TypeIcon.svelte new file mode 100644 index 0000000..1b3f7b7 --- /dev/null +++ b/src/lib/components/transactions/TypeIcon.svelte @@ -0,0 +1,27 @@ + + + + {TYPE_LABEL[type] ?? type} + diff --git a/src/lib/format.ts b/src/lib/format.ts index 9485993..ff4c0d6 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -51,10 +51,21 @@ export function formatNumber( return numberFormat(decimals, signed).format(toNumber(value)); } +function signOf(value: number, signed: boolean): string { + if (settings.anonymous || value === 0) { + return ""; + } + if (value < 0) { + return "-"; + } + return signed ? "+" : ""; +} + export function formatMoney(money: Money, signed = false): string { - const number = formatNumber(money.amount, money.decimals ?? 2, signed); + const value = toNumber(money.amount); + const number = formatNumber(Math.abs(value), money.decimals ?? 2); const symbol = money.symbol ?? money.code ?? ""; - return symbol ? `${symbol}${number}` : number; + return `${signOf(value, signed)}${symbol}${number}`; } export function compactMoney(value: number, symbol = ""): string { diff --git a/src/lib/transactions/mass.ts b/src/lib/transactions/mass.ts new file mode 100644 index 0000000..7b43437 --- /dev/null +++ b/src/lib/transactions/mass.ts @@ -0,0 +1,162 @@ +import { api } from "#lib/api/client.js"; +import type { One, Split, TransactionGroup } from "#lib/api/types.js"; +import { type Group, groupTitle, journalId } from "./split.js"; + +export type Patch = Record; +export type Targets = Map | null>; + +export interface Row { + count: number; + group: Group; + key: string; + kind: "single" | "head" | "split"; + split?: Split; +} + +export interface Outcome { + done: number; + errors: string[]; + total: number; +} + +export function buildRows(groups: Group[], collapsed: Set): Row[] { + const rows: Row[] = []; + for (const group of groups) { + const splits = group.attributes.transactions; + const count = splits.length; + if (count === 1) { + rows.push({ + count, + group, + key: group.id, + kind: "single", + split: splits[0], + }); + continue; + } + rows.push({ count, group, key: group.id, kind: "head" }); + if (collapsed.has(group.id)) { + continue; + } + for (const split of splits) { + rows.push({ + count, + group, + key: `${group.id}:${journalId(split)}`, + kind: "split", + split, + }); + } + } + return rows; +} + +export function targetsOf(rows: Row[]): Targets { + const targets: Targets = new Map(); + for (const row of rows) { + const set = targets.get(row.group.id) ?? new Set(); + const splits = row.split ? [row.split] : row.group.attributes.transactions; + for (const split of splits) { + set.add(journalId(split)); + } + targets.set(row.group.id, set); + } + return targets; +} + +export function splitsOf(rows: Row[]): Split[] { + return rows.flatMap((row) => + row.split ? [row.split] : row.group.attributes.transactions + ); +} + +async function load(id: string) { + const fresh = await api>(`/v1/transactions/${id}`); + return fresh.data; +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function each( + targets: Targets, + run: (id: string, journals: Set | null) => Promise, + progress: (outcome: Outcome) => void +): Promise { + const outcome: Outcome = { done: 0, errors: [], total: targets.size }; + progress(outcome); + await Promise.all( + [...targets].map(async ([id, journals]) => { + try { + await run(id, journals); + outcome.done += 1; + } catch (error) { + outcome.errors.push(`#${id}: ${message(error)}`); + } + progress({ ...outcome }); + }) + ); + return outcome; +} + +export function updateGroups( + targets: Targets, + patch: (split: Split) => Patch | null, + progress: (outcome: Outcome) => void +): Promise { + return each( + targets, + async (id, journals) => { + const fresh = await load(id); + const splits = fresh.attributes.transactions; + let changed = false; + const transactions = splits.map((split) => { + const hit = journals === null || journals.has(journalId(split)); + const change = hit ? patch(split) : null; + changed ||= change !== null; + return { transaction_journal_id: journalId(split), ...change }; + }); + if (!changed) { + return; + } + await api(`/v1/transactions/${id}`, { + body: { + transactions, + ...(splits.length > 1 && { group_title: groupTitle(fresh) }), + }, + method: "PUT", + }); + }, + progress + ); +} + +export function deleteTargets( + targets: Targets, + progress: (outcome: Outcome) => void +): Promise { + return each( + targets, + async (id, journals) => { + const fresh = await load(id); + const whole = + journals === null || + fresh.attributes.transactions.every((split) => + journals.has(journalId(split)) + ); + if (whole) { + await api(`/v1/transactions/${id}`, { method: "DELETE" }); + return; + } + await [...journals].reduce( + (chain, journal) => + chain.then(() => + api(`/v1/transaction-journals/${journal}`, { method: "DELETE" }) + ), + Promise.resolve() + ); + }, + progress + ); +} diff --git a/src/lib/transactions/query.ts b/src/lib/transactions/query.ts new file mode 100644 index 0000000..93b8f41 --- /dev/null +++ b/src/lib/transactions/query.ts @@ -0,0 +1,312 @@ +import { api } from "#lib/api/client.js"; + +export interface Operator { + alias: boolean; + alias_for?: string; + name: string; +} + +export type ValueKind = + | "account" + | "amount" + | "bill" + | "bool" + | "budget" + | "category" + | "currency" + | "date" + | "id" + | "none" + | "tag" + | "text" + | "type"; + +export interface Token { + end: number; + field: string | null; + negated: boolean; + start: number; + value: string; +} + +export interface Suggestion { + hint?: string; + id: string; + insert: string; + label: string; +} + +const BOOL_NAMES = new Set([ + "reconciled", + "exists", + "source_is_cash", + "destination_is_cash", + "account_is_cash", +]); +const ACCOUNT_ALIASES = new Set(["source", "destination", "from", "to"]); +const BOOL_PREFIX = /^(has_|any_|no_)/; +const DATE_PART = /(^|_)(date|on|before|after|at)(_|$)/; +const AMOUNT_PART = /amount|balance|^less$|^more$/; +const ID_END = /(^|_)id$/; +const ACCOUNT_NAME = /(^|_)account_(is|contains|ends|starts)$/; +const NEEDS_QUOTES = /[\s"()]/; +const SEPARATOR = /[\s()]/; +const QUOTE = /"/g; +const TRAILING_QUOTE = /"$/; +const LEADING_SPACE = /^ /; + +export const TYPES = [ + "withdrawal", + "deposit", + "transfer", + "reconciliation", + "opening balance", +]; + +export function kindOf(name: string): ValueKind { + const checks: [boolean, ValueKind][] = [ + [name === "user_action", "none"], + [BOOL_PREFIX.test(name) || BOOL_NAMES.has(name), "bool"], + [name === "transaction_type" || name === "type", "type"], + [DATE_PART.test(name), "date"], + [AMOUNT_PART.test(name), "amount"], + [ID_END.test(name) && !name.startsWith("external"), "id"], + [name.includes("currency"), "currency"], + [name.startsWith("category"), "category"], + [name.startsWith("budget"), "budget"], + [name.startsWith("tag"), "tag"], + [name.startsWith("bill") || name.startsWith("subscription"), "bill"], + [ACCOUNT_NAME.test(name) || ACCOUNT_ALIASES.has(name), "account"], + ]; + return checks.find(([hit]) => hit)?.[1] ?? "text"; +} + +interface OperatorConfig { + data: { + value: Record; + }; +} + +export const operatorsQuery = () => ({ + queryFn: async (): Promise => { + const config = await api( + "/v1/configuration/search.operators" + ); + return Object.entries(config.data.value) + .filter(([name]) => name !== "user_action") + .map(([name, item]) => ({ + alias: item.alias, + alias_for: item.alias_for, + name, + })); + }, + queryKey: ["search-operators"], + staleTime: Number.POSITIVE_INFINITY, +}); + +export function quote(value: string): string { + return value === "" || NEEDS_QUOTES.test(value) + ? `"${value.replace(QUOTE, '\\"')}"` + : value; +} + +function unquote(value: string): string { + return value.startsWith('"') + ? value.slice(1).replace(TRAILING_QUOTE, "").replaceAll('\\"', '"') + : value; +} + +export function tokenAt(text: string, caret: number): Token { + let start = 0; + let quoted = false; + for (let i = 0; i < caret; i += 1) { + const char = text[i]; + if (char === '"' && text[i - 1] !== "\\") { + quoted = !quoted; + } else if (!quoted && SEPARATOR.test(char)) { + start = i + 1; + } + } + let end = caret; + while (end < text.length && (quoted || !SEPARATOR.test(text[end]))) { + if (text[end] === '"' && text[end - 1] !== "\\") { + quoted = !quoted; + } + end += 1; + } + const raw = text.slice(start, caret); + const negated = raw.startsWith("-"); + const body = negated ? raw.slice(1) : raw; + const colon = body.indexOf(":"); + return { + end, + field: colon < 0 ? null : body.slice(0, colon).toLowerCase(), + negated, + start, + value: colon < 0 ? body : unquote(body.slice(colon + 1)), + }; +} + +export function tokens(text: string): string[] { + const list: string[] = []; + let current = ""; + let quoted = false; + for (let i = 0; i < text.length; i += 1) { + const char = text[i]; + if (char === '"' && text[i - 1] !== "\\") { + quoted = !quoted; + } + if (!quoted && SEPARATOR.test(char)) { + if (current) { + list.push(current); + } + current = ""; + } else { + current += char; + } + } + if (current) { + list.push(current); + } + return list; +} + +function rank(operator: Operator, prefix: string): number { + const position = operator.name.indexOf(prefix); + if (position < 0) { + return -1; + } + return (position === 0 ? 0 : 2) + (operator.alias ? 1 : 0); +} + +export function operatorSuggestions( + operators: Operator[], + token: Token, + limit = 8 +): Suggestion[] { + const prefix = token.value.toLowerCase(); + if (!prefix) { + return []; + } + const sign = token.negated ? "-" : ""; + return operators + .map((operator, index) => ({ + index, + operator, + score: rank(operator, prefix), + })) + .filter((entry) => entry.score >= 0) + .sort((a, b) => a.score - b.score || a.index - b.index) + .slice(0, limit) + .map(({ operator }) => ({ + hint: operator.alias_for + ? `same as ${operator.alias_for}` + : hintFor(kindOf(operator.name)), + id: `op:${operator.name}`, + insert: `${sign}${operator.name}:`, + label: `${sign}${operator.name}:`, + })); +} + +function hintFor(kind: ValueKind): string { + const hints: Partial> = { + account: "account name", + amount: "amount", + bill: "subscription", + bool: "true or false", + budget: "budget", + category: "category", + currency: "currency", + date: "date", + id: "ids, comma separated", + tag: "tag", + type: "transaction type", + }; + return hints[kind] ?? "text"; +} + +export function valueSuggestions( + token: Token, + values: string[], + hints: Record = {}, + limit = 8 +): Suggestion[] { + const needle = token.value.toLowerCase(); + const sign = token.negated ? "-" : ""; + const starts: string[] = []; + const contains: string[] = []; + for (const value of new Set(values)) { + const lower = value.toLowerCase(); + if (lower.startsWith(needle)) { + starts.push(value); + } else if (lower.includes(needle)) { + contains.push(value); + } + } + return [...starts, ...contains].slice(0, limit).map((value) => ({ + hint: hints[value], + id: `value:${value}`, + insert: `${sign}${token.field}:${quote(value)} `, + label: value, + })); +} + +export function replaceToken( + text: string, + token: Token, + insert: string +): { caret: number; text: string } { + const after = text + .slice(token.end) + .replace(LEADING_SPACE, insert.endsWith(" ") ? "" : " "); + const next = text.slice(0, token.start) + insert; + return { caret: next.length, text: next + (after.trim() ? after : "") }; +} + +function humanize(name: string): string { + return name.replaceAll("_", " "); +} + +function describe( + field: string, + value: string, + negated: boolean, + known: Map +): string { + const root = known.get(field); + if (known.size > 0 && !root) { + return `ignored ${field}:${value}`; + } + const name = humanize(root ?? field); + if (kindOf(root ?? field) === "bool") { + return (value === "true") === negated ? `not ${name}` : name; + } + return `${negated ? "not " : ""}${name} "${value}"`; +} + +export function explain(text: string, operators: Operator[]): string[] { + const known = new Map(operators.map((o) => [o.name, o.alias_for ?? o.name])); + const words: string[] = []; + const excluded: string[] = []; + const parts: string[] = []; + for (const raw of tokens(text)) { + const negated = raw.startsWith("-"); + const body = negated ? raw.slice(1) : raw; + const colon = body.indexOf(":"); + if (colon < 0) { + (negated ? excluded : words).push(unquote(body)); + } else { + const field = body.slice(0, colon).toLowerCase(); + parts.push( + describe(field, unquote(body.slice(colon + 1)), negated, known) + ); + } + } + if (excluded.length > 0) { + parts.unshift(`description without "${excluded.join(" ")}"`); + } + if (words.length > 0) { + parts.unshift(`description contains "${words.join(" ")}"`); + } + return parts; +} diff --git a/src/lib/transactions/split.ts b/src/lib/transactions/split.ts new file mode 100644 index 0000000..069011b --- /dev/null +++ b/src/lib/transactions/split.ts @@ -0,0 +1,151 @@ +import type { Item, Split, TransactionGroup } from "#lib/api/types.js"; +import type { Money } from "#lib/format.js"; + +export type Group = Item; +export type Tone = "income" | "expense" | "transfer" | "neutral"; + +export interface Signed extends Money { + amount: number; + tone: Tone; +} + +export const TYPE_LABEL: Record = { + deposit: "Deposit", + "liability credit": "Liability credit", + "opening balance": "Opening balance", + reconciliation: "Reconciliation", + transfer: "Transfer", + withdrawal: "Withdrawal", +}; + +export const LIST_TITLE: Record = { + all: "All transactions", + deposit: "Revenue / income", + transfer: "Transfers", + withdrawal: "Expenses", +}; + +export function listType(param: string): string { + return param === "transfers" ? "transfer" : param; +} + +export function listPath(type: string): string { + return `/transactions/${type === "transfer" ? "transfers" : type}`; +} + +export function groupType(group: Group): string { + return group.attributes.transactions[0]?.type ?? "withdrawal"; +} + +export function groupTitle(group: Group): string { + const { group_title, transactions } = group.attributes; + return group_title || transactions[0]?.description || `#${group.id}`; +} + +export function journalId(split: Split): string { + return split.transaction_journal_id ?? ""; +} + +export function createType(type: string): string { + return ["withdrawal", "deposit", "transfer"].includes(type) + ? type + : "withdrawal"; +} + +export const showUrl = (id: string) => `/transactions/show/${id}`; +export const editUrl = (id: string) => `/transactions/edit/${id}`; +export const cloneUrl = (group: Group) => + `/transactions/create/${createType(groupType(group))}?clone=${group.id}`; + +function signFor(split: Split, accountId?: string): 1 | -1 { + switch (split.type as string) { + case "withdrawal": + return -1; + case "transfer": + return accountId && split.source_id === accountId ? -1 : 1; + case "opening balance": + return split.source_type === "Initial balance account" ? 1 : -1; + case "reconciliation": + return split.source_type === "Reconciliation account" ? 1 : -1; + case "liability credit": + return (split.source_type as string) === "Liability credit account" + ? -1 + : 1; + default: + return 1; + } +} + +export function toneOf(split: Split, sign: 1 | -1): Tone { + if (split.type === "transfer") { + return "transfer"; + } + return sign < 0 ? "expense" : "income"; +} + +export function signedAmount(split: Split, accountId?: string): Signed { + const sign = signFor(split, accountId); + return { + amount: sign * Number(split.amount), + code: split.currency_code, + decimals: split.currency_decimal_places, + symbol: split.currency_symbol, + tone: toneOf(split, sign), + }; +} + +export function signedForeign(split: Split, accountId?: string): Signed | null { + if (!(split.foreign_amount && split.foreign_currency_code)) { + return null; + } + const sign = signFor(split, accountId); + return { + amount: sign * Number(split.foreign_amount), + code: split.foreign_currency_code, + decimals: split.foreign_currency_decimal_places, + symbol: split.foreign_currency_symbol, + tone: toneOf(split, sign), + }; +} + +export function sumByCurrency(splits: Split[], accountId?: string): Signed[] { + const sums = new Map(); + for (const split of splits) { + const money = signedAmount(split, accountId); + const key = money.code ?? ""; + const sum = sums.get(key); + if (sum) { + sum.amount += money.amount; + } else { + sums.set(key, { ...money }); + } + } + return [...sums.values()]; +} + +export function balanceAfter(split: Split, accountId?: string): Money | null { + if (!accountId) { + return null; + } + const value = + split.source_id === accountId + ? split.source_balance_after + : split.destination_balance_after; + if (value === null || value === undefined) { + return null; + } + return { + amount: value, + code: split.currency_code, + decimals: split.currency_decimal_places, + symbol: split.currency_symbol, + }; +} + +export function accountUrl(id: string | null | undefined): string | undefined { + return id ? `/accounts/show/${id}` : undefined; +} + +export function isCash(type: string | null | undefined): boolean { + return type === "Cash account"; +} diff --git a/src/routes/search/+page.svelte b/src/routes/search/+page.svelte new file mode 100644 index 0000000..a2bc609 --- /dev/null +++ b/src/routes/search/+page.svelte @@ -0,0 +1,195 @@ + + + + + +
{ + event.preventDefault(); + submit(draft.trim()); + }} + > + + + + {#if query} + Create new rule from search query + {/if} + + {#if query && explanation.length > 0} +

+ Searching for: {explanation.join(", ")} +

+ {:else if !query} +

+ Type words to search descriptions, or operators: + {#each EXAMPLES as example (example)} + + {/each} + Tab or Enter accepts a suggestion. +

+ {/if} +
+ +{#if query} + + {#snippet tools()} + {#if pagination && !results.isFetching} + Found {pagination.total} + {pagination.total === 1 ? "transaction" : "transactions"} + in + {formatNumber(seconds, 2)} + seconds + {:else if results.isFetching} + Searching... + {/if} + + + + {/snippet} + {#if results.error} +

+ An error occurred while searching: {results.error.message} +

+ {:else} + + {/if} + {#snippet footer()} + + + + {/snippet} +
+{/if} diff --git a/src/routes/transactions/[type=txtype]/ListPage.svelte b/src/routes/transactions/[type=txtype]/ListPage.svelte new file mode 100644 index 0000000..c236ebf --- /dev/null +++ b/src/routes/transactions/[type=txtype]/ListPage.svelte @@ -0,0 +1,229 @@ + + + + {#snippet actions()} + + + {/snippet} + + +{#if !everything} + +{/if} + + + {#snippet tools()} + + + + {/snippet} + + {#snippet footer()} + {#if everything} + Show the current period and overview + {:else} + Show all transactions without grouping them by date + {/if} + + + + {/snippet} + diff --git a/src/routes/transactions/[type=txtype]/[[start=date]]/[[end=date]]/+page.svelte b/src/routes/transactions/[type=txtype]/[[start=date]]/[[end=date]]/+page.svelte new file mode 100644 index 0000000..c2f7563 --- /dev/null +++ b/src/routes/transactions/[type=txtype]/[[start=date]]/[[end=date]]/+page.svelte @@ -0,0 +1,10 @@ + + + diff --git a/src/routes/transactions/[type=txtype]/all/+page.svelte b/src/routes/transactions/[type=txtype]/all/+page.svelte new file mode 100644 index 0000000..7168f56 --- /dev/null +++ b/src/routes/transactions/[type=txtype]/all/+page.svelte @@ -0,0 +1,6 @@ + + + diff --git a/src/routes/transactions/show/[id=id]/+page.svelte b/src/routes/transactions/show/[id=id]/+page.svelte new file mode 100644 index 0000000..6ee744c --- /dev/null +++ b/src/routes/transactions/show/[id=id]/+page.svelte @@ -0,0 +1,761 @@ + + +{#snippet money( + value: Signed +)} + +{/snippet} + +{#snippet accountLink( + accountId: string | null | undefined, + name: string | null | undefined, + type: string | null | undefined +)} + {#if isCash(type)} + (cash) + {:else} + {name} + {/if} +{/snippet} + +{#snippet row( + label: string +)} + + {label} + +{/snippet} + + + {#snippet actions()} + {#if group} +
+ {#if editable} + + {#if type !== "reconciliation"} + + {/if} + + Delete d + + {/if} + + + More + + + {#if editable && type !== "reconciliation"} + {#each others as target (target)} + + location.assign(`/transactions/convert/${target}/${id}`)} + > + Convert to a {target} + + {/each} + + {/if} + + location.assign( + `/rules/create-from-journal/${journalId(splits[0])}` + )} + > + Create rule based on transaction + + + location.assign( + `/recurring/create-from-transaction/${journalId(splits[0])}` + )} + > + Create recurring transaction based on transaction + + + oldUi()}> + Open in old UI + + + +
+ {/if} + {/snippet} +
+ +{#if groupQuery.isPending} +

Loading transaction...

+{:else if groupQuery.error || !group} + +

+ {groupQuery.error?.message ?? "This transaction does not exist."} +

+
+{:else} +
+ + + + + {@render row("ID")} + + + + {@render row("Type")} + + + + {@render row("Description")} + + + + {@render row("Date")} + + + +
#{id}
+ {TYPE_LABEL[type] ?? type} +
{title}
{dateLabel(splits[0]?.date)}
+ {#if deleteError} +

{deleteError}

+ {/if} +
+ + + + + + {@render row("Source account(s)")} + + + + {@render row("Destination account(s)")} + + + + {@render row("Total amount")} + + + +
+ {#each uniqueAccounts("source") as acc, i (acc.id)} + {#if i > 0} + , + {/if} + {@render accountLink( + acc.id, + acc.name, + acc.cash ? "Cash account" : "" + )} + {/each} +
+ {#each uniqueAccounts("destination") as acc, i (acc.id)} + {#if i > 0} + , + {/if} + {@render accountLink( + acc.id, + acc.name, + acc.cash ? "Cash account" : "" + )} + {/each} +
+ + {#each sumByCurrency(splits) as sum (sum.code)} + {@render money(sum)} + {/each} + {#each foreignTotals as sum (sum.code)} + ({formatMoney(sum)}) + {/each} + +
+
+
+ + {#if splits.length > 1} +

Splits

+ {/if} + +
+ {#each splits as split, index (journalId(split))} + {@const foreign = signedForeign(split)} + {@const files = attachmentsFor(split)} + {@const events = eventsFor(split)} + {@const links = linksQuery.data?.[journalId(split)] ?? []} +
+ + {#snippet tools()} +

+ {split.description} + {#if split.reconciled} + + {/if} + {#if splits.length > 1} + {index + 1} + / {splits.length} + {/if} +

+ {/snippet} + + + + + + + {@render row("Transaction journal ID")} + + + + {@render row("Category")} + + + {#if split.type === "withdrawal"} + + {@render row("Budget")} + + + {#if split.bill_id} + + {@render row("Subscription")} + + + {/if} + {/if} + {#each DATE_FIELDS as [field, label] (field)} + {#if split[field]} + + {@render row(label)} + + + {/if} + {/each} + {#each META_FIELDS as [field, label] (field)} + {#if split[field]} + + {@render row(label)} + + + {/if} + {/each} + {#if split.external_url} + + {@render row("External URL")} + + + {/if} + {#if split.notes} + + {@render row("Notes")} + + + {/if} + {#if split.recurrence_id} + + {@render row("Recurring transaction")} + + + {/if} + {#if split.tags?.length} + + {@render row("Tags")} + + + {/if} + +
+
+ {@render accountLink( + split.source_id, + split.source_name, + split.source_type + )} + → + {@render money(signedAmount(split))} + {#if foreign} + ({formatMoney(foreign)}) + {/if} + → + {@render accountLink( + split.destination_id, + split.destination_name, + split.destination_type + )} +
+
#{journalId(split)}
+ {#if split.category_id} + {split.category_name} + {:else} + (no category) + {/if} +
+ {#if split.budget_id} + {split.budget_name} + {:else} + (no budget) + {/if} +
+ {split.bill_name} +
{formatDate(split[field])}
{split[field]}
+ {split.external_url} +
+ {split.notes} +
+ {split.recurrence_count} + of {split.recurrence_total || "∞"} +
+ {#each split.tags as tag, i (tag)} + {#if i > 0} + , + {/if} + {tag} + {/each} +
+
+ + {#if links.length > 0} + +
    + {#each links as link (link.id)} +
  • + {link.phrase} + {#if link.group} + "{link.description}" + {:else} + "{link.description}" + {/if} + {#if link.amount} + ({@render money(link.amount)}) + {/if} + {#if link.notes} + ({link.notes}) + {/if} + removeLink(link.id)} + > + Remove link + +
  • + {/each} +
+
+ {/if} + + {#if files.length > 0} + + + + {/if} + + {#if events.length > 0} + +
    + {#each events as piggyEvent (piggyEvent.id)} + {@const piggy = piggyOf(piggyEvent)} +
  • + {#if piggy.id} + {piggy.name} + {:else} + Piggy bank + {/if} + +
  • + {/each} +
+
+ {/if} +
+ {/each} +
+{/if}