feat(transactions,search): dense split-aware tables, mass edit, query-language search and show page
This commit is contained in:
1 parent
7e19684cff
commit
772047c0a1
16 files changed
+3300
-2
No files matched your search
@@ -0,0 +1,293 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { tick } from "svelte";
|
||||||
|
import { invalidate, lists } from "#lib/api/lists.js";
|
||||||
|
import type { Split } from "#lib/api/types.js";
|
||||||
|
import { fuzzyFilter } from "#lib/fuzzy.js";
|
||||||
|
import {
|
||||||
|
deleteTargets,
|
||||||
|
type Outcome,
|
||||||
|
type Patch,
|
||||||
|
type Row,
|
||||||
|
splitsOf,
|
||||||
|
targetsOf,
|
||||||
|
updateGroups,
|
||||||
|
} from "#lib/transactions/mass.js";
|
||||||
|
import type { Suggestion } from "#lib/transactions/query.js";
|
||||||
|
import SuggestInput from "./SuggestInput.svelte";
|
||||||
|
|
||||||
|
export type Mode = "category" | "budget" | "tag" | "untag";
|
||||||
|
|
||||||
|
const ARM_MS = 3000;
|
||||||
|
const STATUS_MS = 8000;
|
||||||
|
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]));
|
||||||
|
|
||||||
|
let {
|
||||||
|
targets,
|
||||||
|
selected,
|
||||||
|
onclear,
|
||||||
|
}: { targets: Row[]; selected: number; onclear: () => void } = $props();
|
||||||
|
|
||||||
|
let mode = $state<Mode | null>(null);
|
||||||
|
let active = $state<Row[]>([]);
|
||||||
|
let text = $state("");
|
||||||
|
let input = $state<HTMLInputElement>();
|
||||||
|
let status = $state("");
|
||||||
|
let busy = $state(false);
|
||||||
|
let armed = $state(false);
|
||||||
|
let armTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let statusTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const categories = createQuery(() => ({
|
||||||
|
...lists.categories(),
|
||||||
|
enabled: mode === "category",
|
||||||
|
}));
|
||||||
|
const budgets = createQuery(() => ({
|
||||||
|
...lists.budgets(),
|
||||||
|
enabled: mode === "budget",
|
||||||
|
}));
|
||||||
|
const tags = createQuery(() => ({
|
||||||
|
...lists.tags(),
|
||||||
|
enabled: mode === "tag",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const visible = $derived(
|
||||||
|
selected > 0 || mode !== null || armed || status !== ""
|
||||||
|
);
|
||||||
|
const count = $derived(targetsOf(mode || armed ? active : targets).size);
|
||||||
|
|
||||||
|
function options(current: Mode | null): [string[], string | null, boolean] {
|
||||||
|
switch (current) {
|
||||||
|
case "category":
|
||||||
|
return [
|
||||||
|
(categories.data ?? []).map((c) => c.attributes.name),
|
||||||
|
"(no category)",
|
||||||
|
true,
|
||||||
|
];
|
||||||
|
case "budget":
|
||||||
|
return [
|
||||||
|
(budgets.data ?? [])
|
||||||
|
.filter((b) => b.attributes.active !== false)
|
||||||
|
.map((b) => b.attributes.name),
|
||||||
|
"(no budget)",
|
||||||
|
false,
|
||||||
|
];
|
||||||
|
case "tag":
|
||||||
|
return [(tags.data ?? []).map((t) => t.attributes.tag), null, true];
|
||||||
|
case "untag":
|
||||||
|
return [
|
||||||
|
[...new Set(splitsOf(active).flatMap((s) => s.tags ?? []))],
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
];
|
||||||
|
default:
|
||||||
|
return [[], null, false];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = $derived.by<Suggestion[]>(() => {
|
||||||
|
const [names, none, free] = options(mode);
|
||||||
|
const query = text.trim();
|
||||||
|
const found: Suggestion[] = fuzzyFilter(names, query, (n) => n, 8).map(
|
||||||
|
(name) => ({ id: name, insert: name, label: name })
|
||||||
|
);
|
||||||
|
const exact = names.some((n) => n.toLowerCase() === query.toLowerCase());
|
||||||
|
if (free && query && !exact) {
|
||||||
|
found.push({
|
||||||
|
hint: "new",
|
||||||
|
id: `new:${query}`,
|
||||||
|
insert: query,
|
||||||
|
label: query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (none && !query) {
|
||||||
|
found.unshift({ id: NONE, insert: NONE, label: none });
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
});
|
||||||
|
|
||||||
|
function say(message: string) {
|
||||||
|
status = message;
|
||||||
|
clearTimeout(statusTimer);
|
||||||
|
if (!busy) {
|
||||||
|
statusTimer = setTimeout(() => (status = ""), STATUS_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function report(verb: string, outcome: Outcome) {
|
||||||
|
const errors = outcome.errors.length
|
||||||
|
? `. Failed: ${outcome.errors.join("; ")}`
|
||||||
|
: "";
|
||||||
|
say(`${verb} ${outcome.done} of ${outcome.total}${errors}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function start(next: Mode, rows: Row[] = targets) {
|
||||||
|
if (rows.length === 0 || busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
active = rows;
|
||||||
|
mode = next;
|
||||||
|
text = "";
|
||||||
|
status = "";
|
||||||
|
await tick();
|
||||||
|
input?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
mode = null;
|
||||||
|
text = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function patcher(current: Mode, value: string | null) {
|
||||||
|
const budgetId =
|
||||||
|
(budgets.data ?? []).find((b) => b.attributes.name === value)?.id ?? null;
|
||||||
|
const patches: Record<Mode, (split: Split) => Patch | null> = {
|
||||||
|
budget: (split) =>
|
||||||
|
split.type !== "withdrawal" || (split.budget_id ?? null) === budgetId
|
||||||
|
? null
|
||||||
|
: { budget_id: budgetId },
|
||||||
|
category: (split) =>
|
||||||
|
(split.category_name ?? null) === value
|
||||||
|
? null
|
||||||
|
: { category_name: value },
|
||||||
|
tag: (split) =>
|
||||||
|
!value || split.tags?.includes(value)
|
||||||
|
? null
|
||||||
|
: { tags: [...(split.tags ?? []), value] },
|
||||||
|
untag: (split) =>
|
||||||
|
value && split.tags?.includes(value)
|
||||||
|
? { tags: split.tags.filter((tag) => tag !== value) }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
return patches[current];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apply(raw: string) {
|
||||||
|
if (!mode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = raw === NONE ? null : raw.trim() || null;
|
||||||
|
if (value === null && (mode === "tag" || mode === "untag")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const verb = VERB[mode];
|
||||||
|
const patch = patcher(mode, value);
|
||||||
|
const rows = active;
|
||||||
|
mode = null;
|
||||||
|
busy = true;
|
||||||
|
const outcome = await updateGroups(targetsOf(rows), patch, (progress) =>
|
||||||
|
say(`${verb}: updated ${progress.done} of ${progress.total}`)
|
||||||
|
);
|
||||||
|
await invalidate();
|
||||||
|
busy = false;
|
||||||
|
report("Updated", outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function remove(rows: Row[] = targets, confirmed = false) {
|
||||||
|
if (busy || rows.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (confirmed) {
|
||||||
|
active = rows;
|
||||||
|
} else if (!armed) {
|
||||||
|
active = rows;
|
||||||
|
armed = true;
|
||||||
|
clearTimeout(armTimer);
|
||||||
|
armTimer = setTimeout(() => (armed = false), ARM_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(armTimer);
|
||||||
|
armed = false;
|
||||||
|
busy = true;
|
||||||
|
const outcome = await deleteTargets(targetsOf(active), (progress) =>
|
||||||
|
say(`Deleted ${progress.done} of ${progress.total}`)
|
||||||
|
);
|
||||||
|
onclear();
|
||||||
|
await invalidate();
|
||||||
|
busy = false;
|
||||||
|
report("Deleted", outcome);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if visible}
|
||||||
|
<div
|
||||||
|
aria-label="Selection actions"
|
||||||
|
class="m-2 flex min-h-10 flex-wrap items-center gap-2 rounded-cladd-md bg-row-selected/50 px-2.5 py-1.5 text-[12px]"
|
||||||
|
role="toolbar"
|
||||||
|
>
|
||||||
|
<span class="font-semibold text-cladd-fg">
|
||||||
|
{count}
|
||||||
|
{count === 1 ? "transaction" : "transactions"}
|
||||||
|
{selected > 0 && !mode ? "selected" : ""}
|
||||||
|
</span>
|
||||||
|
{#if mode}
|
||||||
|
<label class="text-cladd-fg-soft" for="action-bar-input"
|
||||||
|
>{VERB[mode]}:</label
|
||||||
|
>
|
||||||
|
<SuggestInput
|
||||||
|
class="w-72"
|
||||||
|
id="action-bar-input"
|
||||||
|
onaccept={(item) => {
|
||||||
|
apply(item.insert);
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
onblur={cancel}
|
||||||
|
oncancel={cancel}
|
||||||
|
onsubmit={() => apply(text)}
|
||||||
|
placeholder="Type to filter"
|
||||||
|
{suggestions}
|
||||||
|
bind:input
|
||||||
|
bind:value={text}
|
||||||
|
/>
|
||||||
|
<span class="text-cladd-fg-softer"
|
||||||
|
><span class="kbd">↵</span>
|
||||||
|
apply <span class="kbd">Esc</span> cancel</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
{#each MODES as item (item.mode)}
|
||||||
|
<button
|
||||||
|
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2.5 font-semibold text-cladd-fg disabled:opacity-50"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={() => start(item.mode)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
<span class="kbd">{item.keys}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<button
|
||||||
|
aria-live="polite"
|
||||||
|
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2.5 font-semibold disabled:opacity-50 {armed
|
||||||
|
? "cladd-color-red text-cladd-primary"
|
||||||
|
: "text-cladd-fg"}"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={() => remove()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{#if armed}
|
||||||
|
Press again to delete {count}
|
||||||
|
{:else}
|
||||||
|
Delete <span class="kbd">d</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{#if selected > 0}
|
||||||
|
<button
|
||||||
|
class="inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2 text-cladd-fg-soft hover:text-cladd-fg"
|
||||||
|
onclick={onclear}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Clear <span class="kbd">u</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
<span aria-live="polite" class="ml-auto text-cladd-fg-soft">{status}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useKeys } from "#lib/keys.svelte.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
|
let {
|
||||||
|
page,
|
||||||
|
pages,
|
||||||
|
href,
|
||||||
|
keys = false,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pages: number;
|
||||||
|
href: (page: number) => string;
|
||||||
|
keys?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const numbers = $derived.by(() => {
|
||||||
|
const list: (number | null)[] = [];
|
||||||
|
for (let n = 1; n <= pages; n += 1) {
|
||||||
|
if (n === 1 || n === pages || Math.abs(n - page) <= 2) {
|
||||||
|
list.push(n);
|
||||||
|
} else if (list.at(-1) !== null) {
|
||||||
|
list.push(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
function go(target: number) {
|
||||||
|
if (target >= 1 && target <= pages && target !== page) {
|
||||||
|
goto(href(target));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useKeys(() =>
|
||||||
|
keys
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
group: "List",
|
||||||
|
keys: ">",
|
||||||
|
label: "Next page",
|
||||||
|
run: () => go(page + 1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "List",
|
||||||
|
keys: "<",
|
||||||
|
label: "Previous page",
|
||||||
|
run: () => go(page - 1),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if pages > 1}
|
||||||
|
<nav aria-label="Pages" class="flex items-center gap-0.5 text-[12px]">
|
||||||
|
{#if page > 1}
|
||||||
|
<a
|
||||||
|
class="rounded-cladd-sm px-2 py-1 text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg"
|
||||||
|
href={href(page - 1)}
|
||||||
|
rel="prev"
|
||||||
|
title="Previous page <"
|
||||||
|
>Previous</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#each numbers as n, i (n ?? `gap-${i}`)}
|
||||||
|
{#if n === null}
|
||||||
|
<span class="px-1 text-cladd-fg-softer">...</span>
|
||||||
|
{:else if n === page}
|
||||||
|
<span
|
||||||
|
aria-current="page"
|
||||||
|
class="num rounded-cladd-sm bg-row-selected px-2 py-1 font-semibold text-cladd-fg"
|
||||||
|
>{n}</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
class="num rounded-cladd-sm px-2 py-1 text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg"
|
||||||
|
href={href(n)}
|
||||||
|
>{n}</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{#if page < pages}
|
||||||
|
<a
|
||||||
|
class="rounded-cladd-sm px-2 py-1 text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg"
|
||||||
|
href={href(page + 1)}
|
||||||
|
rel="next"
|
||||||
|
title="Next page >"
|
||||||
|
>Next</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Split } from "#lib/api/types.js";
|
||||||
|
import Amount from "#lib/components/Amount.svelte";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import {
|
||||||
|
type Group,
|
||||||
|
type Signed,
|
||||||
|
signedAmount,
|
||||||
|
sumByCurrency,
|
||||||
|
} from "#lib/transactions/split.js";
|
||||||
|
|
||||||
|
const TOP = 5;
|
||||||
|
const TOTALS = [
|
||||||
|
["withdrawal", "Spent"],
|
||||||
|
["deposit", "Earned"],
|
||||||
|
["transfer", "Transferred"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface Entry extends Signed {
|
||||||
|
href?: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
groups,
|
||||||
|
type,
|
||||||
|
loading = false,
|
||||||
|
}: { groups: Group[]; type: string; loading?: boolean } = $props();
|
||||||
|
|
||||||
|
const splits = $derived(groups.flatMap((g) => g.attributes.transactions));
|
||||||
|
|
||||||
|
const totals = $derived(
|
||||||
|
TOTALS.map(([kind, label]) => ({
|
||||||
|
label,
|
||||||
|
sums: sumByCurrency(splits.filter((s) => s.type === kind)),
|
||||||
|
})).filter((row) => row.sums.length > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
function aggregate(
|
||||||
|
list: Split[],
|
||||||
|
pick: (split: Split) => [string, string | undefined]
|
||||||
|
): { entries: Entry[]; more: number } {
|
||||||
|
const map = new Map<string, Entry>();
|
||||||
|
for (const split of list) {
|
||||||
|
const [name, href] = pick(split);
|
||||||
|
const money = signedAmount(split);
|
||||||
|
const key = `${name}|${money.code}`;
|
||||||
|
const entry = map.get(key);
|
||||||
|
if (entry) {
|
||||||
|
entry.amount += money.amount;
|
||||||
|
} else {
|
||||||
|
map.set(key, { ...money, href, name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sorted = [...map.values()].sort(
|
||||||
|
(a, b) => Math.abs(b.amount) - Math.abs(a.amount)
|
||||||
|
);
|
||||||
|
return { entries: sorted.slice(0, TOP), more: sorted.length - TOP };
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks = $derived([
|
||||||
|
{
|
||||||
|
title: "Categories",
|
||||||
|
...aggregate(splits, (s) =>
|
||||||
|
s.category_id
|
||||||
|
? [s.category_name ?? "", `/categories/show/${s.category_id}`]
|
||||||
|
: ["(no category)", undefined]
|
||||||
|
),
|
||||||
|
},
|
||||||
|
type === "withdrawal"
|
||||||
|
? {
|
||||||
|
title: "Budgets",
|
||||||
|
...aggregate(splits, (s) =>
|
||||||
|
s.budget_id
|
||||||
|
? [s.budget_name ?? "", `/budgets/show/${s.budget_id}`]
|
||||||
|
: ["(no budget)", undefined]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: "Source accounts",
|
||||||
|
...aggregate(splits, (s) => [
|
||||||
|
s.source_name ?? "(cash)",
|
||||||
|
s.source_id ? `/accounts/show/${s.source_id}` : undefined,
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Destination accounts",
|
||||||
|
...aggregate(splits, (s) => [
|
||||||
|
s.destination_name ?? "(cash)",
|
||||||
|
s.destination_id ? `/accounts/show/${s.destination_id}` : undefined,
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet amount(
|
||||||
|
value: Signed
|
||||||
|
)}
|
||||||
|
<Amount
|
||||||
|
amount={value.amount}
|
||||||
|
code={value.code}
|
||||||
|
decimals={value.decimals}
|
||||||
|
symbol={value.symbol}
|
||||||
|
tone={value.tone === "transfer" ? "transfer" : "auto"}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="mb-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<Box body="px-3 py-2" title="Overview">
|
||||||
|
{#if loading && groups.length === 0}
|
||||||
|
<p class="text-cladd-fg-soft">Loading...</p>
|
||||||
|
{:else}
|
||||||
|
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
|
||||||
|
<dt class="text-cladd-fg-soft">Transactions</dt>
|
||||||
|
<dd class="num text-right">{groups.length}</dd>
|
||||||
|
{#each totals as row (row.label)}
|
||||||
|
<dt class="text-cladd-fg-soft">{row.label}</dt>
|
||||||
|
<dd class="flex flex-col items-end">
|
||||||
|
{#each row.sums as sum (sum.code)}
|
||||||
|
{@render amount(sum)}
|
||||||
|
{/each}
|
||||||
|
</dd>
|
||||||
|
{/each}
|
||||||
|
</dl>
|
||||||
|
{/if}
|
||||||
|
</Box>
|
||||||
|
{#each blocks as block (block.title)}
|
||||||
|
<Box body="py-1" class="max-md:hidden" title={block.title}>
|
||||||
|
<ul>
|
||||||
|
{#each block.entries as entry (`${entry.name}|${entry.code}`)}
|
||||||
|
<li class="flex h-6 items-center justify-between gap-3 px-3">
|
||||||
|
{#if entry.href}
|
||||||
|
<a class="truncate hover:underline" href={entry.href}
|
||||||
|
>{entry.name}</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class="truncate text-cladd-fg-soft">{entry.name}</span>
|
||||||
|
{/if}
|
||||||
|
{@render amount(entry)}
|
||||||
|
</li>
|
||||||
|
{:else}
|
||||||
|
<li class="px-3 py-1 text-cladd-fg-soft">
|
||||||
|
{loading ? "Loading..." : "Nothing in this period."}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{#if block.more > 0}
|
||||||
|
<li class="px-3 text-[12px] text-cladd-fg-softer">
|
||||||
|
and {block.more} more
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
</Box>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { tick } from "svelte";
|
||||||
|
import { lists } from "#lib/api/lists.js";
|
||||||
|
import { addDays, today } from "#lib/dates.js";
|
||||||
|
import { period } from "#lib/period.svelte.js";
|
||||||
|
import { session } from "#lib/session.svelte.js";
|
||||||
|
import {
|
||||||
|
kindOf,
|
||||||
|
operatorSuggestions,
|
||||||
|
operatorsQuery,
|
||||||
|
replaceToken,
|
||||||
|
type Suggestion,
|
||||||
|
TYPES,
|
||||||
|
tokenAt,
|
||||||
|
type ValueKind,
|
||||||
|
valueSuggestions,
|
||||||
|
} from "#lib/transactions/query.js";
|
||||||
|
import SuggestInput from "./SuggestInput.svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(""),
|
||||||
|
input = $bindable(),
|
||||||
|
onsubmit,
|
||||||
|
id = "query",
|
||||||
|
placeholder = "Search transactions, e.g. category_is:Groceries amount_more:20",
|
||||||
|
class: className = "",
|
||||||
|
}: {
|
||||||
|
value?: string;
|
||||||
|
input?: HTMLInputElement;
|
||||||
|
onsubmit: (query: string) => void;
|
||||||
|
id?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let caret = $state(0);
|
||||||
|
let used = $state(false);
|
||||||
|
|
||||||
|
const operators = createQuery(() => ({ ...operatorsQuery(), enabled: used }));
|
||||||
|
const categories = createQuery(() => ({
|
||||||
|
...lists.categories(),
|
||||||
|
enabled: used,
|
||||||
|
}));
|
||||||
|
const budgets = createQuery(() => ({ ...lists.budgets(), enabled: used }));
|
||||||
|
const tags = createQuery(() => ({ ...lists.tags(), enabled: used }));
|
||||||
|
const bills = createQuery(() => ({ ...lists.bills(), enabled: used }));
|
||||||
|
const accounts = createQuery(() => ({
|
||||||
|
...lists.accounts("all"),
|
||||||
|
enabled: used,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function dateValues(): [string[], Record<string, string>] {
|
||||||
|
const now = today();
|
||||||
|
const hints: Record<string, string> = {
|
||||||
|
[addDays(now, -1)]: "yesterday",
|
||||||
|
[now]: "today",
|
||||||
|
[period.end]: "period end",
|
||||||
|
[period.start]: "period start",
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
"today",
|
||||||
|
"yesterday",
|
||||||
|
"start of this month",
|
||||||
|
"end of this month",
|
||||||
|
"start of this year",
|
||||||
|
period.start,
|
||||||
|
period.end,
|
||||||
|
now,
|
||||||
|
"-7d",
|
||||||
|
"-1m",
|
||||||
|
],
|
||||||
|
hints,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function names<T>(
|
||||||
|
items: { attributes: T }[] | undefined,
|
||||||
|
pick: (attributes: T) => string | null | undefined
|
||||||
|
): string[] {
|
||||||
|
return (items ?? [])
|
||||||
|
.map((item) => pick(item.attributes))
|
||||||
|
.filter((name): name is string => Boolean(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function values(kind: ValueKind): [string[], Record<string, string>] {
|
||||||
|
switch (kind) {
|
||||||
|
case "category":
|
||||||
|
return [names(categories.data, (c) => c.name), {}];
|
||||||
|
case "budget":
|
||||||
|
return [names(budgets.data, (b) => b.name), {}];
|
||||||
|
case "tag":
|
||||||
|
return [names(tags.data, (t) => t.tag), {}];
|
||||||
|
case "bill":
|
||||||
|
return [names(bills.data, (b) => b.name), {}];
|
||||||
|
case "account":
|
||||||
|
return [names(accounts.data, (a) => a.name), {}];
|
||||||
|
case "currency":
|
||||||
|
return [names(session.currencies, (c) => c.code), {}];
|
||||||
|
case "type":
|
||||||
|
return [TYPES, {}];
|
||||||
|
case "bool":
|
||||||
|
return [["true", "false"], {}];
|
||||||
|
case "date":
|
||||||
|
return dateValues();
|
||||||
|
default:
|
||||||
|
return [[], {}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = $derived(tokenAt(value, caret));
|
||||||
|
const suggestions = $derived.by<Suggestion[]>(() => {
|
||||||
|
if (token.field === null) {
|
||||||
|
return operatorSuggestions(operators.data ?? [], token);
|
||||||
|
}
|
||||||
|
const [list, hints] = values(kindOf(token.field));
|
||||||
|
return valueSuggestions(token, list, hints);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function accept(item: Suggestion): Promise<void> {
|
||||||
|
const { caret: position, text } = replaceToken(value, token, item.insert);
|
||||||
|
value = text;
|
||||||
|
caret = position;
|
||||||
|
await tick();
|
||||||
|
input?.setSelectionRange(position, position);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onaccept(item: Suggestion) {
|
||||||
|
const current = value.slice(token.start, token.end);
|
||||||
|
if (current === item.insert.trimEnd() && item.insert.endsWith(" ")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
accept(item);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SuggestInput
|
||||||
|
class={className}
|
||||||
|
{id}
|
||||||
|
{onaccept}
|
||||||
|
onfocus={() => (used = true)}
|
||||||
|
onsubmit={() => onsubmit(value.trim())}
|
||||||
|
{placeholder}
|
||||||
|
{suggestions}
|
||||||
|
bind:caret
|
||||||
|
bind:input
|
||||||
|
bind:value
|
||||||
|
/>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLInputAttributes } from "svelte/elements";
|
||||||
|
import type { Suggestion } from "#lib/transactions/query.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(""),
|
||||||
|
caret = $bindable(0),
|
||||||
|
input = $bindable(),
|
||||||
|
suggestions,
|
||||||
|
onaccept,
|
||||||
|
onsubmit,
|
||||||
|
oncancel,
|
||||||
|
onfocus,
|
||||||
|
onblur,
|
||||||
|
id,
|
||||||
|
class: className = "",
|
||||||
|
...rest
|
||||||
|
}: Omit<HTMLInputAttributes, "value" | "onsubmit"> & {
|
||||||
|
value?: string;
|
||||||
|
caret?: number;
|
||||||
|
input?: HTMLInputElement;
|
||||||
|
suggestions: Suggestion[];
|
||||||
|
onaccept: (item: Suggestion) => boolean | undefined;
|
||||||
|
onsubmit: () => void;
|
||||||
|
oncancel?: () => void;
|
||||||
|
id: string;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let cursor = $state(0);
|
||||||
|
let focused = $state(false);
|
||||||
|
let dismissed = $state(false);
|
||||||
|
|
||||||
|
const shown = $derived(focused && !dismissed && suggestions.length > 0);
|
||||||
|
const listId = $derived(`${id}-suggestions`);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (suggestions) {
|
||||||
|
cursor = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
caret = input?.selectionStart ?? value.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(delta: number) {
|
||||||
|
const count = suggestions.length;
|
||||||
|
cursor = (cursor + delta + count) % count;
|
||||||
|
document
|
||||||
|
.getElementById(`${listId}-${cursor}`)
|
||||||
|
?.scrollIntoView({ block: "nearest" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function accept(index: number): boolean {
|
||||||
|
const item = suggestions[index];
|
||||||
|
return item ? onaccept(item) !== false : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onkeydown(event: KeyboardEvent) {
|
||||||
|
const ctrl = event.ctrlKey || event.metaKey;
|
||||||
|
const down = event.key === "ArrowDown" || (ctrl && event.key === "n");
|
||||||
|
const up = event.key === "ArrowUp" || (ctrl && event.key === "p");
|
||||||
|
if (shown && (down || up)) {
|
||||||
|
event.preventDefault();
|
||||||
|
move(down ? 1 : -1);
|
||||||
|
} else if (shown && event.key === "Tab" && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
accept(cursor);
|
||||||
|
} else if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!(shown && accept(cursor))) {
|
||||||
|
onsubmit();
|
||||||
|
}
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (shown) {
|
||||||
|
dismissed = true;
|
||||||
|
} else {
|
||||||
|
oncancel?.();
|
||||||
|
input?.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative {className}">
|
||||||
|
<input
|
||||||
|
{...rest}
|
||||||
|
aria-activedescendant={shown ? `${listId}-${cursor}` : undefined}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-controls={listId}
|
||||||
|
aria-expanded={shown}
|
||||||
|
autocomplete="off"
|
||||||
|
class="cladd-cut cladd-focusable-input h-8 w-full rounded-cladd-md px-2.5 font-mono text-[13px] text-cladd-fg placeholder:font-sans placeholder:text-cladd-fg-softer"
|
||||||
|
{id}
|
||||||
|
onblur={(event) => {
|
||||||
|
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}
|
||||||
|
<div
|
||||||
|
class="cladd-surface cladd-outline absolute top-full right-0 left-0 z-30 mt-1 max-h-72 overflow-y-auto rounded-cladd-md p-1 shadow-cladd-popover"
|
||||||
|
id={listId}
|
||||||
|
role="listbox"
|
||||||
|
>
|
||||||
|
{#each suggestions as item, index (item.id)}
|
||||||
|
<div
|
||||||
|
aria-selected={index === cursor}
|
||||||
|
class="flex h-7 cursor-pointer items-center gap-3 rounded-cladd-sm px-2 text-[13px] {index ===
|
||||||
|
cursor
|
||||||
|
? "bg-row-cursor"
|
||||||
|
: "hover:bg-row-hover"}"
|
||||||
|
id={`${listId}-${index}`}
|
||||||
|
onmousedown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
accept(index);
|
||||||
|
}}
|
||||||
|
role="option"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 flex-1 truncate font-mono">{item.label}</span>
|
||||||
|
{#if item.hint}
|
||||||
|
<span class="shrink-0 text-[11px] text-cladd-fg-softer"
|
||||||
|
>{item.hint}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,595 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Check from "@lucide/svelte/icons/check";
|
||||||
|
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||||
|
import Paperclip from "@lucide/svelte/icons/paperclip";
|
||||||
|
import type { Split } from "#lib/api/types.js";
|
||||||
|
import Amount from "#lib/components/Amount.svelte";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from "#lib/components/ui/context-menu/index.js";
|
||||||
|
import { formatDate, formatMoney, type Money } from "#lib/format.js";
|
||||||
|
import type { Binding } from "#lib/keys.svelte.js";
|
||||||
|
import { ListNav } from "#lib/list-nav.svelte.js";
|
||||||
|
import { oldUiUrl } from "#lib/spa-paths.js";
|
||||||
|
import { buildRows, type Row } from "#lib/transactions/mass.js";
|
||||||
|
import {
|
||||||
|
accountUrl,
|
||||||
|
balanceAfter,
|
||||||
|
cloneUrl,
|
||||||
|
editUrl,
|
||||||
|
type Group,
|
||||||
|
groupType,
|
||||||
|
isCash,
|
||||||
|
type Signed,
|
||||||
|
showUrl,
|
||||||
|
signedAmount,
|
||||||
|
signedForeign,
|
||||||
|
sumByCurrency,
|
||||||
|
} from "#lib/transactions/split.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import ActionBar, { type Mode } from "./ActionBar.svelte";
|
||||||
|
import TypeIcon from "./TypeIcon.svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
groups,
|
||||||
|
context = {},
|
||||||
|
selectable = true,
|
||||||
|
loading = false,
|
||||||
|
id = "transactions",
|
||||||
|
empty = "No transactions.",
|
||||||
|
}: {
|
||||||
|
groups: Group[];
|
||||||
|
context?: { accountId?: string };
|
||||||
|
selectable?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
id?: string;
|
||||||
|
empty?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let collapsed = $state(new Set<string>());
|
||||||
|
let opened = $state(new Set<string>());
|
||||||
|
let bar = $state<ActionBar>();
|
||||||
|
let menuRows = $state<Row[]>([]);
|
||||||
|
let menuArmed = $state(false);
|
||||||
|
let barHeight = $state(0);
|
||||||
|
|
||||||
|
const CHECKBOX = "size-3.5 cursor-pointer align-middle accent-(--ff-brand)";
|
||||||
|
|
||||||
|
const accountId = $derived(context.accountId);
|
||||||
|
const rows = $derived(buildRows(groups, collapsed));
|
||||||
|
|
||||||
|
function toggled(set: Set<string>, key: string, on: boolean) {
|
||||||
|
const next = new Set(set);
|
||||||
|
if (on) {
|
||||||
|
next.add(key);
|
||||||
|
} else {
|
||||||
|
next.delete(key);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(row: Row | undefined) {
|
||||||
|
if (row) {
|
||||||
|
goto(showUrl(row.group.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expand(row: Row | undefined) {
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row.kind === "head" && collapsed.has(row.group.id)) {
|
||||||
|
collapsed = toggled(collapsed, row.group.id, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
opened = toggled(opened, row.key, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapse(row: Row | undefined) {
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (opened.has(row.key)) {
|
||||||
|
opened = toggled(opened, row.key, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row.kind === "single") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
collapsed = toggled(collapsed, row.group.id, true);
|
||||||
|
nav.move(rows.findIndex((r) => r.key === row.group.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function action(mode: Mode, label: string, keys: string): Binding {
|
||||||
|
return { group: "Selection", keys, label, run: () => bar?.start(mode) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindings(): Binding[] {
|
||||||
|
const list: Binding[] = [
|
||||||
|
{
|
||||||
|
group: "List",
|
||||||
|
keys: "l",
|
||||||
|
label: "Expand row: splits, notes",
|
||||||
|
run: () => expand(nav.current),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "List",
|
||||||
|
keys: "h",
|
||||||
|
label: "Collapse row",
|
||||||
|
run: () => collapse(nav.current),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "List",
|
||||||
|
hidden: true,
|
||||||
|
keys: "ArrowRight",
|
||||||
|
label: "Expand row",
|
||||||
|
run: () => expand(nav.current),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "List",
|
||||||
|
hidden: true,
|
||||||
|
keys: "ArrowLeft",
|
||||||
|
label: "Collapse row",
|
||||||
|
run: () => collapse(nav.current),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "List",
|
||||||
|
keys: "c",
|
||||||
|
label: "Clone transaction",
|
||||||
|
run: () => nav.current && goto(cloneUrl(nav.current.group)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (selectable) {
|
||||||
|
list.push(
|
||||||
|
action("category", "Set category", "s c"),
|
||||||
|
action("budget", "Set budget", "s b"),
|
||||||
|
action("tag", "Add tag", "s t"),
|
||||||
|
action("untag", "Remove tag", "s T"),
|
||||||
|
{
|
||||||
|
group: "Selection",
|
||||||
|
keys: "d",
|
||||||
|
label: "Delete (press twice)",
|
||||||
|
run: () => bar?.remove(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNav() {
|
||||||
|
return new ListNav<Row>({
|
||||||
|
edit: (row) => goto(editUrl(row.group.id)),
|
||||||
|
extra: bindings,
|
||||||
|
id,
|
||||||
|
items: () => rows,
|
||||||
|
key: (row) => row.key,
|
||||||
|
open,
|
||||||
|
selectable,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const nav = createNav();
|
||||||
|
nav.use();
|
||||||
|
|
||||||
|
function oncontextmenu(event: MouseEvent) {
|
||||||
|
const tr = (event.target as Element).closest("tr[data-index]");
|
||||||
|
menuArmed = false;
|
||||||
|
if (!tr) {
|
||||||
|
menuRows = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const index = Number(tr.getAttribute("data-index"));
|
||||||
|
const row = rows[index];
|
||||||
|
nav.cursor = index;
|
||||||
|
menuRows = nav.isSelected(row) ? nav.selection : [row];
|
||||||
|
}
|
||||||
|
|
||||||
|
function oldUi(row: Row) {
|
||||||
|
location.assign(
|
||||||
|
oldUiUrl(new URL(showUrl(row.group.id), location.origin).href)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function common(splits: Split[], pick: (split: Split) => string | null) {
|
||||||
|
const values = new Set(splits.map(pick));
|
||||||
|
return values.size === 1 ? [...values][0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowClass(row: Row, index: number) {
|
||||||
|
const selected = nav.isSelected(row);
|
||||||
|
const cursor = nav.cursor === index;
|
||||||
|
let background = "hover:bg-row-hover";
|
||||||
|
if (selected) {
|
||||||
|
background = "bg-row-selected";
|
||||||
|
} else if (cursor) {
|
||||||
|
background = "bg-row-cursor";
|
||||||
|
}
|
||||||
|
const border = row.kind === "head" ? "border-cladd-outline border-t" : "";
|
||||||
|
const mark = cursor ? "shadow-[inset_2px_0_0_var(--ff-brand)]" : "";
|
||||||
|
return `${background} ${border} ${mark}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet day(
|
||||||
|
value: string | undefined
|
||||||
|
)}
|
||||||
|
<span class="md:hidden">{formatDate(value?.slice(0, 10), "short")}</span>
|
||||||
|
<span class="max-md:hidden">{formatDate(value?.slice(0, 10))}</span>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet tagLinks(
|
||||||
|
tags: string[] | null | undefined
|
||||||
|
)}
|
||||||
|
{#each tags ?? [] as tag (tag)}
|
||||||
|
<a
|
||||||
|
class="text-cladd-fg-soft after:text-cladd-fg-softer hover:text-cladd-fg hover:underline [&:not(:last-child)]:after:content-[',_']"
|
||||||
|
href={`/tags/show/${encodeURIComponent(tag)}`}
|
||||||
|
>{tag}</a
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet money(
|
||||||
|
value: Signed
|
||||||
|
)}
|
||||||
|
<Amount
|
||||||
|
amount={value.amount}
|
||||||
|
code={value.code}
|
||||||
|
decimals={value.decimals}
|
||||||
|
symbol={value.symbol}
|
||||||
|
tone={value.tone}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet plain(
|
||||||
|
value: Money | null
|
||||||
|
)}
|
||||||
|
{#if value}
|
||||||
|
<span class="num text-cladd-fg-soft">{formatMoney(value)}</span>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet account(
|
||||||
|
accountIdValue: string | null | undefined,
|
||||||
|
name: string | null | undefined,
|
||||||
|
type: string | null | undefined
|
||||||
|
)}
|
||||||
|
{#if isCash(type)}
|
||||||
|
<span class="text-income">(cash)</span>
|
||||||
|
{:else if accountIdValue}
|
||||||
|
<a
|
||||||
|
class="block truncate hover:underline"
|
||||||
|
href={accountUrl(accountIdValue)}
|
||||||
|
title={name}
|
||||||
|
>{name}</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet splitCells(
|
||||||
|
row: Row,
|
||||||
|
split: Split
|
||||||
|
)}
|
||||||
|
{@const amount = signedAmount(split, accountId)}
|
||||||
|
{@const foreign = signedForeign(split, accountId)}
|
||||||
|
{@const wide = opened.has(row.key)}
|
||||||
|
<td class="min-w-0 {row.kind === "split" ? "pl-6" : ""}">
|
||||||
|
<div class="flex min-w-0 items-center gap-1.5">
|
||||||
|
{#if split.reconciled}
|
||||||
|
<Check aria-label="Reconciled" class="size-3.5 shrink-0 text-income" />
|
||||||
|
{/if}
|
||||||
|
{#if split.has_attachments}
|
||||||
|
<Paperclip
|
||||||
|
aria-label="Has attachments"
|
||||||
|
class="size-3.5 shrink-0 text-cladd-fg-soft"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<a
|
||||||
|
class="min-w-0 hover:underline {wide ? "break-words" : "truncate"}"
|
||||||
|
href={showUrl(row.group.id)}
|
||||||
|
title={split.description}
|
||||||
|
>{split.description}</a
|
||||||
|
>
|
||||||
|
{#if split.tags?.length}
|
||||||
|
<span
|
||||||
|
class="ml-auto max-w-[45%] shrink-0 truncate text-[12px] 2xl:hidden"
|
||||||
|
>{@render tagLinks(split.tags)}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if split.notes}
|
||||||
|
<div
|
||||||
|
class="flex min-w-0 items-start gap-0.5 text-[12px] text-cladd-fg-soft"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-expanded={wide}
|
||||||
|
aria-label={wide ? "Collapse notes" : "Expand notes"}
|
||||||
|
class="-ml-0.5 grid size-4 shrink-0 place-items-center rounded hover:bg-cladd-surface-next hover:text-cladd-fg"
|
||||||
|
onclick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
opened = toggled(opened, row.key, !wide);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
class="size-3 transition-transform {wide ? "rotate-90" : ""}"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
class="min-w-0 {wide
|
||||||
|
? "whitespace-pre-line break-words"
|
||||||
|
: "truncate"}"
|
||||||
|
>{split.notes}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<div class="flex flex-wrap justify-end gap-x-1">
|
||||||
|
{@render money(amount)}
|
||||||
|
{#if foreign}
|
||||||
|
<span class="num text-[12px] text-cladd-fg-soft"
|
||||||
|
>({formatMoney(foreign)})</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{#if accountId}
|
||||||
|
<td class="text-right">{@render plain(balanceAfter(split, accountId))}</td>
|
||||||
|
{/if}
|
||||||
|
<td class="num text-cladd-fg-soft">
|
||||||
|
{#if row.kind !== "split"}
|
||||||
|
{@render day(split.date)}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="max-md:hidden">
|
||||||
|
{@render account(split.source_id, split.source_name, split.source_type)}
|
||||||
|
</td>
|
||||||
|
<td class="max-md:hidden">
|
||||||
|
{@render account(
|
||||||
|
split.destination_id,
|
||||||
|
split.destination_name,
|
||||||
|
split.destination_type
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td class="truncate max-lg:hidden">
|
||||||
|
{#if split.category_id}
|
||||||
|
<a class="hover:underline" href={`/categories/show/${split.category_id}`}
|
||||||
|
>{split.category_name}</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="truncate max-xl:hidden">
|
||||||
|
{#if split.budget_id}
|
||||||
|
<a class="hover:underline" href={`/budgets/show/${split.budget_id}`}
|
||||||
|
>{split.budget_name}</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="max-2xl:hidden {wide ? "" : "truncate"}">
|
||||||
|
{@render tagLinks(split.tags)}
|
||||||
|
</td>
|
||||||
|
{/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)}
|
||||||
|
<td class="min-w-0">
|
||||||
|
<div class="flex min-w-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
aria-expanded={!folded}
|
||||||
|
aria-label={folded ? "Show splits" : "Hide splits"}
|
||||||
|
class="-ml-1 grid size-5 shrink-0 place-items-center rounded hover:bg-cladd-surface-next"
|
||||||
|
onclick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
collapsed = toggled(collapsed, row.group.id, !folded);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
class="size-3.5 transition-transform {folded ? "" : "rotate-90"}"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
class="truncate font-semibold hover:underline"
|
||||||
|
href={showUrl(row.group.id)}
|
||||||
|
>{row.group.attributes.group_title || splits[0]?.description}</a
|
||||||
|
>
|
||||||
|
<span class="shrink-0 text-[12px] text-cladd-fg-softer"
|
||||||
|
>{row.count}
|
||||||
|
splits</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<div class="flex flex-wrap justify-end gap-x-1 font-semibold">
|
||||||
|
{#each sums as sum, i (sum.code)}
|
||||||
|
{@render money(sum)}
|
||||||
|
{#if i < sums.length - 1}
|
||||||
|
,
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{#if accountId}
|
||||||
|
<td></td>
|
||||||
|
{/if}
|
||||||
|
<td class="num text-cladd-fg-soft">
|
||||||
|
{@render day(splits[0]?.date)}
|
||||||
|
</td>
|
||||||
|
<td class="max-md:hidden">
|
||||||
|
{#if source}
|
||||||
|
{@render account(source, splits[0].source_name, splits[0].source_type)}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="max-md:hidden">
|
||||||
|
{#if destination}
|
||||||
|
{@render account(
|
||||||
|
destination,
|
||||||
|
splits[0].destination_name,
|
||||||
|
splits[0].destination_type
|
||||||
|
)}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="max-lg:hidden"></td>
|
||||||
|
<td class="max-xl:hidden"></td>
|
||||||
|
<td class="max-2xl:hidden"></td>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="sticky top-12 z-20 bg-cladd-surface"
|
||||||
|
bind:clientHeight={barHeight}
|
||||||
|
>
|
||||||
|
<ActionBar
|
||||||
|
onclear={() => nav.clear()}
|
||||||
|
selected={nav.selected.size}
|
||||||
|
targets={nav.targets}
|
||||||
|
bind:this={bar}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger class="block outline-none" {oncontextmenu}>
|
||||||
|
<table class="w-full table-fixed border-collapse text-[13px]">
|
||||||
|
<thead
|
||||||
|
class="sticky z-10 bg-cladd-surface"
|
||||||
|
style:top={`calc(3rem + ${barHeight}px)`}
|
||||||
|
>
|
||||||
|
<tr
|
||||||
|
class="border-cladd-outline border-b text-left text-[12px] text-cladd-fg-soft [&>th]:h-8 [&>th]:truncate [&>th]:px-2 [&>th]:font-medium"
|
||||||
|
>
|
||||||
|
{#if selectable}
|
||||||
|
<th class="w-8 max-sm:hidden">
|
||||||
|
<input
|
||||||
|
aria-label="Select all"
|
||||||
|
checked={rows.length > 0 && nav.selected.size === rows.length}
|
||||||
|
class={CHECKBOX}
|
||||||
|
onclick={(event) => {
|
||||||
|
event.currentTarget.blur();
|
||||||
|
nav.selectAll();
|
||||||
|
}}
|
||||||
|
type="checkbox"
|
||||||
|
>
|
||||||
|
</th>
|
||||||
|
{/if}
|
||||||
|
<th class="w-6 max-sm:hidden"><span class="sr-only">Type</span></th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th class="w-28 text-right md:w-44">Amount</th>
|
||||||
|
{#if accountId}
|
||||||
|
<th class="w-32 text-right">Balance</th>
|
||||||
|
{/if}
|
||||||
|
<th class="w-16 md:w-[6.5rem]">Date</th>
|
||||||
|
<th class="w-34 max-md:hidden">Source account</th>
|
||||||
|
<th class="w-34 max-md:hidden">Destination account</th>
|
||||||
|
<th class="w-28 max-lg:hidden">Category</th>
|
||||||
|
<th class="w-24 max-xl:hidden">Budget</th>
|
||||||
|
<th class="w-28 max-2xl:hidden">Tags</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class={loading && rows.length > 0 ? "opacity-60" : ""}>
|
||||||
|
{#each rows as row, index (row.key)}
|
||||||
|
<tr
|
||||||
|
class="align-top [&>td]:px-2 [&>td]:py-1 {rowClass(row, index)}"
|
||||||
|
data-index={index}
|
||||||
|
id={nav.rowId(index)}
|
||||||
|
onclick={(event) => nav.click(index, event)}
|
||||||
|
ondblclick={() => open(row)}
|
||||||
|
>
|
||||||
|
{#if selectable}
|
||||||
|
<td class="max-sm:hidden">
|
||||||
|
<input
|
||||||
|
aria-label="Select row"
|
||||||
|
checked={nav.isSelected(row)}
|
||||||
|
class={CHECKBOX}
|
||||||
|
onclick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.currentTarget.blur();
|
||||||
|
nav.toggle(row);
|
||||||
|
}}
|
||||||
|
type="checkbox"
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
<td class="pt-1.5! max-sm:hidden">
|
||||||
|
{#if row.kind !== "split"}
|
||||||
|
<TypeIcon type={groupType(row.group)} />
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
{#if row.split}
|
||||||
|
{@render splitCells(row, row.split)}
|
||||||
|
{:else}
|
||||||
|
{@render headCells(row)}
|
||||||
|
{/if}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{#if rows.length === 0}
|
||||||
|
<p class="px-3 py-6 text-center text-cladd-fg-soft">
|
||||||
|
{loading ? "Loading transactions..." : empty}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent class="w-60">
|
||||||
|
{#if menuRows.length > 0}
|
||||||
|
{@const first = menuRows[0]}
|
||||||
|
<ContextMenuItem onSelect={() => open(first)}>
|
||||||
|
Open <ContextMenuShortcut>↵</ContextMenuShortcut>
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onSelect={() => goto(editUrl(first.group.id))}>
|
||||||
|
Edit <ContextMenuShortcut>e</ContextMenuShortcut>
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onSelect={() => goto(cloneUrl(first.group))}>
|
||||||
|
Clone <ContextMenuShortcut>c</ContextMenuShortcut>
|
||||||
|
</ContextMenuItem>
|
||||||
|
{#if selectable}
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem onSelect={() => bar?.start("category", menuRows)}>
|
||||||
|
Set category... <ContextMenuShortcut>s c</ContextMenuShortcut>
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onSelect={() => bar?.start("budget", menuRows)}>
|
||||||
|
Set budget... <ContextMenuShortcut>s b</ContextMenuShortcut>
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onSelect={() => bar?.start("tag", menuRows)}>
|
||||||
|
Add tag... <ContextMenuShortcut>s t</ContextMenuShortcut>
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onSelect={() => bar?.start("untag", menuRows)}>
|
||||||
|
Remove tag... <ContextMenuShortcut>s T</ContextMenuShortcut>
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
onSelect={(event) => {
|
||||||
|
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` : ""}
|
||||||
|
<ContextMenuShortcut>d</ContextMenuShortcut>
|
||||||
|
</ContextMenuItem>
|
||||||
|
{/if}
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem onSelect={() => oldUi(first)}>
|
||||||
|
Open in old UI
|
||||||
|
</ContextMenuItem>
|
||||||
|
{:else}
|
||||||
|
<ContextMenuItem disabled>Right-click a row</ContextMenuItem>
|
||||||
|
{/if}
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ArrowLeft from "@lucide/svelte/icons/arrow-left";
|
||||||
|
import ArrowLeftRight from "@lucide/svelte/icons/arrow-left-right";
|
||||||
|
import ArrowRight from "@lucide/svelte/icons/arrow-right";
|
||||||
|
import Calculator from "@lucide/svelte/icons/calculator";
|
||||||
|
import Star from "@lucide/svelte/icons/star";
|
||||||
|
import { TYPE_LABEL } from "#lib/transactions/split.js";
|
||||||
|
|
||||||
|
let { type, class: className = "" }: { type: string; class?: string } =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
deposit: ArrowRight,
|
||||||
|
reconciliation: Calculator,
|
||||||
|
transfer: ArrowLeftRight,
|
||||||
|
withdrawal: ArrowLeft,
|
||||||
|
};
|
||||||
|
const Icon = $derived(icons[type as keyof typeof icons] ?? Star);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Icon
|
||||||
|
aria-label={TYPE_LABEL[type] ?? type}
|
||||||
|
class="size-3.5 shrink-0 text-cladd-fg-softer {className}"
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
|
<title>{TYPE_LABEL[type] ?? type}</title>
|
||||||
|
</Icon>
|
||||||
+13
-2
@@ -51,10 +51,21 @@ export function formatNumber(
|
|||||||
return numberFormat(decimals, signed).format(toNumber(value));
|
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 {
|
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 ?? "";
|
const symbol = money.symbol ?? money.code ?? "";
|
||||||
return symbol ? `${symbol}${number}` : number;
|
return `${signOf(value, signed)}${symbol}${number}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function compactMoney(value: number, symbol = ""): string {
|
export function compactMoney(value: number, symbol = ""): string {
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
export type Targets = Map<string, Set<string> | 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<string>): 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<string>();
|
||||||
|
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<One<TransactionGroup>>(`/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<string> | null) => Promise<void>,
|
||||||
|
progress: (outcome: Outcome) => void
|
||||||
|
): Promise<Outcome> {
|
||||||
|
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<Outcome> {
|
||||||
|
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<Outcome> {
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, { alias: boolean; alias_for?: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const operatorsQuery = () => ({
|
||||||
|
queryFn: async (): Promise<Operator[]> => {
|
||||||
|
const config = await api<OperatorConfig>(
|
||||||
|
"/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<Record<ValueKind, string>> = {
|
||||||
|
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<string, string> = {},
|
||||||
|
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, string>
|
||||||
|
): 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;
|
||||||
|
}
|
||||||
@@ -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<TransactionGroup>;
|
||||||
|
export type Tone = "income" | "expense" | "transfer" | "neutral";
|
||||||
|
|
||||||
|
export interface Signed extends Money {
|
||||||
|
amount: number;
|
||||||
|
tone: Tone;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TYPE_LABEL: Record<string, string> = {
|
||||||
|
deposit: "Deposit",
|
||||||
|
"liability credit": "Liability credit",
|
||||||
|
"opening balance": "Opening balance",
|
||||||
|
reconciliation: "Reconciliation",
|
||||||
|
transfer: "Transfer",
|
||||||
|
withdrawal: "Withdrawal",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LIST_TITLE: Record<string, string> = {
|
||||||
|
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<string, Signed>();
|
||||||
|
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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Search from "@lucide/svelte/icons/search";
|
||||||
|
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import type { Page, TransactionGroup } from "#lib/api/types.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
|
import Pager from "#lib/components/transactions/Pager.svelte";
|
||||||
|
import QueryInput from "#lib/components/transactions/QueryInput.svelte";
|
||||||
|
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
|
||||||
|
import Button from "#lib/components/ui/button/button.svelte";
|
||||||
|
import { formatNumber } from "#lib/format.js";
|
||||||
|
import { useKeys } from "#lib/keys.svelte.js";
|
||||||
|
import { session } from "#lib/session.svelte.js";
|
||||||
|
import { explain, operatorsQuery } from "#lib/transactions/query.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
const MS = 1000;
|
||||||
|
const EXAMPLES = [
|
||||||
|
"category_is:",
|
||||||
|
"budget_is:",
|
||||||
|
"tag_is:",
|
||||||
|
"amount_more:",
|
||||||
|
"date_after:",
|
||||||
|
"has_no_category:true",
|
||||||
|
"notes_contains:",
|
||||||
|
"source_account_is:",
|
||||||
|
];
|
||||||
|
|
||||||
|
let input = $state<HTMLInputElement>();
|
||||||
|
let seconds = $state(0);
|
||||||
|
|
||||||
|
const query = $derived(page.url.searchParams.get("search")?.trim() ?? "");
|
||||||
|
const pageNo = $derived(
|
||||||
|
Math.max(1, Number(page.url.searchParams.get("page")) || 1)
|
||||||
|
);
|
||||||
|
let draft = $derived(query);
|
||||||
|
|
||||||
|
const operators = createQuery(() => ({
|
||||||
|
...operatorsQuery(),
|
||||||
|
enabled: query !== "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const results = createQuery(() => ({
|
||||||
|
enabled: query !== "",
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
queryFn: async () => {
|
||||||
|
const started = performance.now();
|
||||||
|
const data = await api<Page<TransactionGroup>>(
|
||||||
|
"/v1/search/transactions",
|
||||||
|
{ query: { limit: session.pageSize, page: pageNo, query } }
|
||||||
|
);
|
||||||
|
seconds = (performance.now() - started) / MS;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: ["search", query, pageNo, session.pageSize],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pagination = $derived(results.data?.meta.pagination);
|
||||||
|
const explanation = $derived(explain(query, operators.data ?? []));
|
||||||
|
|
||||||
|
function submit(text: string) {
|
||||||
|
const url = new URL(page.url.href);
|
||||||
|
url.searchParams.set("search", text);
|
||||||
|
url.searchParams.delete("page");
|
||||||
|
goto(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageHref(n: number) {
|
||||||
|
const url = new URL(page.url.href);
|
||||||
|
url.searchParams.set("page", String(n));
|
||||||
|
return url.pathname + url.search;
|
||||||
|
}
|
||||||
|
|
||||||
|
function focus() {
|
||||||
|
input?.focus();
|
||||||
|
input?.setSelectionRange(draft.length, draft.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insert(example: string) {
|
||||||
|
draft = `${draft.trim()} ${example}`.trimStart();
|
||||||
|
focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (input && query === "") {
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useKeys(() => [
|
||||||
|
{ group: "Global", keys: "/", label: "Focus search", run: focus },
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ label: "Search" }]}
|
||||||
|
subtitle={query ? `"${query}"` : undefined}
|
||||||
|
title="Search"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box class="mb-4" title="Search">
|
||||||
|
<form
|
||||||
|
class="flex flex-wrap items-center gap-2"
|
||||||
|
onsubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
submit(draft.trim());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label class="text-cladd-fg-soft" for="search-query">Query</label>
|
||||||
|
<QueryInput
|
||||||
|
class="min-w-64 flex-1"
|
||||||
|
id="search-query"
|
||||||
|
onsubmit={submit}
|
||||||
|
bind:input
|
||||||
|
bind:value={draft}
|
||||||
|
/>
|
||||||
|
<Button size="sm" type="submit"><Search />Search</Button>
|
||||||
|
{#if query}
|
||||||
|
<a
|
||||||
|
class="text-[12px] text-brand hover:underline"
|
||||||
|
data-sveltekit-reload
|
||||||
|
href={`/rules/create?from_query=${encodeURIComponent(query)}`}
|
||||||
|
>Create new rule from search query</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
{#if query && explanation.length > 0}
|
||||||
|
<p class="mt-2 text-cladd-fg-soft">
|
||||||
|
Searching for: {explanation.join(", ")}
|
||||||
|
</p>
|
||||||
|
{:else if !query}
|
||||||
|
<p class="mt-2 flex flex-wrap items-center gap-1.5 text-cladd-fg-soft">
|
||||||
|
Type words to search descriptions, or operators:
|
||||||
|
{#each EXAMPLES as example (example)}
|
||||||
|
<button
|
||||||
|
class="kbd cursor-pointer hover:text-cladd-fg"
|
||||||
|
onclick={() => insert(example)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{example}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<span>Tab or Enter accepts a suggestion.</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{#if query}
|
||||||
|
<Box body="p-0" title="Transactions">
|
||||||
|
{#snippet tools()}
|
||||||
|
{#if pagination && !results.isFetching}
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft max-sm:hidden"
|
||||||
|
>Found {pagination.total}
|
||||||
|
{pagination.total === 1 ? "transaction" : "transactions"}
|
||||||
|
in
|
||||||
|
{formatNumber(seconds, 2)}
|
||||||
|
seconds</span
|
||||||
|
>
|
||||||
|
{:else if results.isFetching}
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft">Searching...</span>
|
||||||
|
{/if}
|
||||||
|
<span class="max-sm:hidden">
|
||||||
|
<Pager
|
||||||
|
href={pageHref}
|
||||||
|
keys
|
||||||
|
page={pageNo}
|
||||||
|
pages={pagination?.total_pages ?? 1}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
{#if results.error}
|
||||||
|
<p class="px-3 py-4 text-expense">
|
||||||
|
An error occurred while searching: {results.error.message}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<TransactionTable
|
||||||
|
empty="Nothing found. Check the query above."
|
||||||
|
groups={results.data?.data ?? []}
|
||||||
|
id="tx-search"
|
||||||
|
loading={results.isFetching}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#snippet footer()}
|
||||||
|
<span class="ml-auto">
|
||||||
|
<Pager
|
||||||
|
href={pageHref}
|
||||||
|
page={pageNo}
|
||||||
|
pages={pagination?.total_pages ?? 1}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</Box>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Plus from "@lucide/svelte/icons/plus";
|
||||||
|
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||||
|
import { tick, untrack } from "svelte";
|
||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import { all } from "#lib/api/lists.js";
|
||||||
|
import type { Page, TransactionGroup } from "#lib/api/types.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
|
import Pager from "#lib/components/transactions/Pager.svelte";
|
||||||
|
import PeriodSummary from "#lib/components/transactions/PeriodSummary.svelte";
|
||||||
|
import QueryInput from "#lib/components/transactions/QueryInput.svelte";
|
||||||
|
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
|
||||||
|
import Button from "#lib/components/ui/button/button.svelte";
|
||||||
|
import { formatDate } from "#lib/format.js";
|
||||||
|
import { useKeys } from "#lib/keys.svelte.js";
|
||||||
|
import { period } from "#lib/period.svelte.js";
|
||||||
|
import { session } from "#lib/session.svelte.js";
|
||||||
|
import {
|
||||||
|
createType,
|
||||||
|
LIST_TITLE,
|
||||||
|
listPath,
|
||||||
|
listType,
|
||||||
|
} from "#lib/transactions/split.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
const SUBTITLE: Record<string, string> = {
|
||||||
|
all: "All transactions",
|
||||||
|
deposit: "All revenue and income",
|
||||||
|
transfer: "All transfers",
|
||||||
|
withdrawal: "All expenses",
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
param,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
everything = false,
|
||||||
|
}: {
|
||||||
|
param: string;
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
|
everything?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let query = $state("");
|
||||||
|
let input = $state<HTMLInputElement>();
|
||||||
|
let lastRange = "";
|
||||||
|
|
||||||
|
const type = $derived(listType(param));
|
||||||
|
const pageNo = $derived(
|
||||||
|
Math.max(1, Number(page.url.searchParams.get("page")) || 1)
|
||||||
|
);
|
||||||
|
const range = $derived(
|
||||||
|
everything ? null : { end: period.end, start: period.start }
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (period.ready && start && end) {
|
||||||
|
const fromPath = { end, start };
|
||||||
|
untrack(() => period.set(fromPath));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!period.ready) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = `${period.start}|${period.end}`;
|
||||||
|
untrack(() => {
|
||||||
|
const moved = lastRange !== "" && lastRange !== key;
|
||||||
|
lastRange = key;
|
||||||
|
if (!moved || everything) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = new URL(page.url.href);
|
||||||
|
url.searchParams.delete("page");
|
||||||
|
url.searchParams.set("start", period.start);
|
||||||
|
url.searchParams.set("end", period.end);
|
||||||
|
if (start && end && (start !== period.start || end !== period.end)) {
|
||||||
|
url.pathname = listPath(type);
|
||||||
|
}
|
||||||
|
if (url.href !== page.url.href) {
|
||||||
|
tick().then(() => goto(url, { replace: true }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = createQuery(() => ({
|
||||||
|
enabled: everything || period.ready,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
queryFn: () =>
|
||||||
|
api<Page<TransactionGroup>>("/v1/transactions", {
|
||||||
|
query: {
|
||||||
|
end: range?.end,
|
||||||
|
limit: session.pageSize,
|
||||||
|
page: pageNo,
|
||||||
|
start: range?.start,
|
||||||
|
type,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
queryKey: [
|
||||||
|
"transactions",
|
||||||
|
type,
|
||||||
|
range?.start,
|
||||||
|
range?.end,
|
||||||
|
pageNo,
|
||||||
|
session.pageSize,
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const summary = createQuery(() => ({
|
||||||
|
enabled: !everything && period.ready,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
queryFn: () =>
|
||||||
|
all<TransactionGroup>("/v1/transactions", {
|
||||||
|
end: range?.end,
|
||||||
|
start: range?.start,
|
||||||
|
type,
|
||||||
|
}),
|
||||||
|
queryKey: ["transactions-summary", type, range?.start, range?.end],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pagination = $derived(list.data?.meta.pagination);
|
||||||
|
const subtitle = $derived(
|
||||||
|
range
|
||||||
|
? `${SUBTITLE[type]} between ${formatDate(range.start)} and ${formatDate(range.end)}`
|
||||||
|
: SUBTITLE[type]
|
||||||
|
);
|
||||||
|
|
||||||
|
function pageHref(n: number) {
|
||||||
|
const url = new URL(page.url.href);
|
||||||
|
url.searchParams.set("page", String(n));
|
||||||
|
return url.pathname + url.search;
|
||||||
|
}
|
||||||
|
|
||||||
|
function search(text: string) {
|
||||||
|
goto(`/search?search=${encodeURIComponent(text)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
useKeys(() => [
|
||||||
|
{
|
||||||
|
group: "Global",
|
||||||
|
keys: "/",
|
||||||
|
label: "Search in this list",
|
||||||
|
run: () => {
|
||||||
|
if (!query && type !== "all") {
|
||||||
|
query = `type:${type} `;
|
||||||
|
}
|
||||||
|
input?.focus();
|
||||||
|
input?.setSelectionRange(query.length, query.length);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[
|
||||||
|
{ href: "/transactions/all", label: "Transactions" },
|
||||||
|
{ label: LIST_TITLE[type] ?? type },
|
||||||
|
]}
|
||||||
|
{subtitle}
|
||||||
|
title="Transactions"
|
||||||
|
>
|
||||||
|
{#snippet actions()}
|
||||||
|
<QueryInput
|
||||||
|
class="w-80 max-md:w-full"
|
||||||
|
id="list-query"
|
||||||
|
onsubmit={search}
|
||||||
|
placeholder="Search, press / to start"
|
||||||
|
bind:input
|
||||||
|
bind:value={query}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="green"
|
||||||
|
href={`/transactions/create/${createType(type)}`}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
Create a new transaction
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{#if !everything}
|
||||||
|
<PeriodSummary
|
||||||
|
groups={summary.data ?? []}
|
||||||
|
loading={summary.isPending}
|
||||||
|
{type}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Box body="p-0" title={LIST_TITLE[type] ?? "Transactions"}>
|
||||||
|
{#snippet tools()}
|
||||||
|
<span class="max-sm:hidden">
|
||||||
|
<Pager
|
||||||
|
href={pageHref}
|
||||||
|
keys
|
||||||
|
page={pageNo}
|
||||||
|
pages={pagination?.total_pages ?? 1}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
<TransactionTable
|
||||||
|
empty={everything ? "No transactions." : "No transactions in this period."}
|
||||||
|
groups={list.data?.data ?? []}
|
||||||
|
id="tx-list"
|
||||||
|
loading={list.isFetching}
|
||||||
|
/>
|
||||||
|
{#snippet footer()}
|
||||||
|
{#if everything}
|
||||||
|
<a class="text-brand hover:underline" href={listPath(type)}
|
||||||
|
>Show the current period and overview</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<a class="text-brand hover:underline" href={`${listPath(type)}/all`}
|
||||||
|
>Show all transactions without grouping them by date</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<span class="ml-auto">
|
||||||
|
<Pager
|
||||||
|
href={pageHref}
|
||||||
|
page={pageNo}
|
||||||
|
pages={pagination?.total_pages ?? 1}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</Box>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { today } from "#lib/dates.js";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import ListPage from "../../ListPage.svelte";
|
||||||
|
|
||||||
|
const start = $derived(page.params.start);
|
||||||
|
const end = $derived(page.params.end ?? (start ? today() : undefined));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ListPage {end} param={page.params.type ?? "all"} {start} />
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import ListPage from "../ListPage.svelte";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ListPage everything param={page.params.type ?? "all"} />
|
||||||
@@ -0,0 +1,761 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Check from "@lucide/svelte/icons/check";
|
||||||
|
import ChevronDown from "@lucide/svelte/icons/chevron-down";
|
||||||
|
import Copy from "@lucide/svelte/icons/copy";
|
||||||
|
import Paperclip from "@lucide/svelte/icons/paperclip";
|
||||||
|
import Pencil from "@lucide/svelte/icons/pencil";
|
||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { DropdownMenu } from "bits-ui";
|
||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import { all, invalidate, lists } from "#lib/api/lists.js";
|
||||||
|
import type {
|
||||||
|
Attachment,
|
||||||
|
Item,
|
||||||
|
LinkType,
|
||||||
|
One,
|
||||||
|
Page,
|
||||||
|
PiggyBankEvent,
|
||||||
|
Split,
|
||||||
|
TransactionGroup,
|
||||||
|
TransactionLink,
|
||||||
|
} from "#lib/api/types.js";
|
||||||
|
import Amount from "#lib/components/Amount.svelte";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
|
||||||
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
|
import TypeIcon from "#lib/components/transactions/TypeIcon.svelte";
|
||||||
|
import Button from "#lib/components/ui/button/button.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 DropdownMenuSeparator from "#lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte";
|
||||||
|
import { formatDate, formatMoney, formatNumber } from "#lib/format.js";
|
||||||
|
import { useKeys } from "#lib/keys.svelte.js";
|
||||||
|
import { oldUiUrl } from "#lib/spa-paths.js";
|
||||||
|
import {
|
||||||
|
accountUrl,
|
||||||
|
cloneUrl,
|
||||||
|
editUrl,
|
||||||
|
groupTitle,
|
||||||
|
groupType,
|
||||||
|
isCash,
|
||||||
|
journalId,
|
||||||
|
LIST_TITLE,
|
||||||
|
listPath,
|
||||||
|
type Signed,
|
||||||
|
signedAmount,
|
||||||
|
signedForeign,
|
||||||
|
sumByCurrency,
|
||||||
|
TYPE_LABEL,
|
||||||
|
} from "#lib/transactions/split.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
interface Linked {
|
||||||
|
amount: Signed | null;
|
||||||
|
description: string;
|
||||||
|
group: string | null;
|
||||||
|
id: string;
|
||||||
|
notes: string;
|
||||||
|
phrase: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Event = Item<PiggyBankEvent> & {
|
||||||
|
links?: Record<string, { uri?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DATE_FIELDS = [
|
||||||
|
["interest_date", "Interest date"],
|
||||||
|
["book_date", "Book date"],
|
||||||
|
["process_date", "Processing date"],
|
||||||
|
["due_date", "Due date"],
|
||||||
|
["payment_date", "Payment date"],
|
||||||
|
["invoice_date", "Invoice date"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const META_FIELDS = [
|
||||||
|
["external_id", "External ID"],
|
||||||
|
["internal_reference", "Internal reference"],
|
||||||
|
["sepa_batch_id", "SEPA batch ID"],
|
||||||
|
["sepa_ct_id", "SEPA end to end identifier"],
|
||||||
|
["sepa_ct_op", "SEPA opposing account identifier"],
|
||||||
|
["sepa_db", "SEPA mandate identifier"],
|
||||||
|
["sepa_country", "SEPA country"],
|
||||||
|
["sepa_cc", "SEPA clearing code"],
|
||||||
|
["sepa_ep", "SEPA external purpose"],
|
||||||
|
["sepa_ci", "SEPA creditor identifier"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const PIGGY_URI = /piggy-banks\/(\d+)/;
|
||||||
|
const BYTES_KB = 1024;
|
||||||
|
|
||||||
|
let confirm = $state<ConfirmButton>();
|
||||||
|
let deleteError = $state("");
|
||||||
|
|
||||||
|
const id = $derived(page.params.id ?? "");
|
||||||
|
|
||||||
|
const groupQuery = createQuery(() => ({
|
||||||
|
queryFn: () => api<One<TransactionGroup>>(`/v1/transactions/${id}`),
|
||||||
|
queryKey: ["transaction", id],
|
||||||
|
}));
|
||||||
|
const group = $derived(groupQuery.data?.data);
|
||||||
|
const splits = $derived(group?.attributes.transactions ?? []);
|
||||||
|
const type = $derived(group ? groupType(group) : "withdrawal");
|
||||||
|
const title = $derived(group ? groupTitle(group) : "");
|
||||||
|
const editable = $derived(
|
||||||
|
["withdrawal", "deposit", "transfer", "reconciliation"].includes(type)
|
||||||
|
);
|
||||||
|
const journals = $derived(splits.map(journalId));
|
||||||
|
|
||||||
|
const attachmentsQuery = createQuery(() => ({
|
||||||
|
queryFn: () =>
|
||||||
|
api<Page<Attachment>>(`/v1/transactions/${id}/attachments`, {
|
||||||
|
query: { limit: 500 },
|
||||||
|
}),
|
||||||
|
queryKey: ["transaction-attachments", id],
|
||||||
|
}));
|
||||||
|
const eventsQuery = createQuery(() => ({
|
||||||
|
queryFn: () =>
|
||||||
|
api<Page<PiggyBankEvent>>(`/v1/transactions/${id}/piggy-bank-events`, {
|
||||||
|
query: { limit: 500 },
|
||||||
|
}),
|
||||||
|
queryKey: ["transaction-piggy-events", id],
|
||||||
|
}));
|
||||||
|
const piggies = createQuery(() => ({
|
||||||
|
...lists.piggyBanks(),
|
||||||
|
enabled: (eventsQuery.data?.data.length ?? 0) > 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function loadLinks(ids: string[]): Promise<Record<string, Linked[]>> {
|
||||||
|
const [types, ...pages] = await Promise.all([
|
||||||
|
all<LinkType>("/v1/link-types"),
|
||||||
|
...ids.map((journal) =>
|
||||||
|
api<Page<TransactionLink>>(`/v1/transaction-journals/${journal}/links`)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const result: Record<string, Linked[]> = {};
|
||||||
|
await Promise.all(
|
||||||
|
ids.map(async (journal, index) => {
|
||||||
|
result[journal] = await Promise.all(
|
||||||
|
pages[index].data.map(async (link) => {
|
||||||
|
const { inward_id, outward_id, link_type_id, notes } =
|
||||||
|
link.attributes;
|
||||||
|
const source = inward_id === journal;
|
||||||
|
const other = source ? outward_id : inward_id;
|
||||||
|
const linkType = types.find((t) => t.id === link_type_id);
|
||||||
|
const found = await api<One<TransactionGroup>>(
|
||||||
|
`/v1/transaction-journals/${other}`
|
||||||
|
).catch(() => null);
|
||||||
|
const split = found?.data.attributes.transactions.find(
|
||||||
|
(s) => journalId(s) === other
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
amount: split ? signedAmount(split) : null,
|
||||||
|
description: split?.description ?? `#${other}`,
|
||||||
|
group: found?.data.id ?? null,
|
||||||
|
id: link.id,
|
||||||
|
notes: notes ?? "",
|
||||||
|
phrase:
|
||||||
|
(source
|
||||||
|
? linkType?.attributes.outward
|
||||||
|
: linkType?.attributes.inward) ?? "linked to",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const linksQuery = createQuery(() => ({
|
||||||
|
enabled: journals.length > 0,
|
||||||
|
queryFn: () => loadLinks(journals),
|
||||||
|
queryKey: ["transaction-links", id, journals.join(",")],
|
||||||
|
}));
|
||||||
|
|
||||||
|
function uniqueAccounts(side: "source" | "destination") {
|
||||||
|
const seen = new Map<string, { id: string; name: string; cash: boolean }>();
|
||||||
|
for (const split of splits) {
|
||||||
|
const accountId = split[`${side}_id`] ?? "";
|
||||||
|
seen.set(accountId, {
|
||||||
|
cash: isCash(split[`${side}_type`]),
|
||||||
|
id: accountId,
|
||||||
|
name: split[`${side}_name`] ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [...seen.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
const foreignTotals = $derived.by(() => {
|
||||||
|
const sums = new Map<string, Signed>();
|
||||||
|
for (const split of splits) {
|
||||||
|
const foreign = signedForeign(split);
|
||||||
|
if (!foreign) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sum = sums.get(foreign.code ?? "");
|
||||||
|
if (sum) {
|
||||||
|
sum.amount += foreign.amount;
|
||||||
|
} else {
|
||||||
|
sums.set(foreign.code ?? "", { ...foreign });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...sums.values()];
|
||||||
|
});
|
||||||
|
|
||||||
|
function attachmentsFor(split: Split) {
|
||||||
|
return (attachmentsQuery.data?.data ?? []).filter(
|
||||||
|
(a) => a.attributes.attachable_id === journalId(split)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventsFor(split: Split) {
|
||||||
|
return ((eventsQuery.data?.data ?? []) as Event[]).filter(
|
||||||
|
(e) =>
|
||||||
|
(e.attributes as { transaction_journal_id?: string })
|
||||||
|
.transaction_journal_id === journalId(split)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function piggyOf(event: Event) {
|
||||||
|
const uri = Object.values(event.links ?? {}).find((l) => l?.uri)?.uri;
|
||||||
|
const piggyId = uri?.match(PIGGY_URI)?.[1];
|
||||||
|
const name = (piggies.data ?? []).find((p) => p.id === piggyId)?.attributes
|
||||||
|
.name;
|
||||||
|
return { id: piggyId, name: name ?? (piggyId ? `#${piggyId}` : "") };
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateLabel(value: string | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const time = value.slice(11, 16);
|
||||||
|
const day = formatDate(value.slice(0, 10), "long");
|
||||||
|
return time && time !== "00:00" ? `${day}, ${time}` : day;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
deleteError = "";
|
||||||
|
try {
|
||||||
|
await api(`/v1/transactions/${id}`, { method: "DELETE" });
|
||||||
|
await invalidate();
|
||||||
|
await goto(listPath(type));
|
||||||
|
} catch (error) {
|
||||||
|
deleteError = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeLink(linkId: string) {
|
||||||
|
await api(`/v1/transaction-links/${linkId}`, { method: "DELETE" });
|
||||||
|
await linksQuery.refetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function oldUi(path = page.url.pathname) {
|
||||||
|
location.assign(oldUiUrl(new URL(path, location.origin).href));
|
||||||
|
}
|
||||||
|
|
||||||
|
const others = $derived(
|
||||||
|
["withdrawal", "deposit", "transfer"].filter((t) => t !== type)
|
||||||
|
);
|
||||||
|
|
||||||
|
useKeys(() =>
|
||||||
|
group && editable
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
group: "Transaction",
|
||||||
|
keys: "e",
|
||||||
|
label: "Edit transaction",
|
||||||
|
run: () => goto(editUrl(id)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Transaction",
|
||||||
|
keys: "c",
|
||||||
|
label: "Clone transaction",
|
||||||
|
run: () => goto(cloneUrl(group)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Transaction",
|
||||||
|
keys: "d",
|
||||||
|
label: "Delete transaction (press twice)",
|
||||||
|
run: () => confirm?.press(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet money(
|
||||||
|
value: Signed
|
||||||
|
)}
|
||||||
|
<Amount
|
||||||
|
amount={value.amount}
|
||||||
|
code={value.code}
|
||||||
|
decimals={value.decimals}
|
||||||
|
symbol={value.symbol}
|
||||||
|
tone={value.tone}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet accountLink(
|
||||||
|
accountId: string | null | undefined,
|
||||||
|
name: string | null | undefined,
|
||||||
|
type: string | null | undefined
|
||||||
|
)}
|
||||||
|
{#if isCash(type)}
|
||||||
|
<span class="text-income">(cash)</span>
|
||||||
|
{:else}
|
||||||
|
<a class="text-brand hover:underline" href={accountUrl(accountId)}
|
||||||
|
>{name}</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet row(
|
||||||
|
label: string
|
||||||
|
)}
|
||||||
|
<th
|
||||||
|
class="w-44 px-3 py-1.5 text-left align-top font-normal text-cladd-fg-soft"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</th>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[
|
||||||
|
{ href: listPath(type), label: LIST_TITLE[type] ?? "Transactions" },
|
||||||
|
{ label: title || `#${id}` },
|
||||||
|
]}
|
||||||
|
subtitle={group ? `${TYPE_LABEL[type] ?? type}: "${title}"` : undefined}
|
||||||
|
title="Transactions"
|
||||||
|
>
|
||||||
|
{#snippet actions()}
|
||||||
|
{#if group}
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||||
|
{#if editable}
|
||||||
|
<Button href={editUrl(id)} size="sm" variant="outline">
|
||||||
|
<Pencil />Edit <span class="kbd">e</span>
|
||||||
|
</Button>
|
||||||
|
{#if type !== "reconciliation"}
|
||||||
|
<Button href={cloneUrl(group)} size="sm" variant="outline">
|
||||||
|
<Copy />Clone <span class="kbd">c</span>
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
<ConfirmButton onconfirm={remove} bind:this={confirm}>
|
||||||
|
Delete <span class="kbd">d</span>
|
||||||
|
</ConfirmButton>
|
||||||
|
{/if}
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-sm items-center gap-1 rounded-cladd-sm px-2 font-semibold text-[12px] text-cladd-fg"
|
||||||
|
>
|
||||||
|
More <ChevronDown class="size-3.5" />
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenuContent align="end" class="w-72">
|
||||||
|
{#if editable && type !== "reconciliation"}
|
||||||
|
{#each others as target (target)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() =>
|
||||||
|
location.assign(`/transactions/convert/${target}/${id}`)}
|
||||||
|
>
|
||||||
|
Convert to a {target}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{/each}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{/if}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() =>
|
||||||
|
location.assign(
|
||||||
|
`/rules/create-from-journal/${journalId(splits[0])}`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Create rule based on transaction
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() =>
|
||||||
|
location.assign(
|
||||||
|
`/recurring/create-from-transaction/${journalId(splits[0])}`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Create recurring transaction based on transaction
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onSelect={() => oldUi()}>
|
||||||
|
Open in old UI
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{#if groupQuery.isPending}
|
||||||
|
<p class="text-cladd-fg-soft">Loading transaction...</p>
|
||||||
|
{:else if groupQuery.error || !group}
|
||||||
|
<Box title="Transaction not found">
|
||||||
|
<p class="text-cladd-fg-soft">
|
||||||
|
{groupQuery.error?.message ?? "This transaction does not exist."}
|
||||||
|
</p>
|
||||||
|
</Box>
|
||||||
|
{:else}
|
||||||
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Box body="p-0" title="Transaction information">
|
||||||
|
<table class="w-full">
|
||||||
|
<tbody class="[&>tr+tr]:border-cladd-outline [&>tr+tr]:border-t">
|
||||||
|
<tr>
|
||||||
|
{@render row("ID")}
|
||||||
|
<td class="num px-3 py-1.5">#{id}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
{@render row("Type")}
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
<span class="inline-flex items-center gap-1.5"
|
||||||
|
><TypeIcon {type} />{TYPE_LABEL[type] ?? type}</span
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
{@render row("Description")}
|
||||||
|
<td class="px-3 py-1.5">{title}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
{@render row("Date")}
|
||||||
|
<td class="num px-3 py-1.5">{dateLabel(splits[0]?.date)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{#if deleteError}
|
||||||
|
<p class="px-3 pb-2 text-expense">{deleteError}</p>
|
||||||
|
{/if}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box body="p-0" title="Meta information">
|
||||||
|
<table class="w-full">
|
||||||
|
<tbody class="[&>tr+tr]:border-cladd-outline [&>tr+tr]:border-t">
|
||||||
|
<tr>
|
||||||
|
{@render row("Source account(s)")}
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
{#each uniqueAccounts("source") as acc, i (acc.id)}
|
||||||
|
{#if i > 0}
|
||||||
|
,
|
||||||
|
{/if}
|
||||||
|
{@render accountLink(
|
||||||
|
acc.id,
|
||||||
|
acc.name,
|
||||||
|
acc.cash ? "Cash account" : ""
|
||||||
|
)}
|
||||||
|
{/each}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
{@render row("Destination account(s)")}
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
{#each uniqueAccounts("destination") as acc, i (acc.id)}
|
||||||
|
{#if i > 0}
|
||||||
|
,
|
||||||
|
{/if}
|
||||||
|
{@render accountLink(
|
||||||
|
acc.id,
|
||||||
|
acc.name,
|
||||||
|
acc.cash ? "Cash account" : ""
|
||||||
|
)}
|
||||||
|
{/each}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
{@render row("Total amount")}
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
<span class="flex flex-wrap gap-x-2">
|
||||||
|
{#each sumByCurrency(splits) as sum (sum.code)}
|
||||||
|
{@render money(sum)}
|
||||||
|
{/each}
|
||||||
|
{#each foreignTotals as sum (sum.code)}
|
||||||
|
<span class="num text-cladd-fg-soft"
|
||||||
|
>({formatMoney(sum)})</span
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if splits.length > 1}
|
||||||
|
<h2 class="mt-6 mb-3 font-semibold text-[16px]">Splits</h2>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-4 grid gap-4 {splits.length > 2
|
||||||
|
? "lg:grid-cols-2 xl:grid-cols-3"
|
||||||
|
: "lg:grid-cols-2"}"
|
||||||
|
>
|
||||||
|
{#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)] ?? []}
|
||||||
|
<div class="flex min-w-0 flex-col gap-4">
|
||||||
|
<Box body="p-0">
|
||||||
|
{#snippet tools()}
|
||||||
|
<h3
|
||||||
|
class="flex min-w-0 flex-1 items-center gap-1.5 font-semibold text-[13px]"
|
||||||
|
>
|
||||||
|
<span class="truncate">{split.description}</span>
|
||||||
|
{#if split.reconciled}
|
||||||
|
<Check aria-label="Reconciled" class="size-3.5 text-income" />
|
||||||
|
{/if}
|
||||||
|
{#if splits.length > 1}
|
||||||
|
<small class="num font-normal text-cladd-fg-soft"
|
||||||
|
>{index + 1}
|
||||||
|
/ {splits.length}</small
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</h3>
|
||||||
|
{/snippet}
|
||||||
|
<table class="w-full">
|
||||||
|
<tbody class="[&>tr+tr]:border-cladd-outline [&>tr+tr]:border-t">
|
||||||
|
<tr>
|
||||||
|
<td class="px-3 py-2" colspan="2">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
{@render accountLink(
|
||||||
|
split.source_id,
|
||||||
|
split.source_name,
|
||||||
|
split.source_type
|
||||||
|
)}
|
||||||
|
<span class="text-cladd-fg-softer">→</span>
|
||||||
|
{@render money(signedAmount(split))}
|
||||||
|
{#if foreign}
|
||||||
|
<span class="num text-cladd-fg-soft"
|
||||||
|
>({formatMoney(foreign)})</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<span class="text-cladd-fg-softer">→</span>
|
||||||
|
{@render accountLink(
|
||||||
|
split.destination_id,
|
||||||
|
split.destination_name,
|
||||||
|
split.destination_type
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
{@render row("Transaction journal ID")}
|
||||||
|
<td class="num px-3 py-1.5">#{journalId(split)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
{@render row("Category")}
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
{#if split.category_id}
|
||||||
|
<a
|
||||||
|
class="text-brand hover:underline"
|
||||||
|
href={`/categories/show/${split.category_id}`}
|
||||||
|
>{split.category_name}</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class="text-cladd-fg-softer">(no category)</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{#if split.type === "withdrawal"}
|
||||||
|
<tr>
|
||||||
|
{@render row("Budget")}
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
{#if split.budget_id}
|
||||||
|
<a
|
||||||
|
class="text-brand hover:underline"
|
||||||
|
href={`/budgets/show/${split.budget_id}`}
|
||||||
|
>{split.budget_name}</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class="text-cladd-fg-softer">(no budget)</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{#if split.bill_id}
|
||||||
|
<tr>
|
||||||
|
{@render row("Subscription")}
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
<a
|
||||||
|
class="text-brand hover:underline"
|
||||||
|
href={`/subscriptions/show/${split.bill_id}`}
|
||||||
|
>{split.bill_name}</a
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#each DATE_FIELDS as [field, label] (field)}
|
||||||
|
{#if split[field]}
|
||||||
|
<tr>
|
||||||
|
{@render row(label)}
|
||||||
|
<td class="num px-3 py-1.5">{formatDate(split[field])}</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{#each META_FIELDS as [field, label] (field)}
|
||||||
|
{#if split[field]}
|
||||||
|
<tr>
|
||||||
|
{@render row(label)}
|
||||||
|
<td class="px-3 py-1.5 break-all">{split[field]}</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{#if split.external_url}
|
||||||
|
<tr>
|
||||||
|
{@render row("External URL")}
|
||||||
|
<td class="px-3 py-1.5 break-all">
|
||||||
|
<a
|
||||||
|
class="text-brand hover:underline"
|
||||||
|
href={split.external_url}
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
|
target="_blank"
|
||||||
|
>{split.external_url}</a
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{#if split.notes}
|
||||||
|
<tr>
|
||||||
|
{@render row("Notes")}
|
||||||
|
<td class="px-3 py-1.5 whitespace-pre-line break-words">
|
||||||
|
{split.notes}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{#if split.recurrence_id}
|
||||||
|
<tr>
|
||||||
|
{@render row("Recurring transaction")}
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
<a
|
||||||
|
class="text-brand hover:underline"
|
||||||
|
href={`/recurring/show/${split.recurrence_id}`}
|
||||||
|
>{split.recurrence_count}
|
||||||
|
of {split.recurrence_total || "∞"}</a
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{#if split.tags?.length}
|
||||||
|
<tr>
|
||||||
|
{@render row("Tags")}
|
||||||
|
<td class="px-3 py-1.5">
|
||||||
|
{#each split.tags as tag, i (tag)}
|
||||||
|
{#if i > 0}
|
||||||
|
,
|
||||||
|
{/if}
|
||||||
|
<a
|
||||||
|
class="text-brand hover:underline"
|
||||||
|
href={`/tags/show/${encodeURIComponent(tag)}`}
|
||||||
|
>{tag}</a
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{#if links.length > 0}
|
||||||
|
<Box body="p-0" title="Transaction links">
|
||||||
|
<ul class="[&>li+li]:border-cladd-outline [&>li+li]:border-t">
|
||||||
|
{#each links as link (link.id)}
|
||||||
|
<li
|
||||||
|
class="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 py-1.5"
|
||||||
|
>
|
||||||
|
<span class="text-cladd-fg-soft">{link.phrase}</span>
|
||||||
|
{#if link.group}
|
||||||
|
<a
|
||||||
|
class="text-brand hover:underline"
|
||||||
|
href={`/transactions/show/${link.group}`}
|
||||||
|
>"{link.description}"</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span>"{link.description}"</span>
|
||||||
|
{/if}
|
||||||
|
{#if link.amount}
|
||||||
|
<span>({@render money(link.amount)})</span>
|
||||||
|
{/if}
|
||||||
|
{#if link.notes}
|
||||||
|
<span class="text-cladd-fg-soft">({link.notes})</span>
|
||||||
|
{/if}
|
||||||
|
<ConfirmButton
|
||||||
|
class="ml-auto"
|
||||||
|
confirmLabel="Press again to remove"
|
||||||
|
onconfirm={() => removeLink(link.id)}
|
||||||
|
>
|
||||||
|
Remove link
|
||||||
|
</ConfirmButton>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</Box>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if files.length > 0}
|
||||||
|
<Box body="p-0" title="Attachments">
|
||||||
|
<ul class="[&>li+li]:border-cladd-outline [&>li+li]:border-t">
|
||||||
|
{#each files as file (file.id)}
|
||||||
|
<li class="flex flex-col gap-0.5 px-3 py-1.5">
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<Paperclip class="size-3.5 text-cladd-fg-soft" />
|
||||||
|
<a
|
||||||
|
class="min-w-0 truncate text-brand hover:underline"
|
||||||
|
data-sveltekit-reload
|
||||||
|
href={`/attachments/view/${file.id}`}
|
||||||
|
>{file.attributes.title || file.attributes.filename}</a
|
||||||
|
>
|
||||||
|
<span class="num text-cladd-fg-soft"
|
||||||
|
>{formatNumber((file.attributes.size ?? 0) / BYTES_KB, 1)}
|
||||||
|
KB</span
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="ml-auto text-[12px] text-cladd-fg-soft hover:text-cladd-fg hover:underline"
|
||||||
|
data-sveltekit-reload
|
||||||
|
href={`/attachments/download/${file.id}`}
|
||||||
|
>Download</a
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
{#if file.attributes.notes}
|
||||||
|
<span
|
||||||
|
class="whitespace-pre-line text-[12px] text-cladd-fg-soft"
|
||||||
|
>{file.attributes.notes}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</Box>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if events.length > 0}
|
||||||
|
<Box body="p-0" title="Related piggy banks">
|
||||||
|
<ul class="[&>li+li]:border-cladd-outline [&>li+li]:border-t">
|
||||||
|
{#each events as piggyEvent (piggyEvent.id)}
|
||||||
|
{@const piggy = piggyOf(piggyEvent)}
|
||||||
|
<li class="flex items-center justify-between gap-3 px-3 py-1.5">
|
||||||
|
{#if piggy.id}
|
||||||
|
<a
|
||||||
|
class="text-brand hover:underline"
|
||||||
|
href={`/piggy-banks/show/${piggy.id}`}
|
||||||
|
>{piggy.name}</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span>Piggy bank</span>
|
||||||
|
{/if}
|
||||||
|
<Amount
|
||||||
|
amount={piggyEvent.attributes.amount}
|
||||||
|
code={piggyEvent.attributes.currency_code}
|
||||||
|
decimals={piggyEvent.attributes.currency_decimal_places}
|
||||||
|
symbol={piggyEvent.attributes.currency_symbol}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</Box>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
Reference in new issue
Block a user