feat(reports): live firefly reports with drill-down, money flow and spending calendar

This commit is contained in:
hh committed 2026-10-16 03:45:43 +00:00
1 parent 60caee10dc
commit 62cd17ecde
39 files changed
+6596

No files matched your search

@@ -0,0 +1,246 @@
<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 { createQuery } from "@tanstack/svelte-query";
import type { Account } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import { addDays } from "#lib/dates.js";
import { formatDate, formatDateTime } from "#lib/format.js";
import { type AuditColumn, ledger } from "#lib/reports/audit.js";
import { reportQueries } from "#lib/reports/queries.js";
import { money, useReport } from "#lib/reports/report.svelte.js";
let {
id,
account,
before,
after,
columns,
}: {
id: string;
account: Account | undefined;
before: number | undefined;
after: number | undefined;
columns: AuditColumn[];
} = $props();
const report = useReport();
const p = $derived(report.params);
const code = $derived(account?.currency_code ?? "");
const query = createQuery(() => reportQueries.ledger(id, p.start, p.end));
const rows = $derived(
query.data && before !== undefined
? ledger(query.data, id, code, before)
: undefined
);
const show = (key: AuditColumn) => columns.includes(key);
const ICONS = {
deposit: ArrowRight,
"opening balance": Star,
reconciliation: Calculator,
transfer: ArrowLeftRight,
withdrawal: ArrowLeft,
} as const;
const META_DATES = [
"interest_date",
"book_date",
"process_date",
"due_date",
"payment_date",
"invoice_date",
] as const;
const dateColumns = $derived(META_DATES.filter((key) => show(key)));
</script>
<Box body="p-0" href="/accounts/show/{id}" title={account?.name ?? `#${id}`}>
{#if !rows}
<p class="px-3 py-3 text-cladd-fg-soft">Loading</p>
{:else if rows.length === 0}
<p class="px-3 py-3">
No activity was recorded on account
<a
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
href="/accounts/show/{id}"
>{account?.name}</a
>
between {formatDate(p.start, "long")} and {formatDate(p.end, "long")}.
</p>
{:else}
<p class="px-3 py-2 text-[13px]">
Account balance of <strong>{account?.name}</strong> at the end of
{formatDate(addDays(p.start, -1), "long")}
was:
<span class="num font-semibold">{money(code, before ?? 0)}</span>
</p>
<div class="overflow-x-auto">
<table class="w-full border-collapse text-[13px]">
<thead>
<tr class="text-left text-[12px] text-cladd-fg-soft">
{#if show("icon")}
<th class="h-7 w-8 px-2"><span class="sr-only">Type</span></th>
{/if}
{#if show("description")}
<th class="h-7 px-3 font-medium">Description</th>
{/if}
{#if show("balance_before")}
<th class="h-7 px-3 text-right font-medium">Balance before</th>
{/if}
{#if show("amount")}
<th class="h-7 px-3 text-right font-medium">Amount</th>
{/if}
{#if show("balance_after")}
<th class="h-7 px-3 text-right font-medium">Balance after</th>
{/if}
{#if show("date")}
<th class="h-7 px-3 font-medium">Date</th>
{/if}
{#if show("from")}
<th class="h-7 px-3 font-medium">From</th>
{/if}
{#if show("to")}
<th class="h-7 px-3 font-medium">To</th>
{/if}
{#if show("budget")}
<th class="h-7 px-3 font-medium">Budget</th>
{/if}
{#if show("category")}
<th class="h-7 px-3 font-medium">Category</th>
{/if}
{#if show("bill")}
<th class="h-7 px-3 font-medium">Subscription</th>
{/if}
{#if show("created_at")}
<th class="h-7 px-3 font-medium">Created at</th>
{/if}
{#if show("updated_at")}
<th class="h-7 px-3 font-medium">Updated at</th>
{/if}
{#if show("notes")}
<th class="h-7 px-3 font-medium">Notes</th>
{/if}
{#each dateColumns as key (key)}
<th class="h-7 px-3 font-medium">
{key.replace("_date", "").replace("process", "processing")}
date
</th>
{/each}
</tr>
</thead>
<tbody>
{#each rows as row (row.split.transaction_journal_id)}
{@const s = row.split}
{@const Icon =
ICONS[s.type as keyof typeof ICONS] ?? ArrowLeftRight}
<tr
class="h-7 border-cladd-outline border-t align-top hover:bg-row-hover"
>
{#if show("icon")}
<td class="px-2 py-1.5 text-cladd-fg-soft">
<Icon aria-label={s.type} class="size-3.5" />
</td>
{/if}
{#if show("description")}
<td class="max-w-[22rem] px-3 py-1">
<a
class="hover:underline"
href="/transactions/show/{row.group}"
>
{#if row.title}
{row.title}
({s.description})
{:else}
{s.description}
{/if}
</a>
</td>
{/if}
{#if show("balance_before")}
<td
class="num whitespace-nowrap px-3 py-1 text-right text-cladd-fg-soft"
>
{money(code, row.before)}
</td>
{/if}
{#if show("amount")}
<td
class="num whitespace-nowrap px-3 py-1 text-right {row.amount <
0
? "text-expense"
: "text-income"}"
>
{money(code, row.amount)}
</td>
{/if}
{#if show("balance_after")}
<td class="num whitespace-nowrap px-3 py-1 text-right">
{money(code, row.after)}
</td>
{/if}
{#if show("date")}
<td class="num whitespace-nowrap px-3 py-1">
{formatDate(s.date, "medium")}
</td>
{/if}
{#if show("from")}
<td class="max-w-[12rem] truncate px-3 py-1">
<a class="hover:underline" href="/accounts/show/{s.source_id}"
>{s.source_name}</a
>
</td>
{/if}
{#if show("to")}
<td class="max-w-[12rem] truncate px-3 py-1">
<a
class="hover:underline"
href="/accounts/show/{s.destination_id}"
>{s.destination_name}</a
>
</td>
{/if}
{#if show("budget")}
<td class="px-3 py-1">{s.budget_name ?? ""}</td>
{/if}
{#if show("category")}
<td class="px-3 py-1">{s.category_name ?? ""}</td>
{/if}
{#if show("bill")}
<td class="px-3 py-1">{s.bill_name ?? ""}</td>
{/if}
{#if show("created_at")}
<td class="num whitespace-nowrap px-3 py-1 text-cladd-fg-soft">
{formatDateTime(row.created)}
</td>
{/if}
{#if show("updated_at")}
<td class="num whitespace-nowrap px-3 py-1 text-cladd-fg-soft">
{formatDateTime(row.updated)}
</td>
{/if}
{#if show("notes")}
<td
class="max-w-[24rem] whitespace-pre-line px-3 py-1 text-[12px] text-cladd-fg-soft"
>
{s.notes ?? ""}
</td>
{/if}
{#each dateColumns as key (key)}
<td class="num whitespace-nowrap px-3 py-1">
{formatDate(s[key] ?? null, "medium")}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<p class="border-cladd-outline border-t px-3 py-2 text-[13px]">
Account balance of <strong>{account?.name}</strong> at the end of
{formatDate(p.end, "long")}
was:
<span class="num font-semibold">{money(code, after ?? 0)}</span>
</p>
{/if}
</Box>