Month Year selection for Dashboard
This commit is contained in:
@@ -1,15 +1,24 @@
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { monthBounds } from '@/lib/utils/dates'
|
||||
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()
|
||||
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 monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
|
||||
const month = Number(get('month')) || (now.getMonth() + 1)
|
||||
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([
|
||||
prisma.budget.findMany({
|
||||
@@ -23,8 +32,8 @@ export default async function BudgetsPage() {
|
||||
WHERE a."userId" = ${userId}
|
||||
AND t."budgetId" IS NOT NULL
|
||||
AND t.type = 'DEBIT'
|
||||
AND t.date >= ${monthStart}
|
||||
AND t.date <= ${monthEnd}
|
||||
AND t.date >= ${start}
|
||||
AND t.date <= ${end}
|
||||
GROUP BY t."budgetId"
|
||||
`,
|
||||
prisma.budgetRule.findMany({
|
||||
@@ -53,7 +62,11 @@ export default async function BudgetsPage() {
|
||||
}))
|
||||
|
||||
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} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -5,20 +5,27 @@ 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'
|
||||
|
||||
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()
|
||||
if (!session?.user?.id) return null
|
||||
|
||||
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([
|
||||
prisma.account.findMany({
|
||||
where: { userId, isActive: true },
|
||||
select: { id: true, name: true, type: true, currentBalanceCents: true },
|
||||
orderBy: { name: 'asc' },
|
||||
}),
|
||||
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
|
||||
@@ -45,15 +52,45 @@ export default async function DashboardPage() {
|
||||
GROUP BY t."budgetId"
|
||||
`,
|
||||
prisma.transaction.findMany({
|
||||
where: { account: { userId } },
|
||||
where: {
|
||||
account: { userId },
|
||||
date: { gte: start, lte: end },
|
||||
},
|
||||
include: { account: { select: { name: true } } },
|
||||
orderBy: { date: 'desc' },
|
||||
take: 5,
|
||||
}),
|
||||
])
|
||||
|
||||
const bankAccounts = accounts.filter((a) => a.type === 'BANK')
|
||||
const netWorthCents = bankAccounts.reduce((s, a) => s + a.currentBalanceCents, 0)
|
||||
// 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: '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 creditsCents = cfMap['CREDIT'] ?? 0
|
||||
@@ -80,15 +117,15 @@ export default async function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground">{monthLabel()}</p>
|
||||
<MonthYearPicker />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<NetWorthCard netWorthCents={netWorthCents} bankAccounts={bankAccounts} />
|
||||
<NetWorthCard netWorthCents={netWorthCents} bankAccounts={netWorthAccounts} />
|
||||
<CashFlowCard
|
||||
monthLabel={monthLabel()}
|
||||
monthLabel={monthLabel(selectedDate)}
|
||||
creditsCents={creditsCents}
|
||||
debitsCents={debitsCents}
|
||||
netCents={creditsCents - debitsCents}
|
||||
|
||||
@@ -103,7 +103,7 @@ export function BudgetCard({ budget }: { budget: BudgetWithSpend }) {
|
||||
{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 && (
|
||||
|
||||
52
src/components/dashboard/MonthYearPicker.tsx
Normal file
52
src/components/dashboard/MonthYearPicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { formatCents } from '@/lib/utils/currency'
|
||||
interface BankAccount {
|
||||
id: string
|
||||
name: string
|
||||
currentBalanceCents: number
|
||||
balanceCents: number
|
||||
}
|
||||
|
||||
interface NetWorthCardProps {
|
||||
@@ -26,7 +26,7 @@ export function NetWorthCard({ netWorthCents, bankAccounts }: NetWorthCardProps)
|
||||
<div key={a.id} className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground truncate">{a.name}</span>
|
||||
<span className="tabular-nums font-medium ml-4 shrink-0">
|
||||
{formatCents(a.currentBalanceCents)}
|
||||
{formatCents(a.balanceCents)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user