mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 13:25:18 +00:00
104
components/dashboard/income-expense-graph-tooltip.tsx
Normal file
104
components/dashboard/income-expense-graph-tooltip.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="fixed z-50 bg-white border border-gray-200 rounded-lg shadow-lg p-4 max-w-xs pointer-events-none"
|
||||||
|
style={{
|
||||||
|
left: `${position.x + horizontalOffset}px`,
|
||||||
|
top: `${position.y}px`,
|
||||||
|
transform: `translate(${horizontalTransform}, -50%)`, // Center vertically, adjust horizontally
|
||||||
|
width: "320px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-3 pb-2 border-b border-gray-100">
|
||||||
|
<h3 className="font-bold text-gray-900 text-sm">{formatPeriodLabel(data.period, data.date)}</h3>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{data.totalTransactions} transaction{data.totalTransactions !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<div className="mb-3 space-y-1">
|
||||||
|
{data.income > 0 && (
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm font-medium text-green-600">Total Income:</span>
|
||||||
|
<span className="text-sm font-bold text-green-600">{formatCurrency(data.income, defaultCurrency)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.expenses > 0 && (
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm font-medium text-red-600">Total Expenses:</span>
|
||||||
|
<span className="text-sm font-bold text-red-600">{formatCurrency(data.expenses, defaultCurrency)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Income Categories */}
|
||||||
|
{incomeCategories.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<h4 className="text-xs font-semibold text-green-600 mb-2 uppercase tracking-wide">Income by Category</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{incomeCategories.map((category) => (
|
||||||
|
<div key={`income-${category.code}`} className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: category.color }} />
|
||||||
|
<span className="text-xs text-gray-700 truncate">{category.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-green-600 ml-2">
|
||||||
|
{formatCurrency(category.income, defaultCurrency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expense Categories */}
|
||||||
|
{expenseCategories.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold text-red-600 mb-2 uppercase tracking-wide">Expenses by Category</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{expenseCategories.map((category) => (
|
||||||
|
<div key={`expense-${category.code}`} className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: category.color }} />
|
||||||
|
<span className="text-xs text-gray-700 truncate">{category.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-red-600 ml-2">
|
||||||
|
{formatCurrency(category.expenses, defaultCurrency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
191
components/dashboard/income-expense-graph.tsx
Normal file
191
components/dashboard/income-expense-graph.tsx
Normal file
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div className="w-full h-96 flex items-center justify-center text-muted-foreground">
|
||||||
|
No data available for the selected period
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="w-full h-96 flex items-center justify-center text-muted-foreground">
|
||||||
|
No transactions found for the selected period
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-[400px]">
|
||||||
|
{/* Chart container with horizontal scroll */}
|
||||||
|
<div ref={scrollContainerRef} className="relative h-full overflow-x-auto">
|
||||||
|
<div className="h-full flex flex-col" style={{ minWidth: `${Math.max(600, data.length * 94)}px` }}>
|
||||||
|
{/* Income section (top half) */}
|
||||||
|
<div className="h-1/2 flex justify-center gap-1 px-2">
|
||||||
|
{data.map((item, index) => {
|
||||||
|
const incomeHeight = maxValue > 0 ? (item.income / maxValue) * 100 : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`income-${item.period}`}
|
||||||
|
className="flex-1 min-w-[90px] h-full flex flex-col justify-end items-center cursor-pointer"
|
||||||
|
onMouseEnter={(e) => handleBarHover(item, e)}
|
||||||
|
onMouseLeave={handleBarLeave}
|
||||||
|
onClick={() => item.income > 0 && handleBarClick(item, "income")}
|
||||||
|
>
|
||||||
|
{/* Period label above income bars */}
|
||||||
|
<div className="text-sm font-bold text-gray-700 break-all mb-2 text-center">
|
||||||
|
{formatPeriodLabel(item.period, item.date)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.income > 0 && (
|
||||||
|
<>
|
||||||
|
{/* Income amount label */}
|
||||||
|
<div className="text-xs font-semibold text-green-600 mb-1 break-all text-center">
|
||||||
|
{formatCurrency(item.income, defaultCurrency)}
|
||||||
|
</div>
|
||||||
|
{/* Income bar growing upward from bottom */}
|
||||||
|
<div
|
||||||
|
className="w-full bg-gradient-to-t from-green-500 via-green-400 to-emerald-300 border border-green-500/50 rounded-t-lg shadow-sm hover:shadow-md transition-shadow duration-200 min-w-full"
|
||||||
|
style={{ height: `${incomeHeight}%` }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* X-axis line (center) */}
|
||||||
|
<div className="w-full border-t-2 border-gray-600" />
|
||||||
|
|
||||||
|
{/* Expense section (bottom half) */}
|
||||||
|
<div className="h-1/2 flex justify-center gap-1 px-2">
|
||||||
|
{data.map((item, index) => {
|
||||||
|
const expenseHeight = maxValue > 0 ? (item.expenses / maxValue) * 100 : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`expense-${item.period}`}
|
||||||
|
className="flex-1 min-w-[90px] h-full flex flex-col justify-start items-center cursor-pointer"
|
||||||
|
onMouseEnter={(e) => handleBarHover(item, e)}
|
||||||
|
onMouseLeave={handleBarLeave}
|
||||||
|
onClick={() => item.expenses > 0 && handleBarClick(item, "expense")}
|
||||||
|
>
|
||||||
|
{item.expenses > 0 && (
|
||||||
|
<>
|
||||||
|
{/* Expense bar growing downward from top */}
|
||||||
|
<div
|
||||||
|
className="w-full bg-gradient-to-b from-red-500 via-red-400 to-rose-300 border border-red-500/50 rounded-b-lg shadow-sm hover:shadow-md transition-shadow duration-200 min-w-full"
|
||||||
|
style={{ height: `${expenseHeight}%` }}
|
||||||
|
/>
|
||||||
|
{/* Expense amount label */}
|
||||||
|
<div className="text-xs font-semibold text-red-600 mt-1 break-all text-center">
|
||||||
|
{formatCurrency(item.expenses, defaultCurrency)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tooltip */}
|
||||||
|
<IncomeExpenceGraphTooltip
|
||||||
|
data={tooltip.data}
|
||||||
|
defaultCurrency={defaultCurrency}
|
||||||
|
position={tooltip.position}
|
||||||
|
visible={tooltip.visible}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { FiltersWidget } from "@/components/dashboard/filters-widget"
|
import { FiltersWidget } from "@/components/dashboard/filters-widget"
|
||||||
|
import { IncomeExpenseGraph } from "@/components/dashboard/income-expense-graph"
|
||||||
import { ProjectsWidget } from "@/components/dashboard/projects-widget"
|
import { ProjectsWidget } from "@/components/dashboard/projects-widget"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { getCurrentUser } from "@/lib/auth"
|
import { getCurrentUser } from "@/lib/auth"
|
||||||
import { formatCurrency } from "@/lib/utils"
|
import { formatCurrency } from "@/lib/utils"
|
||||||
import { getProjects } from "@/models/projects"
|
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 { TransactionFilters } from "@/models/transactions"
|
||||||
import { ArrowDown, ArrowUp, BicepsFlexed } from "lucide-react"
|
import { ArrowDown, ArrowUp, BicepsFlexed } from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
@@ -12,7 +14,11 @@ import Link from "next/link"
|
|||||||
export async function StatsWidget({ filters }: { filters: TransactionFilters }) {
|
export async function StatsWidget({ filters }: { filters: TransactionFilters }) {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
const projects = await getProjects(user.id)
|
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 stats = await getDashboardStats(user.id, filters)
|
||||||
|
const statsTimeSeries = await getDetailedTimeSeriesStats(user.id, filters, defaultCurrency)
|
||||||
const statsPerProject = Object.fromEntries(
|
const statsPerProject = Object.fromEntries(
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
projects.map((project) => getProjectStats(user.id, project.code, filters).then((stats) => [project.code, stats]))
|
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 })
|
|||||||
<FiltersWidget defaultFilters={filters} defaultRange="last-12-months" />
|
<FiltersWidget defaultFilters={filters} defaultRange="last-12-months" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{statsTimeSeries.length > 0 && <IncomeExpenseGraph data={statsTimeSeries} defaultCurrency={defaultCurrency} />}
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<Link href="/transactions?type=income">
|
<Link href="/transactions?type=income">
|
||||||
<Card className="bg-gradient-to-br from-white via-green-50/30 to-emerald-50/40 border-green-200/50 hover:shadow-lg transition-all duration-300 hover:scale-[1.02] cursor-pointer">
|
<Card className="bg-gradient-to-br from-white via-green-50/30 to-emerald-50/40 border-green-200/50 hover:shadow-lg transition-all duration-300 hover:scale-[1.02] cursor-pointer">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { format, startOfMonth, startOfQuarter, subMonths, subWeeks } from "date-fns"
|
import { format, startOfMonth, startOfQuarter, subMonths, subWeeks } from "date-fns"
|
||||||
import { CalendarIcon } from "lucide-react"
|
import { CalendarIcon } from "lucide-react"
|
||||||
import { useState, useEffect } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { DateRange } from "react-day-picker"
|
import { DateRange } from "react-day-picker"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|||||||
18
lib/utils.ts
18
lib/utils.ts
@@ -116,3 +116,21 @@ export function generateUUID(): string {
|
|||||||
return v.toString(16)
|
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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
242
models/stats.ts
242
models/stats.ts
@@ -79,3 +79,245 @@ export const getProjectStats = cache(async (userId: string, projectId: string, f
|
|||||||
invoicesProcessed,
|
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<TimeSeriesData[]> => {
|
||||||
|
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<string, TimeSeriesData>
|
||||||
|
)
|
||||||
|
|
||||||
|
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<DetailedTimeSeriesData[]> => {
|
||||||
|
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<string, CategoryBreakdown>(),
|
||||||
|
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<string, CategoryBreakdown>
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user