feat(transactions): the transaction page as a receipt with context
The amount is the headline, notes come first at full width, then the last twelve visits to the same place as a dot timeline next to the paying account's days around it with the running balance. Fields edit in place: j and k move, Enter opens and saves, Esc cancels, s c, s b, s t and s s jump to classification. A matching subscription shows as a suggestion, L links it.
This commit is contained in:
1 parent
58b356ea8b
commit
349a6a0b32
14 files changed
+2137
-683
No files matched your search
@@ -0,0 +1,158 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { all } from "#lib/api/lists.js";
|
||||
import type { Split, TransactionGroup } from "#lib/api/types.js";
|
||||
import Amount from "#lib/components/Amount.svelte";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import { addDays } from "#lib/dates.js";
|
||||
import { formatDate, formatMoney, type Money } from "#lib/format.js";
|
||||
import { journalId, signedAmount, toneOf } from "#lib/transactions/split.js";
|
||||
|
||||
const DAYS = 2;
|
||||
const AROUND = 5;
|
||||
|
||||
let {
|
||||
account,
|
||||
currency,
|
||||
day,
|
||||
groupId,
|
||||
}: {
|
||||
account: { id: string; name: string };
|
||||
currency: Omit<Money, "amount">;
|
||||
day: string;
|
||||
groupId: string;
|
||||
} = $props();
|
||||
|
||||
const start = $derived(addDays(day, -DAYS));
|
||||
const end = $derived(addDays(day, DAYS));
|
||||
|
||||
const query = createQuery(() => ({
|
||||
queryFn: () =>
|
||||
all<TransactionGroup>(`/v1/accounts/${account.id}/transactions`, {
|
||||
end,
|
||||
start,
|
||||
}),
|
||||
queryKey: ["receipt", "same-days", account.id, start, end],
|
||||
}));
|
||||
|
||||
function amountOn(split: Split) {
|
||||
const signed = signedAmount(split, account.id);
|
||||
const foreign =
|
||||
split.foreign_currency_code === currency.code &&
|
||||
split.currency_code !== currency.code;
|
||||
if (!foreign) {
|
||||
return signed;
|
||||
}
|
||||
const sign = Math.sign(signed.amount) || 1;
|
||||
return {
|
||||
...currency,
|
||||
amount: sign * Number(split.foreign_amount),
|
||||
tone: toneOf(split, sign < 0 ? -1 : 1),
|
||||
};
|
||||
}
|
||||
|
||||
const rows = $derived(
|
||||
(query.data ?? [])
|
||||
.flatMap((group) =>
|
||||
group.attributes.transactions
|
||||
.filter(
|
||||
(split) =>
|
||||
split.source_id === account.id ||
|
||||
split.destination_id === account.id
|
||||
)
|
||||
.map((split) => ({
|
||||
balance:
|
||||
split.source_id === account.id
|
||||
? split.source_balance_after
|
||||
: split.destination_balance_after,
|
||||
date: split.date.slice(0, 10),
|
||||
description: split.description,
|
||||
group: group.id,
|
||||
key: journalId(split),
|
||||
money: amountOn(split),
|
||||
self: group.id === groupId,
|
||||
}))
|
||||
)
|
||||
.reverse()
|
||||
);
|
||||
const shown = $derived.by(() => {
|
||||
const first = rows.findIndex((row) => row.self);
|
||||
const last = rows.findLastIndex((row) => row.self);
|
||||
return first < 0
|
||||
? rows.slice(0, 2 * AROUND + 1)
|
||||
: rows.slice(Math.max(0, first - AROUND), last + AROUND + 1);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet more()}
|
||||
<a
|
||||
class="text-brand hover:underline"
|
||||
href="/accounts/show/{account.id}/{start}/{end}"
|
||||
>All {rows.length} on {account.name} from {formatDate(start, "short")} to
|
||||
{formatDate(end, "short")}</a
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
<Box
|
||||
body="p-0"
|
||||
footer={shown.length < rows.length ? more : undefined}
|
||||
href="/accounts/show/{account.id}/{start}/{end}"
|
||||
title="Same days on {account.name}"
|
||||
>
|
||||
{#if query.isPending}
|
||||
<p class="px-3 py-2.5 text-cladd-fg-soft">Loading {account.name}...</p>
|
||||
{:else if query.error}
|
||||
<p class="px-3 py-2.5 text-expense">{query.error.message}</p>
|
||||
{:else}
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr
|
||||
class="h-8 border-cladd-outline border-b text-left font-medium text-[12px] text-cladd-fg-soft"
|
||||
>
|
||||
<th class="px-3 font-medium">Date</th>
|
||||
<th class="px-2 font-medium">Description</th>
|
||||
<th class="px-2 text-right font-medium">Amount</th>
|
||||
<th class="px-3 text-right font-medium whitespace-nowrap">
|
||||
Balance after
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each shown as row (row.key)}
|
||||
<tr
|
||||
class="h-7 {row.self
|
||||
? "bg-row-selected font-medium"
|
||||
: "hover:bg-row-hover"}"
|
||||
>
|
||||
<td class="num px-3 whitespace-nowrap text-cladd-fg-soft">
|
||||
{formatDate(row.date, "short")}
|
||||
</td>
|
||||
<td class="w-full max-w-0 truncate px-2">
|
||||
{#if row.self}
|
||||
{row.description}
|
||||
{:else}
|
||||
<a class="hover:underline" href="/transactions/show/{row.group}"
|
||||
>{row.description}</a
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-2 text-right whitespace-nowrap">
|
||||
<Amount
|
||||
amount={row.money.amount}
|
||||
code={row.money.code}
|
||||
decimals={row.money.decimals}
|
||||
symbol={row.money.symbol}
|
||||
tone={row.money.tone}
|
||||
/>
|
||||
</td>
|
||||
<td class="num px-3 text-right whitespace-nowrap">
|
||||
{row.balance === null || row.balance === undefined
|
||||
? ""
|
||||
: formatMoney({ ...currency, amount: row.balance })}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</Box>
|
||||
Reference in new issue
Block a user