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>
|
||||
Reference in new issue
Block a user