feat(reports): live firefly reports with drill-down, money flow and spending calendar
This commit is contained in:
1 parent
60caee10dc
commit
62cd17ecde
39 files changed
+6596
No files matched your search
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { reportQueries } from "#lib/reports/queries.js";
|
||||
import {
|
||||
drillQuery,
|
||||
money,
|
||||
navigate,
|
||||
useReport,
|
||||
} from "#lib/reports/report.svelte.js";
|
||||
import ReportBox from "./ReportBox.svelte";
|
||||
|
||||
const report = useReport();
|
||||
const p = $derived(report.params);
|
||||
const balances = createQuery(() => reportQueries.balances(p.start, p.end));
|
||||
|
||||
const rows = $derived.by(() => {
|
||||
const { data } = balances;
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
return report.selected
|
||||
.map((id) => {
|
||||
const account = report.byId().get(id);
|
||||
const start = data.before.get(id) ?? 0;
|
||||
const end = data.after.get(id) ?? 0;
|
||||
return {
|
||||
code: account?.currency_code ?? "",
|
||||
end,
|
||||
id,
|
||||
name: account?.name ?? `#${id}`,
|
||||
start,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
});
|
||||
const sums = $derived.by(() => {
|
||||
const map = new Map<string, { start: number; end: number }>();
|
||||
for (const row of rows) {
|
||||
const sum = map.get(row.code) ?? { end: 0, start: 0 };
|
||||
sum.start += row.start;
|
||||
sum.end += row.end;
|
||||
map.set(row.code, sum);
|
||||
}
|
||||
const order = report.codes;
|
||||
return [...map.entries()].sort(
|
||||
(a, b) => order.indexOf(a[0]) - order.indexOf(b[0])
|
||||
);
|
||||
});
|
||||
|
||||
function tone(value: number) {
|
||||
if (value === 0) {
|
||||
return "text-cladd-fg-soft";
|
||||
}
|
||||
return value < 0 ? "text-expense" : "text-income";
|
||||
}
|
||||
|
||||
function open(row: { id: string; name: string }) {
|
||||
const current = report.drill;
|
||||
const same = current?.box === "balances" && current.key === row.id;
|
||||
navigate(
|
||||
drillQuery(
|
||||
same
|
||||
? null
|
||||
: { box: "balances", dim: "asset", key: row.id, label: row.name }
|
||||
)
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ReportBox
|
||||
id="balances"
|
||||
loading={!(balances.data && report.accountsReady)}
|
||||
title="Account balances"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr class="text-[12px] text-cladd-fg-soft">
|
||||
<th class="h-7 px-3 text-left font-medium">Name</th>
|
||||
<th class="h-7 px-3 text-right font-medium">
|
||||
Balance at start of period
|
||||
</th>
|
||||
<th class="h-7 px-3 text-right font-medium">
|
||||
Balance at end of period
|
||||
</th>
|
||||
<th class="h-7 px-3 text-right font-medium">Difference</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row (row.id)}
|
||||
{@const active =
|
||||
report.drill?.box === "balances" && report.drill.key === row.id}
|
||||
<tr
|
||||
class="h-7 border-cladd-outline border-t hover:bg-row-hover {active
|
||||
? "bg-row-selected"
|
||||
: ""}"
|
||||
>
|
||||
<td class="max-w-[16rem] px-3">
|
||||
<button
|
||||
aria-pressed={active}
|
||||
class="block max-w-full truncate text-left hover:text-brand dark:hover:text-[var(--ff-series-1)] hover:underline"
|
||||
onclick={() => open(row)}
|
||||
title="Show transactions"
|
||||
type="button"
|
||||
>
|
||||
{row.name}
|
||||
</button>
|
||||
</td>
|
||||
<td class="num px-3 text-right">{money(row.code, row.start)}</td>
|
||||
<td class="num px-3 text-right">{money(row.code, row.end)}</td>
|
||||
<td class="num px-3 text-right {tone(row.end - row.start)}">
|
||||
{money(row.code, row.end - row.start, true)}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
{#each sums as [code, sum], i (code)}
|
||||
<tr
|
||||
class="h-7 font-semibold {i === 0
|
||||
? "border-cladd-outline border-t-2"
|
||||
: ""}"
|
||||
>
|
||||
<td class="px-3">Sum ({code})</td>
|
||||
<td class="num px-3 text-right">{money(code, sum.start)}</td>
|
||||
<td class="num px-3 text-right">{money(code, sum.end)}</td>
|
||||
<td class="num px-3 text-right {tone(sum.end - sum.start)}">
|
||||
{money(code, sum.end - sum.start, true)}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</ReportBox>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import {
|
||||
AUDIT_COLUMNS,
|
||||
type AuditColumn,
|
||||
DEFAULT_AUDIT,
|
||||
} from "#lib/reports/audit.js";
|
||||
import { reportQueries } from "#lib/reports/queries.js";
|
||||
import { navigate, useReport } from "#lib/reports/report.svelte.js";
|
||||
import { page } from "$app/state";
|
||||
import AuditAccount from "./AuditAccount.svelte";
|
||||
|
||||
const report = useReport();
|
||||
const p = $derived(report.params);
|
||||
const balances = createQuery(() => reportQueries.balances(p.start, p.end));
|
||||
|
||||
const columns = $derived.by(() => {
|
||||
const raw = page.url.searchParams.get("cols");
|
||||
if (raw === null) {
|
||||
return DEFAULT_AUDIT;
|
||||
}
|
||||
const keys = new Set(raw.split(","));
|
||||
return AUDIT_COLUMNS.map((c) => c.key).filter((key) => keys.has(key));
|
||||
});
|
||||
const accounts = $derived(
|
||||
report.selected
|
||||
.map((id) => ({ account: report.byId().get(id), id }))
|
||||
.filter((a) => a.account)
|
||||
.sort((a, b) =>
|
||||
(a.account?.name ?? "").localeCompare(b.account?.name ?? "")
|
||||
)
|
||||
);
|
||||
|
||||
function toggle(key: AuditColumn, on: boolean) {
|
||||
const next = AUDIT_COLUMNS.map((c) => c.key).filter((k) =>
|
||||
k === key ? on : columns.includes(k)
|
||||
);
|
||||
navigate({ cols: next.join(",") });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<Box body="px-3 py-2" title="Options">
|
||||
<fieldset class="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<legend class="sr-only">Visible columns</legend>
|
||||
{#each AUDIT_COLUMNS as column (column.key)}
|
||||
<label class="flex h-7 items-center gap-1.5 text-[13px]">
|
||||
<input
|
||||
checked={columns.includes(column.key)}
|
||||
class="size-3.5 accent-[var(--ff-brand)]"
|
||||
onchange={(event) =>
|
||||
toggle(column.key, event.currentTarget.checked)}
|
||||
type="checkbox"
|
||||
>
|
||||
{column.label}
|
||||
</label>
|
||||
{/each}
|
||||
</fieldset>
|
||||
</Box>
|
||||
{#if !report.accountsReady}
|
||||
<p class="text-cladd-fg-soft">Loading</p>
|
||||
{/if}
|
||||
{#each accounts as item (item.id)}
|
||||
<AuditAccount
|
||||
account={item.account}
|
||||
after={balances.data?.after.get(item.id)}
|
||||
before={balances.data?.before.get(item.id)}
|
||||
{columns}
|
||||
id={item.id}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import type { LineSeries } from "#lib/components/charts/chart.js";
|
||||
import LineChart from "#lib/components/charts/LineChart.svelte";
|
||||
import { contains, today } from "#lib/dates.js";
|
||||
import { formatDate } from "#lib/format.js";
|
||||
import { reportQueries } from "#lib/reports/queries.js";
|
||||
import { money, useReport } from "#lib/reports/report.svelte.js";
|
||||
import { modeAxis } from "#lib/reports/view.js";
|
||||
import ReportBox from "./ReportBox.svelte";
|
||||
|
||||
let { net = false }: { net?: boolean } = $props();
|
||||
|
||||
const report = useReport();
|
||||
const p = $derived(report.params);
|
||||
const ids = $derived(
|
||||
report.selected.filter(
|
||||
(id) => !net || report.byId().get(id)?.include_net_worth !== false
|
||||
)
|
||||
);
|
||||
const period = $derived.by(() => {
|
||||
if (!net) {
|
||||
return "1D";
|
||||
}
|
||||
return report.template === "multi-year" ? "1M" : "1W";
|
||||
});
|
||||
const chart = createQuery(() => ({
|
||||
...reportQueries.chart(p.start, p.end, period, ids),
|
||||
enabled: report.accountsReady && ids.length > 0,
|
||||
}));
|
||||
|
||||
const charts = $derived.by(() => {
|
||||
const byCode = new Map<string, LineSeries[]>();
|
||||
for (const set of chart.data ?? []) {
|
||||
const code = set.currency_code ?? "";
|
||||
const points = Object.entries(set.entries ?? {}).map(([x, y]) => ({
|
||||
x: x.slice(0, 10),
|
||||
y: Number(y),
|
||||
}));
|
||||
const list = byCode.get(code) ?? [];
|
||||
if (net && list.length > 0) {
|
||||
const [sum] = list;
|
||||
sum.points = sum.points.map((point, i) => ({
|
||||
x: point.x,
|
||||
y: point.y + (points[i]?.y ?? 0),
|
||||
}));
|
||||
} else {
|
||||
list.push({
|
||||
format: (value: number) => money(code, value),
|
||||
key: net ? code : (set.label ?? code),
|
||||
label: net ? `Net worth in ${code}` : (set.label ?? code),
|
||||
points,
|
||||
});
|
||||
}
|
||||
byCode.set(code, list);
|
||||
}
|
||||
return [...byCode.entries()].sort(
|
||||
(a, b) => report.codes.indexOf(a[0]) - report.codes.indexOf(b[0])
|
||||
);
|
||||
});
|
||||
const marker = $derived(contains(p, today()) ? today() : undefined);
|
||||
const xFormat = $derived(
|
||||
report.template === "multi-year"
|
||||
? (x: string) => formatDate(x, "month")
|
||||
: (x: string) => formatDate(x, "short")
|
||||
);
|
||||
</script>
|
||||
|
||||
<ReportBox
|
||||
body="p-3"
|
||||
empty={charts.length === 0}
|
||||
emptyText="No accounts to chart."
|
||||
id={net ? "net-worth" : "balance-chart"}
|
||||
loading={chart.isPending && ids.length > 0}
|
||||
title={net ? "Net worth" : "Account balances"}
|
||||
>
|
||||
<div class="grid gap-x-6 gap-y-4 {charts.length > 1 ? "xl:grid-cols-2" : ""}">
|
||||
{#each charts as [code, series] (code)}
|
||||
<div class="min-w-0">
|
||||
{#if charts.length > 1 || !net}
|
||||
<h3 class="mb-1 font-semibold text-[12px] text-cladd-fg-soft">
|
||||
{code}
|
||||
</h3>
|
||||
{/if}
|
||||
<LineChart
|
||||
axisFormat={modeAxis(code)}
|
||||
height={charts.length > 1 ? 200 : 240}
|
||||
label="{net ? "Net worth" : "Account balances"} in {code}"
|
||||
{marker}
|
||||
{series}
|
||||
{xFormat}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</ReportBox>
|
||||
@@ -0,0 +1,226 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { formatRange, percent } from "#lib/format.js";
|
||||
import { NONE } from "#lib/reports/flows.js";
|
||||
import { reportQueries } from "#lib/reports/queries.js";
|
||||
import {
|
||||
drillQuery,
|
||||
money,
|
||||
navigate,
|
||||
useReport,
|
||||
} from "#lib/reports/report.svelte.js";
|
||||
import ReportBox from "./ReportBox.svelte";
|
||||
|
||||
let { class: className = "" }: { class?: string } = $props();
|
||||
|
||||
const report = useReport();
|
||||
const p = $derived(report.params);
|
||||
const limits = createQuery(() => reportQueries.limits(p.start, p.end));
|
||||
|
||||
interface Line {
|
||||
budgeted: number | null;
|
||||
budgetId: string;
|
||||
code: string;
|
||||
end: string;
|
||||
key: string;
|
||||
name: string;
|
||||
spent: number;
|
||||
start: string;
|
||||
}
|
||||
|
||||
const lines = $derived.by(() => {
|
||||
const { flows } = report;
|
||||
if (!(flows && limits.data)) {
|
||||
return [];
|
||||
}
|
||||
const withdrawals = flows.filter(
|
||||
(f) => f.dir === -1 && f.type === "withdrawal"
|
||||
);
|
||||
const result: Line[] = limits.data.map((item) => {
|
||||
const a = item.attributes;
|
||||
const start = (a.start ?? p.start).slice(0, 10);
|
||||
const end = (a.end ?? p.end).slice(0, 10);
|
||||
const code = a.currency_code ?? "";
|
||||
const spent = withdrawals
|
||||
.filter(
|
||||
(f) =>
|
||||
f.budget?.id === String(a.budget_id) &&
|
||||
f.currency === code &&
|
||||
f.date >= start &&
|
||||
f.date <= end
|
||||
)
|
||||
.reduce((sum, f) => sum + f.amount, 0);
|
||||
return {
|
||||
budgeted: Number(a.amount ?? 0),
|
||||
budgetId: String(a.budget_id),
|
||||
code,
|
||||
end,
|
||||
key: item.id,
|
||||
name:
|
||||
report.itemName("budget", String(a.budget_id)) ?? `#${a.budget_id}`,
|
||||
spent,
|
||||
start,
|
||||
};
|
||||
});
|
||||
result.sort(
|
||||
(a, b) => a.name.localeCompare(b.name) || a.start.localeCompare(b.start)
|
||||
);
|
||||
const none = new Map<string, number>();
|
||||
for (const f of withdrawals) {
|
||||
if (!f.budget) {
|
||||
none.set(f.currency, (none.get(f.currency) ?? 0) + f.amount);
|
||||
}
|
||||
}
|
||||
for (const [code, spent] of none) {
|
||||
result.push({
|
||||
budgeted: null,
|
||||
budgetId: NONE,
|
||||
code,
|
||||
end: p.end,
|
||||
key: `none-${code}`,
|
||||
name: `(no budget) (${code})`,
|
||||
spent,
|
||||
start: p.start,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const sums = $derived.by(() => {
|
||||
const map = new Map<string, { budgeted: number; spent: number }>();
|
||||
for (const line of lines) {
|
||||
const sum = map.get(line.code) ?? { budgeted: 0, spent: 0 };
|
||||
sum.budgeted += line.budgeted ?? 0;
|
||||
sum.spent += line.spent;
|
||||
map.set(line.code, sum);
|
||||
}
|
||||
return [...map.entries()].sort(
|
||||
(a, b) => report.codes.indexOf(a[0]) - report.codes.indexOf(b[0])
|
||||
);
|
||||
});
|
||||
const sumOf = (code: string) => sums.find(([c]) => c === code)?.[1];
|
||||
|
||||
function open(line: Line) {
|
||||
const current = report.drill;
|
||||
const same = current?.box === "budgets" && current.key === line.budgetId;
|
||||
navigate(
|
||||
drillQuery(
|
||||
same
|
||||
? null
|
||||
: {
|
||||
box: "budgets",
|
||||
dim: "budget",
|
||||
dir: -1,
|
||||
key: line.budgetId,
|
||||
label: line.name,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ReportBox
|
||||
class={className}
|
||||
empty={lines.length === 0}
|
||||
emptyText="No budgets or spending in this period."
|
||||
id="budgets"
|
||||
loading={!(report.flows && limits.data && report.budgets)}
|
||||
title="Budgets"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr class="text-[12px] text-cladd-fg-soft">
|
||||
<th class="h-7 px-3 text-left font-medium">Budget</th>
|
||||
<th class="h-7 px-3 text-left font-medium max-2xl:hidden">Date</th>
|
||||
<th class="h-7 px-3 text-right font-medium">Budgeted</th>
|
||||
<th class="h-7 px-2 text-right font-medium max-md:hidden">pct.</th>
|
||||
<th class="h-7 px-3 text-right font-medium">Spent</th>
|
||||
<th class="h-7 px-2 text-right font-medium max-md:hidden">pct.</th>
|
||||
<th class="h-7 px-3 text-right font-medium">Left</th>
|
||||
<th class="h-7 px-3 text-right font-medium">Overspent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each lines as line (line.key)}
|
||||
{@const sum = sumOf(line.code)}
|
||||
{@const left = (line.budgeted ?? 0) - line.spent}
|
||||
{@const active =
|
||||
report.drill?.box === "budgets" &&
|
||||
report.drill.key === line.budgetId}
|
||||
<tr
|
||||
class="h-7 border-cladd-outline border-t hover:bg-row-hover {active
|
||||
? "bg-row-selected"
|
||||
: ""}"
|
||||
>
|
||||
<td class="max-w-[14rem] px-3">
|
||||
<button
|
||||
aria-pressed={active}
|
||||
class="block max-w-full truncate text-left hover:text-brand dark:hover:text-[var(--ff-series-1)] hover:underline"
|
||||
onclick={() => open(line)}
|
||||
title="Show transactions"
|
||||
type="button"
|
||||
>
|
||||
{line.name}
|
||||
</button>
|
||||
</td>
|
||||
<td
|
||||
class="num whitespace-nowrap px-3 text-cladd-fg-soft max-2xl:hidden"
|
||||
>
|
||||
{#if line.budgeted !== null}
|
||||
{formatRange(line.start, line.end)}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num px-3 text-right">
|
||||
{#if line.budgeted !== null}
|
||||
{money(line.code, line.budgeted)}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num px-2 text-right text-cladd-fg-soft max-md:hidden">
|
||||
{#if line.budgeted && sum?.budgeted}
|
||||
{percent(line.budgeted / sum.budgeted)}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num px-3 text-right text-expense">
|
||||
{line.spent ? money(line.code, -line.spent) : ""}
|
||||
</td>
|
||||
<td class="num px-2 text-right text-cladd-fg-soft max-md:hidden">
|
||||
{#if line.spent && sum?.spent}
|
||||
{percent(line.spent / sum.spent)}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num px-3 text-right text-income">
|
||||
{#if line.budgeted !== null && left > 0}
|
||||
{money(line.code, left)}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num px-3 text-right text-expense">
|
||||
{#if line.budgeted !== null && left < 0}
|
||||
{money(line.code, -left)}
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
{#each sums as [code, sum], i (code)}
|
||||
<tr
|
||||
class="h-7 font-semibold {i === 0
|
||||
? "border-cladd-outline border-t-2"
|
||||
: ""}"
|
||||
>
|
||||
<td class="px-3">Sum ({code})</td>
|
||||
<td class="max-2xl:hidden"></td>
|
||||
<td class="num px-3 text-right">{money(code, sum.budgeted)}</td>
|
||||
<td class="max-md:hidden"></td>
|
||||
<td class="num px-3 text-right text-expense">
|
||||
{money(code, -sum.spent)}
|
||||
</td>
|
||||
<td class="max-md:hidden"></td>
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</ReportBox>
|
||||
@@ -0,0 +1,264 @@
|
||||
<script lang="ts">
|
||||
import { bucketOf, buckets, group, keys, total } from "#lib/reports/flows.js";
|
||||
import { modeLabel, useReport } from "#lib/reports/report.svelte.js";
|
||||
import { bucketLabel } from "#lib/reports/view.js";
|
||||
import AccountBalances from "./AccountBalances.svelte";
|
||||
import BalanceLines from "./BalanceLines.svelte";
|
||||
import BudgetLimits from "./BudgetLimits.svelte";
|
||||
import DeltaToggle from "./DeltaToggle.svelte";
|
||||
import IncomeVsExpenses from "./IncomeVsExpenses.svelte";
|
||||
import Matrix from "./Matrix.svelte";
|
||||
import MoneyFlow from "./MoneyFlow.svelte";
|
||||
import PeriodChart from "./PeriodChart.svelte";
|
||||
import ReportBox from "./ReportBox.svelte";
|
||||
import ReportRow from "./ReportRow.svelte";
|
||||
import SpendingCalendar from "./SpendingCalendar.svelte";
|
||||
import Subscriptions from "./Subscriptions.svelte";
|
||||
import SumTable from "./SumTable.svelte";
|
||||
|
||||
const report = useReport();
|
||||
const flows = $derived(report.flows);
|
||||
const month = $derived(report.template === "month");
|
||||
const income = $derived(flows?.filter((f) => f.dir === 1) ?? []);
|
||||
const expenses = $derived(flows?.filter((f) => f.dir === -1) ?? []);
|
||||
const withdrawals = $derived(expenses.filter((f) => f.type === "withdrawal"));
|
||||
const deposits = $derived(income.filter((f) => f.type === "deposit"));
|
||||
const bucket = (date: string) => bucketOf(date, report.grain);
|
||||
const periodCols = $derived(
|
||||
buckets(report.params.start, report.params.end, report.grain).map(
|
||||
(key) => ({
|
||||
key,
|
||||
label: bucketLabel(key, report.grain, true),
|
||||
title: bucketLabel(key, report.grain),
|
||||
})
|
||||
)
|
||||
);
|
||||
const accountCols = $derived(
|
||||
report.selected
|
||||
.filter((id) => withdrawals.some((f) => f.asset.id === id))
|
||||
.map((id) => ({
|
||||
key: id,
|
||||
label: report.byId().get(id)?.name ?? `#${id}`,
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if month}
|
||||
<BalanceLines />
|
||||
{:else}
|
||||
<PeriodChart {flows} />
|
||||
{/if}
|
||||
|
||||
<ReportRow class="lg:grid-cols-2">
|
||||
<AccountBalances />
|
||||
<IncomeVsExpenses />
|
||||
</ReportRow>
|
||||
|
||||
<MoneyFlow />
|
||||
|
||||
<ReportRow class="lg:grid-cols-2">
|
||||
<ReportBox
|
||||
empty={income.length === 0}
|
||||
id="income"
|
||||
loading={!flows}
|
||||
title="Revenue / income"
|
||||
>
|
||||
<SumTable
|
||||
box="income"
|
||||
columns={[
|
||||
{ kind: "in", label: "Total" },
|
||||
{ kind: "avg-in", label: "Average" },
|
||||
]}
|
||||
counts
|
||||
dim="other"
|
||||
dir={1}
|
||||
footer={total(income)}
|
||||
limit={10}
|
||||
rows={group(income, keys.other)}
|
||||
sort="in"
|
||||
/>
|
||||
</ReportBox>
|
||||
<ReportBox
|
||||
empty={expenses.length === 0}
|
||||
id="expenses"
|
||||
loading={!flows}
|
||||
title="Expenses"
|
||||
>
|
||||
<SumTable
|
||||
box="expenses"
|
||||
columns={[
|
||||
{ kind: "out", label: "Total" },
|
||||
{ kind: "avg-out", label: "Average" },
|
||||
]}
|
||||
counts
|
||||
dim="other"
|
||||
dir={-1}
|
||||
footer={total(expenses)}
|
||||
limit={10}
|
||||
rows={group(expenses, keys.other)}
|
||||
sort="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
</ReportRow>
|
||||
|
||||
{#if month}
|
||||
<ReportRow class="lg:grid-cols-12">
|
||||
<BudgetLimits class="lg:col-span-7" />
|
||||
<ReportBox
|
||||
class="lg:col-span-5"
|
||||
empty={(flows?.length ?? 0) === 0}
|
||||
id="categories"
|
||||
loading={!flows}
|
||||
title="Categories"
|
||||
>
|
||||
<SumTable
|
||||
box="categories"
|
||||
columns={[
|
||||
{ kind: "out", label: "Spent" },
|
||||
{ kind: "in", label: "Earned" },
|
||||
{ kind: "net", label: "Sum" },
|
||||
]}
|
||||
dim="category"
|
||||
footer={report.totals}
|
||||
limit={10}
|
||||
nameLabel="Category"
|
||||
rows={group(flows ?? [], keys.category)}
|
||||
/>
|
||||
</ReportBox>
|
||||
</ReportRow>
|
||||
|
||||
<ReportBox
|
||||
empty={accountCols.length === 0}
|
||||
id="budget-accounts"
|
||||
loading={!flows}
|
||||
title="Budgets (split by account)"
|
||||
>
|
||||
<Matrix
|
||||
box="budget-accounts"
|
||||
colIs="asset"
|
||||
cols={accountCols}
|
||||
dim="budget"
|
||||
dir={-1}
|
||||
multi
|
||||
nameLabel="Budget"
|
||||
rows={group(withdrawals, keys.budget, (f) => f.asset.id)}
|
||||
side="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
|
||||
<ReportRow class="lg:grid-cols-2">
|
||||
<ReportBox
|
||||
empty={(flows?.length ?? 0) === 0}
|
||||
id="tags"
|
||||
loading={!flows}
|
||||
title="Tags"
|
||||
>
|
||||
<SumTable
|
||||
box="tags"
|
||||
columns={[
|
||||
{ kind: "out", label: "Spent" },
|
||||
{ kind: "in", label: "Earned" },
|
||||
]}
|
||||
dim="tag"
|
||||
footer={report.totals}
|
||||
limit={10}
|
||||
nameLabel="Tag"
|
||||
rows={group(flows ?? [], keys.tag)}
|
||||
/>
|
||||
</ReportBox>
|
||||
<SpendingCalendar />
|
||||
</ReportRow>
|
||||
|
||||
<Subscriptions />
|
||||
{:else}
|
||||
<BalanceLines net />
|
||||
|
||||
<ReportBox
|
||||
empty={withdrawals.length === 0}
|
||||
id="budget-periods"
|
||||
loading={!flows}
|
||||
title="Budgets ({modeLabel(report.mode)})"
|
||||
>
|
||||
{#snippet tools()}
|
||||
<DeltaToggle box="budget-periods" />
|
||||
{/snippet}
|
||||
<Matrix
|
||||
average
|
||||
box="budget-periods"
|
||||
cols={periodCols}
|
||||
delta={report.delta === "budget-periods"}
|
||||
dim="budget"
|
||||
dir={-1}
|
||||
nameLabel="Budget"
|
||||
rows={group(withdrawals, keys.budget, (f) => bucket(f.date))}
|
||||
side="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
|
||||
<ReportBox
|
||||
empty={expenses.length === 0}
|
||||
id="category-expenses"
|
||||
loading={!flows}
|
||||
title="Categories (Expenses) ({modeLabel(report.mode)})"
|
||||
>
|
||||
{#snippet tools()}
|
||||
<DeltaToggle box="category-expenses" />
|
||||
{/snippet}
|
||||
<Matrix
|
||||
box="category-expenses"
|
||||
cols={periodCols}
|
||||
delta={report.delta === "category-expenses"}
|
||||
dim="category"
|
||||
dir={-1}
|
||||
nameLabel="Category"
|
||||
rows={group(withdrawals, keys.category, (f) => bucket(f.date))}
|
||||
side="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
|
||||
<ReportBox
|
||||
empty={deposits.length === 0}
|
||||
id="category-income"
|
||||
loading={!flows}
|
||||
title="Categories (Revenue / income) ({modeLabel(report.mode)})"
|
||||
>
|
||||
{#snippet tools()}
|
||||
<DeltaToggle box="category-income" />
|
||||
{/snippet}
|
||||
<Matrix
|
||||
box="category-income"
|
||||
cols={periodCols}
|
||||
delta={report.delta === "category-income"}
|
||||
dim="category"
|
||||
dir={1}
|
||||
nameLabel="Category"
|
||||
rows={group(deposits, keys.category, (f) => bucket(f.date))}
|
||||
side="in"
|
||||
/>
|
||||
</ReportBox>
|
||||
|
||||
<ReportBox
|
||||
empty={(flows?.length ?? 0) === 0}
|
||||
id="tag-periods"
|
||||
loading={!flows}
|
||||
title="Tags (Expenses) ({modeLabel(report.mode)})"
|
||||
>
|
||||
{#snippet tools()}
|
||||
<DeltaToggle box="tag-periods" />
|
||||
{/snippet}
|
||||
<Matrix
|
||||
box="tag-periods"
|
||||
cols={periodCols}
|
||||
delta={report.delta === "tag-periods"}
|
||||
dim="tag"
|
||||
dir={-1}
|
||||
nameLabel="Tag"
|
||||
rows={group(expenses, keys.tag, (f) => bucket(f.date))}
|
||||
side="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
|
||||
<SpendingCalendar />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import ArrowDown from "@lucide/svelte/icons/arrow-down";
|
||||
import ArrowUp from "@lucide/svelte/icons/arrow-up";
|
||||
import { percent } from "#lib/format.js";
|
||||
|
||||
let {
|
||||
now,
|
||||
before,
|
||||
upIsGood,
|
||||
title,
|
||||
}: {
|
||||
now: number;
|
||||
before: number | undefined;
|
||||
upIsGood: boolean;
|
||||
title?: string;
|
||||
} = $props();
|
||||
|
||||
const ratio = $derived(
|
||||
before ? (Math.abs(now) - Math.abs(before)) / Math.abs(before) : null
|
||||
);
|
||||
const up = $derived((ratio ?? 0) > 0);
|
||||
const tone = $derived(up === upIsGood ? "text-income" : "text-expense");
|
||||
</script>
|
||||
|
||||
{#if ratio !== null && Math.abs(ratio) >= 0.005}
|
||||
<span class="num inline-flex items-center gap-0.5 text-[11px] {tone}" {title}>
|
||||
{#if up}
|
||||
<ArrowUp aria-label="up" class="size-3" />
|
||||
{:else}
|
||||
<ArrowDown aria-label="down" class="size-3" />
|
||||
{/if}
|
||||
{percent(Math.abs(ratio))}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { navigate, useReport } from "#lib/reports/report.svelte.js";
|
||||
|
||||
let { box }: { box: string } = $props();
|
||||
|
||||
const report = useReport();
|
||||
const on = $derived(report.delta === box);
|
||||
</script>
|
||||
|
||||
<button
|
||||
aria-pressed={on}
|
||||
class="h-6 rounded-cladd-sm px-2 text-[12px] {on
|
||||
? "cladd-cut font-semibold text-cladd-fg"
|
||||
: "text-cladd-fg-soft hover:text-cladd-fg"}"
|
||||
onclick={() => navigate({ delta: on ? null : box })}
|
||||
title={on ? "Show amounts" : "Show change against the previous period"}
|
||||
type="button"
|
||||
>
|
||||
{on ? "Amounts" : "Change"}
|
||||
</button>
|
||||
@@ -0,0 +1,495 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
bucketOf,
|
||||
buckets,
|
||||
type Dimension,
|
||||
type Flow,
|
||||
group,
|
||||
type Key,
|
||||
keys,
|
||||
topFlows,
|
||||
total,
|
||||
} from "#lib/reports/flows.js";
|
||||
import {
|
||||
LIST_LENGTH,
|
||||
modeLabel,
|
||||
useReport,
|
||||
} from "#lib/reports/report.svelte.js";
|
||||
import { bucketLabel } from "#lib/reports/view.js";
|
||||
import DeltaToggle from "./DeltaToggle.svelte";
|
||||
import FlowTable from "./FlowTable.svelte";
|
||||
import Matrix from "./Matrix.svelte";
|
||||
import PeriodChart from "./PeriodChart.svelte";
|
||||
import ReportBox from "./ReportBox.svelte";
|
||||
import ReportRow from "./ReportRow.svelte";
|
||||
import ShareBars from "./ShareBars.svelte";
|
||||
import SumTable from "./SumTable.svelte";
|
||||
|
||||
const report = useReport();
|
||||
const type = $derived(report.params.type);
|
||||
const flows = $derived(report.scoped);
|
||||
const loading = $derived(!flows);
|
||||
const out = $derived(flows?.filter((f) => f.dir === -1) ?? []);
|
||||
const inn = $derived(flows?.filter((f) => f.dir === 1) ?? []);
|
||||
const hasIncome = $derived(type !== "budget" && inn.length > 0);
|
||||
|
||||
const tagNames = $derived(
|
||||
new Set(
|
||||
report.items
|
||||
.map((id) => report.itemName("tag", id))
|
||||
.filter((name): name is string => !!name)
|
||||
)
|
||||
);
|
||||
|
||||
const itemDim = $derived<Dimension>(
|
||||
(
|
||||
{
|
||||
budget: "budget",
|
||||
category: "category",
|
||||
double: "name",
|
||||
tag: "tag",
|
||||
} as const
|
||||
)[type as "budget" | "category" | "double" | "tag"]
|
||||
);
|
||||
const itemKey = $derived.by(() => {
|
||||
if (type === "tag") {
|
||||
return (flow: Flow) => keys.tag(flow).filter((k) => tagNames.has(k.key));
|
||||
}
|
||||
return keys[itemDim] as (flow: Flow) => Key | Key[] | null;
|
||||
});
|
||||
const NAMES = {
|
||||
budget: { many: "Budgets", one: "Budget" },
|
||||
category: { many: "Categories", one: "Category" },
|
||||
double: { many: "Expense/revenue accounts", one: "Account" },
|
||||
tag: { many: "Tags", one: "Tag" },
|
||||
} as const;
|
||||
const names = $derived(NAMES[type as keyof typeof NAMES]);
|
||||
|
||||
const items = $derived.by(() => {
|
||||
if (type === "double") {
|
||||
const seen = new Map<string, string>();
|
||||
for (const flow of flows ?? []) {
|
||||
const key = keys.name(flow);
|
||||
seen.set(key.key, key.label);
|
||||
}
|
||||
return [...seen.entries()].map(([key, label]) => ({ key, label }));
|
||||
}
|
||||
return report.items.map((id) => {
|
||||
const kind = type === "tag" ? "tag" : type;
|
||||
const label = report.itemName(kind, id) ?? `#${id}`;
|
||||
return { key: type === "tag" ? label : id, label };
|
||||
});
|
||||
});
|
||||
|
||||
const itemRows = $derived(group(flows ?? [], itemKey));
|
||||
const accountRows = $derived(group(flows ?? [], keys.asset));
|
||||
const accountMatrix = $derived(
|
||||
group(flows ?? [], keys.asset, (f) => {
|
||||
const found = itemKey(f);
|
||||
const first = Array.isArray(found) ? found[0] : found;
|
||||
return first?.key ?? null;
|
||||
})
|
||||
);
|
||||
const periodCols = $derived(
|
||||
buckets(report.params.start, report.params.end, report.grain).map(
|
||||
(key) => ({
|
||||
key,
|
||||
label: bucketLabel(key, report.grain, true),
|
||||
title: bucketLabel(key, report.grain),
|
||||
})
|
||||
)
|
||||
);
|
||||
const top = $derived((dir: 1 | -1) =>
|
||||
topFlows(flows ?? [], report.mode, dir, LIST_LENGTH)
|
||||
);
|
||||
const sumColumns = $derived(
|
||||
hasIncome
|
||||
? [
|
||||
{ kind: "out" as const, label: "Spent" },
|
||||
{ kind: "in" as const, label: "Earned" },
|
||||
{ kind: "net" as const, label: "Sum" },
|
||||
]
|
||||
: [{ kind: "out" as const, label: "Spent" }]
|
||||
);
|
||||
const itemColumns = $derived(
|
||||
hasIncome
|
||||
? sumColumns
|
||||
: [
|
||||
{ kind: "out" as const, label: "Spent" },
|
||||
{ kind: "pct-out" as const, label: "pct." },
|
||||
]
|
||||
);
|
||||
const flowColumns = $derived(
|
||||
type === "double"
|
||||
? ["date", "description", "account", "other"]
|
||||
: [
|
||||
"date",
|
||||
"description",
|
||||
"account",
|
||||
"other",
|
||||
type === "budget" ? "budget" : "category",
|
||||
]
|
||||
);
|
||||
const mode = $derived(modeLabel(report.mode));
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<ReportRow class="lg:grid-cols-2">
|
||||
<ReportBox
|
||||
empty={accountRows.length === 0}
|
||||
id="accounts"
|
||||
{loading}
|
||||
title={type === "double"
|
||||
? "Earned and spent (per asset account)"
|
||||
: "Accounts"}
|
||||
>
|
||||
<SumTable
|
||||
box="accounts"
|
||||
columns={sumColumns}
|
||||
dim="asset"
|
||||
footer={report.totals}
|
||||
rows={accountRows}
|
||||
/>
|
||||
</ReportBox>
|
||||
<ReportBox
|
||||
empty={itemRows.length === 0}
|
||||
id="items"
|
||||
{loading}
|
||||
title={type === "double"
|
||||
? "Earned and spent per combination"
|
||||
: names.many}
|
||||
>
|
||||
<SumTable
|
||||
box="items"
|
||||
columns={itemColumns}
|
||||
dim={itemDim}
|
||||
footer={report.totals}
|
||||
nameLabel={names.one}
|
||||
rows={itemRows}
|
||||
/>
|
||||
</ReportBox>
|
||||
</ReportRow>
|
||||
|
||||
{#if type !== "double"}
|
||||
<ReportBox
|
||||
empty={accountMatrix.length === 0}
|
||||
id="account-matrix"
|
||||
{loading}
|
||||
title="Account per {names.one.toLowerCase()}"
|
||||
>
|
||||
<Matrix
|
||||
box="account-matrix"
|
||||
colIs="item"
|
||||
cols={items}
|
||||
dim="asset"
|
||||
{itemDim}
|
||||
multi
|
||||
nameLabel="Account"
|
||||
rows={accountMatrix}
|
||||
side={hasIncome ? "net" : "out"}
|
||||
/>
|
||||
</ReportBox>
|
||||
{/if}
|
||||
|
||||
<ReportBox
|
||||
empty={itemRows.length === 0}
|
||||
id="item-periods"
|
||||
{loading}
|
||||
title="{names.many} per period ({mode})"
|
||||
>
|
||||
{#snippet tools()}
|
||||
<DeltaToggle box="item-periods" />
|
||||
{/snippet}
|
||||
<Matrix
|
||||
average
|
||||
box="item-periods"
|
||||
cols={periodCols}
|
||||
delta={report.delta === "item-periods"}
|
||||
dim={itemDim}
|
||||
dir={-1}
|
||||
nameLabel={names.one}
|
||||
rows={group(out, itemKey, (f) => bucketOf(f.date, report.grain))}
|
||||
side="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
|
||||
<ReportRow class={type === "budget" ? "lg:grid-cols-2" : "lg:grid-cols-3"}>
|
||||
{#if type === "tag"}
|
||||
<ReportBox
|
||||
empty={out.length === 0}
|
||||
id="share-item-out"
|
||||
{loading}
|
||||
title="Expense per tag ({mode})"
|
||||
>
|
||||
<ShareBars
|
||||
box="share-item-out"
|
||||
dim="tag"
|
||||
rows={group(out, itemKey)}
|
||||
side="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
<ReportBox
|
||||
empty={inn.length === 0}
|
||||
id="share-item-in"
|
||||
{loading}
|
||||
title="Income per tag ({mode})"
|
||||
>
|
||||
<ShareBars
|
||||
box="share-item-in"
|
||||
dim="tag"
|
||||
rows={group(inn, itemKey)}
|
||||
side="in"
|
||||
/>
|
||||
</ReportBox>
|
||||
<div class="max-lg:hidden"></div>
|
||||
{/if}
|
||||
{#if type === "budget"}
|
||||
<ReportBox
|
||||
empty={out.length === 0}
|
||||
id="share-budget"
|
||||
{loading}
|
||||
title="Expense per budget ({mode})"
|
||||
>
|
||||
<ShareBars
|
||||
box="share-budget"
|
||||
dim="budget"
|
||||
rows={group(out, keys.budget)}
|
||||
side="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
{/if}
|
||||
<ReportBox
|
||||
empty={out.length === 0}
|
||||
id="share-category-out"
|
||||
{loading}
|
||||
title="Expense per category ({mode})"
|
||||
>
|
||||
<ShareBars
|
||||
box="share-category-out"
|
||||
dim="category"
|
||||
rows={group(out, keys.category)}
|
||||
side="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
{#if hasIncome}
|
||||
<ReportBox
|
||||
empty={inn.length === 0}
|
||||
id="share-category-in"
|
||||
{loading}
|
||||
title="Income per category ({mode})"
|
||||
>
|
||||
<ShareBars
|
||||
box="share-category-in"
|
||||
dim="category"
|
||||
rows={group(inn, keys.category)}
|
||||
side="in"
|
||||
/>
|
||||
</ReportBox>
|
||||
<ReportBox
|
||||
empty={out.length === 0}
|
||||
id="share-budget"
|
||||
{loading}
|
||||
title="Expense per budget ({mode})"
|
||||
>
|
||||
<ShareBars
|
||||
box="share-budget"
|
||||
dim="budget"
|
||||
rows={group(out, keys.budget)}
|
||||
side="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
{/if}
|
||||
</ReportRow>
|
||||
|
||||
{#if type === "double"}
|
||||
<ReportRow class="lg:grid-cols-2">
|
||||
<ReportBox
|
||||
empty={out.length === 0}
|
||||
id="share-tag-out"
|
||||
{loading}
|
||||
title="Expense per tag ({mode})"
|
||||
>
|
||||
<ShareBars
|
||||
box="share-tag-out"
|
||||
dim="tag"
|
||||
rows={group(out, keys.tag)}
|
||||
side="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
<ReportBox
|
||||
empty={inn.length === 0}
|
||||
id="share-tag-in"
|
||||
{loading}
|
||||
title="Income per tag ({mode})"
|
||||
>
|
||||
<ShareBars
|
||||
box="share-tag-in"
|
||||
dim="tag"
|
||||
rows={group(inn, keys.tag)}
|
||||
side="in"
|
||||
/>
|
||||
</ReportBox>
|
||||
</ReportRow>
|
||||
{:else}
|
||||
<ReportRow class="lg:grid-cols-2">
|
||||
<ReportBox
|
||||
empty={out.length === 0}
|
||||
id="share-source"
|
||||
{loading}
|
||||
title="Expenses per source account ({mode})"
|
||||
>
|
||||
<ShareBars
|
||||
box="share-source"
|
||||
dim="asset"
|
||||
rows={group(out, keys.asset)}
|
||||
side="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
{#if hasIncome}
|
||||
<ReportBox
|
||||
empty={inn.length === 0}
|
||||
id="share-income-source"
|
||||
{loading}
|
||||
title="Income per source account ({mode})"
|
||||
>
|
||||
<ShareBars
|
||||
box="share-income-source"
|
||||
dim="other"
|
||||
rows={group(inn, keys.other)}
|
||||
side="in"
|
||||
/>
|
||||
</ReportBox>
|
||||
{/if}
|
||||
<ReportBox
|
||||
empty={out.length === 0}
|
||||
id="share-destination"
|
||||
{loading}
|
||||
title="Expenses per destination account ({mode})"
|
||||
>
|
||||
<ShareBars
|
||||
box="share-destination"
|
||||
dim="other"
|
||||
rows={group(out, keys.other)}
|
||||
side="out"
|
||||
/>
|
||||
</ReportBox>
|
||||
{#if hasIncome}
|
||||
<ReportBox
|
||||
empty={inn.length === 0}
|
||||
id="share-income-destination"
|
||||
{loading}
|
||||
title="Income per destination account ({mode})"
|
||||
>
|
||||
<ShareBars
|
||||
box="share-income-destination"
|
||||
dim="asset"
|
||||
rows={group(inn, keys.asset)}
|
||||
side="in"
|
||||
/>
|
||||
</ReportBox>
|
||||
{/if}
|
||||
</ReportRow>
|
||||
{/if}
|
||||
|
||||
{#each items as item (item.key)}
|
||||
{@const subset = flows?.filter((f) => {
|
||||
const found = itemKey(f);
|
||||
return Array.isArray(found)
|
||||
? found.some((k) => k.key === item.key)
|
||||
: found?.key === item.key;
|
||||
})}
|
||||
{#if !subset || subset.length > 0}
|
||||
<PeriodChart
|
||||
dim={itemDim}
|
||||
flows={subset}
|
||||
id="item-{item.key}"
|
||||
itemKey={item.key}
|
||||
title={type === "budget"
|
||||
? `Expenses (${item.label}),`
|
||||
: `Income and expenses (${item.label}),`}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<ReportRow class="lg:grid-cols-2">
|
||||
<ReportBox
|
||||
empty={out.length === 0}
|
||||
id="avg-out"
|
||||
{loading}
|
||||
title={type === "double"
|
||||
? "Average expense per source account"
|
||||
: "Average expense per destination account"}
|
||||
>
|
||||
<SumTable
|
||||
averageAll
|
||||
box="avg-out"
|
||||
columns={[
|
||||
{ kind: "avg-out", label: "Spent (average)" },
|
||||
{ kind: "out", label: "Total" },
|
||||
{ kind: "count", label: "Transaction count" },
|
||||
]}
|
||||
dim={type === "double" ? "asset" : "other"}
|
||||
dir={-1}
|
||||
limit={LIST_LENGTH}
|
||||
nameLabel="Account"
|
||||
rows={group(out, type === "double" ? keys.asset : keys.other)}
|
||||
/>
|
||||
</ReportBox>
|
||||
{#if hasIncome}
|
||||
<ReportBox
|
||||
empty={inn.length === 0}
|
||||
id="avg-in"
|
||||
{loading}
|
||||
title={type === "double"
|
||||
? "Average earning per destination account"
|
||||
: "Average earning per source account"}
|
||||
>
|
||||
<SumTable
|
||||
averageAll
|
||||
box="avg-in"
|
||||
columns={[
|
||||
{ kind: "avg-in", label: "Income (average)" },
|
||||
{ kind: "in", label: "Total" },
|
||||
{ kind: "count", label: "Transaction count" },
|
||||
]}
|
||||
dim={type === "double" ? "asset" : "other"}
|
||||
dir={1}
|
||||
limit={LIST_LENGTH}
|
||||
nameLabel="Account"
|
||||
rows={group(inn, type === "double" ? keys.asset : keys.other)}
|
||||
sort="in"
|
||||
/>
|
||||
</ReportBox>
|
||||
{:else}
|
||||
<ReportBox
|
||||
empty={top(-1).length === 0}
|
||||
id="top-out"
|
||||
{loading}
|
||||
title="Expenses (top {LIST_LENGTH}, {mode})"
|
||||
>
|
||||
<FlowTable columns={flowColumns} flows={top(-1)} />
|
||||
</ReportBox>
|
||||
{/if}
|
||||
</ReportRow>
|
||||
|
||||
{#if hasIncome}
|
||||
<ReportRow class="lg:grid-cols-2">
|
||||
<ReportBox
|
||||
empty={top(-1).length === 0}
|
||||
id="top-out"
|
||||
{loading}
|
||||
title="Expenses (top {LIST_LENGTH}, {mode})"
|
||||
>
|
||||
<FlowTable columns={flowColumns} flows={top(-1)} />
|
||||
</ReportBox>
|
||||
<ReportBox
|
||||
empty={top(1).length === 0}
|
||||
id="top-in"
|
||||
{loading}
|
||||
title="Revenue / income (top {LIST_LENGTH}, {mode})"
|
||||
>
|
||||
<FlowTable columns={flowColumns} flows={top(1)} />
|
||||
</ReportBox>
|
||||
</ReportRow>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import X from "@lucide/svelte/icons/x";
|
||||
import Columns from "#lib/components/charts/Columns.svelte";
|
||||
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
|
||||
import { useKeys } from "#lib/keys.svelte.js";
|
||||
import { bucketOf, buckets, total } from "#lib/reports/flows.js";
|
||||
import {
|
||||
type Drill,
|
||||
drillFilter,
|
||||
drillQuery,
|
||||
navigate,
|
||||
useReport,
|
||||
} from "#lib/reports/report.svelte.js";
|
||||
import {
|
||||
bucketLabel,
|
||||
bucketRows,
|
||||
FLOW_KEYS,
|
||||
modeAxis,
|
||||
modeFormat,
|
||||
upright,
|
||||
} from "#lib/reports/view.js";
|
||||
import Money from "./Money.svelte";
|
||||
|
||||
let { drill }: { drill: Drill } = $props();
|
||||
|
||||
const report = useReport();
|
||||
|
||||
const subset = $derived(
|
||||
(report.scoped ?? [])
|
||||
.filter(drillFilter(drill, (date) => bucketOf(date, report.grain)))
|
||||
.sort((a, b) => b.date.localeCompare(a.date) || b.amount - a.amount)
|
||||
);
|
||||
const totals = $derived(total(subset));
|
||||
const groups = $derived.by(() => {
|
||||
const ids = new Set(subset.map((flow) => flow.group));
|
||||
return (report.groups ?? []).filter((group) => ids.has(group.id));
|
||||
});
|
||||
const showChart = $derived(
|
||||
!drill.at &&
|
||||
drill.dim !== "day" &&
|
||||
drill.dim !== "flow" &&
|
||||
report.grain !== "day" &&
|
||||
subset.length > 0
|
||||
);
|
||||
const rows = $derived(
|
||||
showChart
|
||||
? upright(
|
||||
bucketRows(
|
||||
subset,
|
||||
buckets(report.params.start, report.params.end, report.grain),
|
||||
report.grain,
|
||||
report.mode
|
||||
)
|
||||
)
|
||||
: []
|
||||
);
|
||||
const keys = $derived(
|
||||
FLOW_KEYS.filter((key) =>
|
||||
rows.some((row) => (row.values[key.key] ?? 0) !== 0)
|
||||
)
|
||||
);
|
||||
const title = $derived(
|
||||
[drill.label, drill.at ? bucketLabel(drill.at, report.grain) : ""]
|
||||
.filter(Boolean)
|
||||
.join(", ")
|
||||
);
|
||||
|
||||
function close() {
|
||||
navigate(drillQuery(null));
|
||||
}
|
||||
|
||||
useKeys(() => [
|
||||
{
|
||||
group: "Report",
|
||||
keys: "Escape",
|
||||
label: "Close transactions list",
|
||||
run: close,
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<section
|
||||
aria-label="Transactions for {title}"
|
||||
class="border-cladd-outline border-t bg-cladd-surface-next/50"
|
||||
>
|
||||
<header class="flex flex-wrap items-start gap-x-4 gap-y-1 px-3 pt-2.5 pb-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="truncate font-semibold text-[13px]">{title}</h3>
|
||||
<p class="text-[12px] text-cladd-fg-soft">
|
||||
{subset.length}
|
||||
{subset.length === 1 ? "transaction" : "transactions"}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-4 text-right text-[12px]">
|
||||
{#each [
|
||||
{ label: "In", side: "in" as const },
|
||||
{ label: "Out", side: "out" as const },
|
||||
] as column (column.side)}
|
||||
{#if [...totals.cur.values()].some((m) => m[column.side] > 0)}
|
||||
<div>
|
||||
<div class="text-cladd-fg-soft">{column.label}</div>
|
||||
<Money codes={report.codes} side={column.side} {totals} />
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
aria-label="Close"
|
||||
class="grid size-7 place-items-center rounded-cladd-sm text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg"
|
||||
onclick={close}
|
||||
title="Close Esc"
|
||||
type="button"
|
||||
>
|
||||
<X class="size-4" />
|
||||
</button>
|
||||
</header>
|
||||
{#if showChart && keys.length > 0}
|
||||
<div class="px-3 pb-2">
|
||||
<Columns
|
||||
axisFormat={modeAxis(report.mode)}
|
||||
format={modeFormat(report.mode)}
|
||||
height={180}
|
||||
{keys}
|
||||
label="{title} per period"
|
||||
onselect={(row) => navigate(drillQuery({ ...drill, at: row.key }))}
|
||||
{rows}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if subset.length > 0}
|
||||
<TransactionTable {groups} id="drill" />
|
||||
{:else}
|
||||
<p class="px-3 pb-3 text-cladd-fg-soft">No transactions match.</p>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { formatDate } from "#lib/format.js";
|
||||
import type { Flow } from "#lib/reports/flows.js";
|
||||
import { money } from "#lib/reports/report.svelte.js";
|
||||
|
||||
let {
|
||||
flows,
|
||||
columns = ["date", "description", "account", "other", "category", "budget"],
|
||||
}: {
|
||||
flows: Flow[];
|
||||
columns?: string[];
|
||||
} = $props();
|
||||
|
||||
const has = (name: string) => columns.includes(name);
|
||||
</script>
|
||||
|
||||
<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 has("date")}
|
||||
<th class="h-7 px-3 font-medium">Date</th>
|
||||
{/if}
|
||||
<th class="h-7 px-3 font-medium">Description</th>
|
||||
{#if has("account")}
|
||||
<th class="h-7 px-3 font-medium max-md:hidden">Account</th>
|
||||
{/if}
|
||||
{#if has("other")}
|
||||
<th class="h-7 px-3 font-medium max-md:hidden">Counterparty</th>
|
||||
{/if}
|
||||
{#if has("category")}
|
||||
<th class="h-7 px-3 font-medium max-lg:hidden">Category</th>
|
||||
{/if}
|
||||
{#if has("budget")}
|
||||
<th class="h-7 px-3 font-medium max-lg:hidden">Budget</th>
|
||||
{/if}
|
||||
<th class="h-7 px-3 text-right font-medium">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each flows as flow (`${flow.journal}:${flow.dir}`)}
|
||||
<tr class="h-7 border-cladd-outline border-t hover:bg-row-hover">
|
||||
{#if has("date")}
|
||||
<td class="num whitespace-nowrap px-3 text-cladd-fg-soft">
|
||||
{formatDate(flow.date, "short")}
|
||||
</td>
|
||||
{/if}
|
||||
<td class="max-w-[22rem] truncate px-3">
|
||||
<a class="hover:underline" href="/transactions/show/{flow.group}"
|
||||
>{flow.description}</a
|
||||
>
|
||||
</td>
|
||||
{#if has("account")}
|
||||
<td
|
||||
class="max-w-[12rem] truncate px-3 text-cladd-fg-soft max-md:hidden"
|
||||
>
|
||||
{flow.asset.name}
|
||||
</td>
|
||||
{/if}
|
||||
{#if has("other")}
|
||||
<td class="max-w-[14rem] truncate px-3 max-md:hidden">
|
||||
<a class="hover:underline" href="/accounts/show/{flow.other.id}"
|
||||
>{flow.other.name}</a
|
||||
>
|
||||
</td>
|
||||
{/if}
|
||||
{#if has("category")}
|
||||
<td
|
||||
class="max-w-[10rem] truncate px-3 text-cladd-fg-soft max-lg:hidden"
|
||||
>
|
||||
{flow.category?.name ?? ""}
|
||||
</td>
|
||||
{/if}
|
||||
{#if has("budget")}
|
||||
<td
|
||||
class="max-w-[10rem] truncate px-3 text-cladd-fg-soft max-lg:hidden"
|
||||
>
|
||||
{flow.budget?.name ?? ""}
|
||||
</td>
|
||||
{/if}
|
||||
<td
|
||||
class="num whitespace-nowrap px-3 text-right {flow.dir === 1
|
||||
? "text-income"
|
||||
: "text-expense"}"
|
||||
>
|
||||
{money(flow.currency, flow.dir * flow.amount)}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { formatRange } from "#lib/format.js";
|
||||
import {
|
||||
currencies,
|
||||
measureOf,
|
||||
type Totals,
|
||||
toFlows,
|
||||
total,
|
||||
} from "#lib/reports/flows.js";
|
||||
import { shiftRange } from "#lib/reports/params.js";
|
||||
import { reportQueries } from "#lib/reports/queries.js";
|
||||
import {
|
||||
drillQuery,
|
||||
modeLabel,
|
||||
money,
|
||||
navigate,
|
||||
useReport,
|
||||
} from "#lib/reports/report.svelte.js";
|
||||
import Delta from "./Delta.svelte";
|
||||
import ReportBox from "./ReportBox.svelte";
|
||||
|
||||
const report = useReport();
|
||||
const previous = $derived(shiftRange(report.params, -1));
|
||||
const earlier = createQuery(() =>
|
||||
reportQueries.transactions(previous.start, previous.end)
|
||||
);
|
||||
const before = $derived(
|
||||
earlier.data && report.accountsReady
|
||||
? total(
|
||||
toFlows(
|
||||
earlier.data,
|
||||
new Set(report.selected),
|
||||
(id) => report.byId().get(id)?.currency_code
|
||||
)
|
||||
)
|
||||
: undefined
|
||||
);
|
||||
const was = (code: string) =>
|
||||
before && (code !== "pc" || before.pcOk)
|
||||
? measureOf(before, code)
|
||||
: undefined;
|
||||
|
||||
const rows = $derived.by(() => {
|
||||
const totals: Totals | undefined = report.totals;
|
||||
if (!totals) {
|
||||
return [];
|
||||
}
|
||||
const list = currencies(totals, report.codes).map((code) => {
|
||||
const m = totals.cur.get(code) ?? { in: 0, n: 0, out: 0 };
|
||||
return { code, in: m.in, label: code, out: m.out };
|
||||
});
|
||||
if (totals.pcOk && list.length > 1) {
|
||||
list.push({
|
||||
code: "pc",
|
||||
in: totals.pc.in,
|
||||
label: modeLabel("pc"),
|
||||
out: totals.pc.out,
|
||||
});
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
function open(dir: 1 | -1) {
|
||||
const label = dir === 1 ? "Money in" : "Money out";
|
||||
const current = report.drill;
|
||||
const same = current?.box === "operations" && current.dir === dir;
|
||||
navigate(drillQuery(same ? null : { box: "operations", dir, label }));
|
||||
}
|
||||
</script>
|
||||
|
||||
<ReportBox
|
||||
empty={rows.length === 0}
|
||||
id="operations"
|
||||
loading={!report.totals}
|
||||
title="Income vs. expenses"
|
||||
>
|
||||
{#snippet tools()}
|
||||
{#if before && before.n > 0}
|
||||
<span class="text-[12px] text-cladd-fg-soft"
|
||||
>Change against {formatRange(previous.start, previous.end)}</span
|
||||
>
|
||||
{/if}
|
||||
{/snippet}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr class="text-[12px] text-cladd-fg-soft">
|
||||
<th class="h-7 px-3 text-left font-medium">Currency</th>
|
||||
<th class="h-7 px-3 text-right font-medium">In</th>
|
||||
<th class="h-7 px-3 text-right font-medium">Out</th>
|
||||
<th class="h-7 px-3 text-right font-medium">Difference</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row (row.code)}
|
||||
<tr
|
||||
class="h-7 border-cladd-outline border-t hover:bg-row-hover {row.code ===
|
||||
"pc"
|
||||
? "font-semibold"
|
||||
: ""}"
|
||||
>
|
||||
<td class="px-3">{row.label}</td>
|
||||
<td class="p-0 text-right">
|
||||
<button
|
||||
class="num h-7 w-full px-3 text-right text-income hover:bg-row-cursor"
|
||||
onclick={() => open(1)}
|
||||
title="Show money in"
|
||||
type="button"
|
||||
>
|
||||
<Delta
|
||||
before={was(row.code)?.in}
|
||||
now={row.in}
|
||||
title="Previous period: {money(
|
||||
row.code,
|
||||
was(row.code)?.in ?? 0
|
||||
)}"
|
||||
upIsGood
|
||||
/>
|
||||
{money(row.code, row.in)}
|
||||
</button>
|
||||
</td>
|
||||
<td class="p-0 text-right">
|
||||
<button
|
||||
class="num h-7 w-full px-3 text-right text-expense hover:bg-row-cursor"
|
||||
onclick={() => open(-1)}
|
||||
title="Show money out"
|
||||
type="button"
|
||||
>
|
||||
<Delta
|
||||
before={was(row.code)?.out}
|
||||
now={row.out}
|
||||
title="Previous period: {money(
|
||||
row.code,
|
||||
-(was(row.code)?.out ?? 0)
|
||||
)}"
|
||||
upIsGood={false}
|
||||
/>
|
||||
{money(row.code, -row.out)}
|
||||
</button>
|
||||
</td>
|
||||
<td
|
||||
class="num px-3 text-right {row.in - row.out < 0
|
||||
? "text-expense"
|
||||
: "text-income"}"
|
||||
>
|
||||
{money(row.code, row.in - row.out, true)}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ReportBox>
|
||||
@@ -0,0 +1,312 @@
|
||||
<script lang="ts">
|
||||
import ArrowDown from "@lucide/svelte/icons/arrow-down";
|
||||
import ArrowUp from "@lucide/svelte/icons/arrow-up";
|
||||
import { formatNumber, percent } from "#lib/format.js";
|
||||
import {
|
||||
amountOf,
|
||||
type Dimension,
|
||||
merge,
|
||||
type Row,
|
||||
rank,
|
||||
type Side,
|
||||
} from "#lib/reports/flows.js";
|
||||
import {
|
||||
type Drill,
|
||||
drillQuery,
|
||||
money,
|
||||
navigate,
|
||||
useReport,
|
||||
} from "#lib/reports/report.svelte.js";
|
||||
import Money from "./Money.svelte";
|
||||
|
||||
interface Col {
|
||||
key: string;
|
||||
label: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
box,
|
||||
rows,
|
||||
cols,
|
||||
side,
|
||||
dim,
|
||||
dir,
|
||||
colIs = "at",
|
||||
itemDim,
|
||||
delta = false,
|
||||
average = false,
|
||||
nameLabel = "Name",
|
||||
multi = false,
|
||||
}: {
|
||||
box: string;
|
||||
rows: Row[];
|
||||
cols: Col[];
|
||||
side: Side;
|
||||
dim: Dimension;
|
||||
dir?: 1 | -1;
|
||||
colIs?: "at" | "asset" | "item";
|
||||
itemDim?: Dimension;
|
||||
delta?: boolean;
|
||||
average?: boolean;
|
||||
nameLabel?: string;
|
||||
multi?: boolean;
|
||||
} = $props();
|
||||
|
||||
const report = useReport();
|
||||
const mode = $derived(report.mode);
|
||||
|
||||
const sorted = $derived(
|
||||
multi
|
||||
? rank(rows, mode, side)
|
||||
: rank(rows, mode, side).filter(
|
||||
(row) => amountOf(row.totals, mode, side) !== 0
|
||||
)
|
||||
);
|
||||
const colMerged = $derived(
|
||||
multi
|
||||
? cols.map((col) => merge(sorted.map((row) => row.cells.get(col.key))))
|
||||
: []
|
||||
);
|
||||
const sign = $derived(side === "out" ? -1 : 1);
|
||||
const compact = $derived(cols.length > 6);
|
||||
const short = (value: number) =>
|
||||
compact ? formatNumber(value, 0) : money(mode, value);
|
||||
const colTotals = $derived(
|
||||
cols.map((col) =>
|
||||
sorted.reduce(
|
||||
(sum, row) => sum + amountOf(row.cells.get(col.key), mode, side),
|
||||
0
|
||||
)
|
||||
)
|
||||
);
|
||||
const grand = $derived(colTotals.reduce((a, b) => a + b, 0));
|
||||
|
||||
function cellDrill(row: Row | null, col: Col | null): Drill {
|
||||
const label = [row?.label, col?.title ?? col?.label]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
if (colIs === "item") {
|
||||
return {
|
||||
asset: row && col ? row.key : undefined,
|
||||
box,
|
||||
dim: col ? itemDim : dim,
|
||||
dir,
|
||||
key: col ? col.key : row?.key,
|
||||
label,
|
||||
};
|
||||
}
|
||||
return {
|
||||
asset: colIs === "asset" ? col?.key : undefined,
|
||||
at: colIs === "at" ? col?.key : undefined,
|
||||
box,
|
||||
dim: row ? dim : undefined,
|
||||
dir,
|
||||
key: row?.key,
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
function isActive(row: Row | null, col: Col | null) {
|
||||
const current = report.drill;
|
||||
if (current?.box !== box) {
|
||||
return false;
|
||||
}
|
||||
const next = cellDrill(row, col);
|
||||
return (
|
||||
current.key === next.key &&
|
||||
current.at === next.at &&
|
||||
current.asset === next.asset
|
||||
);
|
||||
}
|
||||
|
||||
function open(row: Row | null, col: Col | null) {
|
||||
navigate(drillQuery(isActive(row, col) ? null : cellDrill(row, col)));
|
||||
}
|
||||
|
||||
function change(row: Row, index: number) {
|
||||
if (index === 0) {
|
||||
return null;
|
||||
}
|
||||
const now = amountOf(row.cells.get(cols[index].key), mode, side);
|
||||
const before = amountOf(row.cells.get(cols[index - 1].key), mode, side);
|
||||
if (!(before || now)) {
|
||||
return null;
|
||||
}
|
||||
const ratio = before ? (now - before) / Math.abs(before) : null;
|
||||
const up = now > before;
|
||||
const good = side === "out" ? !up : up;
|
||||
return { good, ratio, up };
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-px mb-px overflow-x-auto">
|
||||
<table class="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr class="text-[12px] text-cladd-fg-soft">
|
||||
<th
|
||||
class="sticky left-0 z-[1] h-7 bg-cladd-surface px-3 text-left font-medium"
|
||||
>
|
||||
{nameLabel}
|
||||
</th>
|
||||
{#each cols as col (col.key)}
|
||||
<th
|
||||
class="h-7 whitespace-nowrap px-2 text-right font-medium"
|
||||
title={col.title}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
{/each}
|
||||
{#if average}
|
||||
<th class="h-7 whitespace-nowrap px-2 text-right font-medium">
|
||||
Average
|
||||
</th>
|
||||
{/if}
|
||||
<th class="h-7 whitespace-nowrap px-3 text-right font-medium">Sum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sorted as row (row.key)}
|
||||
{@const rowTotal = amountOf(row.totals, mode, side)}
|
||||
<tr class="h-7 border-cladd-outline border-t hover:bg-row-hover">
|
||||
<th
|
||||
class="sticky left-0 z-[1] max-w-[16rem] bg-cladd-surface px-3 text-left font-normal"
|
||||
scope="row"
|
||||
>
|
||||
<button
|
||||
aria-pressed={isActive(row, null)}
|
||||
class="block max-w-full truncate text-left hover:text-brand dark:hover:text-[var(--ff-series-1)] hover:underline {isActive(
|
||||
row,
|
||||
null
|
||||
)
|
||||
? "font-semibold text-brand dark:text-[var(--ff-series-1)]"
|
||||
: ""}"
|
||||
onclick={() => open(row, null)}
|
||||
title="Show transactions for {row.label}"
|
||||
type="button"
|
||||
>
|
||||
{row.label}
|
||||
</button>
|
||||
</th>
|
||||
{#each cols as col, i (col.key)}
|
||||
{@const value = amountOf(row.cells.get(col.key), mode, side)}
|
||||
{@const diff = delta ? change(row, i) : null}
|
||||
{@const cell = row.cells.get(col.key)}
|
||||
<td class="p-0 text-right">
|
||||
{#if multi && cell}
|
||||
<button
|
||||
class="w-full px-2 py-1 text-right hover:bg-row-cursor {isActive(
|
||||
row,
|
||||
col
|
||||
)
|
||||
? "bg-row-selected font-semibold"
|
||||
: ""}"
|
||||
onclick={() => open(row, col)}
|
||||
tabindex="-1"
|
||||
title="{row.label}, {col.title ?? col.label}"
|
||||
type="button"
|
||||
>
|
||||
<Money codes={report.codes} {side} totals={cell} />
|
||||
</button>
|
||||
{:else if !multi && (value || diff)}
|
||||
<button
|
||||
class="num h-7 w-full whitespace-nowrap px-2 text-right hover:bg-row-cursor {isActive(
|
||||
row,
|
||||
col
|
||||
)
|
||||
? "bg-row-selected font-semibold"
|
||||
: ""}"
|
||||
onclick={() => open(row, col)}
|
||||
tabindex="-1"
|
||||
title="{row.label}, {col.title ?? col.label}: {money(
|
||||
mode,
|
||||
sign * value
|
||||
)}"
|
||||
type="button"
|
||||
>
|
||||
{#if diff}
|
||||
<span
|
||||
class="inline-flex items-center gap-0.5 {diff.good
|
||||
? "text-income"
|
||||
: "text-expense"}"
|
||||
>
|
||||
{#if diff.up}
|
||||
<ArrowUp aria-label="up" class="size-3" />
|
||||
{:else}
|
||||
<ArrowDown aria-label="down" class="size-3" />
|
||||
{/if}
|
||||
{diff.ratio === null
|
||||
? "new"
|
||||
: percent(Math.abs(diff.ratio))}
|
||||
</span>
|
||||
{:else}
|
||||
{short(sign * value)}
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
{#if average}
|
||||
<td
|
||||
class="num whitespace-nowrap px-2 text-right text-cladd-fg-soft"
|
||||
>
|
||||
{short((sign * rowTotal) / Math.max(1, cols.length))}
|
||||
</td>
|
||||
{/if}
|
||||
<td class="num whitespace-nowrap px-3 text-right font-semibold">
|
||||
{#if multi}
|
||||
<Money codes={report.codes} {side} totals={row.totals} />
|
||||
{:else}
|
||||
{short(sign * rowTotal)}
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="h-7 border-cladd-outline border-t-2 font-semibold">
|
||||
<th
|
||||
class="sticky left-0 z-[1] bg-cladd-surface px-3 text-left"
|
||||
scope="row"
|
||||
>
|
||||
Sum
|
||||
</th>
|
||||
{#each cols as col, i (col.key)}
|
||||
<td class="p-0 text-right">
|
||||
{#if multi}
|
||||
<div class="px-2 py-1">
|
||||
<Money codes={report.codes} {side} totals={colMerged[i]} />
|
||||
</div>
|
||||
{:else if colTotals[i]}
|
||||
<button
|
||||
class="num h-7 w-full whitespace-nowrap px-2 text-right hover:bg-row-cursor {isActive(
|
||||
null,
|
||||
col
|
||||
)
|
||||
? "bg-row-selected"
|
||||
: ""}"
|
||||
onclick={() => open(null, col)}
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
{short(sign * colTotals[i])}
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
{#if average}
|
||||
<td class="num whitespace-nowrap px-2 text-right">
|
||||
{short((sign * grand) / Math.max(1, cols.length))}
|
||||
</td>
|
||||
{/if}
|
||||
<td class="num whitespace-nowrap px-3 text-right">
|
||||
{#if multi}
|
||||
<Money codes={report.codes} {side} totals={merge(colMerged)} />
|
||||
{:else}
|
||||
{short(sign * grand)}
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
amountOf,
|
||||
currencies,
|
||||
type Side,
|
||||
type Totals,
|
||||
} from "#lib/reports/flows.js";
|
||||
import { money } from "#lib/reports/report.svelte.js";
|
||||
|
||||
let {
|
||||
totals,
|
||||
side,
|
||||
codes,
|
||||
mode,
|
||||
}: {
|
||||
totals: Totals | undefined;
|
||||
side: Side;
|
||||
codes: string[];
|
||||
mode?: string;
|
||||
} = $props();
|
||||
|
||||
const lines = $derived.by(() => {
|
||||
if (!totals) {
|
||||
return [];
|
||||
}
|
||||
const list = mode === "pc" ? ["pc"] : currencies(totals, codes);
|
||||
return list
|
||||
.map((code) => ({ code, value: amountOf(totals, code, side) }))
|
||||
.filter((line) => Math.abs(line.value) > 0.000_001);
|
||||
});
|
||||
|
||||
function tone(value: number) {
|
||||
if (side === "out") {
|
||||
return "text-expense";
|
||||
}
|
||||
if (side === "in") {
|
||||
return "text-income";
|
||||
}
|
||||
return value < 0 ? "text-expense" : "text-income";
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each lines as line (line.code)}
|
||||
<div class="num {tone(line.value)}">
|
||||
{money(line.code, side === "out" ? -line.value : line.value)}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -0,0 +1,323 @@
|
||||
<script lang="ts">
|
||||
import { percent } from "#lib/format.js";
|
||||
import { type Flow, flowValue, keys } from "#lib/reports/flows.js";
|
||||
import {
|
||||
drillQuery,
|
||||
modeLabel,
|
||||
money,
|
||||
navigate,
|
||||
useReport,
|
||||
} from "#lib/reports/report.svelte.js";
|
||||
import ReportBox from "./ReportBox.svelte";
|
||||
|
||||
const LEFT_MAX = 5;
|
||||
const RIGHT_MAX = 9;
|
||||
const SLOT = 34;
|
||||
const BAR = 10;
|
||||
const CORE = 14;
|
||||
const CORE_HEIGHT = 260;
|
||||
const GAP = 4;
|
||||
const CENT = 0.01;
|
||||
|
||||
interface Node {
|
||||
color: string;
|
||||
dir: 1 | -1;
|
||||
drillable: boolean;
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const report = useReport();
|
||||
const mode = $derived(report.mode);
|
||||
let width = $state(0);
|
||||
|
||||
function side(flows: Flow[], dir: 1 | -1, max: number): Node[] {
|
||||
const sums = new Map<string, { label: string; value: number }>();
|
||||
for (const flow of flows) {
|
||||
const value = flowValue(flow, mode);
|
||||
if (flow.dir !== dir || value === null) {
|
||||
continue;
|
||||
}
|
||||
const key = keys.category(flow);
|
||||
const entry = sums.get(key.key) ?? { label: key.label, value: 0 };
|
||||
entry.value += value;
|
||||
sums.set(key.key, entry);
|
||||
}
|
||||
const sorted = [...sums.entries()]
|
||||
.map(([key, entry]) => ({ key, ...entry }))
|
||||
.filter((entry) => entry.value > 0)
|
||||
.sort((a, b) => b.value - a.value);
|
||||
const color = dir === 1 ? "var(--ff-income)" : "var(--ff-expense)";
|
||||
const head: Node[] = sorted.slice(0, max).map((entry) => ({
|
||||
color,
|
||||
dir,
|
||||
drillable: true,
|
||||
key: entry.key,
|
||||
label: entry.label,
|
||||
value: entry.value,
|
||||
}));
|
||||
const rest = sorted.slice(max);
|
||||
if (rest.length > 0) {
|
||||
head.push({
|
||||
color: "var(--cladd-fg-softest)",
|
||||
dir,
|
||||
drillable: false,
|
||||
key: `other-${dir}`,
|
||||
label: `Other (${rest.length})`,
|
||||
value: rest.reduce((sum, entry) => sum + entry.value, 0),
|
||||
});
|
||||
}
|
||||
return head;
|
||||
}
|
||||
|
||||
const net = $derived.by(() => {
|
||||
const sums = new Map<string, number>();
|
||||
for (const swap of report.exchanges) {
|
||||
if (swap.to === mode) {
|
||||
sums.set(swap.from, (sums.get(swap.from) ?? 0) + swap.toAmount);
|
||||
}
|
||||
if (swap.from === mode) {
|
||||
sums.set(swap.to, (sums.get(swap.to) ?? 0) - swap.fromAmount);
|
||||
}
|
||||
}
|
||||
return [...sums.entries()].filter(([, value]) => value !== 0);
|
||||
});
|
||||
|
||||
function swaps(dir: 1 | -1): Node[] {
|
||||
return net
|
||||
.filter(([, value]) => Math.sign(value) === dir)
|
||||
.map(([code, value]) => ({
|
||||
color: "var(--ff-transfer)",
|
||||
dir,
|
||||
drillable: false,
|
||||
key: `swap-${dir}-${code}`,
|
||||
label: dir === 1 ? `Exchanged from ${code}` : `Exchanged to ${code}`,
|
||||
value: Math.abs(value),
|
||||
}));
|
||||
}
|
||||
|
||||
const model = $derived.by(() => {
|
||||
const flows = report.flows ?? [];
|
||||
const left = side(flows, 1, LEFT_MAX);
|
||||
const right = side(flows, -1, RIGHT_MAX);
|
||||
if (mode !== "pc") {
|
||||
left.push(...swaps(1));
|
||||
right.push(...swaps(-1));
|
||||
}
|
||||
const totalIn = left.reduce((sum, n) => sum + n.value, 0);
|
||||
const totalOut = right.reduce((sum, n) => sum + n.value, 0);
|
||||
if (totalIn - totalOut >= CENT) {
|
||||
right.push({
|
||||
color: "var(--ff-series-1)",
|
||||
dir: 1,
|
||||
drillable: false,
|
||||
key: "kept",
|
||||
label: "Left over",
|
||||
value: totalIn - totalOut,
|
||||
});
|
||||
} else if (totalOut - totalIn >= CENT) {
|
||||
left.push({
|
||||
color: "var(--ff-warn)",
|
||||
dir: -1,
|
||||
drillable: false,
|
||||
key: "deficit",
|
||||
label: "From balance",
|
||||
value: totalOut - totalIn,
|
||||
});
|
||||
}
|
||||
return { left, right, total: Math.max(totalIn, totalOut) };
|
||||
});
|
||||
|
||||
const narrow = $derived(width < 640);
|
||||
const labelWidth = $derived(Math.min(220, Math.max(110, width * 0.27)));
|
||||
const scale = $derived(model.total > 0 ? CORE_HEIGHT / model.total : 0);
|
||||
const slotOf = (n: Node) => Math.max(SLOT, n.value * scale + GAP * 2);
|
||||
const height = $derived(
|
||||
Math.max(
|
||||
CORE_HEIGHT + 20,
|
||||
model.left.reduce((s, n) => s + slotOf(n), 0),
|
||||
model.right.reduce((s, n) => s + slotOf(n), 0)
|
||||
)
|
||||
);
|
||||
const leftX = $derived(labelWidth);
|
||||
const rightX = $derived(width - labelWidth - BAR);
|
||||
const coreX = $derived((leftX + BAR + rightX) / 2 - CORE / 2);
|
||||
const coreTop = $derived((height - model.total * scale) / 2);
|
||||
|
||||
function layout(nodes: Node[]) {
|
||||
const used = nodes.reduce((s, n) => s + slotOf(n), 0);
|
||||
let y = (height - used) / 2;
|
||||
let core = coreTop;
|
||||
return nodes.map((node) => {
|
||||
const slot = slotOf(node);
|
||||
const h = Math.max(1.5, node.value * scale);
|
||||
const top = y + (slot - h) / 2;
|
||||
const placed = { center: y + slot / 2, core, h, node, top };
|
||||
y += slot;
|
||||
core += node.value * scale;
|
||||
return placed;
|
||||
});
|
||||
}
|
||||
|
||||
const left = $derived(layout(model.left));
|
||||
const right = $derived(layout(model.right));
|
||||
|
||||
function ribbon(x0: number, y0: number, x1: number, y1: number, h: number) {
|
||||
const mid = (x0 + x1) / 2;
|
||||
return `M${x0},${y0}C${mid},${y0} ${mid},${y1} ${x1},${y1}V${y1 + h}C${mid},${y1 + h} ${mid},${y0 + h} ${x0},${y0 + h}Z`;
|
||||
}
|
||||
|
||||
function open(node: Node) {
|
||||
if (!node.drillable) {
|
||||
return;
|
||||
}
|
||||
const { key } = node;
|
||||
const current = report.drill;
|
||||
const same =
|
||||
current?.box === "flow" &&
|
||||
current.key === key &&
|
||||
current.dir === node.dir;
|
||||
navigate(
|
||||
drillQuery(
|
||||
same
|
||||
? null
|
||||
: {
|
||||
box: "flow",
|
||||
dim: "category",
|
||||
dir: node.dir,
|
||||
key,
|
||||
label: `${node.dir === 1 ? "Income" : "Expenses"}: ${node.label}`,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const share = (n: Node) =>
|
||||
model.total ? percent(n.value / model.total) : "";
|
||||
</script>
|
||||
|
||||
<ReportBox
|
||||
body="p-3"
|
||||
empty={model.total === 0}
|
||||
emptyText="No money moved in {modeLabel(mode)} in this period."
|
||||
id="flow"
|
||||
loading={!report.flows}
|
||||
title="Where the money went ({modeLabel(mode)})"
|
||||
>
|
||||
<div bind:clientWidth={width}>
|
||||
{#if width > 0 && narrow}
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{#each [
|
||||
{ list: model.left, title: "In" },
|
||||
{ list: model.right, title: "Out" },
|
||||
] as column (column.title)}
|
||||
<div>
|
||||
<h3 class="mb-1 font-semibold text-[12px] text-cladd-fg-soft">
|
||||
{column.title}
|
||||
</h3>
|
||||
{#each column.list as node (node.key)}
|
||||
<button
|
||||
class="grid h-7 w-full grid-cols-[0.5rem_1fr_auto_2.5rem] items-center gap-2 rounded-cladd-sm px-1 text-left hover:bg-row-hover disabled:hover:bg-transparent"
|
||||
disabled={!node.drillable}
|
||||
onclick={() => open(node)}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="size-2 rounded-full"
|
||||
style:background={node.color}
|
||||
></span>
|
||||
<span class="truncate">{node.label}</span>
|
||||
<span class="num">{money(mode, node.value)}</span>
|
||||
<span class="num text-right text-cladd-fg-soft"
|
||||
>{share(node)}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if width > 0}
|
||||
<div class="relative" style:height="{height}px">
|
||||
<svg aria-hidden="true" class="absolute inset-0" {height} {width}>
|
||||
{#each left as item (item.node.key)}
|
||||
<path
|
||||
d={ribbon(leftX + BAR, item.top, coreX, item.core, item.h)}
|
||||
fill="color-mix(in oklch, {item.node.color} 22%, transparent)"
|
||||
/>
|
||||
<rect
|
||||
fill={item.node.color}
|
||||
height={item.h}
|
||||
rx="2"
|
||||
width={BAR}
|
||||
x={leftX}
|
||||
y={item.top}
|
||||
/>
|
||||
{/each}
|
||||
{#each right as item (item.node.key)}
|
||||
<path
|
||||
d={ribbon(coreX + CORE, item.core, rightX, item.top, item.h)}
|
||||
fill="color-mix(in oklch, {item.node.color} 22%, transparent)"
|
||||
/>
|
||||
<rect
|
||||
fill={item.node.color}
|
||||
height={item.h}
|
||||
rx="2"
|
||||
width={BAR}
|
||||
x={rightX}
|
||||
y={item.top}
|
||||
/>
|
||||
{/each}
|
||||
<rect
|
||||
fill="var(--cladd-fg-soft)"
|
||||
height={model.total * scale}
|
||||
rx="3"
|
||||
width={CORE}
|
||||
x={coreX}
|
||||
y={coreTop}
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="num absolute -translate-x-1/2 font-semibold text-[12px]"
|
||||
style:left="{coreX + CORE / 2}px"
|
||||
style:top="{coreTop - 22}px"
|
||||
>{money(mode, model.total)}</span
|
||||
>
|
||||
{#each [
|
||||
...left.map((item) => ({ ...item, isLeft: true })),
|
||||
...right.map((item) => ({ ...item, isLeft: false })),
|
||||
] as item (`${item.isLeft}-${item.node.key}`)}
|
||||
{@const active =
|
||||
report.drill?.box === "flow" &&
|
||||
report.drill.key === item.node.key &&
|
||||
report.drill.dir === item.node.dir}
|
||||
<button
|
||||
class="absolute flex h-8 flex-col justify-center rounded-cladd-sm px-1 leading-tight hover:bg-row-hover disabled:hover:bg-transparent {item.isLeft
|
||||
? "items-end text-right"
|
||||
: "items-start text-left"} {active ? "bg-row-selected" : ""}"
|
||||
disabled={!item.node.drillable}
|
||||
onclick={() => open(item.node)}
|
||||
title={item.node.drillable
|
||||
? `Show transactions for ${item.node.label}`
|
||||
: undefined}
|
||||
type="button"
|
||||
style:left="{item.isLeft ? 0 : rightX + BAR + 4}px"
|
||||
style:top="{item.center - 16}px"
|
||||
style:width="{labelWidth - 6}px"
|
||||
>
|
||||
<span
|
||||
class="max-w-full truncate text-[12px] {active
|
||||
? "font-semibold"
|
||||
: ""}"
|
||||
>{item.node.label}</span
|
||||
>
|
||||
<span class="num text-[11px] text-cladd-fg-soft"
|
||||
>{money(mode, item.node.value)}
|
||||
· {share(item.node)}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</ReportBox>
|
||||
@@ -0,0 +1,205 @@
|
||||
<script lang="ts">
|
||||
import ChevronDown from "@lucide/svelte/icons/chevron-down";
|
||||
import { Popover } from "bits-ui";
|
||||
import type { Option } from "#lib/reports/report.svelte.js";
|
||||
|
||||
let {
|
||||
options,
|
||||
value,
|
||||
onchange,
|
||||
label,
|
||||
id,
|
||||
open = $bindable(false),
|
||||
loading = false,
|
||||
class: className = "",
|
||||
}: {
|
||||
options: Option[];
|
||||
value: string[];
|
||||
onchange: (next: string[]) => void;
|
||||
label: string;
|
||||
id: string;
|
||||
open?: boolean;
|
||||
loading?: boolean;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
let query = $state("");
|
||||
let active = $state(0);
|
||||
|
||||
const chosen = $derived(new Set(value));
|
||||
const visible = $derived(
|
||||
query
|
||||
? options.filter((o) =>
|
||||
`${o.label} ${o.group ?? ""}`
|
||||
.toLowerCase()
|
||||
.includes(query.toLowerCase())
|
||||
)
|
||||
: options
|
||||
);
|
||||
const groups = $derived.by(() => {
|
||||
const map = new Map<string, Option[]>();
|
||||
for (const option of visible) {
|
||||
const key = option.group ?? "";
|
||||
map.set(key, [...(map.get(key) ?? []), option]);
|
||||
}
|
||||
return [...map.entries()];
|
||||
});
|
||||
const summary = $derived.by(() => {
|
||||
const picked = options.filter((o) => chosen.has(o.id));
|
||||
if (picked.length === 0) {
|
||||
return loading ? "Loading" : "None selected";
|
||||
}
|
||||
if (picked.length === options.length && options.length > 2) {
|
||||
return `All selected (${options.length})`;
|
||||
}
|
||||
if (picked.length <= 2) {
|
||||
return picked.map((o) => o.label).join(", ");
|
||||
}
|
||||
return `${picked.length} selected: ${picked.map((o) => o.label).join(", ")}`;
|
||||
});
|
||||
|
||||
function toggle(ids: string[], on: boolean) {
|
||||
const next = new Set(chosen);
|
||||
for (const optionId of ids) {
|
||||
if (on) {
|
||||
next.add(optionId);
|
||||
} else {
|
||||
next.delete(optionId);
|
||||
}
|
||||
}
|
||||
onchange(options.filter((o) => next.has(o.id)).map((o) => o.id));
|
||||
}
|
||||
|
||||
function onkeydown(event: KeyboardEvent) {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
const step = event.key === "ArrowDown" ? 1 : -1;
|
||||
active = Math.max(0, Math.min(visible.length - 1, active + step));
|
||||
document
|
||||
.getElementById(`${id}-opt-${active}`)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
if (event.key === "Enter" && !(event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
const option = visible[active];
|
||||
if (option) {
|
||||
toggle([option.id], !chosen.has(option.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root
|
||||
onOpenChange={(next) => {
|
||||
if (!next) {
|
||||
query = "";
|
||||
active = 0;
|
||||
}
|
||||
}}
|
||||
bind:open
|
||||
>
|
||||
<Popover.Trigger
|
||||
aria-label="{label}: {summary}"
|
||||
class="cladd-cut cladd-focusable-input flex h-8 min-w-0 items-center gap-2 rounded-cladd-sm px-2.5 text-left text-[13px] text-cladd-fg {className}"
|
||||
{id}
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{summary}</span>
|
||||
<ChevronDown class="size-3.5 shrink-0 text-cladd-fg-soft" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="start"
|
||||
class="cladd-surface cladd-gradient cladd-outline cladd-level-1 cladd-anim-popover z-50 flex max-h-[min(70vh,32rem)] w-80 flex-col rounded-cladd-lg text-cladd-fg shadow-cladd-popover outline-none"
|
||||
sideOffset={6}
|
||||
>
|
||||
<div class="flex flex-col gap-2 border-cladd-outline border-b p-2">
|
||||
<input
|
||||
aria-label="Find {label.toLowerCase()}"
|
||||
class="cladd-cut cladd-focusable-input h-8 rounded-cladd-sm px-2 text-[13px]"
|
||||
{onkeydown}
|
||||
placeholder="Find.."
|
||||
bind:value={query}
|
||||
>
|
||||
<div class="flex items-center gap-3 text-[12px]">
|
||||
<button
|
||||
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
|
||||
onclick={() =>
|
||||
toggle(
|
||||
visible.map((o) => o.id),
|
||||
true
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
<button
|
||||
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
|
||||
onclick={() =>
|
||||
toggle(
|
||||
visible.map((o) => o.id),
|
||||
false
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
None
|
||||
</button>
|
||||
<span class="num ml-auto text-cladd-fg-soft"
|
||||
>{chosen.size}
|
||||
of {options.length}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-1">
|
||||
{#each groups as [group, items] (group)}
|
||||
{#if group}
|
||||
{@const all = items.every((o) => chosen.has(o.id))}
|
||||
<button
|
||||
class="flex h-7 w-full items-center px-2 text-left font-semibold text-[11px] text-cladd-fg-soft uppercase tracking-wide hover:text-cladd-fg"
|
||||
onclick={() =>
|
||||
toggle(
|
||||
items.map((o) => o.id),
|
||||
!all
|
||||
)}
|
||||
title={all ? `Deselect ${group}` : `Select ${group}`}
|
||||
type="button"
|
||||
>
|
||||
{group}
|
||||
</button>
|
||||
{/if}
|
||||
{#each items as option (option.id)}
|
||||
{@const index = visible.indexOf(option)}
|
||||
<label
|
||||
class="flex h-7 cursor-pointer items-center gap-2 rounded-cladd-sm px-2 text-[13px] hover:bg-row-hover {index ===
|
||||
active
|
||||
? "bg-row-cursor"
|
||||
: ""}"
|
||||
id="{id}-opt-{index}"
|
||||
>
|
||||
<input
|
||||
checked={chosen.has(option.id)}
|
||||
class="size-3.5 accent-[var(--ff-brand)]"
|
||||
onchange={(event) =>
|
||||
toggle([option.id], event.currentTarget.checked)}
|
||||
type="checkbox"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{option.label}</span>
|
||||
{#if option.note}
|
||||
<span class="shrink-0 text-[11px] text-cladd-fg-soft"
|
||||
>{option.note}</span
|
||||
>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="px-2 py-1.5 text-cladd-fg-soft">
|
||||
{#if query}
|
||||
Nothing matches
|
||||
{:else}
|
||||
{loading ? "Loading" : "Nothing to choose from"}
|
||||
{/if}
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
@@ -0,0 +1,292 @@
|
||||
<script lang="ts">
|
||||
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
|
||||
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||
import { useKeys } from "#lib/keys.svelte.js";
|
||||
import { recall, remember } from "#lib/reports/memory.js";
|
||||
import { listOptions, listReady } from "#lib/reports/options.js";
|
||||
import {
|
||||
LIST_LABELS,
|
||||
REPORT_KINDS,
|
||||
type ReportParams,
|
||||
type ReportType,
|
||||
rangePresets,
|
||||
shiftRange,
|
||||
} from "#lib/reports/params.js";
|
||||
import {
|
||||
accountOptions,
|
||||
modeLabel,
|
||||
navigate,
|
||||
useReport,
|
||||
} from "#lib/reports/report.svelte.js";
|
||||
import MultiSelect from "./MultiSelect.svelte";
|
||||
|
||||
const report = useReport();
|
||||
|
||||
let accountsOpen = $state(false);
|
||||
let itemsOpen = $state(false);
|
||||
let typeSelect = $state<HTMLSelectElement>();
|
||||
let startInput = $state<HTMLInputElement>();
|
||||
|
||||
const p = $derived(report.params);
|
||||
const presets = rangePresets();
|
||||
const preset = $derived(
|
||||
presets.find((x) => x.range.start === p.start && x.range.end === p.end)
|
||||
?.key ?? "custom"
|
||||
);
|
||||
const options = $derived(accountOptions(report.accounts()));
|
||||
const list = $derived(report.kind?.list);
|
||||
const sources = $derived({
|
||||
budgets: report.budgets,
|
||||
categories: report.categories,
|
||||
expense: report.expense,
|
||||
revenue: report.revenue,
|
||||
tags: report.tags,
|
||||
});
|
||||
const itemOptions = $derived(list ? listOptions(list, sources) : []);
|
||||
const accountValue = $derived(report.selected);
|
||||
|
||||
function go(changes: Partial<ReportParams>) {
|
||||
const next = { ...p, ...changes };
|
||||
remember(next);
|
||||
navigate({}, report.path(changes));
|
||||
}
|
||||
|
||||
function defaultItems(type: ReportType): string[] {
|
||||
const kind = REPORT_KINDS.find((k) => k.type === type)?.list;
|
||||
if (!kind) {
|
||||
return [];
|
||||
}
|
||||
const stored = recall().items[kind];
|
||||
if (stored && stored.length > 0) {
|
||||
return stored;
|
||||
}
|
||||
if (kind === "double") {
|
||||
const top = new Map<string, number>();
|
||||
for (const flow of report.flows ?? []) {
|
||||
if (flow.type === "withdrawal") {
|
||||
top.set(flow.other.id, (top.get(flow.other.id) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const ids = [...top.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3)
|
||||
.map(([id]) => id);
|
||||
return ids.length > 0 ? ids : ["0"];
|
||||
}
|
||||
return [
|
||||
{ budget: "allBudgets", category: "allCategories", tag: "allTags" }[kind],
|
||||
];
|
||||
}
|
||||
|
||||
function setType(type: ReportType) {
|
||||
go({ items: defaultItems(type), type });
|
||||
}
|
||||
|
||||
function setRange(start: string, end: string) {
|
||||
if (start && end && start <= end) {
|
||||
go({ end, start });
|
||||
}
|
||||
}
|
||||
|
||||
function shift(dir: 1 | -1) {
|
||||
const range = shiftRange(p, dir);
|
||||
go({ end: range.end, start: range.start });
|
||||
}
|
||||
|
||||
function cycleMode() {
|
||||
const { available } = report;
|
||||
if (available.length > 1) {
|
||||
const next =
|
||||
available[(available.indexOf(report.mode) + 1) % available.length];
|
||||
navigate({ cur: next });
|
||||
}
|
||||
}
|
||||
|
||||
useKeys(() => [
|
||||
{
|
||||
group: "Report",
|
||||
keys: "[",
|
||||
label: "Shift report back one period",
|
||||
run: () => shift(-1),
|
||||
},
|
||||
{
|
||||
group: "Report",
|
||||
keys: "]",
|
||||
label: "Shift report forward one period",
|
||||
run: () => shift(1),
|
||||
},
|
||||
{
|
||||
group: "Report",
|
||||
keys: "t",
|
||||
label: "Change report type",
|
||||
run: () => typeSelect?.focus(),
|
||||
},
|
||||
{
|
||||
group: "Report",
|
||||
keys: "a",
|
||||
label: "Choose accounts",
|
||||
run: () => (accountsOpen = true),
|
||||
},
|
||||
...(list
|
||||
? [
|
||||
{
|
||||
group: "Report",
|
||||
keys: "f",
|
||||
label: `Choose ${LIST_LABELS[list].many.toLowerCase()}`,
|
||||
run: () => (itemsOpen = true),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
group: "Report",
|
||||
keys: "d",
|
||||
label: "Change dates",
|
||||
run: () => startInput?.focus(),
|
||||
},
|
||||
...(report.available.length > 1
|
||||
? [
|
||||
{
|
||||
group: "Report",
|
||||
keys: "c",
|
||||
label: "Next currency in charts",
|
||||
run: cycleMode,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="cladd-surface cladd-outline z-20 mb-4 lg:sticky lg:top-14 flex flex-wrap items-end justify-between gap-x-4 gap-y-2 rounded-cladd-lg px-3 py-2"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-wrap items-end gap-x-3 gap-y-2">
|
||||
<label class="flex flex-col gap-0.5">
|
||||
<span class="text-[11px] text-cladd-fg-soft">Report type</span>
|
||||
<select
|
||||
class="cladd-cut cladd-focusable-input h-8 max-w-[14rem] rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
|
||||
onchange={(event) => setType(event.currentTarget.value as ReportType)}
|
||||
value={p.type}
|
||||
bind:this={typeSelect}
|
||||
>
|
||||
{#each REPORT_KINDS as kind (kind.type)}
|
||||
<option value={kind.type}>{kind.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<label class="text-[11px] text-cladd-fg-soft" for="report-accounts"
|
||||
>Accounts</label
|
||||
>
|
||||
<MultiSelect
|
||||
class="w-48"
|
||||
id="report-accounts"
|
||||
label="Accounts"
|
||||
loading={!report.accountsReady}
|
||||
onchange={(accounts) => go({ accounts })}
|
||||
{options}
|
||||
value={accountValue}
|
||||
bind:open={accountsOpen}
|
||||
/>
|
||||
</div>
|
||||
{#if list}
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<label class="text-[11px] text-cladd-fg-soft" for="report-items"
|
||||
>{LIST_LABELS[list].many}</label
|
||||
>
|
||||
<MultiSelect
|
||||
class="w-48"
|
||||
id="report-items"
|
||||
label={LIST_LABELS[list].many}
|
||||
loading={!listReady(list, sources)}
|
||||
onchange={(items) => go({ items })}
|
||||
options={itemOptions}
|
||||
value={report.items}
|
||||
bind:open={itemsOpen}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-[11px] text-cladd-fg-soft">Date range</span>
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
aria-label="Previous period"
|
||||
class="grid size-8 place-items-center rounded-cladd-sm text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg"
|
||||
onclick={() => shift(-1)}
|
||||
title="Previous period ["
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft class="size-4" />
|
||||
</button>
|
||||
<input
|
||||
aria-label="Start date"
|
||||
class="cladd-cut cladd-focusable-input num h-8 w-[7.4rem] rounded-cladd-sm px-1.5 text-[13px] sm:w-[8.2rem] sm:px-2 text-cladd-fg"
|
||||
onchange={(event) => setRange(event.currentTarget.value, p.end)}
|
||||
type="date"
|
||||
value={p.start}
|
||||
bind:this={startInput}
|
||||
>
|
||||
<span class="text-cladd-fg-soft">to</span>
|
||||
<input
|
||||
aria-label="End date"
|
||||
class="cladd-cut cladd-focusable-input num h-8 w-[7.4rem] rounded-cladd-sm px-1.5 text-[13px] sm:w-[8.2rem] sm:px-2 text-cladd-fg"
|
||||
onchange={(event) => setRange(p.start, event.currentTarget.value)}
|
||||
type="date"
|
||||
value={p.end}
|
||||
>
|
||||
<button
|
||||
aria-label="Next period"
|
||||
class="grid size-8 place-items-center rounded-cladd-sm text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg"
|
||||
onclick={() => shift(1)}
|
||||
title="Next period ]"
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
aria-label="Preset range"
|
||||
class="cladd-cut cladd-focusable-input h-8 rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
|
||||
onchange={(event) => {
|
||||
const found = presets.find(
|
||||
(x) => x.key === event.currentTarget.value
|
||||
);
|
||||
if (found) {
|
||||
setRange(found.range.start, found.range.end);
|
||||
}
|
||||
}}
|
||||
value={preset}
|
||||
>
|
||||
{#each presets as item (item.key)}
|
||||
<option value={item.key}>{item.label}</option>
|
||||
{/each}
|
||||
<option disabled value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if report.available.length > 1}
|
||||
<fieldset class="shrink-0">
|
||||
<legend class="float-left mb-0.5 w-full text-[11px] text-cladd-fg-soft">
|
||||
Charts in
|
||||
</legend>
|
||||
<div
|
||||
class="cladd-cut clear-both flex h-8 items-center gap-0.5 rounded-cladd-sm p-0.5"
|
||||
>
|
||||
{#each report.available as mode (mode)}
|
||||
<button
|
||||
aria-pressed={report.mode === mode}
|
||||
class="num h-7 rounded-[5px] px-2 text-[12px] {report.mode === mode
|
||||
? "cladd-surface cladd-outline font-semibold text-cladd-fg"
|
||||
: "text-cladd-fg-soft hover:text-cladd-fg"}"
|
||||
onclick={() => navigate({ cur: mode })}
|
||||
title="Show charts in {modeLabel(mode)} c"
|
||||
type="button"
|
||||
>
|
||||
{modeLabel(mode)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import Columns from "#lib/components/charts/Columns.svelte";
|
||||
import { buckets, type Dimension, type Flow } from "#lib/reports/flows.js";
|
||||
import {
|
||||
drillQuery,
|
||||
modeLabel,
|
||||
navigate,
|
||||
useReport,
|
||||
} from "#lib/reports/report.svelte.js";
|
||||
import {
|
||||
bucketLabel,
|
||||
bucketRows,
|
||||
FLOW_KEYS,
|
||||
modeAxis,
|
||||
modeFormat,
|
||||
upright,
|
||||
} from "#lib/reports/view.js";
|
||||
import ReportBox from "./ReportBox.svelte";
|
||||
|
||||
let {
|
||||
id = "period-chart",
|
||||
title = "Income vs. expenses",
|
||||
flows,
|
||||
dim,
|
||||
itemKey,
|
||||
}: {
|
||||
id?: string;
|
||||
title?: string;
|
||||
flows: Flow[] | undefined;
|
||||
dim?: Dimension;
|
||||
itemKey?: string;
|
||||
} = $props();
|
||||
|
||||
const report = useReport();
|
||||
const keys = $derived(
|
||||
buckets(report.params.start, report.params.end, report.grain)
|
||||
);
|
||||
const rows = $derived(
|
||||
flows ? upright(bucketRows(flows, keys, report.grain, report.mode)) : []
|
||||
);
|
||||
const shownKeys = $derived(
|
||||
FLOW_KEYS.filter((key) =>
|
||||
rows.some((row) => (row.values[key.key] ?? 0) !== 0)
|
||||
)
|
||||
);
|
||||
const active = $derived(
|
||||
report.drill?.box === id ? (report.drill.at ?? null) : null
|
||||
);
|
||||
</script>
|
||||
|
||||
<ReportBox
|
||||
body="p-3"
|
||||
empty={shownKeys.length === 0}
|
||||
{id}
|
||||
loading={!flows}
|
||||
title={title.endsWith(",")
|
||||
? `${title} ${modeLabel(report.mode)}`
|
||||
: `${title} (${modeLabel(report.mode)})`}
|
||||
>
|
||||
<Columns
|
||||
{active}
|
||||
axisFormat={modeAxis(report.mode)}
|
||||
format={modeFormat(report.mode)}
|
||||
height={260}
|
||||
keys={shownKeys}
|
||||
label="{title} per period"
|
||||
onselect={(row) =>
|
||||
navigate(
|
||||
drillQuery(
|
||||
active === row.key
|
||||
? null
|
||||
: {
|
||||
at: row.key,
|
||||
box: id,
|
||||
dim: itemKey === undefined ? undefined : dim,
|
||||
key: itemKey,
|
||||
label: [
|
||||
itemKey === undefined ? "" : title,
|
||||
bucketLabel(row.key, report.grain),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
}
|
||||
)
|
||||
)}
|
||||
{rows}
|
||||
/>
|
||||
</ReportBox>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { getContext, type Snippet } from "svelte";
|
||||
import type { SvelteSet } from "svelte/reactivity";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import { useReport } from "#lib/reports/report.svelte.js";
|
||||
import DrillPanel from "./DrillPanel.svelte";
|
||||
import { ROW } from "./ReportRow.svelte";
|
||||
|
||||
let {
|
||||
id,
|
||||
title,
|
||||
loading = false,
|
||||
empty = false,
|
||||
emptyText = "Nothing in this period.",
|
||||
tools,
|
||||
footer,
|
||||
children,
|
||||
body = "p-0",
|
||||
class: className = "",
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
loading?: boolean;
|
||||
empty?: boolean;
|
||||
emptyText?: string;
|
||||
tools?: Snippet;
|
||||
footer?: Snippet;
|
||||
children: Snippet;
|
||||
body?: string;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
const report = useReport();
|
||||
const row = getContext<SvelteSet<string> | undefined>(ROW);
|
||||
$effect(() => {
|
||||
row?.add(id);
|
||||
return () => row?.delete(id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Box {body} class={className} {footer} {title} {tools}>
|
||||
<div class="scroll-mt-24" {id}>
|
||||
{#if loading}
|
||||
<p class="px-3 py-3 text-cladd-fg-soft">Loading</p>
|
||||
{:else if empty}
|
||||
<p class="px-3 py-3 text-cladd-fg-soft">{emptyText}</p>
|
||||
{:else}
|
||||
{@render children()}
|
||||
{/if}
|
||||
{#if !row && report.drill?.box === id && !loading}
|
||||
<DrillPanel drill={report.drill} />
|
||||
{/if}
|
||||
</div>
|
||||
</Box>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||
import { formatRange } from "#lib/format.js";
|
||||
import type { ReportParams } from "#lib/reports/params.js";
|
||||
import { createReport, provideReport } from "#lib/reports/report.svelte.js";
|
||||
import AuditReport from "./AuditReport.svelte";
|
||||
import DefaultReport from "./DefaultReport.svelte";
|
||||
import DimensionReport from "./DimensionReport.svelte";
|
||||
import ParamBar from "./ParamBar.svelte";
|
||||
|
||||
let { params }: { params: ReportParams } = $props();
|
||||
|
||||
const report = createReport(() => params);
|
||||
provideReport(report);
|
||||
|
||||
const SUBTITLES: Record<string, string> = {
|
||||
audit: "Transaction history account overview",
|
||||
budget: "Budget report",
|
||||
category: "Category report",
|
||||
default: "Default financial report",
|
||||
double: "Expense/revenue account report",
|
||||
tag: "Tag report",
|
||||
};
|
||||
|
||||
const subtitle = $derived(
|
||||
`${SUBTITLES[params.type]}, ${formatRange(params.start, params.end)}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
crumbs={[
|
||||
{ href: "/reports", label: "Reports" },
|
||||
{ label: report.kind?.label ?? "" },
|
||||
]}
|
||||
{subtitle}
|
||||
title="Reports"
|
||||
/>
|
||||
<ParamBar />
|
||||
{#if report.error}
|
||||
<p class="mb-4 text-expense">
|
||||
Firefly III did not return the data for this report: {report.error.message}
|
||||
</p>
|
||||
{/if}
|
||||
{#if params.type === "default"}
|
||||
<DefaultReport />
|
||||
{:else if params.type === "audit"}
|
||||
<AuditReport />
|
||||
{:else}
|
||||
{#key params.type}
|
||||
<DimensionReport />
|
||||
{/key}
|
||||
{/if}
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts" module>
|
||||
export const ROW = Symbol("report-row");
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { type Snippet, setContext } from "svelte";
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
import { useReport } from "#lib/reports/report.svelte.js";
|
||||
import DrillPanel from "./DrillPanel.svelte";
|
||||
|
||||
let {
|
||||
children,
|
||||
class: className = "",
|
||||
}: { children: Snippet; class?: string } = $props();
|
||||
|
||||
const boxes = new SvelteSet<string>();
|
||||
setContext(ROW, boxes);
|
||||
|
||||
const report = useReport();
|
||||
const drill = $derived(
|
||||
report.drill && boxes.has(report.drill.box) ? report.drill : null
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="grid gap-4 {className}">
|
||||
{@render children()}
|
||||
</div>
|
||||
{#if drill}
|
||||
<div
|
||||
class="cladd-surface cladd-outline scroll-mt-24 overflow-hidden rounded-cladd-lg"
|
||||
id="drill-{drill.box}"
|
||||
>
|
||||
<DrillPanel {drill} />
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { percent } from "#lib/format.js";
|
||||
import {
|
||||
amountOf,
|
||||
type Dimension,
|
||||
type Row,
|
||||
rank,
|
||||
} from "#lib/reports/flows.js";
|
||||
import {
|
||||
drillQuery,
|
||||
money,
|
||||
navigate,
|
||||
useReport,
|
||||
} from "#lib/reports/report.svelte.js";
|
||||
|
||||
let {
|
||||
box,
|
||||
rows,
|
||||
side,
|
||||
dim,
|
||||
limit = 8,
|
||||
}: {
|
||||
box: string;
|
||||
rows: Row[];
|
||||
side: "in" | "out";
|
||||
dim: Dimension;
|
||||
limit?: number;
|
||||
} = $props();
|
||||
|
||||
const report = useReport();
|
||||
const mode = $derived(report.mode);
|
||||
const dir = $derived(side === "in" ? 1 : -1);
|
||||
|
||||
const ranked = $derived(
|
||||
rank(rows, mode, side)
|
||||
.map((row) => ({ row, value: amountOf(row.totals, mode, side) }))
|
||||
.filter((item) => item.value > 0)
|
||||
);
|
||||
const head = $derived(ranked.slice(0, limit));
|
||||
const rest = $derived(ranked.slice(limit));
|
||||
const restValue = $derived(rest.reduce((acc, item) => acc + item.value, 0));
|
||||
const sum = $derived(ranked.reduce((acc, item) => acc + item.value, 0));
|
||||
const max = $derived(
|
||||
Math.max(restValue, ...head.map((item) => item.value), 1)
|
||||
);
|
||||
const color = $derived(
|
||||
side === "in" ? "var(--ff-income)" : "var(--ff-expense)"
|
||||
);
|
||||
|
||||
function open(row: Row) {
|
||||
const current = report.drill;
|
||||
const same =
|
||||
current?.box === box && current.key === row.key && current.dim === dim;
|
||||
navigate(
|
||||
drillQuery(
|
||||
same ? null : { box, dim, dir, key: row.key, label: row.label }
|
||||
)
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet line(
|
||||
label: string,
|
||||
value: number,
|
||||
fill: string
|
||||
)}
|
||||
<span class="flex w-full items-baseline gap-3">
|
||||
<span class="min-w-0 flex-1 truncate">{label}</span>
|
||||
<span class="num">{money(mode, value)}</span>
|
||||
<span class="num w-9 text-right text-cladd-fg-soft"
|
||||
>{percent(value / sum)}</span
|
||||
>
|
||||
</span>
|
||||
<span class="block h-1.5 w-full rounded-full bg-cladd-surface-next">
|
||||
<span
|
||||
class="block h-full rounded-full"
|
||||
style:background={fill}
|
||||
style:width="{(value / max) * 100}%"
|
||||
></span>
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
{#if ranked.length === 0}
|
||||
<p class="px-3 py-3 text-cladd-fg-soft">
|
||||
Nothing in {mode === "pc" ? "this period" : mode}.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="flex flex-col py-1.5">
|
||||
{#each head as item (item.row.key)}
|
||||
{@const active =
|
||||
report.drill?.box === box && report.drill.key === item.row.key}
|
||||
<li>
|
||||
<button
|
||||
aria-pressed={active}
|
||||
class="flex w-full flex-col gap-1 px-3 py-1 text-left hover:bg-row-hover {active
|
||||
? "bg-row-selected"
|
||||
: ""}"
|
||||
onclick={() => open(item.row)}
|
||||
title="Show transactions for {item.row.label}"
|
||||
type="button"
|
||||
>
|
||||
{@render line(item.row.label, item.value, color)}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
{#if rest.length > 0}
|
||||
<li class="flex flex-col gap-1 px-3 py-1 text-cladd-fg-soft">
|
||||
{@render line(
|
||||
`Other (${rest.length})`,
|
||||
restValue,
|
||||
"var(--cladd-fg-softest)"
|
||||
)}
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
{/if}
|
||||
@@ -0,0 +1,336 @@
|
||||
<script lang="ts">
|
||||
import { addDays, daysBetween, eachDay, parse } from "#lib/dates.js";
|
||||
import { compactMoney, formatDate, formatMoney } from "#lib/format.js";
|
||||
import { daily } from "#lib/reports/flows.js";
|
||||
import {
|
||||
currencyMeta,
|
||||
drillQuery,
|
||||
modeLabel,
|
||||
money,
|
||||
navigate,
|
||||
useReport,
|
||||
} from "#lib/reports/report.svelte.js";
|
||||
import { session } from "#lib/session.svelte.js";
|
||||
import ReportBox from "./ReportBox.svelte";
|
||||
|
||||
const STEPS = [14, 30, 48, 68, 88];
|
||||
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
const BIG_CELL_DAYS = 62;
|
||||
const TOP_DAYS = 5;
|
||||
|
||||
const report = useReport();
|
||||
const p = $derived(report.params);
|
||||
const mode = $derived(report.mode);
|
||||
let width = $state(0);
|
||||
let cursor = $state<string | null>(null);
|
||||
|
||||
const spending = $derived(
|
||||
report.flows ? daily(report.flows, mode, -1) : null
|
||||
);
|
||||
const days = $derived(eachDay(p));
|
||||
const big = $derived(days.length <= BIG_CELL_DAYS);
|
||||
const values = $derived(
|
||||
[...(spending?.values() ?? [])].map((d) => d.value).sort((a, b) => a - b)
|
||||
);
|
||||
const thresholds = $derived(
|
||||
STEPS.slice(1).map(
|
||||
(_, i) =>
|
||||
values[Math.floor(((i + 1) / STEPS.length) * values.length)] ?? 0
|
||||
)
|
||||
);
|
||||
const top = $derived(
|
||||
[...(spending?.entries() ?? [])]
|
||||
.sort((a, b) => b[1].value - a[1].value)
|
||||
.slice(0, TOP_DAYS)
|
||||
);
|
||||
const quiet = $derived(
|
||||
days.filter((d) => !spending?.has(d) && d <= p.end).length
|
||||
);
|
||||
const meta = $derived(
|
||||
mode === "pc"
|
||||
? {
|
||||
decimals: 0,
|
||||
symbol: session.primary?.symbol ?? "",
|
||||
}
|
||||
: { decimals: 0, symbol: currencyMeta(mode).symbol }
|
||||
);
|
||||
|
||||
function level(value: number) {
|
||||
let index = 0;
|
||||
while (index < thresholds.length && value > thresholds[index]) {
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
const fill = (step: number) =>
|
||||
`color-mix(in oklch, var(--ff-expense) ${STEPS[step]}%, var(--cladd-surface))`;
|
||||
|
||||
const monthShort = $derived(
|
||||
new Intl.DateTimeFormat(session.locale, { month: "short" })
|
||||
);
|
||||
|
||||
const weekday = (day: string) => (parse(day).getDay() + 6) % 7;
|
||||
|
||||
const months = $derived.by(() => {
|
||||
const list: { key: string; label: string; cells: (string | null)[] }[] = [];
|
||||
for (const day of days) {
|
||||
const key = day.slice(0, 7);
|
||||
let month = list.at(-1);
|
||||
if (month?.key !== key) {
|
||||
month = {
|
||||
cells: Array.from({ length: weekday(day) }, () => null),
|
||||
key,
|
||||
label: formatDate(`${key}-01`, "month"),
|
||||
};
|
||||
list.push(month);
|
||||
}
|
||||
month.cells.push(day);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
const weeks = $derived.by(() => {
|
||||
if (big) {
|
||||
return [];
|
||||
}
|
||||
const first = addDays(p.start, -weekday(p.start));
|
||||
const count = Math.ceil((daysBetween(first, p.end) + 1) / 7);
|
||||
return Array.from({ length: count }, (_, w) =>
|
||||
Array.from({ length: 7 }, (__, d) => {
|
||||
const day = addDays(first, w * 7 + d);
|
||||
return day < p.start || day > p.end ? null : day;
|
||||
})
|
||||
);
|
||||
});
|
||||
const cellSize = $derived(
|
||||
big
|
||||
? 0
|
||||
: Math.max(
|
||||
8,
|
||||
Math.min(16, Math.floor((width - 40) / Math.max(1, weeks.length)) - 2)
|
||||
)
|
||||
);
|
||||
|
||||
function amount(value: number, compact: boolean) {
|
||||
return compact
|
||||
? compactMoney(value, meta.symbol)
|
||||
: formatMoney({ amount: value, ...meta });
|
||||
}
|
||||
|
||||
function open(day: string) {
|
||||
const current = report.drill;
|
||||
const same = current?.box === "calendar" && current.key === day;
|
||||
navigate(
|
||||
drillQuery(
|
||||
same
|
||||
? null
|
||||
: {
|
||||
box: "calendar",
|
||||
dim: "day",
|
||||
dir: -1,
|
||||
key: day,
|
||||
label: formatDate(day, "long"),
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const focusDay = $derived(
|
||||
cursor ?? days.find((d) => spending?.has(d)) ?? p.start
|
||||
);
|
||||
|
||||
function onkeydown(event: KeyboardEvent) {
|
||||
const moves: Record<string, number> = {
|
||||
ArrowDown: big ? 7 : 1,
|
||||
ArrowLeft: big ? -1 : -7,
|
||||
ArrowRight: big ? 1 : 7,
|
||||
ArrowUp: big ? -7 : -1,
|
||||
};
|
||||
if (event.key in moves) {
|
||||
event.preventDefault();
|
||||
const next = addDays(focusDay, moves[event.key]);
|
||||
if (next >= p.start && next <= p.end) {
|
||||
cursor = next;
|
||||
document.getElementById(`cal-${next}`)?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const label = (day: string) => {
|
||||
const value = spending?.get(day)?.value ?? 0;
|
||||
return `${formatDate(day, "long")}: ${value ? money(mode, -value) : "no spending"}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<ReportBox
|
||||
body="p-3"
|
||||
empty={values.length === 0}
|
||||
emptyText="No spending in {modeLabel(mode)} in this period."
|
||||
id="calendar"
|
||||
loading={!spending}
|
||||
title="Daily spending ({modeLabel(mode)})"
|
||||
>
|
||||
<div bind:clientWidth={width}>
|
||||
{#if big}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each months as month (month.key)}
|
||||
<div>
|
||||
{#if months.length > 1}
|
||||
<h3 class="mb-1 font-semibold text-[12px] text-cladd-fg-soft">
|
||||
{month.label}
|
||||
</h3>
|
||||
{/if}
|
||||
<div class="grid grid-cols-7 gap-0.5">
|
||||
{#each WEEKDAYS as name (name)}
|
||||
<div class="px-1 text-[11px] text-cladd-fg-soft">{name}</div>
|
||||
{/each}
|
||||
{#each month.cells as day, i (day ?? `pad-${i}`)}
|
||||
{#if day}
|
||||
{@const value = spending?.get(day)?.value ?? 0}
|
||||
{@const step = value ? level(value) : -1}
|
||||
<button
|
||||
aria-label={label(day)}
|
||||
class="flex h-11 min-w-0 flex-col justify-between rounded-[4px] px-1.5 py-1 text-left {report
|
||||
.drill?.box === "calendar" && report.drill.key === day
|
||||
? "outline-2 outline-cladd-fg"
|
||||
: ""}"
|
||||
id="cal-{day}"
|
||||
onclick={() => open(day)}
|
||||
onfocus={() => (cursor = day)}
|
||||
{onkeydown}
|
||||
tabindex={day === focusDay ? 0 : -1}
|
||||
type="button"
|
||||
style:background={step >= 0
|
||||
? fill(step)
|
||||
: "var(--cladd-surface-next)"}
|
||||
style:color={step >= 3 ? "white" : undefined}
|
||||
>
|
||||
<span class="num text-[11px] opacity-70"
|
||||
>{Number(day.slice(8, 10))}</span
|
||||
>
|
||||
{#if value}
|
||||
<span
|
||||
class="num self-end truncate text-[12px] font-medium"
|
||||
>{amount(value, width < 520)}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-wrap items-start gap-x-8 gap-y-4">
|
||||
<div class="overflow-x-auto">
|
||||
<div class="flex gap-[2px]">
|
||||
<div class="mr-1 flex flex-col gap-[2px] pt-4">
|
||||
{#each WEEKDAYS as name, i (name)}
|
||||
<div
|
||||
class="text-[10px] leading-none text-cladd-fg-soft"
|
||||
style:height="{cellSize}px"
|
||||
>
|
||||
{i % 2 === 0 ? name : ""}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#each weeks as week, w (w)}
|
||||
{@const firstDay = week.find((d) => d !== null)}
|
||||
<div class="flex flex-col gap-[2px]">
|
||||
<div
|
||||
class="h-4 whitespace-nowrap text-[10px] text-cladd-fg-soft"
|
||||
style:width="{cellSize}px"
|
||||
>
|
||||
{#if firstDay &&
|
||||
(w === 0 || Number(firstDay.slice(8, 10)) <= 7)}
|
||||
{monthShort.format(parse(firstDay))}
|
||||
{/if}
|
||||
</div>
|
||||
{#each week as day, d (d)}
|
||||
{#if day}
|
||||
{@const value = spending?.get(day)?.value ?? 0}
|
||||
{@const step = value ? level(value) : -1}
|
||||
<button
|
||||
aria-label={label(day)}
|
||||
class="rounded-[3px]"
|
||||
id="cal-{day}"
|
||||
onclick={() => open(day)}
|
||||
onfocus={() => (cursor = day)}
|
||||
{onkeydown}
|
||||
tabindex={day === focusDay ? 0 : -1}
|
||||
title={label(day)}
|
||||
type="button"
|
||||
style:background={step >= 0
|
||||
? fill(step)
|
||||
: "var(--cladd-surface-next)"}
|
||||
style:height="{cellSize}px"
|
||||
style:width="{cellSize}px"
|
||||
></button>
|
||||
{:else}
|
||||
<div
|
||||
style:height="{cellSize}px"
|
||||
style:width="{cellSize}px"
|
||||
></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-[14rem] flex-1">
|
||||
<h3 class="mb-1 font-semibold text-[12px] text-cladd-fg-soft">
|
||||
Biggest days
|
||||
</h3>
|
||||
<ul>
|
||||
{#each top as [day, info] (day)}
|
||||
<li>
|
||||
<button
|
||||
class="flex h-7 w-full items-center justify-between gap-3 rounded-cladd-sm px-1 hover:bg-row-hover"
|
||||
onclick={() => open(day)}
|
||||
type="button"
|
||||
>
|
||||
<span class="num text-cladd-fg-soft"
|
||||
>{formatDate(day, "medium")}</span
|
||||
>
|
||||
<span class="text-[11px] text-cladd-fg-soft"
|
||||
>{info.n}
|
||||
{info.n === 1 ? "transaction" : "transactions"}</span
|
||||
>
|
||||
<span class="num ml-auto">{money(mode, -info.value)}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="mt-2 text-[12px] text-cladd-fg-soft">
|
||||
{quiet}
|
||||
of {days.length} days without spending
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="mt-3 flex flex-wrap items-center gap-1.5 text-[11px] text-cladd-fg-soft"
|
||||
>
|
||||
<span>Less</span>
|
||||
{#each STEPS as _, i (i)}
|
||||
<span
|
||||
class="inline-block size-3 rounded-[3px]"
|
||||
title={i === 0
|
||||
? `up to ${amount(thresholds[0] ?? 0, false)}`
|
||||
: `over ${amount(thresholds[i - 1] ?? 0, false)}`}
|
||||
style:background={fill(i)}
|
||||
></span>
|
||||
{/each}
|
||||
<span>More</span>
|
||||
{#if thresholds.length > 0}
|
||||
<span class="num ml-2"
|
||||
>over {amount(thresholds.at(-1) ?? 0, false)} is the darkest</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</ReportBox>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { contains } from "#lib/dates.js";
|
||||
import { formatDate } from "#lib/format.js";
|
||||
import { expectedDates } from "#lib/reports/bills.js";
|
||||
import { reportQueries } from "#lib/reports/queries.js";
|
||||
import { money, useReport } from "#lib/reports/report.svelte.js";
|
||||
import ReportBox from "./ReportBox.svelte";
|
||||
|
||||
const report = useReport();
|
||||
const p = $derived(report.params);
|
||||
const bills = createQuery(() => reportQueries.bills(p.start, p.end));
|
||||
|
||||
const rows = $derived(
|
||||
(bills.data ?? [])
|
||||
.map((item) => {
|
||||
const a = item.attributes;
|
||||
const expected = expectedDates(a, p);
|
||||
const paid = (a.paid_dates ?? []).filter(
|
||||
(d) => d.date && contains(p, d.date)
|
||||
);
|
||||
return { a, expected, id: item.id, paid };
|
||||
})
|
||||
.filter(
|
||||
(row) =>
|
||||
row.a.active && (row.expected.length > 0 || row.paid.length > 0)
|
||||
)
|
||||
.sort((x, y) => (x.a.name ?? "").localeCompare(y.a.name ?? ""))
|
||||
);
|
||||
</script>
|
||||
|
||||
<ReportBox
|
||||
empty={rows.length === 0}
|
||||
emptyText="No subscriptions expected or paid in this period."
|
||||
id="bills"
|
||||
loading={!bills.data}
|
||||
title="Subscriptions"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr class="text-[12px] text-cladd-fg-soft">
|
||||
<th class="h-7 px-3 text-left font-medium">Name</th>
|
||||
<th class="h-7 px-3 text-right font-medium">Minimum amount</th>
|
||||
<th class="h-7 px-3 text-right font-medium">Maximum amount</th>
|
||||
<th class="h-7 px-3 text-left font-medium">Expected on</th>
|
||||
<th class="h-7 px-3 text-left font-medium">Paid</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row (row.id)}
|
||||
{@const code = row.a.currency_code ?? ""}
|
||||
<tr
|
||||
class="border-cladd-outline border-t align-top hover:bg-row-hover"
|
||||
>
|
||||
<td class="max-w-[16rem] truncate px-3 py-1">
|
||||
<a class="hover:underline" href="/subscriptions/show/{row.id}"
|
||||
>{row.a.name}</a
|
||||
>
|
||||
</td>
|
||||
<td class="num px-3 py-1 text-right">
|
||||
{money(code, Number(row.a.amount_min ?? 0))}
|
||||
</td>
|
||||
<td class="num px-3 py-1 text-right">
|
||||
{money(code, Number(row.a.amount_max ?? 0))}
|
||||
</td>
|
||||
<td class="num px-3 py-1 text-cladd-fg-soft">
|
||||
{row.expected.map((d) => formatDate(d, "short")).join(", ")}
|
||||
</td>
|
||||
<td class="px-3 py-1">
|
||||
{#each row.paid as paid (paid.transaction_journal_id)}
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
class="num hover:underline"
|
||||
href="/transactions/show/{paid.transaction_group_id}"
|
||||
>{formatDate(paid.date, "short")}</a
|
||||
>
|
||||
<span class="num text-expense"
|
||||
>{money(
|
||||
paid.currency_code ?? code,
|
||||
-Number(paid.amount ?? 0)
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-cladd-fg-soft">Not charged (yet)</span>
|
||||
{/each}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ReportBox>
|
||||
@@ -0,0 +1,201 @@
|
||||
<script lang="ts" module>
|
||||
export type ColumnKind =
|
||||
| "in"
|
||||
| "out"
|
||||
| "net"
|
||||
| "avg-in"
|
||||
| "avg-out"
|
||||
| "pct-in"
|
||||
| "pct-out"
|
||||
| "count";
|
||||
|
||||
export interface Column {
|
||||
kind: ColumnKind;
|
||||
label: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { formatNumber, percent } from "#lib/format.js";
|
||||
import {
|
||||
currencies,
|
||||
type Dimension,
|
||||
type Measure,
|
||||
type Row,
|
||||
rank,
|
||||
type Side,
|
||||
type Totals,
|
||||
} from "#lib/reports/flows.js";
|
||||
import {
|
||||
drillQuery,
|
||||
money,
|
||||
navigate,
|
||||
useReport,
|
||||
} from "#lib/reports/report.svelte.js";
|
||||
|
||||
let {
|
||||
box,
|
||||
rows,
|
||||
columns,
|
||||
dim,
|
||||
dir,
|
||||
footer,
|
||||
sort = "out",
|
||||
limit,
|
||||
nameLabel = "Name",
|
||||
counts = false,
|
||||
averageAll = false,
|
||||
}: {
|
||||
box: string;
|
||||
rows: Row[];
|
||||
columns: Column[];
|
||||
dim: Dimension;
|
||||
dir?: 1 | -1;
|
||||
footer?: Totals;
|
||||
sort?: Side;
|
||||
limit?: number;
|
||||
nameLabel?: string;
|
||||
counts?: boolean;
|
||||
averageAll?: boolean;
|
||||
} = $props();
|
||||
|
||||
const report = useReport();
|
||||
let expanded = $state(false);
|
||||
|
||||
const sorted = $derived(rank(rows, report.mode, sort));
|
||||
const shown = $derived(limit && !expanded ? sorted.slice(0, limit) : sorted);
|
||||
|
||||
interface Line {
|
||||
code: string;
|
||||
text: string;
|
||||
tone: string;
|
||||
}
|
||||
|
||||
function cell(
|
||||
kind: ColumnKind,
|
||||
m: Measure,
|
||||
whole: Measure | undefined,
|
||||
code: string
|
||||
): Line | null {
|
||||
if (kind === "count") {
|
||||
return { code, text: formatNumber(m.n, 0), tone: "" };
|
||||
}
|
||||
if (kind === "pct-in" || kind === "pct-out") {
|
||||
const part = kind === "pct-in" ? m.in : m.out;
|
||||
const all = (kind === "pct-in" ? whole?.in : whole?.out) ?? 0;
|
||||
return part && all
|
||||
? { code, text: percent(part / all, 1), tone: "" }
|
||||
: null;
|
||||
}
|
||||
const avg = kind === "avg-in" || kind === "avg-out";
|
||||
const base = { in: m.in, net: m.in - m.out, out: -m.out }[
|
||||
kind.replace("avg-", "") as "in" | "net" | "out"
|
||||
];
|
||||
if (!base || (avg && m.n < (averageAll ? 1 : 2))) {
|
||||
return null;
|
||||
}
|
||||
const value = avg ? base / m.n : base;
|
||||
return {
|
||||
code,
|
||||
text: money(code, value),
|
||||
tone: value < 0 ? "text-expense" : "text-income",
|
||||
};
|
||||
}
|
||||
|
||||
function lines(totals: Totals, kind: ColumnKind): Line[] {
|
||||
return currencies(totals, report.codes).flatMap((code) => {
|
||||
const m = totals.cur.get(code);
|
||||
const line = m ? cell(kind, m, footer?.cur.get(code), code) : null;
|
||||
return line ? [line] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function drill(row: Row) {
|
||||
const current = report.drill;
|
||||
const same =
|
||||
current?.box === box && current.key === row.key && current.dim === dim;
|
||||
navigate(
|
||||
drillQuery(
|
||||
same ? null : { box, dim, dir, key: row.key, label: row.label }
|
||||
)
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr class="text-[12px] text-cladd-fg-soft">
|
||||
<th class="h-7 px-3 text-left font-medium">{nameLabel}</th>
|
||||
{#each columns as column (column.kind)}
|
||||
<th class="h-7 whitespace-nowrap px-3 text-right font-medium">
|
||||
{column.label}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each shown as row (row.key)}
|
||||
{@const active =
|
||||
report.drill?.box === box && report.drill.key === row.key}
|
||||
<tr
|
||||
class="border-cladd-outline border-t align-top hover:bg-row-hover {active
|
||||
? "bg-row-selected"
|
||||
: ""}"
|
||||
>
|
||||
<td class="max-w-[18rem] px-3 py-1">
|
||||
<button
|
||||
aria-pressed={active}
|
||||
class="block max-w-full truncate text-left hover:text-brand dark:hover:text-[var(--ff-series-1)] hover:underline"
|
||||
onclick={() => drill(row)}
|
||||
title="Show transactions"
|
||||
type="button"
|
||||
>
|
||||
{row.label}
|
||||
</button>
|
||||
{#if counts && row.totals.n > 1}
|
||||
<span class="text-[11px] text-cladd-fg-soft"
|
||||
>{row.totals.n}
|
||||
transactions</span
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
{#each columns as column (column.kind)}
|
||||
<td class="whitespace-nowrap px-3 py-1 text-right">
|
||||
{#each lines(row.totals, column.kind) as line (line.code)}
|
||||
<div class="num {line.tone}">{line.text}</div>
|
||||
{/each}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
{#if footer && footer.n > 0}
|
||||
<tfoot>
|
||||
<tr class="border-cladd-outline border-t-2 align-top font-semibold">
|
||||
<td class="px-3 py-1">Sum</td>
|
||||
{#each columns as column (column.kind)}
|
||||
<td class="whitespace-nowrap px-3 py-1 text-right">
|
||||
{#if !(
|
||||
column.kind.startsWith("pct") || column.kind.startsWith("avg")
|
||||
)}
|
||||
{#each lines(footer, column.kind) as line (line.code)}
|
||||
<div class="num {line.tone}">{line.text}</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
</tfoot>
|
||||
{/if}
|
||||
</table>
|
||||
</div>
|
||||
{#if limit && sorted.length > limit}
|
||||
<button
|
||||
class="w-full border-cladd-outline border-t px-3 py-1.5 text-left text-[12px] text-brand dark:text-[var(--ff-series-1)] hover:underline"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
type="button"
|
||||
>
|
||||
{expanded ? "Show top" : `Show entire list (${sorted.length})`}
|
||||
</button>
|
||||
{/if}
|
||||
Reference in new issue
Block a user