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:
@@ -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 ?? []}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
96
src/components/budgets/BudgetRulesDialog.tsx
Normal file
96
src/components/budgets/BudgetRulesDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user