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>
97 lines
3.0 KiB
TypeScript
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>
|
|
)
|
|
}
|