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
+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;