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
+2
View File
@@ -225,6 +225,8 @@ export const budgetApi = {
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data),
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories }).then(r => r.data),
}
export const filesApi = {
+109 -20
View File
@@ -4,7 +4,7 @@ import DOM from 'react-dom'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTranslation } from '../../i18n'
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download } from 'lucide-react'
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download, GripVertical } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import { budgetApi } from '../../api/client'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
@@ -443,7 +443,7 @@ interface BudgetPanelProps {
}
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid } = useTripStore()
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore()
const can = useCanDo()
const { t, locale } = useTranslation()
const [newCategoryName, setNewCategoryName] = useState('')
@@ -456,6 +456,14 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
const fmt = (v, cur) => fmtNum(v, locale, cur)
const hasMultipleMembers = tripMembers.length > 1
// Drag state for categories
const [dragCat, setDragCat] = useState<string | null>(null)
const [dragOverCat, setDragOverCat] = useState<string | null>(null)
// Drag state for items within a category
const [dragItem, setDragItem] = useState<number | null>(null)
const [dragOverItem, setDragOverItem] = useState<number | null>(null)
const [dragItemCat, setDragItemCat] = useState<string | null>(null)
// Load settlement data whenever budget items change
useEffect(() => {
if (!hasMultipleMembers) return
@@ -468,21 +476,34 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId])
const grouped = useMemo(() => (budgetItems || []).reduce((acc, item) => {
const cat = item.category || 'Other'
if (!acc[cat]) acc[cat] = []
acc[cat].push(item)
return acc
}, {}), [budgetItems])
const grouped = useMemo(() => {
const map = new Map<string, BudgetItem[]>()
for (const item of (budgetItems || [])) {
const cat = item.category || 'Other'
if (!map.has(cat)) map.set(cat, [])
map.get(cat)!.push(item)
}
return map
}, [budgetItems])
const categoryNames = Object.keys(grouped)
const categoryNames = Array.from(grouped.keys())
// Stable color mapping: assign index-based colors once, never reassign on reorder
const colorMapRef = useRef(new Map<string, string>())
const categoryColor = useCallback((cat: string) => {
const map = colorMapRef.current
if (!map.has(cat)) {
map.set(cat, PIE_COLORS[map.size % PIE_COLORS.length])
}
return map.get(cat)!
}, [])
const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.total_price || 0), 0)
const pieSegments = useMemo(() =>
categoryNames.map((cat, i) => ({
name: cat,
value: grouped[cat].reduce((s, x) => s + (x.total_price || 0), 0),
color: PIE_COLORS[i % PIE_COLORS.length],
value: (grouped.get(cat) || []).reduce((s, x) => s + (x.total_price || 0), 0),
color: categoryColor(cat),
})).filter(s => s.value > 0)
, [grouped, categoryNames])
@@ -490,7 +511,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
const handleUpdateField = async (id, field, value) => { try { await updateBudgetItem(tripId, id, { [field]: value }) } catch {} }
const handleDeleteItem = async (id) => { try { await deleteBudgetItem(tripId, id) } catch {} }
const handleDeleteCategory = async (cat) => {
const items = grouped[cat] || []
const items = grouped.get(cat) || []
for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id)
}
const handleRenameCategory = async (oldName, newName) => {
@@ -515,7 +536,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
const rows = [header.join(sep)]
for (const cat of categoryNames) {
for (const item of (grouped[cat] || [])) {
for (const item of (grouped.get(cat) || [])) {
const pp = calcPP(item.total_price, item.persons)
const pd = calcPD(item.total_price, item.days)
const ppd = calcPPD(item.total_price, item.persons, item.days)
@@ -584,14 +605,50 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
<div style={{ display: 'flex', gap: 20, padding: '0 16px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: 0 }}>
{categoryNames.map((cat, ci) => {
const items = grouped[cat]
const items = grouped.get(cat) || []
const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0)
const color = PIE_COLORS[ci % PIE_COLORS.length]
const color = categoryColor(cat)
return (
<div key={cat} style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff', borderRadius: '10px 10px 0 0', padding: '9px 14px' }}>
<div key={cat} data-drag-cat={cat} style={{
marginBottom: 16, opacity: dragCat === cat ? 0.4 : 1,
transition: 'opacity 0.15s',
position: 'relative',
}}
onDragOver={e => {
if (!dragCat || dragCat === cat || dragItem) return
e.preventDefault(); e.dataTransfer.dropEffect = 'move'
setDragOverCat(cat)
}}
onDragLeave={e => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverCat(null)
}}
onDrop={e => {
e.preventDefault()
if (dragCat && dragCat !== cat) {
const newOrder = [...categoryNames]
const fromIdx = newOrder.indexOf(dragCat)
const toIdx = newOrder.indexOf(cat)
newOrder.splice(fromIdx, 1)
newOrder.splice(toIdx, 0, dragCat)
reorderBudgetCategories(tripId, newOrder)
}
setDragCat(null); setDragOverCat(null)
}}
>
{dragOverCat === cat && <div style={{ position: 'absolute', top: -2, left: 0, right: 0, height: 4, background: 'var(--accent)', borderRadius: 2, zIndex: 10 }} />}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff',
borderRadius: '10px 10px 0 0', padding: '9px 14px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
{canEdit && (
<div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/x-budget-cat', cat); setDragCat(cat) }}
onDragEnd={() => { setDragCat(null); setDragOverCat(null) }}
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
<GripVertical size={14} />
</div>
)}
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
{canEdit && editingCat?.name === cat ? (
<input
@@ -627,7 +684,8 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
</div>
</div>
<div style={{ overflowX: 'auto', border: '1px solid var(--border-primary)', borderTop: 'none', borderRadius: '0 0 10px 10px' }}>
<div style={{ overflowX: 'auto', border: '1px solid var(--border-primary)', borderTop: 'none', borderRadius: '0 0 10px 10px' }}
onDragOver={e => { if (dragCat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move' } }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
@@ -650,10 +708,40 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
const ppd = calcPPD(item.total_price, item.persons, item.days)
const hasMembers = item.members?.length > 0
return (
<tr key={item.id} style={{ transition: 'background 0.1s' }}
<tr key={item.id}
style={{
transition: 'background 0.1s, opacity 0.15s',
opacity: dragItem === item.id ? 0.4 : 1,
boxShadow: dragOverItem === item.id ? 'inset 4px 0 0 0 var(--accent)' : 'none',
}}
onDragOver={e => {
if (dragCat && dragCat !== cat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; return }
if (dragItem && dragItemCat === cat && dragItem !== item.id) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverItem(item.id) }
}}
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverItem(null) }}
onDrop={e => {
if (dragItem && dragItemCat === cat && dragItem !== item.id) {
e.preventDefault(); e.stopPropagation()
const ids = items.map(i => i.id)
const fromIdx = ids.indexOf(dragItem)
const toIdx = ids.indexOf(item.id)
ids.splice(fromIdx, 1)
ids.splice(toIdx, 0, dragItem)
reorderBudgetItems(tripId, ids)
setDragItem(null); setDragOverItem(null); setDragItemCat(null)
}
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<td style={td}>
<td style={{ ...td, display: 'flex', alignItems: 'center', gap: 4 }}>
{canEdit && (
<div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }}
onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }}
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}>
<GripVertical size={12} />
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} />
{/* Mobile: larger chips under name since Persons column is hidden */}
{hasMultipleMembers && (
@@ -668,6 +756,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
/>
</div>
)}
</div>
</td>
<td style={{ ...td, textAlign: 'center' }}>
<InlineEditCell value={item.total_price} type="number" decimals={currencyDecimals(currency)} onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
+50
View File
@@ -14,6 +14,8 @@ export interface BudgetSlice {
deleteBudgetItem: (tripId: number | string, id: number) => Promise<void>
setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: BudgetMember[]; item: BudgetItem }>
toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise<void>
reorderBudgetItems: (tripId: number | string, orderedIds: number[]) => Promise<void>
reorderBudgetCategories: (tripId: number | string, orderedCategories: string[]) => Promise<void>
}
export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => ({
@@ -82,4 +84,52 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
)
}));
},
reorderBudgetItems: async (tripId, orderedIds) => {
// Optimistic: reorder locally
set(state => {
const byId = new Map(state.budgetItems.map(i => [i.id, i]))
const reordered = orderedIds.map((id, idx) => {
const item = byId.get(id)
return item ? { ...item, sort_order: idx } : null
}).filter((i): i is BudgetItem => i !== null)
// Keep items not in orderedIds at the end
const remaining = state.budgetItems.filter(i => !orderedIds.includes(i.id))
return { budgetItems: [...reordered, ...remaining] }
})
try {
await budgetApi.reorderItems(tripId, orderedIds)
} catch {
// Reload on failure
const data = await budgetApi.list(tripId)
set({ budgetItems: data.items })
}
},
reorderBudgetCategories: async (tripId, orderedCategories) => {
// Optimistic: reorder items by new category order (Map preserves insertion order for numeric keys)
set(state => {
const grouped = new Map<string, BudgetItem[]>()
for (const item of state.budgetItems) {
const cat = item.category || 'Other'
if (!grouped.has(cat)) grouped.set(cat, [])
grouped.get(cat)!.push(item)
}
const reordered: BudgetItem[] = []
for (const cat of orderedCategories) {
const items = grouped.get(cat)
if (items) reordered.push(...items)
}
for (const [cat, items] of grouped) {
if (!orderedCategories.includes(cat)) reordered.push(...items)
}
return { budgetItems: reordered }
})
try {
await budgetApi.reorderCategories(tripId, orderedCategories)
} catch {
const data = await budgetApi.list(tripId)
set({ budgetItems: data.items })
}
},
})
@@ -214,6 +214,37 @@ export function handleRemoteEvent(set: SetState, event: WebSocketEvent): void {
: i
),
}
case 'budget:reordered': {
if (payload.orderedIds) {
const orderedIds = payload.orderedIds as number[]
const byId = new Map(state.budgetItems.map(i => [i.id, i]))
const reordered = orderedIds.map((id, idx) => {
const item = byId.get(id)
return item ? { ...item, sort_order: idx } : null
}).filter((i): i is BudgetItem => i !== null)
const remaining = state.budgetItems.filter(i => !orderedIds.includes(i.id))
return { budgetItems: [...reordered, ...remaining] }
}
if (payload.orderedCategories) {
const orderedCategories = payload.orderedCategories as string[]
const grouped = new Map<string, BudgetItem[]>()
for (const item of state.budgetItems) {
const cat = item.category || 'Other'
if (!grouped.has(cat)) grouped.set(cat, [])
grouped.get(cat)!.push(item)
}
const reordered: BudgetItem[] = []
for (const cat of orderedCategories) {
const items = grouped.get(cat)
if (items) reordered.push(...items)
}
for (const [cat, items] of grouped) {
if (!orderedCategories.includes(cat)) reordered.push(...items)
}
return { budgetItems: reordered }
}
return {}
}
// Reservations
case 'reservation:created':
+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));
})();
}