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,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