This commit is contained in:
2026-08-04 11:53:00 -04:00
11 changed files with 80 additions and 25 deletions

11
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"next": "16.2.4", "next": "16.2.4",
"next-auth": "^5.0.0-beta.31", "next-auth": "^5.0.0-beta.31",
"next-themes": "^0.4.6",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
"pg": "^8.20.0", "pg": "^8.20.0",
"react": "19.2.4", "react": "19.2.4",
@@ -8831,6 +8832,16 @@
} }
} }
}, },
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/next/node_modules/postcss": { "node_modules/next/node_modules/postcss": {
"version": "8.4.31", "version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",

View File

@@ -18,6 +18,7 @@
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"next": "16.2.4", "next": "16.2.4",
"next-auth": "^5.0.0-beta.31", "next-auth": "^5.0.0-beta.31",
"next-themes": "^0.4.6",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
"pg": "^8.20.0", "pg": "^8.20.0",
"react": "19.2.4", "react": "19.2.4",

View File

@@ -27,7 +27,7 @@ export default async function BudgetsPage({ searchParams }: { searchParams: Sear
}), }),
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>` prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
SELECT t."budgetId", SELECT t."budgetId",
COALESCE(SUM(CASE WHEN t.type = 'DEBIT' THEN t."amountCents" ELSE -t."amountCents" END), 0)::bigint AS total 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 FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId} WHERE a."userId" = ${userId}

View File

@@ -43,7 +43,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Se
}), }),
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>` prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
SELECT t."budgetId", SELECT t."budgetId",
COALESCE(SUM(CASE WHEN t.type = 'DEBIT' THEN t."amountCents" ELSE -t."amountCents" END), 0)::bigint AS total 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 FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId} WHERE a."userId" = ${userId}

View File

@@ -29,6 +29,11 @@ export async function POST(req: Request) {
const budget = await prisma.budget.findFirst({ where: { id: budgetId, userId: session.user.id } }) const budget = await prisma.budget.findFirst({ where: { id: budgetId, userId: session.user.id } })
if (!budget) return NextResponse.json({ error: 'Budget not found' }, { status: 404 }) if (!budget) return NextResponse.json({ error: 'Budget not found' }, { status: 404 })
const existing = await prisma.budgetRule.findFirst({
where: { budgetId, pattern: { equals: pattern, mode: 'insensitive' } },
})
if (existing) return NextResponse.json({ error: 'Duplicate rule pattern' }, { status: 409 })
const rule = await prisma.budgetRule.create({ const rule = await prisma.budgetRule.create({
data: { userId: session.user.id, budgetId, pattern }, data: { userId: session.user.id, budgetId, pattern },
select: { id: true, budgetId: true, pattern: true }, select: { id: true, budgetId: true, pattern: true },

View File

@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { ThemeProvider } from "@/components/layout/ThemeProvider";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -27,7 +28,9 @@ export default function RootLayout({
lang="en" lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col">{children}</body> <body className="min-h-full flex flex-col">
<ThemeProvider>{children}</ThemeProvider>
</body>
</html> </html>
); );
} }

View File

@@ -24,10 +24,14 @@ export function BudgetRulesDialog({ open, onOpenChange, budgetId, budgetName, ru
const router = useRouter() const router = useRouter()
const [pattern, setPattern] = useState('') const [pattern, setPattern] = useState('')
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false)
const [dupError, setDupError] = useState(false)
async function handleAdd(e: React.FormEvent) { async function handleAdd(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
if (!pattern.trim()) return if (!pattern.trim()) return
const isDup = rules.some((r) => r.pattern.toLowerCase() === pattern.trim().toLowerCase())
if (isDup) { setDupError(true); return }
setDupError(false)
setAdding(true) setAdding(true)
await fetch('/api/budget-rules', { await fetch('/api/budget-rules', {
method: 'POST', method: 'POST',
@@ -36,6 +40,7 @@ export function BudgetRulesDialog({ open, onOpenChange, budgetId, budgetName, ru
}) })
setPattern('') setPattern('')
setAdding(false) setAdding(false)
setDupError(false)
router.refresh() router.refresh()
} }
@@ -59,7 +64,7 @@ export function BudgetRulesDialog({ open, onOpenChange, budgetId, budgetName, ru
{rules.length === 0 ? ( {rules.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-2">No rules yet.</p> <p className="text-sm text-muted-foreground text-center py-2">No rules yet.</p>
) : ( ) : (
<ul className="space-y-1.5"> <ul className="space-y-1.5 max-h-64 overflow-y-auto pr-1">
{rules.map((rule) => ( {rules.map((rule) => (
<li <li
key={rule.id} key={rule.id}
@@ -78,17 +83,22 @@ export function BudgetRulesDialog({ open, onOpenChange, budgetId, budgetName, ru
</ul> </ul>
)} )}
<form onSubmit={handleAdd} className="flex gap-2"> <form onSubmit={handleAdd} className="space-y-1.5">
<Input <div className="flex gap-2">
placeholder="e.g. Amazon, Netflix, Starbucks" <Input
value={pattern} placeholder="e.g. Amazon, Netflix, Starbucks"
onChange={(e) => setPattern(e.target.value)} value={pattern}
className="flex-1" onChange={(e) => { setPattern(e.target.value); setDupError(false) }}
/> className={dupError ? 'flex-1 border-destructive focus-visible:ring-destructive' : 'flex-1'}
<Button type="submit" disabled={adding || !pattern.trim()} size="sm"> />
<Plus className="h-4 w-4 mr-1" /> <Button type="submit" disabled={adding || !pattern.trim()} size="sm">
Add <Plus className="h-4 w-4 mr-1" />
</Button> Add
</Button>
</div>
{dupError && (
<p className="text-xs text-destructive">That pattern already exists for this budget.</p>
)}
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -3,10 +3,11 @@
import Link from 'next/link' import Link from 'next/link'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { signOut } from 'next-auth/react' import { signOut } from 'next-auth/react'
import { useTheme } from 'next-themes'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
LayoutDashboard, CreditCard, ArrowLeftRight, LayoutDashboard, CreditCard, ArrowLeftRight,
Upload, PiggyBank, TrendingUp, LogOut, Upload, PiggyBank, TrendingUp, LogOut, Sun, Moon,
} from 'lucide-react' } from 'lucide-react'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
@@ -21,6 +22,7 @@ const navItems = [
export function Sidebar() { export function Sidebar() {
const pathname = usePathname() const pathname = usePathname()
const { resolvedTheme, setTheme } = useTheme()
return ( return (
<aside className="flex w-56 shrink-0 flex-col border-r bg-card"> <aside className="flex w-56 shrink-0 flex-col border-r bg-card">
@@ -46,7 +48,15 @@ export function Sidebar() {
))} ))}
</nav> </nav>
<Separator /> <Separator />
<div className="p-2"> <div className="p-2 space-y-1">
<button
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
{resolvedTheme === 'dark'
? <><Sun className="h-4 w-4 shrink-0" />Light mode</>
: <><Moon className="h-4 w-4 shrink-0" />Dark mode</>}
</button>
<button <button
onClick={() => signOut({ callbackUrl: '/login' })} onClick={() => signOut({ callbackUrl: '/login' })}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground" className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"

View File

@@ -0,0 +1,11 @@
'use client'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
{children}
</NextThemesProvider>
)
}

View File

@@ -35,11 +35,15 @@ export function TransactionFilters({ accounts }: { accounts: AccountOption[] })
[pathname, router], [pathname, router],
) )
// Debounce search → URL const pushRef = useRef(push)
useEffect(() => { useEffect(() => { pushRef.current = push }, [push])
const t = setTimeout(() => push({ search }), 400) const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
return () => clearTimeout(t)
}, [search, push]) function handleSearchChange(value: string) {
setSearch(value)
if (searchTimer.current) clearTimeout(searchTimer.current)
searchTimer.current = setTimeout(() => pushRef.current({ search: value }), 400)
}
function reset() { function reset() {
setSearch('') setSearch('')
@@ -107,7 +111,7 @@ export function TransactionFilters({ accounts }: { accounts: AccountOption[] })
className="h-8 text-sm" className="h-8 text-sm"
placeholder="Search descriptions…" placeholder="Search descriptions…"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => handleSearchChange(e.target.value)}
/> />
</div> </div>

View File

@@ -41,8 +41,8 @@ export const bankProfiles: BankProfile[] = [
strategy: 'B', strategy: 'B',
dateColumn: 'Transaction Date', dateColumn: 'Transaction Date',
descriptionColumn: 'Description', descriptionColumn: 'Description',
debitColumn: 'Debit', amountColumn: 'Amount',
creditColumn: 'Credit', invertAmountSign: false, // positive = DEBIT (charge), negative = CREDIT (payment/refund)
categoryColumn: 'Category', categoryColumn: 'Category',
detectColumns: ['Transaction Date', 'Posted Date', 'Card No.', 'Description', 'Category', 'Debit', 'Credit'], detectColumns: ['Transaction Date', 'Posted Date', 'Card No.', 'Description', 'Category', 'Debit', 'Credit'],
}, },