feat: spending toggle per category, empty state, income query, auto-enable on rule creation

This commit is contained in:
null 2026-06-04 20:01:51 -05:00
parent 3f0078b930
commit 743379fc94
6 changed files with 139 additions and 12 deletions

View File

@ -256,6 +256,7 @@ export const api = {
createCategory: (data) => post('/categories', data),
reorderCategories: (order) => put('/categories/reorder', order),
updateCategory: (id, data) => put(`/categories/${id}`, data),
toggleCategorySpending: (id, val) => patch(`/categories/${id}/spending`, { spending_enabled: val }),
deleteCategory: (id) => del(`/categories/${id}`),
restoreCategory: (id) => post(`/categories/${id}/restore`),

View File

@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom';
import { toast } from 'sonner';
import {
ArrowDown, ArrowUp, ChevronDown, GripVertical, Plus, Pencil, Trash2, ReceiptText, AlertCircle, RefreshCw,
ArrowDown, ArrowUp, ChevronDown, GripVertical, Plus, Pencil, Trash2, ReceiptText, AlertCircle, RefreshCw, ShoppingCart,
} from 'lucide-react';
import { api } from '@/api.js';
import { Button, buttonVariants } from '@/components/ui/button';
@ -641,6 +641,31 @@ export default function CategoriesPage() {
</div>
<div className="flex items-center justify-end gap-1 opacity-80 transition-opacity group-hover:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={`h-8 w-8 ${cat.spending_enabled ? 'text-emerald-500 hover:text-emerald-400' : 'text-muted-foreground/40 hover:text-muted-foreground'}`}
onClick={async (event) => {
event.stopPropagation();
try {
const result = await api.toggleCategorySpending(cat.id, !cat.spending_enabled);
setCategories(prev => prev.map(c => c.id === cat.id ? { ...c, spending_enabled: result.spending_enabled } : c));
} catch (err) {
toast.error(err.message || 'Failed to update category');
}
}}
aria-label={cat.spending_enabled ? `Disable spending for ${cat.name}` : `Enable spending for ${cat.name}`}
>
<ShoppingCart className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
{cat.spending_enabled ? 'Shown in Spending page — click to disable' : 'Enable for Spending page'}
</TooltipContent>
</Tooltip>
<Button
type="button"
variant="ghost"

View File

@ -45,7 +45,7 @@ function CategoryPicker({ categories, current, onSelect }) {
</button>
{open && (
<div className="absolute right-0 top-full z-50 mt-1 w-44 rounded-lg border border-border/60 bg-popover shadow-lg overflow-hidden">
<div className="absolute right-0 top-full z-50 mt-1 w-48 rounded-lg border border-border/60 bg-popover shadow-lg overflow-hidden">
<button
type="button"
onMouseDown={e => { e.preventDefault(); onSelect(null, false); setOpen(false); }}
@ -53,7 +53,11 @@ function CategoryPicker({ categories, current, onSelect }) {
>
Uncategorized
</button>
{categories.map(cat => (
{categories.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground italic">
No spending categories. Enable some in Categories.
</p>
) : categories.map(cat => (
<button
key={cat.id}
type="button"
@ -403,7 +407,8 @@ export default function SpendingPage() {
const loadCategories = useCallback(async () => {
try {
const d = await api.categories();
setCategories((d.categories || d || []).filter(c => !c.deleted_at));
// Only show spending-enabled categories in the spending UI
setCategories((d.categories || d || []).filter(c => !c.deleted_at && c.spending_enabled));
} catch (err) {
toast.error(err.message || 'Failed to load categories');
}
@ -530,6 +535,18 @@ export default function SpendingPage() {
</div>
</div>
{/* No spending categories notice */}
{categories.length === 0 && (
<div className="flex items-start gap-3 rounded-xl border border-amber-500/25 bg-amber-500/10 px-4 py-3">
<Tag className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
<div className="text-sm text-amber-600 dark:text-amber-400">
No spending categories are enabled yet. Go to{' '}
<a href="/categories" className="underline underline-offset-2 font-medium">Categories</a>
{' '}and enable "Spending" on the categories you want to use here.
</div>
</div>
)}
{/* Category breakdown */}
<div className="rounded-xl border border-border/60 bg-card/80 overflow-hidden">
<div className="px-4 py-3 border-b border-border/40 flex items-center gap-2">

View File

@ -2835,6 +2835,55 @@ function runMigrations() {
console.log('[v0.87] spending: transactions.spending_category_id, spending_category_rules, spending_budgets');
}
},
{
version: 'v0.88',
description: 'categories: spending_enabled flag to separate bill vs spending categories',
dependsOn: ['v0.87'],
run: function() {
const cols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
if (!cols.includes('spending_enabled'))
db.exec('ALTER TABLE categories ADD COLUMN spending_enabled INTEGER NOT NULL DEFAULT 0');
// Mark the v0.87-seeded defaults as spending-enabled
const SPENDING_DEFAULTS = ['Groceries','Dining','Fuel & Transport','Shopping','Entertainment','Health','Travel','Other'];
const placeholder = SPENDING_DEFAULTS.map(() => '?').join(',');
db.exec(`UPDATE categories SET spending_enabled=1 WHERE is_seeded=1 AND name IN (${SPENDING_DEFAULTS.map(n => `'${n.replace("'", "''")}'`).join(',')})`);
// Mark any category already linked to a spending rule as spending-enabled
try {
db.exec(`
UPDATE categories SET spending_enabled=1
WHERE id IN (SELECT DISTINCT category_id FROM spending_category_rules)
`);
} catch { /* spending_category_rules may not exist on legacy paths */ }
console.log('[v0.88] categories.spending_enabled added, seeded defaults marked');
}
},
{
version: 'v0.89',
description: 'categories: seed spending defaults for users who had existing categories before v0.87',
dependsOn: ['v0.88'],
run: function() {
const SPENDING_DEFAULTS = [
'Groceries','Dining','Fuel & Transport','Shopping',
'Entertainment','Health','Travel','Other'
];
const users = db.prepare("SELECT id FROM users WHERE role='user' AND active=1").all();
const insert = db.prepare(`
INSERT OR IGNORE INTO categories (user_id, name, sort_order, is_seeded, spending_enabled)
VALUES (?, ?, ?, 1, 1)
`);
let seeded = 0;
for (const user of users) {
const hasSpending = db.prepare('SELECT 1 FROM categories WHERE user_id=? AND spending_enabled=1 AND deleted_at IS NULL LIMIT 1').get(user.id);
if (!hasSpending) {
SPENDING_DEFAULTS.forEach((name, i) => { insert.run(user.id, name, 200 + i); seeded++; });
}
}
console.log(`[v0.89] spending defaults seeded for users missing them (${seeded} categories inserted)`);
}
}
];

View File

@ -5,11 +5,12 @@ const { getDb, ensureUserDefaultCategories } = require('../db/database');
// GET /api/categories
router.get('/', (req, res) => {
try {
const db = getDb();
ensureUserDefaultCategories(req.user.id);
const categories = db.prepare(`
SELECT id, user_id, name, sort_order, created_at, updated_at
SELECT id, user_id, name, sort_order, spending_enabled, created_at, updated_at
FROM categories
WHERE user_id = ?
AND deleted_at IS NULL
@ -55,6 +56,7 @@ router.get('/', (req, res) => {
return {
...category,
spending_enabled: !!category.spending_enabled,
bill_count: activeBillCount,
active_bill_count: activeBillCount,
inactive_bill_count: inactiveBillCount,
@ -65,6 +67,10 @@ router.get('/', (req, res) => {
});
res.json(shaped);
} catch (err) {
console.error('[categories GET]', err.message);
res.status(500).json({ error: 'Failed to load categories' });
}
});
// PUT /api/categories/reorder
@ -126,21 +132,43 @@ router.post('/', (req, res) => {
// PUT /api/categories/:id
router.put('/:id', (req, res) => {
const db = getDb();
const { name } = req.body;
const { name, spending_enabled } = req.body;
if (!name) return res.status(400).json(standardizeError('name is required', 'VALIDATION_ERROR', 'name'));
const cat = db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
if (!cat) return res.status(404).json(standardizeError('Category not found', 'NOT_FOUND', 'id'));
try {
db.prepare("UPDATE categories SET name = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
.run(name.trim(), req.params.id, req.user.id);
res.json(db.prepare('SELECT * FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id));
const fields = ["name = ?", "updated_at = datetime('now')"];
const values = [name.trim()];
if (spending_enabled !== undefined) { fields.push('spending_enabled = ?'); values.push(spending_enabled ? 1 : 0); }
db.prepare(`UPDATE categories SET ${fields.join(', ')} WHERE id = ? AND user_id = ?`)
.run(...values, req.params.id, req.user.id);
const updated = db.prepare('SELECT id, name, sort_order, spending_enabled, created_at, updated_at FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
res.json({ ...updated, spending_enabled: !!updated.spending_enabled });
} catch (e) {
if (e.message.includes('UNIQUE')) {
if (e.message?.includes('UNIQUE')) {
return res.status(409).json(standardizeError('Category already exists', 'CONFLICT', 'name'));
}
throw e;
console.error('[categories PUT]', e.message);
res.status(500).json({ error: 'Failed to update category' });
}
});
// PATCH /api/categories/:id/spending — toggle spending_enabled without requiring name
router.patch('/:id/spending', (req, res) => {
const db = getDb();
const cat = db.prepare('SELECT id, spending_enabled FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
if (!cat) return res.status(404).json(standardizeError('Category not found', 'NOT_FOUND', 'id'));
const enabled = req.body?.spending_enabled !== undefined ? !!req.body.spending_enabled : !cat.spending_enabled;
try {
db.prepare("UPDATE categories SET spending_enabled=?, updated_at=datetime('now') WHERE id=? AND user_id=?")
.run(enabled ? 1 : 0, req.params.id, req.user.id);
res.json({ id: cat.id, spending_enabled: enabled });
} catch (err) {
console.error('[categories PATCH spending]', err.message);
res.status(500).json({ error: 'Failed to update category' });
}
});

View File

@ -163,7 +163,9 @@ function categorizeTransaction(db, userId, txId, categoryId, saveMerchantRule =
VALUES (?, ?, ?)
ON CONFLICT(user_id, merchant) DO UPDATE SET category_id=excluded.category_id
`).run(userId, categoryId, merchant);
// Apply this rule to all existing matching transactions
// Auto-mark this category as spending-enabled
db.prepare("UPDATE categories SET spending_enabled=1, updated_at=datetime('now') WHERE id=? AND user_id=?")
.run(categoryId, userId);
applySpendingCategoryRules(db, userId, merchant);
} catch { /* safe to ignore */ }
}
@ -257,6 +259,11 @@ function addSpendingCategoryRule(db, userId, categoryId, merchant) {
VALUES (?, ?, ?)
ON CONFLICT(user_id, merchant) DO UPDATE SET category_id=excluded.category_id
`).run(userId, categoryId, normalized);
// Auto-mark this category as spending-enabled so it shows in the picker
try {
db.prepare("UPDATE categories SET spending_enabled=1, updated_at=datetime('now') WHERE id=? AND user_id=?")
.run(categoryId, userId);
} catch { /* non-critical */ }
applySpendingCategoryRules(db, userId, normalized);
}