feat: drag-and-drop reorder for budget categories and items (#479)

Add reordering support for budget categories and line items within
categories. Changes persist via new DB table (budget_category_order)
and existing sort_order column. Live sync via WebSocket budget:reordered
event. Use Map instead of plain objects for category grouping to
preserve insertion order with numeric category names.
This commit is contained in:
Maurice
2026-04-09 19:21:43 +02:00
parent 1f3e27765a
commit 5c0d819fc1
7 changed files with 293 additions and 24 deletions
+20
View File
@@ -864,6 +864,26 @@ function runMigrations(db: Database.Database): void {
for (const d of matchingDays) ins.run(r.id, d.id, r.day_plan_position);
}
},
// Migration: Budget category ordering
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS budget_category_order (
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
category TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (trip_id, category)
);
`);
// Seed existing categories with alphabetical order
const rows = db.prepare('SELECT DISTINCT trip_id, category FROM budget_items ORDER BY trip_id, category').all() as { trip_id: number; category: string }[];
const ins = db.prepare('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)');
let lastTripId = -1;
let idx = 0;
for (const r of rows) {
if (r.trip_id !== lastTripId) { lastTripId = r.trip_id; idx = 0; }
ins.run(r.trip_id, r.category, idx++);
}
},
];
if (currentVersion < migrations.length) {
+34
View File
@@ -14,6 +14,8 @@ import {
toggleMemberPaid,
getPerPersonSummary,
calculateSettlement,
reorderBudgetItems,
reorderBudgetCategories,
} from '../services/budgetService';
const router = express.Router({ mergeParams: true });
@@ -56,6 +58,38 @@ router.post('/', authenticate, (req: Request, res: Response) => {
broadcast(tripId, 'budget:created', { item }, req.headers['x-socket-id'] as string);
});
router.put('/reorder/items', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { orderedIds } = req.body;
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
reorderBudgetItems(tripId, orderedIds);
res.json({ success: true });
broadcast(tripId, 'budget:reordered', { orderedIds }, req.headers['x-socket-id'] as string);
});
router.put('/reorder/categories', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { orderedCategories } = req.body;
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
reorderBudgetCategories(tripId, orderedCategories);
res.json({ success: true });
broadcast(tripId, 'budget:reordered', { orderedCategories }, req.headers['x-socket-id'] as string);
});
router.put('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
+47 -4
View File
@@ -28,9 +28,12 @@ function loadItemMembers(itemId: number | string) {
// ---------------------------------------------------------------------------
export function listBudgetItems(tripId: string | number) {
const items = db.prepare(
'SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC, created_at ASC'
).all(tripId) as BudgetItem[];
const items = db.prepare(`
SELECT bi.* FROM budget_items bi
LEFT JOIN budget_category_order bco ON bco.trip_id = bi.trip_id AND bco.category = bi.category
WHERE bi.trip_id = ?
ORDER BY COALESCE(bco.sort_order, 999999) ASC, bi.sort_order ASC
`).all(tripId) as BudgetItem[];
const itemIds = items.map(i => i.id);
const membersByItem: Record<number, (BudgetItemMember & { avatar_url: string | null })[]> = {};
@@ -64,11 +67,21 @@ export function createBudgetItem(
).get(tripId) as { max: number | null };
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const cat = data.category || 'Other';
// Ensure category has a sort_order entry
const catExists = db.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?').get(tripId, cat);
if (!catExists) {
const maxCatOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_category_order WHERE trip_id = ?').get(tripId) as { max: number | null };
const catOrder = (maxCatOrder?.max !== null && maxCatOrder?.max !== undefined ? maxCatOrder.max : -1) + 1;
db.prepare('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)').run(tripId, cat, catOrder);
}
const result = db.prepare(
'INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(
tripId,
data.category || 'Other',
cat,
data.name,
data.total_price || 0,
data.persons != null ? data.persons : null,
@@ -114,6 +127,16 @@ export function updateBudgetItem(
id,
);
// If category changed, update category order table
if (data.category) {
const catExists = db.prepare('SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?').get(tripId, data.category);
if (!catExists) {
const maxCatOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_category_order WHERE trip_id = ?').get(tripId) as { max: number | null };
const catOrder = (maxCatOrder?.max !== null && maxCatOrder?.max !== undefined ? maxCatOrder.max : -1) + 1;
db.prepare('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)').run(tripId, data.category, catOrder);
}
}
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem & { members?: BudgetItemMember[] };
updated.members = loadItemMembers(id);
return updated;
@@ -255,3 +278,23 @@ export function calculateSettlement(tripId: string | number) {
flows,
};
}
// ---------------------------------------------------------------------------
// Reorder
// ---------------------------------------------------------------------------
export function reorderBudgetItems(tripId: string | number, orderedIds: number[]) {
const update = db.prepare('UPDATE budget_items SET sort_order = ? WHERE id = ? AND trip_id = ?');
db.transaction(() => {
orderedIds.forEach((id, index) => update.run(index, id, tripId));
})();
}
export function reorderBudgetCategories(tripId: string | number, orderedCategories: string[]) {
const upsert = db.prepare(
'INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?) ON CONFLICT(trip_id, category) DO UPDATE SET sort_order = excluded.sort_order'
);
db.transaction(() => {
orderedCategories.forEach((cat, index) => upsert.run(tripId, cat, index));
})();
}