diff --git a/components/dashboard/income-expense-graph-tooltip.tsx b/components/dashboard/income-expense-graph-tooltip.tsx new file mode 100644 index 0000000..2f47efa --- /dev/null +++ b/components/dashboard/income-expense-graph-tooltip.tsx @@ -0,0 +1,104 @@ +import { formatCurrency, formatPeriodLabel } from "@/lib/utils" +import { DetailedTimeSeriesData } from "@/models/stats" + +interface ChartTooltipProps { + data: DetailedTimeSeriesData | null + defaultCurrency: string + position: { x: number; y: number } + visible: boolean + containerWidth?: number +} + +export function IncomeExpenceGraphTooltip({ data, defaultCurrency, position, visible }: ChartTooltipProps) { + if (!visible || !data) { + return null + } + + const incomeCategories = data.categories.filter((cat) => cat.income > 0) + const expenseCategories = data.categories.filter((cat) => cat.expenses > 0) + + // Calculate positioning - show to right if space available, otherwise to left + const tooltipWidth = 320 // estimated max width + const spaceToRight = window.innerWidth - position.x + const spaceToLeft = position.x + const showToRight = spaceToRight >= tooltipWidth + 20 // 20px margin + + const horizontalOffset = showToRight ? 15 : -15 // distance from cursor + const horizontalTransform = showToRight ? "0%" : "-100%" + + return ( +
+ {/* Header */} +
+

{formatPeriodLabel(data.period, data.date)}

+

+ {data.totalTransactions} transaction{data.totalTransactions !== 1 ? "s" : ""} +

+
+ + {/* Totals */} +
+ {data.income > 0 && ( +
+ Total Income: + {formatCurrency(data.income, defaultCurrency)} +
+ )} + {data.expenses > 0 && ( +
+ Total Expenses: + {formatCurrency(data.expenses, defaultCurrency)} +
+ )} +
+ + {/* Income Categories */} + {incomeCategories.length > 0 && ( +
+

Income by Category

+
+ {incomeCategories.map((category) => ( +
+
+
+ {category.name} +
+ + {formatCurrency(category.income, defaultCurrency)} + +
+ ))} +
+
+ )} + + {/* Expense Categories */} + {expenseCategories.length > 0 && ( +
+

Expenses by Category

+
+ {expenseCategories.map((category) => ( +
+
+
+ {category.name} +
+ + {formatCurrency(category.expenses, defaultCurrency)} + +
+ ))} +
+
+ )} +
+ ) +} diff --git a/components/dashboard/income-expense-graph.tsx b/components/dashboard/income-expense-graph.tsx new file mode 100644 index 0000000..6859ffc --- /dev/null +++ b/components/dashboard/income-expense-graph.tsx @@ -0,0 +1,191 @@ +"use client" + +import { formatCurrency, formatPeriodLabel } from "@/lib/utils" +import { DetailedTimeSeriesData } from "@/models/stats" +import { addDays, endOfMonth, format, startOfMonth } from "date-fns" +import { useRouter } from "next/navigation" +import { useEffect, useRef, useState } from "react" +import { IncomeExpenceGraphTooltip } from "./income-expense-graph-tooltip" + +interface IncomeExpenseGraphProps { + data: DetailedTimeSeriesData[] + defaultCurrency: string +} + +export function IncomeExpenseGraph({ data, defaultCurrency }: IncomeExpenseGraphProps) { + const router = useRouter() + const scrollContainerRef = useRef(null) + const [tooltip, setTooltip] = useState<{ + data: DetailedTimeSeriesData | null + position: { x: number; y: number } + visible: boolean + }>({ + data: null, + position: { x: 0, y: 0 }, + visible: false, + }) + + // Auto-scroll to the right to show latest data + useEffect(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollLeft = scrollContainerRef.current.scrollWidth + } + }, [data]) + + const handleBarHover = (item: DetailedTimeSeriesData, event: React.MouseEvent) => { + const rect = event.currentTarget.getBoundingClientRect() + const containerRect = scrollContainerRef.current?.getBoundingClientRect() + + setTooltip({ + data: item, + position: { + x: rect.left + rect.width / 2, + y: containerRect ? containerRect.top + containerRect.height / 2 : rect.top, + }, + visible: true, + }) + } + + const handleBarLeave = () => { + setTooltip((prev) => ({ ...prev, visible: false })) + } + + const handleBarClick = (item: DetailedTimeSeriesData, type: "income" | "expense") => { + // Calculate date range for the period + const isDailyPeriod = item.period.includes("-") && item.period.split("-").length === 3 + + let dateFrom: string + let dateTo: string + + if (isDailyPeriod) { + // Daily period: use the exact date, add 1 day to dateTo + const date = new Date(item.period) + dateFrom = item.period // YYYY-MM-DD format + dateTo = format(addDays(date, 1), "yyyy-MM-dd") + } else { + // Monthly period: use first and last day of the month, add 1 day to dateTo + const [year, month] = item.period.split("-") + const monthDate = new Date(parseInt(year), parseInt(month) - 1, 1) + + dateFrom = format(startOfMonth(monthDate), "yyyy-MM-dd") + dateTo = format(addDays(endOfMonth(monthDate), 1), "yyyy-MM-dd") + } + + // Build URL parameters + const params = new URLSearchParams({ + type, + dateFrom, + dateTo, + }) + + // Navigate to transactions page with filters + router.push(`/transactions?${params.toString()}`) + } + + if (!data.length) { + return ( +
+ No data available for the selected period +
+ ) + } + + const maxIncome = Math.max(...data.map((d) => d.income)) + const maxExpense = Math.max(...data.map((d) => d.expenses)) + const maxValue = Math.max(maxIncome, maxExpense) + + if (maxValue === 0) { + return ( +
+ No transactions found for the selected period +
+ ) + } + + return ( +
+ {/* Chart container with horizontal scroll */} +
+
+ {/* Income section (top half) */} +
+ {data.map((item, index) => { + const incomeHeight = maxValue > 0 ? (item.income / maxValue) * 100 : 0 + + return ( +
handleBarHover(item, e)} + onMouseLeave={handleBarLeave} + onClick={() => item.income > 0 && handleBarClick(item, "income")} + > + {/* Period label above income bars */} +
+ {formatPeriodLabel(item.period, item.date)} +
+ + {item.income > 0 && ( + <> + {/* Income amount label */} +
+ {formatCurrency(item.income, defaultCurrency)} +
+ {/* Income bar growing upward from bottom */} +
+ + )} +
+ ) + })} +
+ + {/* X-axis line (center) */} +
+ + {/* Expense section (bottom half) */} +
+ {data.map((item, index) => { + const expenseHeight = maxValue > 0 ? (item.expenses / maxValue) * 100 : 0 + + return ( +
handleBarHover(item, e)} + onMouseLeave={handleBarLeave} + onClick={() => item.expenses > 0 && handleBarClick(item, "expense")} + > + {item.expenses > 0 && ( + <> + {/* Expense bar growing downward from top */} +
+ {/* Expense amount label */} +
+ {formatCurrency(item.expenses, defaultCurrency)} +
+ + )} +
+ ) + })} +
+
+
+ + {/* Tooltip */} + +
+ ) +} diff --git a/components/dashboard/stats-widget.tsx b/components/dashboard/stats-widget.tsx index 08c17b1..67f42c3 100644 --- a/components/dashboard/stats-widget.tsx +++ b/components/dashboard/stats-widget.tsx @@ -1,10 +1,12 @@ import { FiltersWidget } from "@/components/dashboard/filters-widget" +import { IncomeExpenseGraph } from "@/components/dashboard/income-expense-graph" import { ProjectsWidget } from "@/components/dashboard/projects-widget" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { getCurrentUser } from "@/lib/auth" import { formatCurrency } from "@/lib/utils" import { getProjects } from "@/models/projects" -import { getDashboardStats, getProjectStats } from "@/models/stats" +import { getSettings } from "@/models/settings" +import { getDashboardStats, getDetailedTimeSeriesStats, getProjectStats } from "@/models/stats" import { TransactionFilters } from "@/models/transactions" import { ArrowDown, ArrowUp, BicepsFlexed } from "lucide-react" import Link from "next/link" @@ -12,7 +14,11 @@ import Link from "next/link" export async function StatsWidget({ filters }: { filters: TransactionFilters }) { const user = await getCurrentUser() const projects = await getProjects(user.id) + const settings = await getSettings(user.id) + const defaultCurrency = settings.default_currency || "EUR" + const stats = await getDashboardStats(user.id, filters) + const statsTimeSeries = await getDetailedTimeSeriesStats(user.id, filters, defaultCurrency) const statsPerProject = Object.fromEntries( await Promise.all( projects.map((project) => getProjectStats(user.id, project.code, filters).then((stats) => [project.code, stats])) @@ -27,6 +33,8 @@ export async function StatsWidget({ filters }: { filters: TransactionFilters })
+ {statsTimeSeries.length > 0 && } +
diff --git a/components/forms/date-range-picker.tsx b/components/forms/date-range-picker.tsx index 7b1f868..6e2f585 100644 --- a/components/forms/date-range-picker.tsx +++ b/components/forms/date-range-picker.tsx @@ -2,7 +2,7 @@ import { format, startOfMonth, startOfQuarter, subMonths, subWeeks } from "date-fns" import { CalendarIcon } from "lucide-react" -import { useState, useEffect } from "react" +import { useEffect, useState } from "react" import { DateRange } from "react-day-picker" import { Button } from "@/components/ui/button" diff --git a/lib/utils.ts b/lib/utils.ts index 3210919..108f817 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -116,3 +116,21 @@ export function generateUUID(): string { return v.toString(16) }) } + +export function formatPeriodLabel(period: string, date: Date): string { + if (period.includes("-") && period.split("-").length === 3) { + // Daily format: show day/month/year + return date.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + }) + } else { + // Monthly format: show month/year + return date.toLocaleDateString("en-US", { + month: "long", + year: "numeric", + }) + } +} diff --git a/models/stats.ts b/models/stats.ts index 7a42245..37eebdc 100644 --- a/models/stats.ts +++ b/models/stats.ts @@ -79,3 +79,245 @@ export const getProjectStats = cache(async (userId: string, projectId: string, f invoicesProcessed, } }) + +export type TimeSeriesData = { + period: string + income: number + expenses: number + date: Date +} + +export type CategoryBreakdown = { + code: string + name: string + color: string + income: number + expenses: number + transactionCount: number +} + +export type DetailedTimeSeriesData = { + period: string + income: number + expenses: number + date: Date + categories: CategoryBreakdown[] + totalTransactions: number +} + +export const getTimeSeriesStats = cache( + async ( + userId: string, + filters: TransactionFilters = {}, + defaultCurrency: string = "EUR" + ): Promise => { + const where: Prisma.TransactionWhereInput = { userId } + + if (filters.dateFrom || filters.dateTo) { + where.issuedAt = { + gte: filters.dateFrom ? new Date(filters.dateFrom) : undefined, + lte: filters.dateTo ? new Date(filters.dateTo) : undefined, + } + } + + if (filters.categoryCode) { + where.categoryCode = filters.categoryCode + } + + if (filters.projectCode) { + where.projectCode = filters.projectCode + } + + if (filters.type) { + where.type = filters.type + } + + const transactions = await prisma.transaction.findMany({ + where, + orderBy: { issuedAt: "asc" }, + }) + + if (transactions.length === 0) { + return [] + } + + // Determine if we should group by day or month + const dateFrom = filters.dateFrom ? new Date(filters.dateFrom) : new Date(transactions[0].issuedAt!) + const dateTo = filters.dateTo ? new Date(filters.dateTo) : new Date(transactions[transactions.length - 1].issuedAt!) + const daysDiff = Math.ceil((dateTo.getTime() - dateFrom.getTime()) / (1000 * 60 * 60 * 24)) + const groupByDay = daysDiff <= 50 + + // Group transactions by time period + const grouped = transactions.reduce( + (acc, transaction) => { + if (!transaction.issuedAt) return acc + + const date = new Date(transaction.issuedAt) + const period = groupByDay + ? date.toISOString().split("T")[0] // YYYY-MM-DD + : `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}` // YYYY-MM + + if (!acc[period]) { + acc[period] = { period, income: 0, expenses: 0, date } + } + + // Get amount in default currency + const amount = + transaction.convertedCurrencyCode?.toUpperCase() === defaultCurrency.toUpperCase() + ? transaction.convertedTotal || 0 + : transaction.currencyCode?.toUpperCase() === defaultCurrency.toUpperCase() + ? transaction.total || 0 + : 0 // Skip transactions not in default currency for simplicity + + if (transaction.type === "income") { + acc[period].income += amount + } else if (transaction.type === "expense") { + acc[period].expenses += amount + } + + return acc + }, + {} as Record + ) + + return Object.values(grouped).sort((a, b) => a.date.getTime() - b.date.getTime()) + } +) + +export const getDetailedTimeSeriesStats = cache( + async ( + userId: string, + filters: TransactionFilters = {}, + defaultCurrency: string = "EUR" + ): Promise => { + const where: Prisma.TransactionWhereInput = { userId } + + if (filters.dateFrom || filters.dateTo) { + where.issuedAt = { + gte: filters.dateFrom ? new Date(filters.dateFrom) : undefined, + lte: filters.dateTo ? new Date(filters.dateTo) : undefined, + } + } + + if (filters.categoryCode) { + where.categoryCode = filters.categoryCode + } + + if (filters.projectCode) { + where.projectCode = filters.projectCode + } + + if (filters.type) { + where.type = filters.type + } + + const [transactions, categories] = await Promise.all([ + prisma.transaction.findMany({ + where, + include: { + category: true, + }, + orderBy: { issuedAt: "asc" }, + }), + prisma.category.findMany({ + where: { userId }, + orderBy: { name: "asc" }, + }), + ]) + + if (transactions.length === 0) { + return [] + } + + // Determine if we should group by day or month + const dateFrom = filters.dateFrom ? new Date(filters.dateFrom) : new Date(transactions[0].issuedAt!) + const dateTo = filters.dateTo ? new Date(filters.dateTo) : new Date(transactions[transactions.length - 1].issuedAt!) + const daysDiff = Math.ceil((dateTo.getTime() - dateFrom.getTime()) / (1000 * 60 * 60 * 24)) + const groupByDay = daysDiff <= 50 + + // Create category lookup + const categoryLookup = new Map(categories.map((cat) => [cat.code, cat])) + + // Group transactions by time period + const grouped = transactions.reduce( + (acc, transaction) => { + if (!transaction.issuedAt) return acc + + const date = new Date(transaction.issuedAt) + const period = groupByDay + ? date.toISOString().split("T")[0] // YYYY-MM-DD + : `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}` // YYYY-MM + + if (!acc[period]) { + acc[period] = { + period, + income: 0, + expenses: 0, + date, + categories: new Map(), + totalTransactions: 0, + } + } + + // Get amount in default currency + const amount = + transaction.convertedCurrencyCode?.toUpperCase() === defaultCurrency.toUpperCase() + ? transaction.convertedTotal || 0 + : transaction.currencyCode?.toUpperCase() === defaultCurrency.toUpperCase() + ? transaction.total || 0 + : 0 // Skip transactions not in default currency for simplicity + + const categoryCode = transaction.categoryCode || "other" + const category = categoryLookup.get(categoryCode) || { + code: "other", + name: "Other", + color: "#6b7280", + } + + // Initialize category if not exists + if (!acc[period].categories.has(categoryCode)) { + acc[period].categories.set(categoryCode, { + code: category.code, + name: category.name, + color: category.color || "#6b7280", + income: 0, + expenses: 0, + transactionCount: 0, + }) + } + + const categoryData = acc[period].categories.get(categoryCode)! + categoryData.transactionCount++ + acc[period].totalTransactions++ + + if (transaction.type === "income") { + acc[period].income += amount + categoryData.income += amount + } else if (transaction.type === "expense") { + acc[period].expenses += amount + categoryData.expenses += amount + } + + return acc + }, + {} as Record< + string, + { + period: string + income: number + expenses: number + date: Date + categories: Map + totalTransactions: number + } + > + ) + + return Object.values(grouped) + .map((item) => ({ + ...item, + categories: Array.from(item.categories.values()).filter((cat) => cat.income > 0 || cat.expenses > 0), + })) + .sort((a, b) => a.date.getTime() - b.date.getTime()) + } +)