feat: spending toggle per category, empty state, income query, auto-enable on rule creation
This commit is contained in:
parent
3f0078b930
commit
743379fc94
|
|
@ -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`),
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)`);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue