diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 237d3e64..5848c5c4 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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 = { diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx index f85f4079..47011eaf 100644 --- a/client/src/components/Budget/BudgetPanel.tsx +++ b/client/src/components/Budget/BudgetPanel.tsx @@ -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(null) + const [dragOverCat, setDragOverCat] = useState(null) + // Drag state for items within a category + const [dragItem, setDragItem] = useState(null) + const [dragOverItem, setDragOverItem] = useState(null) + const [dragItemCat, setDragItemCat] = useState(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() + 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()) + 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
{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 ( -
-
+
{ + 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 &&
} +
+ {canEdit && ( +
{ 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 }}> + +
+ )}
{canEdit && editingCat?.name === cat ? (
-
+
{ if (dragCat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move' } }}> @@ -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 ( - { + 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'}> -
+ + {canEdit && ( +
{ 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 }}> + +
+ )} +
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 />
)} +
handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> diff --git a/client/src/store/slices/budgetSlice.ts b/client/src/store/slices/budgetSlice.ts index 21b107eb..04e58fda 100644 --- a/client/src/store/slices/budgetSlice.ts +++ b/client/src/store/slices/budgetSlice.ts @@ -14,6 +14,8 @@ export interface BudgetSlice { deleteBudgetItem: (tripId: number | string, id: number) => Promise setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: BudgetMember[]; item: BudgetItem }> toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise + reorderBudgetItems: (tripId: number | string, orderedIds: number[]) => Promise + reorderBudgetCategories: (tripId: number | string, orderedCategories: string[]) => Promise } 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() + 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 }) + } + }, }) diff --git a/client/src/store/slices/remoteEventHandler.ts b/client/src/store/slices/remoteEventHandler.ts index c3fd784d..e86efd9d 100644 --- a/client/src/store/slices/remoteEventHandler.ts +++ b/client/src/store/slices/remoteEventHandler.ts @@ -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() + 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': diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index aa3a3e99..cb3edb96 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -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) { diff --git a/server/src/routes/budget.ts b/server/src/routes/budget.ts index 05763864..b0ec405b 100644 --- a/server/src/routes/budget.ts +++ b/server/src/routes/budget.ts @@ -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; diff --git a/server/src/services/budgetService.ts b/server/src/services/budgetService.ts index 8fc82240..f98d5d9e 100644 --- a/server/src/services/budgetService.ts +++ b/server/src/services/budgetService.ts @@ -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 = {}; @@ -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)); + })(); +}