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),
|
createCategory: (data) => post('/categories', data),
|
||||||
reorderCategories: (order) => put('/categories/reorder', order),
|
reorderCategories: (order) => put('/categories/reorder', order),
|
||||||
updateCategory: (id, data) => put(`/categories/${id}`, data),
|
updateCategory: (id, data) => put(`/categories/${id}`, data),
|
||||||
|
toggleCategorySpending: (id, val) => patch(`/categories/${id}/spending`, { spending_enabled: val }),
|
||||||
deleteCategory: (id) => del(`/categories/${id}`),
|
deleteCategory: (id) => del(`/categories/${id}`),
|
||||||
restoreCategory: (id) => post(`/categories/${id}/restore`),
|
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 { Link } from 'react-router-dom';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
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';
|
} from 'lucide-react';
|
||||||
import { api } from '@/api.js';
|
import { api } from '@/api.js';
|
||||||
import { Button, buttonVariants } from '@/components/ui/button';
|
import { Button, buttonVariants } from '@/components/ui/button';
|
||||||
|
|
@ -641,6 +641,31 @@ export default function CategoriesPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-1 opacity-80 transition-opacity group-hover:opacity-100">
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ function CategoryPicker({ categories, current, onSelect }) {
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onMouseDown={e => { e.preventDefault(); onSelect(null, false); setOpen(false); }}
|
onMouseDown={e => { e.preventDefault(); onSelect(null, false); setOpen(false); }}
|
||||||
|
|
@ -53,7 +53,11 @@ function CategoryPicker({ categories, current, onSelect }) {
|
||||||
>
|
>
|
||||||
— Uncategorized
|
— Uncategorized
|
||||||
</button>
|
</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
|
<button
|
||||||
key={cat.id}
|
key={cat.id}
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -403,7 +407,8 @@ export default function SpendingPage() {
|
||||||
const loadCategories = useCallback(async () => {
|
const loadCategories = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const d = await api.categories();
|
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) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to load categories');
|
toast.error(err.message || 'Failed to load categories');
|
||||||
}
|
}
|
||||||
|
|
@ -530,6 +535,18 @@ export default function SpendingPage() {
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Category breakdown */}
|
||||||
<div className="rounded-xl border border-border/60 bg-card/80 overflow-hidden">
|
<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">
|
<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');
|
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
|
// GET /api/categories
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
ensureUserDefaultCategories(req.user.id);
|
ensureUserDefaultCategories(req.user.id);
|
||||||
|
|
||||||
const categories = db.prepare(`
|
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
|
FROM categories
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
|
|
@ -55,6 +56,7 @@ router.get('/', (req, res) => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...category,
|
...category,
|
||||||
|
spending_enabled: !!category.spending_enabled,
|
||||||
bill_count: activeBillCount,
|
bill_count: activeBillCount,
|
||||||
active_bill_count: activeBillCount,
|
active_bill_count: activeBillCount,
|
||||||
inactive_bill_count: inactiveBillCount,
|
inactive_bill_count: inactiveBillCount,
|
||||||
|
|
@ -65,6 +67,10 @@ router.get('/', (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(shaped);
|
res.json(shaped);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[categories GET]', err.message);
|
||||||
|
res.status(500).json({ error: 'Failed to load categories' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT /api/categories/reorder
|
// PUT /api/categories/reorder
|
||||||
|
|
@ -126,21 +132,43 @@ router.post('/', (req, res) => {
|
||||||
// PUT /api/categories/:id
|
// PUT /api/categories/:id
|
||||||
router.put('/:id', (req, res) => {
|
router.put('/:id', (req, res) => {
|
||||||
const db = getDb();
|
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'));
|
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);
|
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'));
|
if (!cat) return res.status(404).json(standardizeError('Category not found', 'NOT_FOUND', 'id'));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
db.prepare("UPDATE categories SET name = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
|
const fields = ["name = ?", "updated_at = datetime('now')"];
|
||||||
.run(name.trim(), req.params.id, req.user.id);
|
const values = [name.trim()];
|
||||||
res.json(db.prepare('SELECT * FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id));
|
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) {
|
} catch (e) {
|
||||||
if (e.message.includes('UNIQUE')) {
|
if (e.message?.includes('UNIQUE')) {
|
||||||
return res.status(409).json(standardizeError('Category already exists', 'CONFLICT', 'name'));
|
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 (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
ON CONFLICT(user_id, merchant) DO UPDATE SET category_id=excluded.category_id
|
ON CONFLICT(user_id, merchant) DO UPDATE SET category_id=excluded.category_id
|
||||||
`).run(userId, categoryId, merchant);
|
`).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);
|
applySpendingCategoryRules(db, userId, merchant);
|
||||||
} catch { /* safe to ignore */ }
|
} catch { /* safe to ignore */ }
|
||||||
}
|
}
|
||||||
|
|
@ -257,6 +259,11 @@ function addSpendingCategoryRule(db, userId, categoryId, merchant) {
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
ON CONFLICT(user_id, merchant) DO UPDATE SET category_id=excluded.category_id
|
ON CONFLICT(user_id, merchant) DO UPDATE SET category_id=excluded.category_id
|
||||||
`).run(userId, categoryId, normalized);
|
`).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);
|
applySpendingCategoryRules(db, userId, normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue