- Extract shared defaultReferencePeriod helper (src/utils/referencePeriod.ts) - useHighlights now reads ?refY=YYYY&refM=MM, defaults to previous month - getHighlights signature: (referenceYear, referenceMonth, ytdYear, windowDays, ...) - YTD tile pinned to Jan 1 of current civil year, independent of reference month - CompareReferenceMonthPicker surfaced on /reports/highlights - Hub highlights panel inherits the same default via useHighlights - useCartes and useCompare now delegate their default-period helpers to the shared util
132 lines
4.8 KiB
TypeScript
132 lines
4.8 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link } from "react-router-dom";
|
|
import { ArrowLeft, Tag } from "lucide-react";
|
|
import CompareReferenceMonthPicker from "../components/reports/CompareReferenceMonthPicker";
|
|
import HubNetBalanceTile from "../components/reports/HubNetBalanceTile";
|
|
import HighlightsTopMoversTable from "../components/reports/HighlightsTopMoversTable";
|
|
import HighlightsTopMoversChart from "../components/reports/HighlightsTopMoversChart";
|
|
import HighlightsTopTransactionsList from "../components/reports/HighlightsTopTransactionsList";
|
|
import ViewModeToggle, { readViewMode, type ViewMode } from "../components/reports/ViewModeToggle";
|
|
import ContextMenu from "../components/shared/ContextMenu";
|
|
import AddKeywordDialog from "../components/categories/AddKeywordDialog";
|
|
import { useHighlights } from "../hooks/useHighlights";
|
|
import type { RecentTransaction } from "../shared/types";
|
|
|
|
const STORAGE_KEY = "reports-viewmode-highlights";
|
|
|
|
export default function ReportsHighlightsPage() {
|
|
const { t } = useTranslation();
|
|
const {
|
|
data,
|
|
isLoading,
|
|
error,
|
|
windowDays,
|
|
setWindowDays,
|
|
year,
|
|
month,
|
|
setReferencePeriod,
|
|
} = useHighlights();
|
|
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY));
|
|
const [menu, setMenu] = useState<{ x: number; y: number; tx: RecentTransaction } | null>(null);
|
|
const [pending, setPending] = useState<RecentTransaction | null>(null);
|
|
|
|
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
|
|
|
|
const handleContextMenu = (e: React.MouseEvent, tx: RecentTransaction) => {
|
|
e.preventDefault();
|
|
setMenu({ x: e.clientX, y: e.clientY, tx });
|
|
};
|
|
|
|
return (
|
|
<div className={isLoading ? "opacity-60" : ""}>
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<Link
|
|
to={`/reports${preserveSearch}`}
|
|
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] p-1 rounded-md hover:bg-[var(--muted)]"
|
|
aria-label={t("reports.hub.title")}
|
|
>
|
|
<ArrowLeft size={18} />
|
|
</Link>
|
|
<h1 className="text-2xl font-bold">{t("reports.hub.highlights")}</h1>
|
|
</div>
|
|
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 flex-wrap">
|
|
<CompareReferenceMonthPicker year={year} month={month} onChange={setReferencePeriod} />
|
|
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{data && (
|
|
<div className="flex flex-col gap-6">
|
|
<section>
|
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--muted-foreground)] mb-3">
|
|
{t("reports.highlights.balances")}
|
|
</h2>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<HubNetBalanceTile
|
|
label={t("reports.highlights.netBalanceCurrent")}
|
|
amount={data.netBalanceCurrent}
|
|
series={data.monthlyBalanceSeries.map((m) => m.netBalance)}
|
|
/>
|
|
<HubNetBalanceTile
|
|
label={t("reports.highlights.netBalanceYtd")}
|
|
amount={data.netBalanceYtd}
|
|
series={data.monthlyBalanceSeries.map((m) => m.netBalance)}
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--muted-foreground)] mb-3">
|
|
{t("reports.highlights.topMovers")}
|
|
</h2>
|
|
{viewMode === "chart" ? (
|
|
<HighlightsTopMoversChart movers={data.topMovers} />
|
|
) : (
|
|
<HighlightsTopMoversTable movers={data.topMovers} />
|
|
)}
|
|
</section>
|
|
|
|
<section>
|
|
<HighlightsTopTransactionsList
|
|
transactions={data.topTransactions}
|
|
windowDays={windowDays}
|
|
onWindowChange={setWindowDays}
|
|
onContextMenuRow={handleContextMenu}
|
|
/>
|
|
</section>
|
|
</div>
|
|
)}
|
|
|
|
{menu && (
|
|
<ContextMenu
|
|
x={menu.x}
|
|
y={menu.y}
|
|
header={menu.tx.description}
|
|
onClose={() => setMenu(null)}
|
|
items={[
|
|
{
|
|
icon: <Tag size={14} />,
|
|
label: t("reports.keyword.addFromTransaction"),
|
|
onClick: () => setPending(menu.tx),
|
|
},
|
|
]}
|
|
/>
|
|
)}
|
|
|
|
{pending && (
|
|
<AddKeywordDialog
|
|
initialKeyword={pending.description.split(/\s+/)[0] ?? ""}
|
|
onClose={() => setPending(null)}
|
|
onApplied={() => setPending(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|