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}
+
(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)}
+
+
+ |
+
+
+ {#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 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 @@
+
+
+
+
+
+
+ {#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")}
+ | #{id} |
+
+
+ {@render row("Type")}
+ |
+ {TYPE_LABEL[type] ?? type}
+ |
+
+
+ {@render row("Description")}
+ | {title} |
+
+
+ {@render row("Date")}
+ | {dateLabel(splits[0]?.date)} |
+
+
+
+ {#if deleteError}
+ {deleteError}
+ {/if}
+
+
+
+
+
+
+ {@render row("Source account(s)")}
+ |
+ {#each uniqueAccounts("source") as acc, i (acc.id)}
+ {#if i > 0}
+ ,
+ {/if}
+ {@render accountLink(
+ acc.id,
+ acc.name,
+ acc.cash ? "Cash account" : ""
+ )}
+ {/each}
+ |
+
+
+ {@render row("Destination account(s)")}
+ |
+ {#each uniqueAccounts("destination") as acc, i (acc.id)}
+ {#if i > 0}
+ ,
+ {/if}
+ {@render accountLink(
+ acc.id,
+ acc.name,
+ acc.cash ? "Cash account" : ""
+ )}
+ {/each}
+ |
+
+
+ {@render row("Total amount")}
+ |
+
+ {#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 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
+ )}
+
+ |
+
+
+ {@render row("Transaction journal ID")}
+ | #{journalId(split)} |
+
+
+ {@render row("Category")}
+ |
+ {#if split.category_id}
+ {split.category_name}
+ {:else}
+ (no category)
+ {/if}
+ |
+
+ {#if split.type === "withdrawal"}
+
+ {@render row("Budget")}
+ |
+ {#if split.budget_id}
+ {split.budget_name}
+ {:else}
+ (no budget)
+ {/if}
+ |
+
+ {#if split.bill_id}
+
+ {@render row("Subscription")}
+ |
+ {split.bill_name}
+ |
+
+ {/if}
+ {/if}
+ {#each DATE_FIELDS as [field, label] (field)}
+ {#if split[field]}
+
+ {@render row(label)}
+ | {formatDate(split[field])} |
+
+ {/if}
+ {/each}
+ {#each META_FIELDS as [field, label] (field)}
+ {#if split[field]}
+
+ {@render row(label)}
+ | {split[field]} |
+
+ {/if}
+ {/each}
+ {#if split.external_url}
+
+ {@render row("External URL")}
+ |
+ {split.external_url}
+ |
+
+ {/if}
+ {#if split.notes}
+
+ {@render row("Notes")}
+ |
+ {split.notes}
+ |
+
+ {/if}
+ {#if split.recurrence_id}
+
+ {@render row("Recurring transaction")}
+ |
+ {split.recurrence_count}
+ of {split.recurrence_total || "∞"}
+ |
+
+ {/if}
+ {#if split.tags?.length}
+
+ {@render row("Tags")}
+ |
+ {#each split.tags as tag, i (tag)}
+ {#if i > 0}
+ ,
+ {/if}
+ {tag}
+ {/each}
+ |
+
+ {/if}
+
+
+
+
+ {#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}