feat: add custom date range picker to reports and dashboard
Allow users to select an arbitrary date interval (e.g. Jan 1–15) in addition to the existing preset periods. The "Custom" button opens a dropdown with two date inputs and an Apply button. Works on both the Reports and Dashboard pages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3434c9e11f
commit
446f6effab
6 changed files with 203 additions and 27 deletions
|
|
@ -1,4 +1,6 @@
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Calendar } from "lucide-react";
|
||||||
import type { DashboardPeriod } from "../../shared/types";
|
import type { DashboardPeriod } from "../../shared/types";
|
||||||
|
|
||||||
const PERIODS: DashboardPeriod[] = ["month", "3months", "6months", "12months", "all"];
|
const PERIODS: DashboardPeriod[] = ["month", "3months", "6months", "12months", "all"];
|
||||||
|
|
@ -6,17 +8,59 @@ const PERIODS: DashboardPeriod[] = ["month", "3months", "6months", "12months", "
|
||||||
interface PeriodSelectorProps {
|
interface PeriodSelectorProps {
|
||||||
value: DashboardPeriod;
|
value: DashboardPeriod;
|
||||||
onChange: (period: DashboardPeriod) => void;
|
onChange: (period: DashboardPeriod) => void;
|
||||||
|
customDateFrom?: string;
|
||||||
|
customDateTo?: string;
|
||||||
|
onCustomDateChange?: (dateFrom: string, dateTo: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
|
export default function PeriodSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
customDateFrom,
|
||||||
|
customDateTo,
|
||||||
|
onCustomDateChange,
|
||||||
|
}: PeriodSelectorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [showCustom, setShowCustom] = useState(false);
|
||||||
|
const [localFrom, setLocalFrom] = useState(customDateFrom ?? "");
|
||||||
|
const [localTo, setLocalTo] = useState(customDateTo ?? "");
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (customDateFrom) setLocalFrom(customDateFrom);
|
||||||
|
if (customDateTo) setLocalTo(customDateTo);
|
||||||
|
}, [customDateFrom, customDateTo]);
|
||||||
|
|
||||||
|
// Close panel on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showCustom) return;
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
||||||
|
setShowCustom(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClick);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
|
}, [showCustom]);
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
if (localFrom && localTo && localFrom <= localTo && onCustomDateChange) {
|
||||||
|
onCustomDateChange(localFrom, localTo);
|
||||||
|
setShowCustom(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValid = localFrom && localTo && localFrom <= localTo;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2 items-center relative">
|
||||||
{PERIODS.map((p) => (
|
{PERIODS.map((p) => (
|
||||||
<button
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
onClick={() => onChange(p)}
|
onClick={() => {
|
||||||
|
onChange(p);
|
||||||
|
setShowCustom(false);
|
||||||
|
}}
|
||||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||||
p === value
|
p === value
|
||||||
? "bg-[var(--primary)] text-white"
|
? "bg-[var(--primary)] text-white"
|
||||||
|
|
@ -26,6 +70,60 @@ export default function PeriodSelector({ value, onChange }: PeriodSelectorProps)
|
||||||
{t(`dashboard.period.${p}`)}
|
{t(`dashboard.period.${p}`)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{onCustomDateChange && (
|
||||||
|
<div ref={panelRef} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCustom((prev) => !prev)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors inline-flex items-center gap-1.5 ${
|
||||||
|
value === "custom"
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Calendar size={14} />
|
||||||
|
{t("dashboard.period.custom")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showCustom && (
|
||||||
|
<div className="absolute right-0 top-full mt-2 z-50 bg-[var(--card)] border border-[var(--border)] rounded-xl shadow-lg p-4 flex flex-col gap-3 min-w-[280px]">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-xs font-medium text-[var(--muted-foreground)]">
|
||||||
|
{t("dashboard.dateFrom")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={localFrom}
|
||||||
|
onChange={(e) => setLocalFrom(e.target.value)}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-xs font-medium text-[var(--muted-foreground)]">
|
||||||
|
{t("dashboard.dateTo")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={localTo}
|
||||||
|
onChange={(e) => setLocalTo(e.target.value)}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={!isValid}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
isValid
|
||||||
|
? "bg-[var(--primary)] text-white hover:opacity-90"
|
||||||
|
: "bg-[var(--muted)] text-[var(--muted-foreground)] cursor-not-allowed"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("dashboard.apply")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ interface DashboardState {
|
||||||
categoryBreakdown: CategoryBreakdownItem[];
|
categoryBreakdown: CategoryBreakdownItem[];
|
||||||
recentTransactions: RecentTransaction[];
|
recentTransactions: RecentTransaction[];
|
||||||
period: DashboardPeriod;
|
period: DashboardPeriod;
|
||||||
|
customDateFrom: string;
|
||||||
|
customDateTo: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -31,13 +33,20 @@ type DashboardAction =
|
||||||
recentTransactions: RecentTransaction[];
|
recentTransactions: RecentTransaction[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
| { type: "SET_PERIOD"; payload: DashboardPeriod };
|
| { type: "SET_PERIOD"; payload: DashboardPeriod }
|
||||||
|
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } };
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||||
|
const monthStartStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
|
||||||
|
|
||||||
const initialState: DashboardState = {
|
const initialState: DashboardState = {
|
||||||
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
|
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
|
||||||
categoryBreakdown: [],
|
categoryBreakdown: [],
|
||||||
recentTransactions: [],
|
recentTransactions: [],
|
||||||
period: "month",
|
period: "month",
|
||||||
|
customDateFrom: monthStartStr,
|
||||||
|
customDateTo: todayStr,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
@ -58,13 +67,22 @@ function reducer(state: DashboardState, action: DashboardAction): DashboardState
|
||||||
};
|
};
|
||||||
case "SET_PERIOD":
|
case "SET_PERIOD":
|
||||||
return { ...state, period: action.payload };
|
return { ...state, period: action.payload };
|
||||||
|
case "SET_CUSTOM_DATES":
|
||||||
|
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?: string } {
|
function computeDateRange(
|
||||||
|
period: DashboardPeriod,
|
||||||
|
customDateFrom?: string,
|
||||||
|
customDateTo?: string,
|
||||||
|
): { dateFrom?: string; dateTo?: string } {
|
||||||
if (period === "all") return {};
|
if (period === "all") return {};
|
||||||
|
if (period === "custom" && customDateFrom && customDateTo) {
|
||||||
|
return { dateFrom: customDateFrom, dateTo: customDateTo };
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const year = now.getFullYear();
|
const year = now.getFullYear();
|
||||||
|
|
@ -87,6 +105,9 @@ function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?
|
||||||
case "12months":
|
case "12months":
|
||||||
from = new Date(year, month - 11, 1);
|
from = new Date(year, month - 11, 1);
|
||||||
break;
|
break;
|
||||||
|
default:
|
||||||
|
from = new Date(year, month, 1);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
|
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
|
||||||
|
|
@ -98,13 +119,13 @@ export function useDashboard() {
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
const fetchIdRef = useRef(0);
|
const fetchIdRef = useRef(0);
|
||||||
|
|
||||||
const fetchData = useCallback(async (period: DashboardPeriod) => {
|
const fetchData = useCallback(async (period: DashboardPeriod, customFrom?: string, customTo?: string) => {
|
||||||
const fetchId = ++fetchIdRef.current;
|
const fetchId = ++fetchIdRef.current;
|
||||||
dispatch({ type: "SET_LOADING", payload: true });
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
dispatch({ type: "SET_ERROR", payload: null });
|
dispatch({ type: "SET_ERROR", payload: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { dateFrom, dateTo } = computeDateRange(period);
|
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
||||||
const [summary, categoryBreakdown, recentTransactions] = await Promise.all([
|
const [summary, categoryBreakdown, recentTransactions] = await Promise.all([
|
||||||
getDashboardSummary(dateFrom, dateTo),
|
getDashboardSummary(dateFrom, dateTo),
|
||||||
getExpensesByCategory(dateFrom, dateTo),
|
getExpensesByCategory(dateFrom, dateTo),
|
||||||
|
|
@ -123,12 +144,16 @@ export function useDashboard() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData(state.period);
|
fetchData(state.period, state.customDateFrom, state.customDateTo);
|
||||||
}, [state.period, fetchData]);
|
}, [state.period, state.customDateFrom, state.customDateTo, fetchData]);
|
||||||
|
|
||||||
const setPeriod = useCallback((period: DashboardPeriod) => {
|
const setPeriod = useCallback((period: DashboardPeriod) => {
|
||||||
dispatch({ type: "SET_PERIOD", payload: period });
|
dispatch({ type: "SET_PERIOD", payload: period });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { state, setPeriod };
|
const setCustomDates = useCallback((dateFrom: string, dateTo: string) => {
|
||||||
|
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { state, setPeriod, setCustomDates };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import { getBudgetVsActualData } from "../services/budgetService";
|
||||||
interface ReportsState {
|
interface ReportsState {
|
||||||
tab: ReportTab;
|
tab: ReportTab;
|
||||||
period: DashboardPeriod;
|
period: DashboardPeriod;
|
||||||
|
customDateFrom: string;
|
||||||
|
customDateTo: string;
|
||||||
monthlyTrends: MonthlyTrendItem[];
|
monthlyTrends: MonthlyTrendItem[];
|
||||||
categorySpending: CategoryBreakdownItem[];
|
categorySpending: CategoryBreakdownItem[];
|
||||||
categoryOverTime: CategoryOverTimeData;
|
categoryOverTime: CategoryOverTimeData;
|
||||||
|
|
@ -33,13 +35,18 @@ type ReportsAction =
|
||||||
| { type: "SET_CATEGORY_SPENDING"; payload: CategoryBreakdownItem[] }
|
| { type: "SET_CATEGORY_SPENDING"; payload: CategoryBreakdownItem[] }
|
||||||
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
|
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
|
||||||
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } }
|
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } }
|
||||||
| { type: "SET_BUDGET_VS_ACTUAL"; payload: BudgetVsActualRow[] };
|
| { type: "SET_BUDGET_VS_ACTUAL"; payload: BudgetVsActualRow[] }
|
||||||
|
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } };
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||||
|
const monthStartStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
|
||||||
|
|
||||||
const initialState: ReportsState = {
|
const initialState: ReportsState = {
|
||||||
tab: "trends",
|
tab: "trends",
|
||||||
period: "6months",
|
period: "6months",
|
||||||
|
customDateFrom: monthStartStr,
|
||||||
|
customDateTo: todayStr,
|
||||||
monthlyTrends: [],
|
monthlyTrends: [],
|
||||||
categorySpending: [],
|
categorySpending: [],
|
||||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
||||||
|
|
@ -70,13 +77,22 @@ function reducer(state: ReportsState, action: ReportsAction): ReportsState {
|
||||||
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
|
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
|
||||||
case "SET_BUDGET_VS_ACTUAL":
|
case "SET_BUDGET_VS_ACTUAL":
|
||||||
return { ...state, budgetVsActual: action.payload, isLoading: false };
|
return { ...state, budgetVsActual: action.payload, isLoading: false };
|
||||||
|
case "SET_CUSTOM_DATES":
|
||||||
|
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?: string } {
|
function computeDateRange(
|
||||||
|
period: DashboardPeriod,
|
||||||
|
customDateFrom?: string,
|
||||||
|
customDateTo?: string,
|
||||||
|
): { dateFrom?: string; dateTo?: string } {
|
||||||
if (period === "all") return {};
|
if (period === "all") return {};
|
||||||
|
if (period === "custom" && customDateFrom && customDateTo) {
|
||||||
|
return { dateFrom: customDateFrom, dateTo: customDateTo };
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const year = now.getFullYear();
|
const year = now.getFullYear();
|
||||||
|
|
@ -99,6 +115,9 @@ function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?
|
||||||
case "12months":
|
case "12months":
|
||||||
from = new Date(year, month - 11, 1);
|
from = new Date(year, month - 11, 1);
|
||||||
break;
|
break;
|
||||||
|
default:
|
||||||
|
from = new Date(year, month, 1);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
|
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
|
||||||
|
|
@ -115,6 +134,8 @@ export function useReports() {
|
||||||
period: DashboardPeriod,
|
period: DashboardPeriod,
|
||||||
budgetYear: number,
|
budgetYear: number,
|
||||||
budgetMonth: number,
|
budgetMonth: number,
|
||||||
|
customFrom?: string,
|
||||||
|
customTo?: string,
|
||||||
) => {
|
) => {
|
||||||
const fetchId = ++fetchIdRef.current;
|
const fetchId = ++fetchIdRef.current;
|
||||||
dispatch({ type: "SET_LOADING", payload: true });
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
|
|
@ -123,21 +144,21 @@ export function useReports() {
|
||||||
try {
|
try {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "trends": {
|
case "trends": {
|
||||||
const { dateFrom, dateTo } = computeDateRange(period);
|
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
||||||
const data = await getMonthlyTrends(dateFrom, dateTo);
|
const data = await getMonthlyTrends(dateFrom, dateTo);
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
dispatch({ type: "SET_MONTHLY_TRENDS", payload: data });
|
dispatch({ type: "SET_MONTHLY_TRENDS", payload: data });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "byCategory": {
|
case "byCategory": {
|
||||||
const { dateFrom, dateTo } = computeDateRange(period);
|
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
||||||
const data = await getExpensesByCategory(dateFrom, dateTo);
|
const data = await getExpensesByCategory(dateFrom, dateTo);
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
dispatch({ type: "SET_CATEGORY_SPENDING", payload: data });
|
dispatch({ type: "SET_CATEGORY_SPENDING", payload: data });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "overTime": {
|
case "overTime": {
|
||||||
const { dateFrom, dateTo } = computeDateRange(period);
|
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
||||||
const data = await getCategoryOverTime(dateFrom, dateTo);
|
const data = await getCategoryOverTime(dateFrom, dateTo);
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
|
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
|
||||||
|
|
@ -160,8 +181,8 @@ export function useReports() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth);
|
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo);
|
||||||
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, fetchData]);
|
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, fetchData]);
|
||||||
|
|
||||||
const setTab = useCallback((tab: ReportTab) => {
|
const setTab = useCallback((tab: ReportTab) => {
|
||||||
dispatch({ type: "SET_TAB", payload: tab });
|
dispatch({ type: "SET_TAB", payload: tab });
|
||||||
|
|
@ -184,5 +205,9 @@ export function useReports() {
|
||||||
dispatch({ type: "SET_BUDGET_MONTH", payload: { year: newYear, month: newMonth } });
|
dispatch({ type: "SET_BUDGET_MONTH", payload: { year: newYear, month: newMonth } });
|
||||||
}, [state.budgetYear, state.budgetMonth]);
|
}, [state.budgetYear, state.budgetMonth]);
|
||||||
|
|
||||||
return { state, setTab, setPeriod, navigateBudgetMonth };
|
const setCustomDates = useCallback((dateFrom: string, dateTo: string) => {
|
||||||
|
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,15 @@ import type { CategoryBreakdownItem, DashboardPeriod } from "../shared/types";
|
||||||
|
|
||||||
const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" });
|
const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" });
|
||||||
|
|
||||||
function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?: string } {
|
function computeDateRange(
|
||||||
|
period: DashboardPeriod,
|
||||||
|
customDateFrom?: string,
|
||||||
|
customDateTo?: string,
|
||||||
|
): { dateFrom?: string; dateTo?: string } {
|
||||||
if (period === "all") return {};
|
if (period === "all") return {};
|
||||||
|
if (period === "custom" && customDateFrom && customDateTo) {
|
||||||
|
return { dateFrom: customDateFrom, dateTo: customDateTo };
|
||||||
|
}
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const year = now.getFullYear();
|
const year = now.getFullYear();
|
||||||
const month = now.getMonth();
|
const month = now.getMonth();
|
||||||
|
|
@ -24,6 +31,7 @@ function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?
|
||||||
case "3months": from = new Date(year, month - 2, 1); break;
|
case "3months": from = new Date(year, month - 2, 1); break;
|
||||||
case "6months": from = new Date(year, month - 5, 1); break;
|
case "6months": from = new Date(year, month - 5, 1); break;
|
||||||
case "12months": from = new Date(year, month - 11, 1); break;
|
case "12months": from = new Date(year, month - 11, 1); break;
|
||||||
|
default: from = new Date(year, month, 1); break;
|
||||||
}
|
}
|
||||||
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
|
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
|
||||||
return { dateFrom, dateTo };
|
return { dateFrom, dateTo };
|
||||||
|
|
@ -31,7 +39,7 @@ function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { state, setPeriod } = useDashboard();
|
const { state, setPeriod, setCustomDates } = useDashboard();
|
||||||
const { summary, categoryBreakdown, recentTransactions, period, isLoading } = state;
|
const { summary, categoryBreakdown, recentTransactions, period, isLoading } = state;
|
||||||
|
|
||||||
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
||||||
|
|
@ -81,7 +89,7 @@ export default function DashboardPage() {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const { dateFrom, dateTo } = computeDateRange(period);
|
const { dateFrom, dateTo } = computeDateRange(period, state.customDateFrom, state.customDateTo);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||||
|
|
@ -90,7 +98,13 @@ export default function DashboardPage() {
|
||||||
<h1 className="text-2xl font-bold">{t("dashboard.title")}</h1>
|
<h1 className="text-2xl font-bold">{t("dashboard.title")}</h1>
|
||||||
<PageHelp helpKey="dashboard" />
|
<PageHelp helpKey="dashboard" />
|
||||||
</div>
|
</div>
|
||||||
<PeriodSelector value={period} onChange={setPeriod} />
|
<PeriodSelector
|
||||||
|
value={period}
|
||||||
|
onChange={setPeriod}
|
||||||
|
customDateFrom={state.customDateFrom}
|
||||||
|
customDateTo={state.customDateTo}
|
||||||
|
onCustomDateChange={setCustomDates}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,15 @@ import TransactionDetailModal from "../components/shared/TransactionDetailModal"
|
||||||
|
|
||||||
const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual"];
|
const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual"];
|
||||||
|
|
||||||
function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?: string } {
|
function computeDateRange(
|
||||||
|
period: DashboardPeriod,
|
||||||
|
customDateFrom?: string,
|
||||||
|
customDateTo?: string,
|
||||||
|
): { dateFrom?: string; dateTo?: string } {
|
||||||
if (period === "all") return {};
|
if (period === "all") return {};
|
||||||
|
if (period === "custom" && customDateFrom && customDateTo) {
|
||||||
|
return { dateFrom: customDateFrom, dateTo: customDateTo };
|
||||||
|
}
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const year = now.getFullYear();
|
const year = now.getFullYear();
|
||||||
const month = now.getMonth();
|
const month = now.getMonth();
|
||||||
|
|
@ -26,6 +33,7 @@ function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?
|
||||||
case "3months": from = new Date(year, month - 2, 1); break;
|
case "3months": from = new Date(year, month - 2, 1); break;
|
||||||
case "6months": from = new Date(year, month - 5, 1); break;
|
case "6months": from = new Date(year, month - 5, 1); break;
|
||||||
case "12months": from = new Date(year, month - 11, 1); break;
|
case "12months": from = new Date(year, month - 11, 1); break;
|
||||||
|
default: from = new Date(year, month, 1); break;
|
||||||
}
|
}
|
||||||
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
|
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
|
||||||
return { dateFrom, dateTo };
|
return { dateFrom, dateTo };
|
||||||
|
|
@ -33,7 +41,7 @@ function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { state, setTab, setPeriod, navigateBudgetMonth } = useReports();
|
const { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth } = useReports();
|
||||||
|
|
||||||
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
||||||
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
|
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
|
||||||
|
|
@ -53,7 +61,7 @@ export default function ReportsPage() {
|
||||||
setDetailModal(item);
|
setDetailModal(item);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { dateFrom, dateTo } = computeDateRange(state.period);
|
const { dateFrom, dateTo } = computeDateRange(state.period, state.customDateFrom, state.customDateTo);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
|
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||||
|
|
@ -69,7 +77,13 @@ export default function ReportsPage() {
|
||||||
onNavigate={navigateBudgetMonth}
|
onNavigate={navigateBudgetMonth}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<PeriodSelector value={state.period} onChange={setPeriod} />
|
<PeriodSelector
|
||||||
|
value={state.period}
|
||||||
|
onChange={setPeriod}
|
||||||
|
customDateFrom={state.customDateFrom}
|
||||||
|
customDateTo={state.customDateTo}
|
||||||
|
onCustomDateChange={setCustomDates}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -247,7 +247,7 @@ export interface ImportReport {
|
||||||
|
|
||||||
// --- Dashboard Types ---
|
// --- Dashboard Types ---
|
||||||
|
|
||||||
export type DashboardPeriod = "month" | "3months" | "6months" | "12months" | "all";
|
export type DashboardPeriod = "month" | "3months" | "6months" | "12months" | "all" | "custom";
|
||||||
|
|
||||||
export interface DashboardSummary {
|
export interface DashboardSummary {
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue