init
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user