481 lines
16 KiB
TypeScript
481 lines
16 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import {
|
|
ArrowDownLeft,
|
|
ArrowUpRight,
|
|
Wallet,
|
|
ArrowRight,
|
|
TrendingUp,
|
|
} from "lucide-react";
|
|
import type { Dashboard, Dataset, Filter, Group } from "./api";
|
|
import { money, request } from "./api";
|
|
import { Empty, ErrorMessage, Filters } from "./ui";
|
|
export function Overview({
|
|
data,
|
|
revision,
|
|
filter,
|
|
setFilter,
|
|
navigate,
|
|
}: {
|
|
data: Dataset;
|
|
revision: string;
|
|
filter: Filter;
|
|
setFilter: (f: Filter) => void;
|
|
navigate: (page: string) => void;
|
|
}) {
|
|
const [dashboard, setDashboard] = useState<Dashboard | null>(null);
|
|
const [error, setError] = useState("");
|
|
const [loading, setLoading] = useState(true);
|
|
const [retry, setRetry] = useState(0);
|
|
useEffect(() => {
|
|
const controller = new AbortController();
|
|
setLoading(true);
|
|
setError("");
|
|
const params = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(filter))
|
|
if (value) params.set(key, value);
|
|
request<Dashboard>(`/api/dashboard?${params}`, undefined, controller.signal)
|
|
.then((value) => {
|
|
for (const key of [
|
|
"totals",
|
|
"previous",
|
|
"monthly",
|
|
"categories",
|
|
"tags",
|
|
"merchants",
|
|
"accounts",
|
|
"recurring",
|
|
] as const) {
|
|
if (!(key in value))
|
|
throw new Error(`Dashboard response is missing ${key}.`);
|
|
if (value[key] === null) Object.assign(value, { [key]: [] });
|
|
}
|
|
setDashboard(value);
|
|
})
|
|
.catch((err) => {
|
|
if (!controller.signal.aborted) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
setDashboard(null);
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (!controller.signal.aborted) setLoading(false);
|
|
});
|
|
return () => controller.abort();
|
|
}, [revision, filter, retry]);
|
|
return (
|
|
<>
|
|
<div className="section-heading">
|
|
<div>
|
|
<h2>Your financial picture</h2>
|
|
<p>A little clarity for the decisions ahead.</p>
|
|
</div>
|
|
<button
|
|
className="button secondary"
|
|
onClick={() => navigate("transactions")}
|
|
>
|
|
View transactions <ArrowRight size={16} />
|
|
</button>
|
|
</div>
|
|
<Filters data={data} value={filter} onChange={setFilter} />
|
|
<ErrorMessage error={error} />
|
|
{error && (
|
|
<button
|
|
className="button secondary"
|
|
onClick={() => setRetry(retry + 1)}
|
|
>
|
|
Retry analytics
|
|
</button>
|
|
)}
|
|
{loading ? (
|
|
<div className="loading-block" role="status">
|
|
<span className="spinner" />
|
|
Reading your financial picture…
|
|
</div>
|
|
) : (
|
|
dashboard && (
|
|
<>
|
|
{!data.transactions.length && (
|
|
<section className="welcome panel">
|
|
<div className="welcome-copy">
|
|
<span className="eyebrow">A fresh start</span>
|
|
<h3>
|
|
All your finances.
|
|
<br />A space of your own.
|
|
</h3>
|
|
<p>
|
|
Your private journal is ready. Add an account and import
|
|
your first statement to see the bigger picture.
|
|
</p>
|
|
<button
|
|
className="button primary"
|
|
onClick={() => navigate("accounts")}
|
|
>
|
|
Set up your accounts <ArrowRight size={16} />
|
|
</button>
|
|
</div>
|
|
<div className="welcome-art" aria-hidden="true">
|
|
<Wallet size={70} />
|
|
<span>Your data. Your server.</span>
|
|
</div>
|
|
</section>
|
|
)}
|
|
{dashboard.totals.map((total) => {
|
|
const previous = dashboard.previous.find(
|
|
(t) => t.currency === total.currency,
|
|
);
|
|
return (
|
|
<div className="stat-grid" key={total.currency}>
|
|
{(
|
|
[
|
|
{
|
|
key: "income",
|
|
label: "Money in",
|
|
Icon: ArrowDownLeft,
|
|
className: "positive",
|
|
},
|
|
{
|
|
key: "expenses",
|
|
label: "Money out",
|
|
Icon: ArrowUpRight,
|
|
className: "",
|
|
},
|
|
{
|
|
key: "net",
|
|
label: "Net cash flow",
|
|
Icon: Wallet,
|
|
className: total.net.startsWith("-") ? "" : "positive",
|
|
},
|
|
] as const
|
|
).map(({ key, label, Icon, className }) => (
|
|
<section className="panel stat" key={key}>
|
|
<div className="stat-top">
|
|
<span>{label}</span>
|
|
<span className={`stat-icon ${key}`}>
|
|
<Icon size={19} />
|
|
</span>
|
|
</div>
|
|
<strong className={`stat-value ${className}`}>
|
|
{money(total[key], total.currency)}
|
|
</strong>
|
|
<small>
|
|
{previous
|
|
? `Previous period ${money(previous[key], previous.currency)}`
|
|
: "No previous-period activity"}
|
|
</small>
|
|
</section>
|
|
))}
|
|
</div>
|
|
);
|
|
})}
|
|
{data.transactions.length > 0 && !dashboard.totals.length && (
|
|
<section className="panel">
|
|
<Empty title="No activity in this view">
|
|
Adjust your filters to include more transactions.
|
|
</Empty>
|
|
</section>
|
|
)}
|
|
<div className="dashboard-grid">
|
|
<section className="panel chart-panel">
|
|
<div className="panel-heading">
|
|
<div>
|
|
<h3>Monthly cash flow</h3>
|
|
<p>Net movement over time · transfers excluded</p>
|
|
</div>
|
|
<TrendingUp size={20} />
|
|
</div>
|
|
<MonthlyChart groups={dashboard.monthly} />
|
|
</section>
|
|
<CategoryTree
|
|
data={data}
|
|
groups={dashboard.categories}
|
|
onSelect={(id) => {
|
|
setFilter({ ...filter, category_id: id });
|
|
navigate("transactions");
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="dashboard-grid thirds">
|
|
<GroupPanel
|
|
title="Merchants"
|
|
subtitle="Where your money goes"
|
|
groups={dashboard.merchants}
|
|
onSelect={(id) => {
|
|
setFilter({ ...filter, merchant_id: id });
|
|
navigate("transactions");
|
|
}}
|
|
/>
|
|
<GroupPanel
|
|
title="Accounts"
|
|
subtitle="Movement by account"
|
|
groups={dashboard.accounts}
|
|
onSelect={(id) => {
|
|
setFilter({ ...filter, account_id: id });
|
|
navigate("transactions");
|
|
}}
|
|
/>
|
|
<GroupPanel
|
|
title="Tags"
|
|
subtitle="Your custom perspective"
|
|
groups={dashboard.tags}
|
|
onSelect={(id) => {
|
|
setFilter({ ...filter, tag_id: id });
|
|
navigate("transactions");
|
|
}}
|
|
/>
|
|
</div>
|
|
<section className="panel">
|
|
<div className="panel-heading">
|
|
<div>
|
|
<h3>Recurring patterns</h3>
|
|
<p>Repeated payments detected in the selected period</p>
|
|
</div>
|
|
<span className="badge neutral">Observed, not forecast</span>
|
|
</div>
|
|
{dashboard.recurring.length ? (
|
|
<div className="table-scroll">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Merchant / payment</th>
|
|
<th>Frequency</th>
|
|
<th>Occurrences</th>
|
|
<th className="numeric">Observed total</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{dashboard.recurring.map((g, i) => (
|
|
<tr key={`${g.id}-${g.currency}-${i}`}>
|
|
<td>{g.name}</td>
|
|
<td>{g.period}</td>
|
|
<td>{g.count}</td>
|
|
<td className="numeric money">
|
|
{money(g.amount, g.currency)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<div className="compact-empty">
|
|
No recurring patterns detected in this period.
|
|
</div>
|
|
)}
|
|
</section>
|
|
</>
|
|
)
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
function CategoryTree({
|
|
data,
|
|
groups,
|
|
onSelect,
|
|
}: {
|
|
data: Dataset;
|
|
groups: Group[];
|
|
onSelect: (id: string) => void;
|
|
}) {
|
|
const [expanded, setExpanded] = useState<string[]>(
|
|
data.categories.filter((c) => !c.parent_id).map((c) => c.id),
|
|
);
|
|
const [showAll, setShowAll] = useState(false);
|
|
const available = new Set(groups.map((g) => g.id));
|
|
const roots = data.categories.filter(
|
|
(c) => !c.parent_id && available.has(c.id),
|
|
);
|
|
const node = (id: string, depth: number): React.ReactNode => {
|
|
const category = data.categories.find((c) => c.id === id);
|
|
const children = data.categories.filter(
|
|
(c) => c.parent_id === id && available.has(c.id),
|
|
);
|
|
const rows = groups.filter((g) => g.id === id);
|
|
return (
|
|
<div key={id} className="category-node">
|
|
<div
|
|
className="category-line"
|
|
style={{ paddingLeft: `${depth * 17}px` }}
|
|
>
|
|
{children.length ? (
|
|
<button
|
|
className="icon-button"
|
|
aria-label={`${expanded.includes(id) ? "Collapse" : "Expand"} ${category?.name || id}`}
|
|
aria-expanded={expanded.includes(id)}
|
|
onClick={() =>
|
|
setExpanded(
|
|
expanded.includes(id)
|
|
? expanded.filter((v) => v !== id)
|
|
: [...expanded, id],
|
|
)
|
|
}
|
|
>
|
|
<ArrowRight
|
|
size={13}
|
|
style={{
|
|
transform: expanded.includes(id)
|
|
? "rotate(90deg)"
|
|
: undefined,
|
|
}}
|
|
/>
|
|
</button>
|
|
) : (
|
|
<span className="tree-spacer" />
|
|
)}
|
|
<button className="category-drill" onClick={() => onSelect(id)}>
|
|
<span>{category?.name || id}</span>
|
|
<span>
|
|
{rows.map((g) => (
|
|
<strong className="money" key={g.currency}>
|
|
{money(g.amount, g.currency)}
|
|
</strong>
|
|
))}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
{expanded.includes(id) &&
|
|
(showAll ? children : children.slice(0, 6)).map((c) =>
|
|
node(c.id, depth + 1),
|
|
)}
|
|
{expanded.includes(id) && !showAll && children.length > 6 && (
|
|
<button className="button subtle" onClick={() => setShowAll(true)}>
|
|
Show remaining {children.length - 6} categories
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
return (
|
|
<section className="panel">
|
|
<div className="panel-heading">
|
|
<div>
|
|
<h3>Category breakdown</h3>
|
|
<p>Expand the tree · parents include their descendants</p>
|
|
</div>
|
|
</div>
|
|
{groups.length ? (
|
|
<div className="category-tree">{roots.map((c) => node(c.id, 0))}</div>
|
|
) : (
|
|
<div className="compact-empty">
|
|
Your category breakdown appears after importing transactions.
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
function MonthlyChart({ groups }: { groups: Group[] }) {
|
|
if (!groups.length)
|
|
return (
|
|
<Empty title="Room for a bigger picture">
|
|
Your monthly trend appears after importing transactions.
|
|
</Empty>
|
|
);
|
|
const currencies = Array.from(new Set(groups.map((g) => g.currency)));
|
|
return (
|
|
<div className="monthly-charts">
|
|
{currencies.map((currency) => {
|
|
const rows = groups
|
|
.filter((g) => g.currency === currency)
|
|
.sort((a, b) => a.period.localeCompare(b.period));
|
|
const max = Math.max(...rows.map((g) => Math.abs(Number(g.amount))), 1);
|
|
return (
|
|
<div key={currency}>
|
|
<span className="eyebrow">{currency}</span>
|
|
<div
|
|
className="bar-chart"
|
|
role="img"
|
|
aria-label={`Monthly net cash flow in ${currency}. ${rows.map((g) => `${g.period}: ${g.amount}`).join("; ")}`}
|
|
>
|
|
{rows.map((g, i) => (
|
|
<div className="bar-column" key={`${g.period}-${i}`}>
|
|
<span className="bar-value">{money(g.amount, currency)}</span>
|
|
<div className="bar-track">
|
|
<div
|
|
className={`bar ${g.amount.startsWith("-") ? "negative" : ""}`}
|
|
style={{
|
|
height: `${Math.max((Math.abs(Number(g.amount)) / max) * 100, 1)}%`,
|
|
}}
|
|
title={`${g.period}: ${money(g.amount, currency)}`}
|
|
/>
|
|
</div>
|
|
<span className="bar-label">{g.period}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
function GroupPanel({
|
|
title,
|
|
subtitle,
|
|
groups,
|
|
onSelect,
|
|
}: {
|
|
title: string;
|
|
subtitle: string;
|
|
groups: Group[];
|
|
onSelect: (id: string) => void;
|
|
}) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const maxima: Record<string, number> = {};
|
|
for (const g of groups)
|
|
maxima[g.currency] = Math.max(
|
|
maxima[g.currency] || 1,
|
|
Math.abs(Number(g.amount)),
|
|
);
|
|
const sorted = [...groups].sort(
|
|
(a, b) =>
|
|
a.currency.localeCompare(b.currency) ||
|
|
Math.abs(Number(b.amount)) - Math.abs(Number(a.amount)),
|
|
);
|
|
return (
|
|
<section className="panel">
|
|
<div className="panel-heading">
|
|
<div>
|
|
<h3>{title}</h3>
|
|
<p>{subtitle}</p>
|
|
</div>
|
|
</div>
|
|
{groups.length ? (
|
|
<div className="group-list">
|
|
{(expanded ? sorted : sorted.slice(0, 6)).map((g, i) => (
|
|
<button
|
|
className="group-row"
|
|
key={`${g.id}-${g.currency}-${i}`}
|
|
onClick={() => onSelect(g.id)}
|
|
>
|
|
<div>
|
|
<span className="group-name">{g.name || "Unassigned"}</span>
|
|
<strong className="money">{money(g.amount, g.currency)}</strong>
|
|
</div>
|
|
<div className="group-track">
|
|
<span
|
|
style={{
|
|
width: `${Math.max(1, (Math.abs(Number(g.amount)) / maxima[g.currency]) * 100)}%`,
|
|
}}
|
|
/>
|
|
</div>
|
|
<small>
|
|
{g.count} transaction{g.count === 1 ? "" : "s"}
|
|
</small>
|
|
</button>
|
|
))}
|
|
{groups.length > 6 && (
|
|
<button
|
|
className="button subtle"
|
|
onClick={() => setExpanded(!expanded)}
|
|
>
|
|
{expanded ? "Show less" : `Show all ${groups.length}`}
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="compact-empty">No activity in this view.</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|