TRANSFER type fell into the ELSE branch of the spend CASE expression, treating transfers as negative spend like refunds. Explicitly handle each type: DEBIT=positive, CREDIT=negative, TRANSFER=0. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
143 lines
5.0 KiB
TypeScript
143 lines
5.0 KiB
TypeScript
import { auth } from '@/lib/auth'
|
|
import { prisma } from '@/lib/prisma'
|
|
import { monthBounds, monthLabel } from '@/lib/utils/dates'
|
|
import { NetWorthCard } from '@/components/dashboard/NetWorthCard'
|
|
import { CashFlowCard } from '@/components/dashboard/CashFlowCard'
|
|
import { RecentTransactions } from '@/components/dashboard/RecentTransactions'
|
|
import { BudgetSummary } from '@/components/dashboard/BudgetSummary'
|
|
import { MonthYearPicker } from '@/components/dashboard/MonthYearPicker'
|
|
|
|
type SearchParams = Promise<Record<string, string | string[] | undefined>>
|
|
|
|
export default async function DashboardPage({ searchParams }: { searchParams: SearchParams }) {
|
|
const session = await auth()
|
|
if (!session?.user?.id) return null
|
|
|
|
const userId = session.user.id
|
|
const sp = await searchParams
|
|
const get = (k: string) => (Array.isArray(sp[k]) ? sp[k][0] : sp[k]) ?? ''
|
|
|
|
const now = new Date()
|
|
const month = Number(get('month')) || (now.getMonth() + 1)
|
|
const year = Number(get('year')) || now.getFullYear()
|
|
const isCurrentMonth = month === (now.getMonth() + 1) && year === now.getFullYear()
|
|
|
|
const selectedDate = new Date(year, month - 1, 1)
|
|
const { start, end } = monthBounds(selectedDate)
|
|
|
|
const [cashFlowRows, budgets, spendRows, recentTx] = await Promise.all([
|
|
prisma.$queryRaw<{ type: string; total: bigint }[]>`
|
|
SELECT t.type, COALESCE(SUM(t."amountCents"), 0)::bigint AS total
|
|
FROM "Transaction" t
|
|
JOIN "Account" a ON t."accountId" = a.id
|
|
WHERE a."userId" = ${userId}
|
|
AND a.type = 'BANK'
|
|
AND t.type != 'TRANSFER'
|
|
AND t.date >= ${start}
|
|
AND t.date <= ${end}
|
|
GROUP BY t.type
|
|
`,
|
|
prisma.budget.findMany({
|
|
where: { userId, isActive: true },
|
|
orderBy: { name: 'asc' },
|
|
}),
|
|
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
|
|
SELECT t."budgetId",
|
|
COALESCE(SUM(CASE WHEN t.type = 'DEBIT' THEN t."amountCents" WHEN t.type = 'CREDIT' THEN -t."amountCents" ELSE 0 END), 0)::bigint AS total
|
|
FROM "Transaction" t
|
|
JOIN "Account" a ON t."accountId" = a.id
|
|
WHERE a."userId" = ${userId}
|
|
AND t."budgetId" IS NOT NULL
|
|
AND t.date >= ${start}
|
|
AND t.date <= ${end}
|
|
GROUP BY t."budgetId"
|
|
`,
|
|
prisma.transaction.findMany({
|
|
where: {
|
|
account: { userId },
|
|
date: { gte: start, lte: end },
|
|
},
|
|
include: { account: { select: { name: true } } },
|
|
orderBy: { date: 'desc' },
|
|
take: 5,
|
|
}),
|
|
])
|
|
|
|
// Net worth: live balances for current month, snapshots for past months
|
|
let netWorthCents: number
|
|
let netWorthAccounts: { id: string; name: string; balanceCents: number }[]
|
|
|
|
if (isCurrentMonth) {
|
|
const accounts = await prisma.account.findMany({
|
|
where: { userId, isActive: true, type: { in: ['BANK', 'INVESTMENT'] } },
|
|
select: { id: true, name: true, currentBalanceCents: true },
|
|
orderBy: { name: 'asc' },
|
|
})
|
|
netWorthAccounts = accounts.map((a) => ({ id: a.id, name: a.name, balanceCents: a.currentBalanceCents }))
|
|
netWorthCents = netWorthAccounts.reduce((s, a) => s + a.balanceCents, 0)
|
|
} else {
|
|
const snapshots = await prisma.$queryRaw<{ accountId: string; name: string; balanceCents: bigint }[]>`
|
|
SELECT bs."accountId", a.name, bs."balanceCents"
|
|
FROM "BalanceSnapshot" bs
|
|
JOIN "Account" a ON bs."accountId" = a.id
|
|
WHERE a."userId" = ${userId}
|
|
AND a.type IN ('BANK', 'INVESTMENT')
|
|
AND bs.year = ${year}
|
|
AND bs.month = ${month}
|
|
`
|
|
netWorthAccounts = snapshots.map((s) => ({
|
|
id: s.accountId,
|
|
name: s.name,
|
|
balanceCents: Number(s.balanceCents),
|
|
}))
|
|
netWorthCents = netWorthAccounts.reduce((s, a) => s + a.balanceCents, 0)
|
|
}
|
|
|
|
const cfMap = Object.fromEntries(cashFlowRows.map((r) => [r.type, Number(r.total)]))
|
|
const creditsCents = cfMap['CREDIT'] ?? 0
|
|
const debitsCents = cfMap['DEBIT'] ?? 0
|
|
|
|
const spendMap = new Map(spendRows.map((r) => [r.budgetId, Number(r.total)]))
|
|
|
|
const recentTransactions = recentTx.map((t) => ({
|
|
id: t.id,
|
|
date: t.date.toISOString(),
|
|
description: t.description,
|
|
amountCents: t.amountCents,
|
|
type: t.type,
|
|
accountName: t.account.name,
|
|
}))
|
|
|
|
const budgetsWithSpend = budgets.map((b) => ({
|
|
id: b.id,
|
|
name: b.name,
|
|
limitCents: b.limitCents,
|
|
color: b.color,
|
|
spendCents: spendMap.get(b.id) ?? 0,
|
|
}))
|
|
|
|
return (
|
|
<div className="p-6 space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl font-bold">Dashboard</h1>
|
|
<MonthYearPicker />
|
|
</div>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<NetWorthCard netWorthCents={netWorthCents} bankAccounts={netWorthAccounts} />
|
|
<CashFlowCard
|
|
monthLabel={monthLabel(selectedDate)}
|
|
creditsCents={creditsCents}
|
|
debitsCents={debitsCents}
|
|
netCents={creditsCents - debitsCents}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-4 lg:grid-cols-2">
|
|
<RecentTransactions transactions={recentTransactions} />
|
|
<BudgetSummary budgets={budgetsWithSpend} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|