feat(dashboard,accounts,charts): v1 dashboard redrawn, account lists and pages, svg chart kit
This commit is contained in:
1 parent
62cd17ecde
commit
9e89829bae
36 files changed
+5678
-16
No files matched your search
@@ -0,0 +1,591 @@
|
||||
<script lang="ts">
|
||||
import Check from "@lucide/svelte/icons/check";
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { untrack } from "svelte";
|
||||
import { api } from "#lib/api/client.js";
|
||||
import { all, invalidate } from "#lib/api/lists.js";
|
||||
import type {
|
||||
Account,
|
||||
One,
|
||||
Split,
|
||||
TransactionGroup,
|
||||
} from "#lib/api/types.js";
|
||||
import Amount from "#lib/components/Amount.svelte";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import { currency, money } from "#lib/components/dashboard/data.js";
|
||||
import DateInput from "#lib/components/inputs/DateInput.svelte";
|
||||
import { INPUT } from "#lib/components/inputs/styles.js";
|
||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||
import { Button } from "#lib/components/ui/button/index.js";
|
||||
import { addDays } from "#lib/dates.js";
|
||||
import { formatDate, toNumber } from "#lib/format.js";
|
||||
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
|
||||
import { ListNav } from "#lib/list-nav.svelte.js";
|
||||
import {
|
||||
type Group,
|
||||
groupTitle,
|
||||
signedAmount,
|
||||
signedForeign,
|
||||
} from "#lib/transactions/split.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { indexUrl, showUrl, TYPE_TITLE } from "./links.js";
|
||||
|
||||
const MARGIN_DAYS = 3;
|
||||
const CENT = 0.005;
|
||||
|
||||
interface Row {
|
||||
amount: number;
|
||||
cleared: boolean;
|
||||
date: string;
|
||||
group: Group;
|
||||
inRange: boolean;
|
||||
key: string;
|
||||
split: Split;
|
||||
}
|
||||
|
||||
let { id, start, end }: { id: string; start: string; end: string } = $props();
|
||||
|
||||
let startInput = $state(untrack(() => start));
|
||||
let endInput = $state(untrack(() => end));
|
||||
let startText = $state("");
|
||||
let endText = $state("");
|
||||
let startTouched = $state(false);
|
||||
let endTouched = $state(false);
|
||||
let correct = $state(false);
|
||||
let busy = $state(false);
|
||||
let status = $state("");
|
||||
let failure = $state("");
|
||||
|
||||
const account = createQuery(() => ({
|
||||
queryFn: () => api<One<Account>>(`/v1/accounts/${id}`),
|
||||
queryKey: ["account", id, "reconcile"],
|
||||
}));
|
||||
const a = $derived(account.data?.data.attributes);
|
||||
const cur = $derived(currency(a?.currency_code ?? ""));
|
||||
const fmt = $derived(money(cur));
|
||||
|
||||
const balanceAt = (date: string) => async () => {
|
||||
const res = await api<One<Account>>(`/v1/accounts/${id}`, {
|
||||
query: { date },
|
||||
});
|
||||
return toNumber(res.data.attributes.current_balance);
|
||||
};
|
||||
const before = createQuery(() => ({
|
||||
queryFn: balanceAt(addDays(start, -1)),
|
||||
queryKey: ["reconcile-balance", id, addDays(start, -1)],
|
||||
staleTime: 0,
|
||||
}));
|
||||
const after = createQuery(() => ({
|
||||
queryFn: balanceAt(end),
|
||||
queryKey: ["reconcile-balance", id, end],
|
||||
staleTime: 0,
|
||||
}));
|
||||
const list = createQuery(() => ({
|
||||
queryFn: () =>
|
||||
all<TransactionGroup>(`/v1/accounts/${id}/transactions`, {
|
||||
end: addDays(end, MARGIN_DAYS),
|
||||
start: addDays(start, -MARGIN_DAYS),
|
||||
}),
|
||||
queryKey: ["reconcile-list", id, start, end],
|
||||
staleTime: 0,
|
||||
}));
|
||||
|
||||
function net(split: Split): number {
|
||||
const code = a?.currency_code;
|
||||
if (split.currency_code !== code && split.foreign_currency_code === code) {
|
||||
return signedForeign(split, id)?.amount ?? 0;
|
||||
}
|
||||
return signedAmount(split, id).amount;
|
||||
}
|
||||
|
||||
const rows = $derived<Row[]>(
|
||||
(list.data ?? [])
|
||||
.flatMap((group) =>
|
||||
group.attributes.transactions
|
||||
.filter((s) => s.source_id === id || s.destination_id === id)
|
||||
.map((split) => {
|
||||
const date = split.date.slice(0, 10);
|
||||
return {
|
||||
amount: net(split),
|
||||
cleared: split.reconciled === true,
|
||||
date,
|
||||
group,
|
||||
inRange: date >= start && date <= end,
|
||||
key: split.transaction_journal_id ?? "",
|
||||
split,
|
||||
};
|
||||
})
|
||||
)
|
||||
.sort((x, y) => y.split.date.localeCompare(x.split.date))
|
||||
);
|
||||
const open = $derived(rows.filter((r) => r.inRange && !r.cleared));
|
||||
const later = $derived(rows.filter((r) => r.date > end));
|
||||
const inside = $derived(rows.filter((r) => r.inRange));
|
||||
const earlier = $derived(rows.filter((r) => r.date < start));
|
||||
|
||||
const nav = new ListNav<Row>({
|
||||
extra: () => [
|
||||
{
|
||||
group: "Reconcile",
|
||||
input: true,
|
||||
keys: "mod+Enter",
|
||||
label: "Store reconciliation",
|
||||
run: () => store(),
|
||||
},
|
||||
],
|
||||
id: "reconcile",
|
||||
items: () => open,
|
||||
key: (row) => row.key,
|
||||
selectable: true,
|
||||
});
|
||||
nav.use();
|
||||
|
||||
useKeys(() => [
|
||||
{
|
||||
group: "Reconcile",
|
||||
keys: "c",
|
||||
label: "Toggle the correction",
|
||||
run: () => {
|
||||
correct = canCorrect && !correct;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const startBalance = $derived(
|
||||
startTouched ? toNumber(startText) : (before.data ?? 0)
|
||||
);
|
||||
const endBalance = $derived(
|
||||
endTouched ? toNumber(endText) : (after.data ?? 0)
|
||||
);
|
||||
const selected = $derived(open.filter((r) => nav.isSelected(r)));
|
||||
const selectedSum = $derived(selected.reduce((s, r) => s + r.amount, 0));
|
||||
const cleared = $derived(inside.filter((r) => r.cleared));
|
||||
const clearedSum = $derived(cleared.reduce((s, r) => s + r.amount, 0));
|
||||
const sum = $derived(startBalance + selectedSum + clearedSum);
|
||||
const difference = $derived.by(() => {
|
||||
const value = sum - endBalance;
|
||||
return Math.abs(value) < CENT ? 0 : value;
|
||||
});
|
||||
const reconciliationAccounts = createQuery(() => ({
|
||||
queryFn: () => all<Account>("/v1/accounts", { type: "reconciliation" }),
|
||||
queryKey: ["accounts", "reconciliation"],
|
||||
}));
|
||||
const reconciliationId = $derived(
|
||||
reconciliationAccounts.data?.find(
|
||||
(r) => r.attributes.name === `${a?.name} reconciliation (${cur.code})`
|
||||
)?.id
|
||||
);
|
||||
const canCorrect = $derived(Boolean(reconciliationId));
|
||||
const correcting = $derived(correct && canCorrect && difference !== 0);
|
||||
const ready = $derived(before.isSuccess && after.isSuccess && list.isSuccess);
|
||||
|
||||
function setDates(nextStart: string, nextEnd: string) {
|
||||
if (!(nextStart && nextEnd) || nextStart > nextEnd) {
|
||||
return;
|
||||
}
|
||||
startTouched = false;
|
||||
endTouched = false;
|
||||
nav.clear();
|
||||
goto(`/accounts/reconcile/${id}/index/${nextStart}/${nextEnd}`, {
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function clear(group: Group, journals: Set<string>) {
|
||||
let splits = group.attributes.transactions;
|
||||
let title = group.attributes.group_title;
|
||||
if (title) {
|
||||
const full = await api<One<TransactionGroup>>(
|
||||
`/v1/transactions/${group.id}`
|
||||
);
|
||||
splits = full.data.attributes.transactions;
|
||||
title = full.data.attributes.group_title || groupTitle(full.data);
|
||||
}
|
||||
const body: Record<string, unknown> = {
|
||||
transactions: splits.map((s) =>
|
||||
journals.has(s.transaction_journal_id ?? "")
|
||||
? {
|
||||
reconciled: true,
|
||||
transaction_journal_id: s.transaction_journal_id,
|
||||
}
|
||||
: { transaction_journal_id: s.transaction_journal_id }
|
||||
),
|
||||
};
|
||||
if (splits.length > 1) {
|
||||
body.group_title = title;
|
||||
}
|
||||
await api(`/v1/transactions/${group.id}`, { body, method: "PUT" });
|
||||
}
|
||||
|
||||
function correction(other: string) {
|
||||
const sides =
|
||||
difference > 0
|
||||
? { destination_id: other, source_id: id }
|
||||
: { destination_id: id, source_id: other };
|
||||
return {
|
||||
apply_rules: false,
|
||||
fire_webhooks: false,
|
||||
transactions: [
|
||||
{
|
||||
amount: Math.abs(difference).toFixed(cur.decimals),
|
||||
currency_code: cur.code,
|
||||
date: end,
|
||||
description: `Reconciliation (${formatDate(start)} to ${formatDate(end)})`,
|
||||
reconciled: true,
|
||||
type: "reconciliation",
|
||||
...sides,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function store() {
|
||||
if (busy || !ready || (selected.length === 0 && !correcting)) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
failure = "";
|
||||
const byGroup = new Map<string, { group: Group; journals: Set<string> }>();
|
||||
for (const row of selected) {
|
||||
const entry = byGroup.get(row.group.id) ?? {
|
||||
group: row.group,
|
||||
journals: new Set<string>(),
|
||||
};
|
||||
entry.journals.add(row.key);
|
||||
byGroup.set(row.group.id, entry);
|
||||
}
|
||||
try {
|
||||
let done = 0;
|
||||
status = `Clearing ${selected.length} transactions`;
|
||||
await Promise.all(
|
||||
[...byGroup.values()].map(async ({ group, journals }) => {
|
||||
await clear(group, journals);
|
||||
done += 1;
|
||||
status = `Cleared ${done} of ${byGroup.size}`;
|
||||
})
|
||||
);
|
||||
if (correcting) {
|
||||
status = "Booking the correction";
|
||||
await api("/v1/transactions", {
|
||||
body: correction(reconciliationId ?? ""),
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
status = "Reconciliation stored";
|
||||
await invalidate();
|
||||
await goto(showUrl(id));
|
||||
} catch (e) {
|
||||
failure = e instanceof Error ? e.message : String(e);
|
||||
status = "";
|
||||
await list.refetch();
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function rowClass(row: Row) {
|
||||
const i = open.indexOf(row);
|
||||
if (i >= 0 && nav.cursor === i) {
|
||||
return "bg-row-cursor shadow-[inset_2px_0_0_var(--ff-brand)]";
|
||||
}
|
||||
if (nav.isSelected(row)) {
|
||||
return "bg-row-selected";
|
||||
}
|
||||
return row.inRange ? "hover:bg-row-hover" : "text-cladd-fg-softer";
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet marker(
|
||||
text: string
|
||||
)}
|
||||
<tr class="h-7 border-cladd-outline border-t bg-cladd-surface-cut">
|
||||
<td class="px-3 font-semibold text-[12px] text-cladd-fg-soft" colspan="6">
|
||||
{text}
|
||||
</td>
|
||||
</tr>
|
||||
{/snippet}
|
||||
|
||||
{#snippet line(
|
||||
row: Row
|
||||
)}
|
||||
{@const i = open.indexOf(row)}
|
||||
<tr
|
||||
class="h-7 border-cladd-outline/60 border-t {rowClass(row)}"
|
||||
id={i >= 0 ? nav.rowId(i) : undefined}
|
||||
onclick={(event) => i >= 0 && nav.click(i, event)}
|
||||
>
|
||||
<td class="w-9 px-3">
|
||||
{#if row.cleared}
|
||||
<Check aria-label="Already reconciled" class="size-3.5 text-income" />
|
||||
{:else if row.inRange}
|
||||
<input
|
||||
aria-label="Reconcile {row.split.description}"
|
||||
checked={nav.isSelected(row)}
|
||||
class="size-4 accent-[var(--ff-brand)]"
|
||||
onchange={() => nav.toggle(row)}
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
type="checkbox"
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num w-28 whitespace-nowrap px-3 text-cladd-fg-soft">
|
||||
{formatDate(row.date)}
|
||||
</td>
|
||||
<td class="max-w-0 truncate px-3">
|
||||
<a class="hover:underline" href="/transactions/show/{row.group.id}"
|
||||
>{row.split.description}</a
|
||||
>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 text-right">
|
||||
<Amount amount={row.amount} {...cur} />
|
||||
</td>
|
||||
<td class="hidden max-w-0 truncate px-3 text-cladd-fg-soft md:table-cell">
|
||||
{row.split.source_name}
|
||||
</td>
|
||||
<td class="hidden max-w-0 truncate px-3 text-cladd-fg-soft md:table-cell">
|
||||
{row.split.destination_name}
|
||||
</td>
|
||||
</tr>
|
||||
{/snippet}
|
||||
|
||||
<PageHeader
|
||||
crumbs={[
|
||||
{ href: indexUrl("asset"), label: TYPE_TITLE.asset },
|
||||
{ href: showUrl(id), label: a?.name ?? "" },
|
||||
{ label: "Reconcile" },
|
||||
]}
|
||||
title="Reconcile {a?.name ?? "account"}"
|
||||
/>
|
||||
|
||||
{#if a && a.type !== "asset"}
|
||||
<p class="text-expense">
|
||||
You can only reconcile asset accounts.
|
||||
<a class="text-brand underline" href={showUrl(id)}>Back to {a.name}</a>
|
||||
</p>
|
||||
{:else}
|
||||
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_25rem]">
|
||||
<Box body="flex flex-col p-0" title="Reconciliation range">
|
||||
<table class="w-full text-[13px]">
|
||||
<thead class="border-cladd-outline border-b text-left">
|
||||
<tr class="h-8 text-[12px] text-cladd-fg-soft">
|
||||
<th class="px-3 font-semibold" scope="col"></th>
|
||||
<th class="px-3 font-semibold" scope="col">Date</th>
|
||||
<th class="px-3 text-right font-semibold" scope="col">Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="h-11">
|
||||
<th class="px-3 text-left font-semibold" scope="row">
|
||||
Start balance
|
||||
</th>
|
||||
<td class="px-3">
|
||||
<DateInput aria-label="Start date" bind:value={startInput} />
|
||||
</td>
|
||||
<td class="px-3">
|
||||
<input
|
||||
aria-label="Start balance"
|
||||
class="{INPUT} num text-right"
|
||||
inputmode="decimal"
|
||||
oninput={(event) => {
|
||||
startTouched = true;
|
||||
startText = event.currentTarget.value;
|
||||
}}
|
||||
value={startTouched
|
||||
? startText
|
||||
: (before.data?.toFixed(cur.decimals) ?? "")}
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="h-11 border-cladd-outline/60 border-t">
|
||||
<th class="px-3 text-left font-semibold" scope="row">
|
||||
End balance
|
||||
</th>
|
||||
<td class="px-3">
|
||||
<DateInput aria-label="End date" bind:value={endInput} />
|
||||
</td>
|
||||
<td class="px-3">
|
||||
<input
|
||||
aria-label="End balance"
|
||||
class="{INPUT} num text-right"
|
||||
inputmode="decimal"
|
||||
oninput={(event) => {
|
||||
endTouched = true;
|
||||
endText = event.currentTarget.value;
|
||||
}}
|
||||
value={endTouched
|
||||
? endText
|
||||
: (after.data?.toFixed(cur.decimals) ?? "")}
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{#if startInput !== start || endInput !== end}
|
||||
<div
|
||||
class="flex items-center gap-3 border-cladd-outline border-t px-3 py-2"
|
||||
>
|
||||
<Button onclick={() => setDates(startInput, endInput)} size="sm"
|
||||
>Update selection</Button
|
||||
>
|
||||
<span class="text-[12px] text-warn"
|
||||
>Changing the range clears the ticks.</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
<p
|
||||
class="mt-auto border-cladd-outline border-t px-3 py-2 text-[12px] text-cladd-fg-soft"
|
||||
>
|
||||
Match the dates and balances to your bank statement, then tick the
|
||||
transactions that appear on it.
|
||||
<span class="kbd">x</span>
|
||||
ticks the row under the cursor,
|
||||
<span class="kbd">*</span>
|
||||
ticks all.
|
||||
</p>
|
||||
</Box>
|
||||
|
||||
<Box body="flex flex-col gap-3 p-3" title="Reconciliation options">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class="text-cladd-fg-soft">Difference</span>
|
||||
<span
|
||||
class="num font-semibold text-[20px] {difference === 0
|
||||
? "text-income"
|
||||
: "text-expense"}"
|
||||
>{fmt(difference)}</span
|
||||
>
|
||||
</div>
|
||||
<dl class="grid grid-cols-[1fr_auto] gap-x-3 gap-y-0.5 text-[12px]">
|
||||
<dt class="text-cladd-fg-soft">Submitted start balance</dt>
|
||||
<dd class="num text-right">{fmt(startBalance)}</dd>
|
||||
<dt class="text-cladd-fg-soft">
|
||||
Selected transactions ({selected.length})
|
||||
</dt>
|
||||
<dd class="num text-right">{fmt(selectedSum)}</dd>
|
||||
<dt class="text-cladd-fg-soft">
|
||||
Already cleared transactions ({cleared.length})
|
||||
</dt>
|
||||
<dd class="num text-right">{fmt(clearedSum)}</dd>
|
||||
<dt class="text-cladd-fg-soft">Sum of reconciliation</dt>
|
||||
<dd class="num text-right">{fmt(sum)}</dd>
|
||||
<dt class="text-cladd-fg-soft">Submitted end balance</dt>
|
||||
<dd class="num text-right">{fmt(endBalance)}</dd>
|
||||
</dl>
|
||||
{#if difference !== 0}
|
||||
<p class="text-[12px] text-cladd-fg-soft">
|
||||
Your Firefly III ledger has {difference > 0 ? "more" : "less"} money
|
||||
in it than your bank says.
|
||||
</p>
|
||||
<fieldset class="m-0 flex flex-col gap-1.5 border-0 p-0 text-[12px]">
|
||||
<legend class="sr-only">What to do with the difference</legend>
|
||||
<label class="flex items-start gap-2">
|
||||
<input
|
||||
class="mt-0.5 accent-[var(--ff-brand)]"
|
||||
name="correct"
|
||||
type="radio"
|
||||
value={false}
|
||||
bind:group={correct}
|
||||
>
|
||||
Clear the selected transactions, but do not correct.
|
||||
</label>
|
||||
<label
|
||||
class="flex items-start gap-2 {canCorrect
|
||||
? ""
|
||||
: "text-cladd-fg-softer"}"
|
||||
>
|
||||
<input
|
||||
class="mt-0.5 accent-[var(--ff-brand)]"
|
||||
disabled={!canCorrect}
|
||||
name="correct"
|
||||
type="radio"
|
||||
value={true}
|
||||
bind:group={correct}
|
||||
>
|
||||
Clear the selected transactions, and create a correction
|
||||
{difference > 0 ? "removing" : "adding"}
|
||||
{fmt(Math.abs(difference))}
|
||||
{difference > 0 ? "from" : "to"}
|
||||
this asset account.
|
||||
<span class="kbd">c</span>
|
||||
</label>
|
||||
{#if !canCorrect && reconciliationAccounts.isSuccess}
|
||||
<p class="text-cladd-fg-soft">
|
||||
The first correction for this account has to be booked in
|
||||
<a
|
||||
class="text-brand underline"
|
||||
data-sveltekit-reload
|
||||
href="{page.url.pathname}?ffui=old"
|
||||
>Firefly's own reconcile page</a
|
||||
>: through the API, Firefly III cannot create the account that
|
||||
holds corrections.
|
||||
</p>
|
||||
{/if}
|
||||
</fieldset>
|
||||
{/if}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
disabled={busy || !ready || (selected.length === 0 && !correcting)}
|
||||
onclick={store}
|
||||
type="button"
|
||||
>Store reconciliation</Button
|
||||
>
|
||||
<span class="flex gap-1">
|
||||
{#each prettyKeys("mod+Enter") as key (key)}
|
||||
<span class="kbd">{key}</span>
|
||||
{/each}
|
||||
</span>
|
||||
</div>
|
||||
{#if status}
|
||||
<p class="text-[12px] text-cladd-fg-soft" role="status">{status}</p>
|
||||
{/if}
|
||||
{#if failure}
|
||||
<p class="text-[12px] text-expense" role="alert">{failure}</p>
|
||||
{/if}
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
<Box body="p-0" class="mt-3" title="Transactions">
|
||||
{#snippet tools()}
|
||||
<span class="num text-[12px] text-cladd-fg-soft"
|
||||
>{open.length}
|
||||
to check, {cleared.length} already cleared</span
|
||||
>
|
||||
{/snippet}
|
||||
{#if list.isPending}
|
||||
<div class="h-40"></div>
|
||||
{:else if list.isError}
|
||||
<p class="p-3 text-expense">{list.error.message}</p>
|
||||
{:else}
|
||||
<table class="w-full table-fixed border-collapse text-[13px]">
|
||||
<colgroup>
|
||||
<col class="w-9">
|
||||
<col class="w-28">
|
||||
<col>
|
||||
<col class="w-36">
|
||||
<col class="hidden md:table-column md:w-48">
|
||||
<col class="hidden md:table-column md:w-48">
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{#each later as row (row.key)}
|
||||
{@render line(row)}
|
||||
{/each}
|
||||
{@render marker(`End of reconcile period: ${formatDate(end)}`)}
|
||||
{#each inside as row (row.key)}
|
||||
{@render line(row)}
|
||||
{/each}
|
||||
{#if inside.length === 0}
|
||||
<tr>
|
||||
<td class="px-3 py-4 text-center text-cladd-fg-soft" colspan="6">
|
||||
No transactions in this range.
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{@render marker(`Start of reconcile period: ${formatDate(start)}`)}
|
||||
{#each earlier as row (row.key)}
|
||||
{@render line(row)}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</Box>
|
||||
{/if}
|
||||
Reference in new issue
Block a user