diff --git a/src/components/dashboard/PeriodSelector.tsx b/src/components/dashboard/PeriodSelector.tsx index 6eec01d..5828f7d 100644 --- a/src/components/dashboard/PeriodSelector.tsx +++ b/src/components/dashboard/PeriodSelector.tsx @@ -1,4 +1,6 @@ +import { useState, useRef, useEffect } from "react"; import { useTranslation } from "react-i18next"; +import { Calendar } from "lucide-react"; import type { DashboardPeriod } from "../../shared/types"; const PERIODS: DashboardPeriod[] = ["month", "3months", "6months", "12months", "all"]; @@ -6,17 +8,59 @@ const PERIODS: DashboardPeriod[] = ["month", "3months", "6months", "12months", " interface PeriodSelectorProps { value: DashboardPeriod; 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 [showCustom, setShowCustom] = useState(false); + const [localFrom, setLocalFrom] = useState(customDateFrom ?? ""); + const [localTo, setLocalTo] = useState(customDateTo ?? ""); + const panelRef = useRef(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 ( -
+
{PERIODS.map((p) => ( ))} + + {onCustomDateChange && ( +
+ + + {showCustom && ( +
+
+ + setLocalFrom(e.target.value)} + className="px-3 py-1.5 rounded-lg text-sm border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]" + /> +
+
+ + setLocalTo(e.target.value)} + className="px-3 py-1.5 rounded-lg text-sm border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]" + /> +
+ +
+ )} +
+ )}
); } diff --git a/src/hooks/useDashboard.ts b/src/hooks/useDashboard.ts index fd78bfb..a2eaec6 100644 --- a/src/hooks/useDashboard.ts +++ b/src/hooks/useDashboard.ts @@ -16,6 +16,8 @@ interface DashboardState { categoryBreakdown: CategoryBreakdownItem[]; recentTransactions: RecentTransaction[]; period: DashboardPeriod; + customDateFrom: string; + customDateTo: string; isLoading: boolean; error: string | null; } @@ -31,13 +33,20 @@ type DashboardAction = 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 = { summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 }, categoryBreakdown: [], recentTransactions: [], period: "month", + customDateFrom: monthStartStr, + customDateTo: todayStr, isLoading: false, error: null, }; @@ -58,13 +67,22 @@ function reducer(state: DashboardState, action: DashboardAction): DashboardState }; case "SET_PERIOD": return { ...state, period: action.payload }; + case "SET_CUSTOM_DATES": + return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo }; default: 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 === "custom" && customDateFrom && customDateTo) { + return { dateFrom: customDateFrom, dateTo: customDateTo }; + } const now = new Date(); const year = now.getFullYear(); @@ -87,6 +105,9 @@ function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo? 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")}`; @@ -98,13 +119,13 @@ export function useDashboard() { const [state, dispatch] = useReducer(reducer, initialState); const fetchIdRef = useRef(0); - const fetchData = useCallback(async (period: DashboardPeriod) => { + const fetchData = useCallback(async (period: DashboardPeriod, customFrom?: string, customTo?: string) => { const fetchId = ++fetchIdRef.current; dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_ERROR", payload: null }); try { - const { dateFrom, dateTo } = computeDateRange(period); + const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo); const [summary, categoryBreakdown, recentTransactions] = await Promise.all([ getDashboardSummary(dateFrom, dateTo), getExpensesByCategory(dateFrom, dateTo), @@ -123,12 +144,16 @@ export function useDashboard() { }, []); useEffect(() => { - fetchData(state.period); - }, [state.period, fetchData]); + fetchData(state.period, state.customDateFrom, state.customDateTo); + }, [state.period, state.customDateFrom, state.customDateTo, fetchData]); const setPeriod = useCallback((period: DashboardPeriod) => { 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 }; } diff --git a/src/hooks/useReports.ts b/src/hooks/useReports.ts index 9598b29..2dd8fc5 100644 --- a/src/hooks/useReports.ts +++ b/src/hooks/useReports.ts @@ -14,6 +14,8 @@ import { getBudgetVsActualData } from "../services/budgetService"; interface ReportsState { tab: ReportTab; period: DashboardPeriod; + customDateFrom: string; + customDateTo: string; monthlyTrends: MonthlyTrendItem[]; categorySpending: CategoryBreakdownItem[]; categoryOverTime: CategoryOverTimeData; @@ -33,13 +35,18 @@ type ReportsAction = | { type: "SET_CATEGORY_SPENDING"; payload: CategoryBreakdownItem[] } | { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData } | { 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 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 = { tab: "trends", period: "6months", + customDateFrom: monthStartStr, + customDateTo: todayStr, monthlyTrends: [], categorySpending: [], 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 }; case "SET_BUDGET_VS_ACTUAL": 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: 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 === "custom" && customDateFrom && customDateTo) { + return { dateFrom: customDateFrom, dateTo: customDateTo }; + } const now = new Date(); const year = now.getFullYear(); @@ -99,6 +115,9 @@ function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo? 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")}`; @@ -115,6 +134,8 @@ export function useReports() { period: DashboardPeriod, budgetYear: number, budgetMonth: number, + customFrom?: string, + customTo?: string, ) => { const fetchId = ++fetchIdRef.current; dispatch({ type: "SET_LOADING", payload: true }); @@ -123,21 +144,21 @@ export function useReports() { try { switch (tab) { case "trends": { - const { dateFrom, dateTo } = computeDateRange(period); + const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo); const data = await getMonthlyTrends(dateFrom, dateTo); if (fetchId !== fetchIdRef.current) return; dispatch({ type: "SET_MONTHLY_TRENDS", payload: data }); break; } case "byCategory": { - const { dateFrom, dateTo } = computeDateRange(period); + const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo); const data = await getExpensesByCategory(dateFrom, dateTo); if (fetchId !== fetchIdRef.current) return; dispatch({ type: "SET_CATEGORY_SPENDING", payload: data }); break; } case "overTime": { - const { dateFrom, dateTo } = computeDateRange(period); + const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo); const data = await getCategoryOverTime(dateFrom, dateTo); if (fetchId !== fetchIdRef.current) return; dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data }); @@ -160,8 +181,8 @@ export function useReports() { }, []); useEffect(() => { - fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth); - }, [state.tab, state.period, state.budgetYear, state.budgetMonth, fetchData]); + fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo); + }, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, fetchData]); const setTab = useCallback((tab: ReportTab) => { dispatch({ type: "SET_TAB", payload: tab }); @@ -184,5 +205,9 @@ export function useReports() { dispatch({ type: "SET_BUDGET_MONTH", payload: { year: newYear, month: newMonth } }); }, [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 }; } diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index 56bdefe..408dc87 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -11,8 +11,15 @@ import type { CategoryBreakdownItem, DashboardPeriod } from "../shared/types"; 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 === "custom" && customDateFrom && customDateTo) { + return { dateFrom: customDateFrom, dateTo: customDateTo }; + } const now = new Date(); const year = now.getFullYear(); 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 "6months": from = new Date(year, month - 5, 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")}`; return { dateFrom, dateTo }; @@ -31,7 +39,7 @@ function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo? export default function DashboardPage() { const { t } = useTranslation(); - const { state, setPeriod } = useDashboard(); + const { state, setPeriod, setCustomDates } = useDashboard(); const { summary, categoryBreakdown, recentTransactions, period, isLoading } = state; const [hiddenCategories, setHiddenCategories] = useState>(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 (
@@ -90,7 +98,13 @@ export default function DashboardPage() {

{t("dashboard.title")}

- +
diff --git a/src/pages/ReportsPage.tsx b/src/pages/ReportsPage.tsx index 2f4eb5f..4c9586b 100644 --- a/src/pages/ReportsPage.tsx +++ b/src/pages/ReportsPage.tsx @@ -13,8 +13,15 @@ import TransactionDetailModal from "../components/shared/TransactionDetailModal" 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 === "custom" && customDateFrom && customDateTo) { + return { dateFrom: customDateFrom, dateTo: customDateTo }; + } const now = new Date(); const year = now.getFullYear(); 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 "6months": from = new Date(year, month - 5, 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")}`; return { dateFrom, dateTo }; @@ -33,7 +41,7 @@ function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo? export default function ReportsPage() { const { t } = useTranslation(); - const { state, setTab, setPeriod, navigateBudgetMonth } = useReports(); + const { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth } = useReports(); const [hiddenCategories, setHiddenCategories] = useState>(new Set()); const [detailModal, setDetailModal] = useState(null); @@ -53,7 +61,7 @@ export default function ReportsPage() { setDetailModal(item); }, []); - const { dateFrom, dateTo } = computeDateRange(state.period); + const { dateFrom, dateTo } = computeDateRange(state.period, state.customDateFrom, state.customDateTo); return (
@@ -69,7 +77,13 @@ export default function ReportsPage() { onNavigate={navigateBudgetMonth} /> ) : ( - + )}
diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 12681ea..79a5217 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -247,7 +247,7 @@ export interface ImportReport { // --- Dashboard Types --- -export type DashboardPeriod = "month" | "3months" | "6months" | "12months" | "all"; +export type DashboardPeriod = "month" | "3months" | "6months" | "12months" | "all" | "custom"; export interface DashboardSummary { totalCount: number;