Files
finance-app/src/components/budgets/BudgetRulesDialog.tsx
jerick 60fc836b73 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>
2026-04-20 22:56:41 -04:00

97 lines
3.0 KiB
TypeScript

'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>
)
}