feat(reports): a year board per currency instead of the generator
/reports opens the year: per currency spent so far, a typical month and this month against typical by today, one lane of category columns per currency with a dotted typical line and income as dots, what grew next to the month under the cursor, an honest by-currency table and net worth lanes weighted by size. Arrows move the month, Enter opens its transactions, [ ] change the year, m shows the old tables, ?view=month pins a month with the money flow. Old report URLs still work. Shared pieces for other pages: Lanes, ledgerQuery for twelve months, typical-month helpers.
This commit is contained in:
1 parent
8c4e72666b
commit
26903b0066
20 files changed
+2519
-593
No files matched your search
@@ -1,384 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { lists } from "#lib/api/lists.js";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import MultiSelect from "#lib/components/reports/MultiSelect.svelte";
|
||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||
import { today } from "#lib/dates.js";
|
||||
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
|
||||
import { period } from "#lib/period.svelte.js";
|
||||
import { recall, remember } from "#lib/reports/memory.js";
|
||||
import { listOptions, listReady } from "#lib/reports/options.js";
|
||||
import {
|
||||
fiscalYear,
|
||||
LIST_LABELS,
|
||||
type ListKind,
|
||||
missing,
|
||||
REPORT_KINDS,
|
||||
type ReportParams,
|
||||
type ReportType,
|
||||
rangePresets,
|
||||
reportPath,
|
||||
} from "#lib/reports/params.js";
|
||||
import { reportQueries } from "#lib/reports/queries.js";
|
||||
import { accountOptions } from "#lib/reports/report.svelte.js";
|
||||
import { session } from "#lib/session.svelte.js";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
const memory = recall();
|
||||
let type = $state<ReportType>(memory.type);
|
||||
let accounts = $state<string[]>(memory.accounts);
|
||||
let items = $state<Partial<Record<ListKind, string[]>>>(memory.items);
|
||||
let start = $derived(period.start);
|
||||
let end = $derived(period.end);
|
||||
let problem = $state("");
|
||||
|
||||
const kind = $derived(REPORT_KINDS.find((k) => k.type === type));
|
||||
const list = $derived(kind?.list);
|
||||
|
||||
const assets = createQuery(() => lists.accounts("asset"));
|
||||
const liabilities = createQuery(() => lists.accounts("liabilities"));
|
||||
const budgets = createQuery(() => ({
|
||||
...lists.budgets(),
|
||||
enabled: list === "budget",
|
||||
}));
|
||||
const categories = createQuery(() => ({
|
||||
...lists.categories(),
|
||||
enabled: list === "category",
|
||||
}));
|
||||
const tags = createQuery(() => ({
|
||||
...lists.tags(),
|
||||
enabled: list === "tag",
|
||||
}));
|
||||
const expense = createQuery(() => ({
|
||||
...lists.accounts("expense"),
|
||||
enabled: list === "double",
|
||||
}));
|
||||
const revenue = createQuery(() => ({
|
||||
...lists.accounts("revenue"),
|
||||
enabled: list === "double",
|
||||
}));
|
||||
const first = createQuery(() => reportQueries.first());
|
||||
|
||||
const all = $derived([...(assets.data ?? []), ...(liabilities.data ?? [])]);
|
||||
const accountReady = $derived(!!(assets.data && liabilities.data));
|
||||
const options = $derived(accountOptions(all));
|
||||
const sources = $derived({
|
||||
budgets: budgets.data,
|
||||
categories: categories.data,
|
||||
expense: expense.data,
|
||||
revenue: revenue.data,
|
||||
tags: tags.data,
|
||||
});
|
||||
const itemOptions = $derived(list ? listOptions(list, sources) : []);
|
||||
const allIds = $derived(all.map((a) => a.id).join(","));
|
||||
const fiscal = $derived(
|
||||
session.prefs.customFiscalYear
|
||||
? String(session.prefs.fiscalYearStart ?? "01-01")
|
||||
: null
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (accountReady && accounts.length === 0 && memory.accounts.length === 0) {
|
||||
accounts = (assets.data ?? [])
|
||||
.filter((a) => a.attributes.active)
|
||||
.map((a) => a.id);
|
||||
}
|
||||
});
|
||||
|
||||
const params = $derived<ReportParams>({
|
||||
accounts: accounts.filter(
|
||||
(id) => all.length === 0 || all.some((a) => a.id === id)
|
||||
),
|
||||
end,
|
||||
items: list ? (items[list] ?? []) : [],
|
||||
start,
|
||||
type,
|
||||
});
|
||||
|
||||
const years = $derived.by(() => {
|
||||
const now = Number(today().slice(0, 4));
|
||||
const from = Number((first.data ?? today()).slice(0, 4));
|
||||
return Array.from(
|
||||
{ length: Math.max(1, now - from + 1) },
|
||||
(_, i) => from + i
|
||||
);
|
||||
});
|
||||
const monthName = $derived(
|
||||
new Intl.DateTimeFormat(session.locale, { month: "short" })
|
||||
);
|
||||
|
||||
function setRange(range: { start: string; end: string }) {
|
||||
({ start, end } = range);
|
||||
problem = "";
|
||||
}
|
||||
|
||||
function monthRange(year: number, month: number) {
|
||||
const last = new Date(year, month + 1, 0).getDate();
|
||||
const mm = String(month + 1).padStart(2, "0");
|
||||
return { end: `${year}-${mm}-${last}`, start: `${year}-${mm}-01` };
|
||||
}
|
||||
|
||||
function quarterRange(year: number, quarter: number) {
|
||||
const a = monthRange(year, quarter * 3);
|
||||
const b = monthRange(year, quarter * 3 + 2);
|
||||
return { end: b.end, start: a.start };
|
||||
}
|
||||
|
||||
function submit() {
|
||||
problem = missing(params) ?? "";
|
||||
if (problem) {
|
||||
return;
|
||||
}
|
||||
remember(params);
|
||||
goto(reportPath(params));
|
||||
}
|
||||
|
||||
useKeys(() => [
|
||||
{
|
||||
group: "Report",
|
||||
input: true,
|
||||
keys: "mod+Enter",
|
||||
label: "Show report",
|
||||
run: submit,
|
||||
},
|
||||
]);
|
||||
|
||||
const quick = $derived([
|
||||
{
|
||||
label: "Current month, all accounts",
|
||||
range: "currentMonthStart/currentMonthEnd",
|
||||
},
|
||||
{
|
||||
label: "Current year, all accounts",
|
||||
range: "currentYearStart/currentYearEnd",
|
||||
},
|
||||
...(fiscal
|
||||
? [
|
||||
{
|
||||
label: "Current fiscal year, all accounts",
|
||||
range: "currentFiscalYearStart/currentFiscalYearEnd",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "Last month, all accounts",
|
||||
only: "default",
|
||||
range: "previousMonthStart/previousMonthEnd",
|
||||
},
|
||||
{
|
||||
label: "All-time, all accounts",
|
||||
range: `${(first.data ?? today()).replaceAll("-", "")}/currentMonthEnd`,
|
||||
},
|
||||
]);
|
||||
import Board from "#lib/components/reports/Board.svelte";
|
||||
</script>
|
||||
|
||||
<PageHeader crumbs={[{ label: "Reports" }]} title="Reports" />
|
||||
|
||||
<div class="grid items-start gap-4 lg:grid-cols-2">
|
||||
<form
|
||||
class="flex min-w-0 flex-col gap-4"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<Box body="p-4" title="Reports">
|
||||
<div class="flex flex-col gap-4">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="font-medium text-[12px] text-cladd-fg-soft"
|
||||
>Report type</span
|
||||
>
|
||||
<select
|
||||
class="cladd-cut cladd-focusable-input h-8 rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
|
||||
onchange={() => (problem = "")}
|
||||
bind:value={type}
|
||||
>
|
||||
{#each REPORT_KINDS as option (option.type)}
|
||||
<option value={option.type}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label
|
||||
class="font-medium text-[12px] text-cladd-fg-soft"
|
||||
for="gen-accounts"
|
||||
>Included accounts</label
|
||||
>
|
||||
<MultiSelect
|
||||
id="gen-accounts"
|
||||
label="Included accounts"
|
||||
loading={!accountReady}
|
||||
onchange={(next) => {
|
||||
accounts = next;
|
||||
problem = "";
|
||||
}}
|
||||
{options}
|
||||
value={params.accounts}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-medium text-[12px] text-cladd-fg-soft"
|
||||
>Date range</span
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
aria-label="Start date"
|
||||
class="cladd-cut cladd-focusable-input num h-8 rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
|
||||
min={first.data ?? undefined}
|
||||
type="date"
|
||||
bind:value={start}
|
||||
>
|
||||
<span class="text-cladd-fg-soft">to</span>
|
||||
<input
|
||||
aria-label="End date"
|
||||
class="cladd-cut cladd-focusable-input num h-8 rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
|
||||
type="date"
|
||||
bind:value={end}
|
||||
>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[12px]">
|
||||
{#each rangePresets() as preset (preset.key)}
|
||||
<button
|
||||
class="text-brand dark:text-[var(--ff-series-1)] hover:underline {preset
|
||||
.range.start === start && preset.range.end === end
|
||||
? "font-semibold"
|
||||
: ""}"
|
||||
onclick={() => setRange(preset.range)}
|
||||
type="button"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-medium text-[12px] text-cladd-fg-soft"
|
||||
>Pre-set ranges</span
|
||||
>
|
||||
<ul class="flex flex-col gap-1 text-[12px]">
|
||||
{#each years as year (year)}
|
||||
<li class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<button
|
||||
class="num font-semibold text-brand dark:text-[var(--ff-series-1)] hover:underline"
|
||||
onclick={() =>
|
||||
setRange(
|
||||
fiscal
|
||||
? fiscalYear(`${year}-12-31`, fiscal)
|
||||
: { end: `${year}-12-31`, start: `${year}-01-01` }
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
{year}{fiscal ? " (fiscal year)" : ""}
|
||||
</button>
|
||||
{#each [0, 1, 2, 3] as q (q)}
|
||||
<button
|
||||
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
|
||||
onclick={() => setRange(quarterRange(year, q))}
|
||||
type="button"
|
||||
>
|
||||
Q{q + 1}
|
||||
</button>
|
||||
{/each}
|
||||
<span class="text-cladd-fg-softer">·</span>
|
||||
{#each Array.from({ length: 12 }, (_, m) => m) as m (m)}
|
||||
{#if `${year}-${String(m + 1).padStart(2, "0")}` <=
|
||||
today().slice(0, 7)}
|
||||
<button
|
||||
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
|
||||
onclick={() => setRange(monthRange(year, m))}
|
||||
title="{monthName.format(new Date(year, m, 1))} {year}"
|
||||
type="button"
|
||||
>
|
||||
{monthName.format(new Date(year, m, 1))}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box body="p-4" title="Extra options">
|
||||
{#if list}
|
||||
<div class="flex flex-col gap-1">
|
||||
<label
|
||||
class="font-medium text-[12px] text-cladd-fg-soft"
|
||||
for="gen-items"
|
||||
>Select {LIST_LABELS[list].many.toLowerCase()}</label
|
||||
>
|
||||
<MultiSelect
|
||||
id="gen-items"
|
||||
label={LIST_LABELS[list].many}
|
||||
loading={!listReady(list, sources)}
|
||||
onchange={(next) => {
|
||||
items = { ...items, [list]: next };
|
||||
problem = "";
|
||||
}}
|
||||
options={itemOptions}
|
||||
value={params.items}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-cladd-fg-soft">This report has no extra options</p>
|
||||
{/if}
|
||||
</Box>
|
||||
|
||||
<Box body="p-4" title="View report">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable cladd-color-brand h-8 rounded-cladd-sm px-4 font-semibold text-[13px] text-cladd-primary"
|
||||
type="submit"
|
||||
>
|
||||
Show report
|
||||
</button>
|
||||
<span class="flex items-center gap-1 text-[12px] text-cladd-fg-soft">
|
||||
{#each prettyKeys("mod+Enter") as part (part)}
|
||||
<kbd class="kbd">{part}</kbd>
|
||||
{/each}
|
||||
</span>
|
||||
{#if problem}
|
||||
<span class="text-expense" role="alert">{problem}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Box>
|
||||
</form>
|
||||
|
||||
<Box body="p-4" title="Quick links">
|
||||
{#if accountReady && all.length === 0}
|
||||
<p class="text-expense">
|
||||
In order to generate reports, you need to add at least one asset account
|
||||
to Firefly III.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-4">
|
||||
{#each [
|
||||
{ label: "Default financial report", type: "default" },
|
||||
{ label: "Transaction history overview", type: "audit" },
|
||||
] as section (section.type)}
|
||||
<section>
|
||||
<h3 class="mb-1 font-semibold text-[13px]">{section.label}</h3>
|
||||
<ul class="flex flex-col gap-0.5">
|
||||
{#each quick.filter(
|
||||
(q) => !q.only || q.only === section.type
|
||||
) as link (link.label)}
|
||||
<li>
|
||||
<a
|
||||
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
|
||||
href="/reports/{section.type}/{allIds}/{link.range}"
|
||||
>{link.label}</a
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/each}
|
||||
<p class="text-[12px] text-cladd-fg-soft">
|
||||
Remember that reports can be bookmarked.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Box>
|
||||
</div>
|
||||
<Board />
|
||||
@@ -28,7 +28,7 @@
|
||||
<a
|
||||
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
|
||||
href="/reports"
|
||||
>Back to the report generator</a
|
||||
>Back to reports</a
|
||||
>
|
||||
</Box>
|
||||
{/if}
|
||||
Reference in new issue
Block a user