Month Year selection for Dashboard

This commit is contained in:
2026-04-20 23:31:59 -04:00
parent d865b02752
commit 91a33cdfec
5 changed files with 126 additions and 24 deletions

View File

@@ -1,15 +1,24 @@
import { auth } from '@/lib/auth' import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { monthBounds } from '@/lib/utils/dates'
import { BudgetList } from '@/components/budgets/BudgetList' import { BudgetList } from '@/components/budgets/BudgetList'
import { MonthYearPicker } from '@/components/dashboard/MonthYearPicker'
export default async function BudgetsPage() { type SearchParams = Promise<Record<string, string | string[] | undefined>>
export default async function BudgetsPage({ searchParams }: { searchParams: SearchParams }) {
const session = await auth() const session = await auth()
if (!session?.user?.id) return null if (!session?.user?.id) return null
const userId = session.user.id 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 now = new Date()
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1) const month = Number(get('month')) || (now.getMonth() + 1)
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999) const year = Number(get('year')) || now.getFullYear()
const selectedDate = new Date(year, month - 1, 1)
const { start, end } = monthBounds(selectedDate)
const [budgets, spendRows, rules] = await Promise.all([ const [budgets, spendRows, rules] = await Promise.all([
prisma.budget.findMany({ prisma.budget.findMany({
@@ -23,8 +32,8 @@ export default async function BudgetsPage() {
WHERE a."userId" = ${userId} WHERE a."userId" = ${userId}
AND t."budgetId" IS NOT NULL AND t."budgetId" IS NOT NULL
AND t.type = 'DEBIT' AND t.type = 'DEBIT'
AND t.date >= ${monthStart} AND t.date >= ${start}
AND t.date <= ${monthEnd} AND t.date <= ${end}
GROUP BY t."budgetId" GROUP BY t."budgetId"
`, `,
prisma.budgetRule.findMany({ prisma.budgetRule.findMany({
@@ -53,7 +62,11 @@ export default async function BudgetsPage() {
})) }))
return ( return (
<div className="p-6"> <div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Budgets</h1>
<MonthYearPicker />
</div>
<BudgetList budgets={budgetsWithSpend} /> <BudgetList budgets={budgetsWithSpend} />
</div> </div>
) )

View File

@@ -5,20 +5,27 @@ import { NetWorthCard } from '@/components/dashboard/NetWorthCard'
import { CashFlowCard } from '@/components/dashboard/CashFlowCard' import { CashFlowCard } from '@/components/dashboard/CashFlowCard'
import { RecentTransactions } from '@/components/dashboard/RecentTransactions' import { RecentTransactions } from '@/components/dashboard/RecentTransactions'
import { BudgetSummary } from '@/components/dashboard/BudgetSummary' import { BudgetSummary } from '@/components/dashboard/BudgetSummary'
import { MonthYearPicker } from '@/components/dashboard/MonthYearPicker'
export default async function DashboardPage() { type SearchParams = Promise<Record<string, string | string[] | undefined>>
export default async function DashboardPage({ searchParams }: { searchParams: SearchParams }) {
const session = await auth() const session = await auth()
if (!session?.user?.id) return null if (!session?.user?.id) return null
const userId = session.user.id const userId = session.user.id
const { start, end } = monthBounds() const sp = await searchParams
const get = (k: string) => (Array.isArray(sp[k]) ? sp[k][0] : sp[k]) ?? ''
const [accounts, cashFlowRows, budgets, spendRows, recentTx] = await Promise.all([ const now = new Date()
prisma.account.findMany({ const month = Number(get('month')) || (now.getMonth() + 1)
where: { userId, isActive: true }, const year = Number(get('year')) || now.getFullYear()
select: { id: true, name: true, type: true, currentBalanceCents: true }, const isCurrentMonth = month === (now.getMonth() + 1) && year === now.getFullYear()
orderBy: { name: 'asc' },
}), 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 }[]>` prisma.$queryRaw<{ type: string; total: bigint }[]>`
SELECT t.type, COALESCE(SUM(t."amountCents"), 0)::bigint AS total SELECT t.type, COALESCE(SUM(t."amountCents"), 0)::bigint AS total
FROM "Transaction" t FROM "Transaction" t
@@ -45,15 +52,45 @@ export default async function DashboardPage() {
GROUP BY t."budgetId" GROUP BY t."budgetId"
`, `,
prisma.transaction.findMany({ prisma.transaction.findMany({
where: { account: { userId } }, where: {
account: { userId },
date: { gte: start, lte: end },
},
include: { account: { select: { name: true } } }, include: { account: { select: { name: true } } },
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
take: 5, take: 5,
}), }),
]) ])
const bankAccounts = accounts.filter((a) => a.type === 'BANK') // Net worth: live balances for current month, snapshots for past months
const netWorthCents = bankAccounts.reduce((s, a) => s + a.currentBalanceCents, 0) let netWorthCents: number
let netWorthAccounts: { id: string; name: string; balanceCents: number }[]
if (isCurrentMonth) {
const accounts = await prisma.account.findMany({
where: { userId, isActive: true, type: 'BANK' },
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 = 'BANK'
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 cfMap = Object.fromEntries(cashFlowRows.map((r) => [r.type, Number(r.total)]))
const creditsCents = cfMap['CREDIT'] ?? 0 const creditsCents = cfMap['CREDIT'] ?? 0
@@ -80,15 +117,15 @@ export default async function DashboardPage() {
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Dashboard</h1> <h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-sm text-muted-foreground">{monthLabel()}</p> <MonthYearPicker />
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<NetWorthCard netWorthCents={netWorthCents} bankAccounts={bankAccounts} /> <NetWorthCard netWorthCents={netWorthCents} bankAccounts={netWorthAccounts} />
<CashFlowCard <CashFlowCard
monthLabel={monthLabel()} monthLabel={monthLabel(selectedDate)}
creditsCents={creditsCents} creditsCents={creditsCents}
debitsCents={debitsCents} debitsCents={debitsCents}
netCents={creditsCents - debitsCents} netCents={creditsCents - debitsCents}

View File

@@ -103,7 +103,7 @@ export function BudgetCard({ budget }: { budget: BudgetWithSpend }) {
{budget.limitCents ? ( {budget.limitCents ? (
<BudgetProgress spendCents={budget.spendCents} limitCents={budget.limitCents} /> <BudgetProgress spendCents={budget.spendCents} limitCents={budget.limitCents} />
) : ( ) : (
<p className="text-xs text-muted-foreground">This month</p> <p className="text-xs text-muted-foreground">All time</p>
)} )}
{!budget.isActive && ( {!budget.isActive && (

View File

@@ -0,0 +1,52 @@
'use client'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
export function MonthYearPicker() {
const searchParams = useSearchParams()
const router = useRouter()
const pathname = usePathname()
const now = new Date()
const currentMonth = now.getMonth() + 1
const currentYear = now.getFullYear()
const month = Number(searchParams.get('month')) || currentMonth
const year = Number(searchParams.get('year')) || currentYear
const isCurrentMonth = month === currentMonth && year === currentYear
function navigate(delta: number) {
let m = month + delta
let y = year
if (m < 1) { m = 12; y-- }
if (m > 12) { m = 1; y++ }
const params = new URLSearchParams(searchParams.toString())
params.set('month', String(m))
params.set('year', String(y))
router.push(`${pathname}?${params.toString()}`)
}
const label = new Date(year, month - 1, 1).toLocaleDateString('en-US', {
month: 'long', year: 'numeric',
})
return (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => navigate(-1)}>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm font-medium min-w-[140px] text-center">{label}</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => navigate(1)}
disabled={isCurrentMonth}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)
}

View File

@@ -4,7 +4,7 @@ import { formatCents } from '@/lib/utils/currency'
interface BankAccount { interface BankAccount {
id: string id: string
name: string name: string
currentBalanceCents: number balanceCents: number
} }
interface NetWorthCardProps { interface NetWorthCardProps {
@@ -26,7 +26,7 @@ export function NetWorthCard({ netWorthCents, bankAccounts }: NetWorthCardProps)
<div key={a.id} className="flex items-center justify-between text-sm"> <div key={a.id} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground truncate">{a.name}</span> <span className="text-muted-foreground truncate">{a.name}</span>
<span className="tabular-nums font-medium ml-4 shrink-0"> <span className="tabular-nums font-medium ml-4 shrink-0">
{formatCents(a.currentBalanceCents)} {formatCents(a.balanceCents)}
</span> </span>
</div> </div>
))} ))}