mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
feat: custom year range on Cash Flow and Reports, drop redundant 12m preset
"This year" and "12 months" were two different ways of showing roughly the same recent window, which read as duplicated. Replaced the "12m" preset with "custom": pick an arbitrary fromYear-toYear span via a new YearRangeSelector (two dropdowns built on the same internal component YearSelector already uses). Also de-duplicated the RangePreset/computeRange logic that was copied verbatim between CashFlowPage and ReportsPage into shared lib/dateRange.ts — both pages had the identical bug/duplication.
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "aurum-frontend",
|
"name": "aurum-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.16.1",
|
"version": "0.17.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -2,15 +2,15 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface YearSelectorProps {
|
interface YearDropdownProps {
|
||||||
years: number[];
|
years: number[];
|
||||||
year: number;
|
year: number;
|
||||||
onChange: (year: number) => void;
|
onChange: (year: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compact dropdown, most recent year first — sits to the right of
|
/** Compact single-year dropdown, most recent year first. Shared building
|
||||||
* MonthSelector instead of competing with it as a second row of pills. */
|
* block behind both YearSelector and YearRangeSelector below. */
|
||||||
export function YearSelector({ years, year, onChange }: YearSelectorProps) {
|
function YearDropdown({ years, year, onChange }: YearDropdownProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const sortedYears = [...years].sort((a, b) => b - a);
|
const sortedYears = [...years].sort((a, b) => b - a);
|
||||||
@@ -78,3 +78,29 @@ export function YearSelector({ years, year, onChange }: YearSelectorProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sits to the right of MonthSelector instead of competing with it as a
|
||||||
|
* second row of pills. */
|
||||||
|
export function YearSelector(props: YearDropdownProps) {
|
||||||
|
return <YearDropdown {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YearRangeSelectorProps {
|
||||||
|
years: number[];
|
||||||
|
fromYear: number;
|
||||||
|
toYear: number;
|
||||||
|
onChange: (range: { fromYear: number; toYear: number }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two year dropdowns for picking an arbitrary "from year – to year" span
|
||||||
|
* (CashFlow/Reports "custom" range preset). Keeps fromYear <= toYear by
|
||||||
|
* dragging the other bound along instead of allowing an inverted range. */
|
||||||
|
export function YearRangeSelector({ years, fromYear, toYear, onChange }: YearRangeSelectorProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
<YearDropdown years={years} year={fromYear} onChange={(value) => onChange({ fromYear: value, toYear: Math.max(toYear, value) })} />
|
||||||
|
<span className="text-sm text-text-muted">–</span>
|
||||||
|
<YearDropdown years={years} year={toYear} onChange={(value) => onChange({ fromYear: Math.min(fromYear, value), toYear: value })} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
33
frontend/src/lib/dateRange.ts
Normal file
33
frontend/src/lib/dateRange.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/** Shared by CashFlowPage and ReportsPage — both offer the same four
|
||||||
|
* period presets over their respective date-ranged endpoints. */
|
||||||
|
export type RangePreset = "all" | "this_year" | "5y" | "custom";
|
||||||
|
|
||||||
|
export interface CustomYearRange {
|
||||||
|
fromYear: number;
|
||||||
|
toYear: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoDate(date: Date): string {
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeRange(
|
||||||
|
preset: RangePreset,
|
||||||
|
custom?: CustomYearRange
|
||||||
|
): { startDate?: string; endDate?: string } {
|
||||||
|
const today = new Date();
|
||||||
|
switch (preset) {
|
||||||
|
case "all":
|
||||||
|
return {};
|
||||||
|
case "this_year":
|
||||||
|
return { startDate: `${today.getFullYear()}-01-01`, endDate: isoDate(today) };
|
||||||
|
case "5y": {
|
||||||
|
const start = new Date(today.getFullYear() - 5, today.getMonth(), 1);
|
||||||
|
return { startDate: isoDate(start), endDate: isoDate(today) };
|
||||||
|
}
|
||||||
|
case "custom": {
|
||||||
|
if (!custom) return {};
|
||||||
|
return { startDate: `${custom.fromYear}-01-01`, endDate: `${custom.toYear}-12-31` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,9 +169,9 @@ const ru = {
|
|||||||
"reports.expenseGroup": "Расходы",
|
"reports.expenseGroup": "Расходы",
|
||||||
"reports.incomeGroup": "Доходы",
|
"reports.incomeGroup": "Доходы",
|
||||||
"reports.rangeAll": "Всё время",
|
"reports.rangeAll": "Всё время",
|
||||||
"reports.range5y": "5 лет",
|
|
||||||
"reports.range12m": "12 мес",
|
|
||||||
"reports.rangeThisYear": "Этот год",
|
"reports.rangeThisYear": "Этот год",
|
||||||
|
"reports.range5y": "5 лет",
|
||||||
|
"reports.rangeCustom": "Диапазон лет",
|
||||||
"reports.transactionsTitle": "Транзакции",
|
"reports.transactionsTitle": "Транзакции",
|
||||||
"reports.defaultChartTitle": "Расходы по категории",
|
"reports.defaultChartTitle": "Расходы по категории",
|
||||||
"reports.averagePerMonth": "{{amount}}/мес в среднем",
|
"reports.averagePerMonth": "{{amount}}/мес в среднем",
|
||||||
@@ -491,9 +491,9 @@ const en: Record<keyof typeof ru, string> = {
|
|||||||
"reports.expenseGroup": "Expenses",
|
"reports.expenseGroup": "Expenses",
|
||||||
"reports.incomeGroup": "Income",
|
"reports.incomeGroup": "Income",
|
||||||
"reports.rangeAll": "All time",
|
"reports.rangeAll": "All time",
|
||||||
"reports.range5y": "5 years",
|
|
||||||
"reports.range12m": "12 mo",
|
|
||||||
"reports.rangeThisYear": "This year",
|
"reports.rangeThisYear": "This year",
|
||||||
|
"reports.range5y": "5 years",
|
||||||
|
"reports.rangeCustom": "Year range",
|
||||||
"reports.transactionsTitle": "Transactions",
|
"reports.transactionsTitle": "Transactions",
|
||||||
"reports.defaultChartTitle": "Spending by category",
|
"reports.defaultChartTitle": "Spending by category",
|
||||||
"reports.averagePerMonth": "{{amount}}/mo average",
|
"reports.averagePerMonth": "{{amount}}/mo average",
|
||||||
|
|||||||
@@ -1,51 +1,44 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { PillSelector } from "@/components/layout/PillSelector";
|
import { PillSelector } from "@/components/layout/PillSelector";
|
||||||
|
import { YearRangeSelector } from "@/components/layout/YearSelector";
|
||||||
import { CashFlowChart } from "@/components/cashflow/CashFlowChart";
|
import { CashFlowChart } from "@/components/cashflow/CashFlowChart";
|
||||||
import { useCashFlow } from "@/hooks/useCashFlow";
|
import { useCashFlow } from "@/hooks/useCashFlow";
|
||||||
|
import { useTransactionYears } from "@/hooks/useTransactions";
|
||||||
|
import { computeRange, type CustomYearRange, type RangePreset } from "@/lib/dateRange";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
|
||||||
type RangePreset = "all" | "5y" | "12m" | "this_year";
|
|
||||||
|
|
||||||
function isoDate(date: Date): string {
|
|
||||||
return date.toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeRange(preset: RangePreset): { startDate?: string; endDate?: string } {
|
|
||||||
const today = new Date();
|
|
||||||
const end = isoDate(today);
|
|
||||||
switch (preset) {
|
|
||||||
case "all":
|
|
||||||
return {};
|
|
||||||
case "this_year":
|
|
||||||
return { startDate: `${today.getFullYear()}-01-01`, endDate: end };
|
|
||||||
case "12m": {
|
|
||||||
const start = new Date(today.getFullYear(), today.getMonth() - 11, 1);
|
|
||||||
return { startDate: isoDate(start), endDate: end };
|
|
||||||
}
|
|
||||||
case "5y": {
|
|
||||||
const start = new Date(today.getFullYear() - 5, today.getMonth(), 1);
|
|
||||||
return { startDate: isoDate(start), endDate: end };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CashFlowPage() {
|
export function CashFlowPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [range, setRange] = useState<RangePreset>("12m");
|
const now = new Date();
|
||||||
|
const { data: years } = useTransactionYears();
|
||||||
|
const [range, setRange] = useState<RangePreset>("this_year");
|
||||||
|
const [customRange, setCustomRange] = useState<CustomYearRange>({
|
||||||
|
fromYear: now.getFullYear(),
|
||||||
|
toYear: now.getFullYear(),
|
||||||
|
});
|
||||||
|
|
||||||
const RANGE_OPTIONS: Array<{ value: RangePreset; label: string }> = [
|
const RANGE_OPTIONS: Array<{ value: RangePreset; label: string }> = [
|
||||||
{ value: "all", label: t("reports.rangeAll") },
|
{ value: "all", label: t("reports.rangeAll") },
|
||||||
{ value: "5y", label: t("reports.range5y") },
|
|
||||||
{ value: "12m", label: t("reports.range12m") },
|
|
||||||
{ value: "this_year", label: t("reports.rangeThisYear") },
|
{ value: "this_year", label: t("reports.rangeThisYear") },
|
||||||
|
{ value: "5y", label: t("reports.range5y") },
|
||||||
|
{ value: "custom", label: t("reports.rangeCustom") },
|
||||||
];
|
];
|
||||||
|
|
||||||
const { startDate, endDate } = computeRange(range);
|
const { startDate, endDate } = computeRange(range, customRange);
|
||||||
const { data: cashFlow, isLoading } = useCashFlow(startDate, endDate);
|
const { data: cashFlow, isLoading } = useCashFlow(startDate, endDate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="flex justify-end">
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
<PillSelector options={RANGE_OPTIONS} value={range} onChange={setRange} />
|
<PillSelector options={RANGE_OPTIONS} value={range} onChange={setRange} />
|
||||||
|
{range === "custom" && (
|
||||||
|
<YearRangeSelector
|
||||||
|
years={years ?? [now.getFullYear()]}
|
||||||
|
fromYear={customRange.fromYear}
|
||||||
|
toYear={customRange.toYear}
|
||||||
|
onChange={setCustomRange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CashFlowChart cashFlow={cashFlow} isLoading={isLoading} />
|
<CashFlowChart cashFlow={cashFlow} isLoading={isLoading} />
|
||||||
|
|||||||
@@ -3,56 +3,39 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
|||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Label, Select } from "@/components/ui/Input";
|
import { Label, Select } from "@/components/ui/Input";
|
||||||
import { PillSelector } from "@/components/layout/PillSelector";
|
import { PillSelector } from "@/components/layout/PillSelector";
|
||||||
|
import { YearRangeSelector } from "@/components/layout/YearSelector";
|
||||||
import { CategoryRankingCard } from "@/components/reports/CategoryRankingCard";
|
import { CategoryRankingCard } from "@/components/reports/CategoryRankingCard";
|
||||||
import { CategorySpendingChart } from "@/components/reports/CategorySpendingChart";
|
import { CategorySpendingChart } from "@/components/reports/CategorySpendingChart";
|
||||||
import { TransactionsTable } from "@/components/transactions/TransactionsTable";
|
import { TransactionsTable } from "@/components/transactions/TransactionsTable";
|
||||||
import { TransactionFormModal } from "@/components/transactions/TransactionFormModal";
|
import { TransactionFormModal } from "@/components/transactions/TransactionFormModal";
|
||||||
import { useCategories } from "@/hooks/useCategories";
|
import { useCategories } from "@/hooks/useCategories";
|
||||||
import { useCategoryRanking, useCategorySpendingReport } from "@/hooks/useReports";
|
import { useCategoryRanking, useCategorySpendingReport } from "@/hooks/useReports";
|
||||||
import { useDeleteTransaction, useTransactions } from "@/hooks/useTransactions";
|
import { useDeleteTransaction, useTransactions, useTransactionYears } from "@/hooks/useTransactions";
|
||||||
import type { TransactionSort } from "@/api/transactions";
|
import type { TransactionSort } from "@/api/transactions";
|
||||||
|
import { computeRange, type CustomYearRange, type RangePreset } from "@/lib/dateRange";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||||
import type { Transaction } from "@/types";
|
import type { Transaction } from "@/types";
|
||||||
|
|
||||||
type RangePreset = "all" | "5y" | "12m" | "this_year";
|
|
||||||
|
|
||||||
function isoDate(date: Date): string {
|
|
||||||
return date.toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeRange(preset: RangePreset): { startDate?: string; endDate?: string } {
|
|
||||||
const today = new Date();
|
|
||||||
const end = isoDate(today);
|
|
||||||
switch (preset) {
|
|
||||||
case "all":
|
|
||||||
return {};
|
|
||||||
case "this_year":
|
|
||||||
return { startDate: `${today.getFullYear()}-01-01`, endDate: end };
|
|
||||||
case "12m": {
|
|
||||||
const start = new Date(today.getFullYear(), today.getMonth() - 11, 1);
|
|
||||||
return { startDate: isoDate(start), endDate: end };
|
|
||||||
}
|
|
||||||
case "5y": {
|
|
||||||
const start = new Date(today.getFullYear() - 5, today.getMonth(), 1);
|
|
||||||
return { startDate: isoDate(start), endDate: end };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
export function ReportsPage() {
|
export function ReportsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const now = new Date();
|
||||||
const RANGE_OPTIONS: Array<{ value: RangePreset; label: string }> = [
|
const RANGE_OPTIONS: Array<{ value: RangePreset; label: string }> = [
|
||||||
{ value: "all", label: t("reports.rangeAll") },
|
{ value: "all", label: t("reports.rangeAll") },
|
||||||
{ value: "5y", label: t("reports.range5y") },
|
|
||||||
{ value: "12m", label: t("reports.range12m") },
|
|
||||||
{ value: "this_year", label: t("reports.rangeThisYear") },
|
{ value: "this_year", label: t("reports.rangeThisYear") },
|
||||||
|
{ value: "5y", label: t("reports.range5y") },
|
||||||
|
{ value: "custom", label: t("reports.rangeCustom") },
|
||||||
];
|
];
|
||||||
const { data: categories } = useCategories();
|
const { data: categories } = useCategories();
|
||||||
|
const { data: years } = useTransactionYears();
|
||||||
const [categoryId, setCategoryId] = useState<number | null>(null);
|
const [categoryId, setCategoryId] = useState<number | null>(null);
|
||||||
const [range, setRange] = useState<RangePreset>("all");
|
const [range, setRange] = useState<RangePreset>("all");
|
||||||
|
const [customRange, setCustomRange] = useState<CustomYearRange>({
|
||||||
|
fromYear: now.getFullYear(),
|
||||||
|
toYear: now.getFullYear(),
|
||||||
|
});
|
||||||
const [sort, setSort] = useState<TransactionSort>("date_desc");
|
const [sort, setSort] = useState<TransactionSort>("date_desc");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
|
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
|
||||||
@@ -65,7 +48,7 @@ export function ReportsPage() {
|
|||||||
}
|
}
|
||||||
}, [categories, categoryId]);
|
}, [categories, categoryId]);
|
||||||
|
|
||||||
const { startDate, endDate } = computeRange(range);
|
const { startDate, endDate } = computeRange(range, customRange);
|
||||||
const { data: ranking, isLoading: isRankingLoading } = useCategoryRanking("expense", startDate, endDate);
|
const { data: ranking, isLoading: isRankingLoading } = useCategoryRanking("expense", startDate, endDate);
|
||||||
const { data: report, isLoading: isReportLoading } = useCategorySpendingReport(categoryId, startDate, endDate);
|
const { data: report, isLoading: isReportLoading } = useCategorySpendingReport(categoryId, startDate, endDate);
|
||||||
const { data: transactions, isLoading: isTransactionsLoading } = useTransactions({
|
const { data: transactions, isLoading: isTransactionsLoading } = useTransactions({
|
||||||
@@ -126,6 +109,7 @@ export function ReportsPage() {
|
|||||||
)}
|
)}
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
<PillSelector
|
<PillSelector
|
||||||
options={RANGE_OPTIONS}
|
options={RANGE_OPTIONS}
|
||||||
value={range}
|
value={range}
|
||||||
@@ -134,6 +118,18 @@ export function ReportsPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{range === "custom" && (
|
||||||
|
<YearRangeSelector
|
||||||
|
years={years ?? [now.getFullYear()]}
|
||||||
|
fromYear={customRange.fromYear}
|
||||||
|
toYear={customRange.toYear}
|
||||||
|
onChange={(value) => {
|
||||||
|
setCustomRange(value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CategoryRankingCard
|
<CategoryRankingCard
|
||||||
|
|||||||
Reference in New Issue
Block a user