feat: category import from CSV + budget auto-assign rules

Category:
- Add categoryColumn to NormalizerConfig and NormalizedRow
- Map 'Category' column for Discover CC profile
- Write category to Transaction on upload

Budget rules:
- Add BudgetRule model (userId, budgetId, pattern)
- API: GET/POST /api/budget-rules, DELETE /api/budget-rules/:id
- Apply rules during upload (first case-insensitive match wins)
- Budgets page fetches and passes rules per budget
- BudgetCard 'Rules' menu item opens BudgetRulesDialog for add/delete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jerick
2026-04-20 22:56:41 -04:00
parent efe42ac366
commit 60fc836b73
10 changed files with 234 additions and 10 deletions

View File

@@ -7,11 +7,17 @@ import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { MoreHorizontal, Pencil, Trash2, EyeOff, Eye } from 'lucide-react'
import { MoreHorizontal, Pencil, Trash2, EyeOff, Eye, ListFilter } from 'lucide-react'
import { BudgetProgress } from './BudgetProgress'
import { CreateBudgetDialog } from './CreateBudgetDialog'
import { BudgetRulesDialog } from './BudgetRulesDialog'
import { formatCents } from '@/lib/utils/currency'
export interface BudgetRule {
id: string
pattern: string
}
export interface BudgetWithSpend {
id: string
name: string
@@ -19,11 +25,13 @@ export interface BudgetWithSpend {
color: string | null
isActive: boolean
spendCents: number
rules?: BudgetRule[]
}
export function BudgetCard({ budget }: { budget: BudgetWithSpend }) {
const router = useRouter()
const [editOpen, setEditOpen] = useState(false)
const [rulesOpen, setRulesOpen] = useState(false)
async function handleToggle() {
await fetch(`/api/budgets/${budget.id}`, {
@@ -62,6 +70,9 @@ export function BudgetCard({ budget }: { budget: BudgetWithSpend }) {
<DropdownMenuItem onClick={() => setEditOpen(true)}>
<Pencil className="h-4 w-4 mr-2" />Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setRulesOpen(true)}>
<ListFilter className="h-4 w-4 mr-2" />Rules
</DropdownMenuItem>
<DropdownMenuItem onClick={handleToggle}>
{budget.isActive
? <><EyeOff className="h-4 w-4 mr-2" />Deactivate</>
@@ -102,6 +113,13 @@ export function BudgetCard({ budget }: { budget: BudgetWithSpend }) {
</Card>
<CreateBudgetDialog open={editOpen} onOpenChange={setEditOpen} budget={budget} />
<BudgetRulesDialog
open={rulesOpen}
onOpenChange={setRulesOpen}
budgetId={budget.id}
budgetName={budget.name}
rules={budget.rules ?? []}
/>
</>
)
}

View File

@@ -0,0 +1,96 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Trash2, Plus } from 'lucide-react'
interface Rule {
id: string
pattern: string
}
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
budgetId: string
budgetName: string
rules: Rule[]
}
export function BudgetRulesDialog({ open, onOpenChange, budgetId, budgetName, rules }: Props) {
const router = useRouter()
const [pattern, setPattern] = useState('')
const [adding, setAdding] = useState(false)
async function handleAdd(e: React.FormEvent) {
e.preventDefault()
if (!pattern.trim()) return
setAdding(true)
await fetch('/api/budget-rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ budgetId, pattern: pattern.trim() }),
})
setPattern('')
setAdding(false)
router.refresh()
}
async function handleDelete(id: string) {
await fetch(`/api/budget-rules/${id}`, { method: 'DELETE' })
router.refresh()
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Auto-assign rules {budgetName}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Transactions whose description contains a pattern (case-insensitive) are automatically
assigned to this budget on upload. First matching rule wins.
</p>
{rules.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-2">No rules yet.</p>
) : (
<ul className="space-y-1.5">
{rules.map((rule) => (
<li
key={rule.id}
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
>
<span className="font-mono truncate">{rule.pattern}</span>
<button
onClick={() => handleDelete(rule.id)}
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors"
aria-label="Delete rule"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
)}
<form onSubmit={handleAdd} className="flex gap-2">
<Input
placeholder="e.g. Amazon, Netflix, Starbucks"
value={pattern}
onChange={(e) => setPattern(e.target.value)}
className="flex-1"
/>
<Button type="submit" disabled={adding || !pattern.trim()} size="sm">
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</form>
</DialogContent>
</Dialog>
)
}