From fac2393388a1b2024b6233e2b7ba98232a92c6be Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 30 Jun 2026 13:55:03 +0200 Subject: [PATCH] feat(lists): reorder packing/to-do lists and private packing items (#969, #858) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add drag-to-reorder to the packing and to-do lists, mirroring the budget panel's native HTML5 drag pattern. A drag within a filtered/grouped view is mapped back onto the global order so untouched items keep their place, and the order persists optimistically via the existing reorder endpoints. Packing items can now be marked private (#858): a private item is visible only to its owner. createItem/bulkImport stamp the owner, listItems filters by the viewer, and the WebSocket broadcasts are scoped to the owner so a private item never reaches another member's screen — including the public/private toggle transitions. Owners get a lock toggle and a private indicator on their items. --- .../Packing/PackingListPanelCategoryGroup.tsx | 36 +++++++- .../Packing/PackingListPanelItemRow.tsx | 62 +++++++++++++- .../Packing/PackingListPanelList.tsx | 4 +- .../components/Packing/usePackingListPanel.ts | 4 +- client/src/components/Todo/TodoListPanel.tsx | 35 +++++++- client/src/components/Todo/TodoRow.tsx | 33 +++++++- client/src/components/Todo/useTodoList.ts | 4 +- client/src/store/slices/packingSlice.test.ts | 48 +++++++++++ client/src/store/slices/packingSlice.ts | 22 +++++ client/src/store/slices/todoSlice.test.ts | 48 +++++++++++ client/src/store/slices/todoSlice.ts | 19 +++++ server/src/db/migrations.ts | 15 ++++ server/src/mcp/resources.ts | 3 +- server/src/mcp/tools/packing.ts | 4 +- server/src/mcp/tools/prompts.ts | 3 +- server/src/nest/packing/packing.controller.ts | 62 +++++++++++--- server/src/nest/packing/packing.service.ts | 37 +++++++-- server/src/services/packingService.ts | 50 +++++++---- server/src/websocket.ts | 6 +- server/tests/integration/packing.test.ts | 40 +++++++++ .../unit/nest/packing.controller.test.ts | 82 ++++++++++++++----- .../tests/unit/nest/packing.service.test.ts | 31 ++++++- .../unit/services/packingService.test.ts | 79 ++++++++++++++++++ shared/src/i18n/ar/packing.ts | 3 + shared/src/i18n/br/packing.ts | 3 + shared/src/i18n/cs/packing.ts | 3 + shared/src/i18n/de/packing.ts | 3 + shared/src/i18n/en/packing.ts | 3 + shared/src/i18n/es/packing.ts | 3 + shared/src/i18n/fr/packing.ts | 3 + shared/src/i18n/gr/packing.ts | 3 + shared/src/i18n/hu/packing.ts | 3 + shared/src/i18n/id/packing.ts | 3 + shared/src/i18n/it/packing.ts | 3 + shared/src/i18n/ja/packing.ts | 3 + shared/src/i18n/ko/packing.ts | 3 + shared/src/i18n/nl/packing.ts | 3 + shared/src/i18n/pl/packing.ts | 3 + shared/src/i18n/ru/packing.ts | 3 + shared/src/i18n/sv/packing.ts | 3 + shared/src/i18n/tr/packing.ts | 3 + shared/src/i18n/uk/packing.ts | 3 + shared/src/i18n/vi/packing.ts | 3 + shared/src/i18n/zh-TW/packing.ts | 3 + shared/src/i18n/zh/packing.ts | 3 + shared/src/packing/packing.schema.ts | 9 ++ 46 files changed, 726 insertions(+), 76 deletions(-) create mode 100644 client/src/store/slices/packingSlice.test.ts create mode 100644 client/src/store/slices/todoSlice.test.ts diff --git a/client/src/components/Packing/PackingListPanelCategoryGroup.tsx b/client/src/components/Packing/PackingListPanelCategoryGroup.tsx index 0d912c81c..f5fa55fee 100644 --- a/client/src/components/Packing/PackingListPanelCategoryGroup.tsx +++ b/client/src/components/Packing/PackingListPanelCategoryGroup.tsx @@ -27,10 +27,34 @@ interface KategorieGruppeProps { bags?: PackingBag[] onCreateBag: (name: string) => Promise canEdit?: boolean + // Drag-to-reorder (#969): the full ordered item list + a persist callback. The + // order is global, so a within-category drag is mapped back onto the full list. + allItems: PackingItem[] + onReorder: (orderedIds: number[]) => void } -export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) { +export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder }: KategorieGruppeProps) { const [offen, setOffen] = useState(true) + const [dragId, setDragId] = useState(null) + const [overId, setOverId] = useState(null) + + const handleReorderDrop = (targetId: number) => { + const from = dragId + setDragId(null); setOverId(null) + if (from == null || from === targetId) return + const catOrder = items.map(i => i.id) + const fi = catOrder.indexOf(from) + const ti = catOrder.indexOf(targetId) + if (fi < 0 || ti < 0) return + catOrder.splice(fi, 1) + catOrder.splice(ti, 0, from) + // Slot the reordered category ids back into the positions this category's + // items occupy in the global list, leaving every other category untouched. + const catIds = new Set(items.map(i => i.id)) + let ci = 0 + const globalIds = allItems.map(i => (catIds.has(i.id) ? catOrder[ci++] : i.id)) + onReorder(globalIds) + } const [editingName, setEditingName] = useState(false) const [editKatName, setEditKatName] = useState(kategorie) const [showMenu, setShowMenu] = useState(false) @@ -232,7 +256,15 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen {offen && (
{items.map(item => ( - {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} /> + {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} + drag={canEdit ? { + isDragging: dragId === item.id, + isOver: overId === item.id && dragId !== null && dragId !== item.id, + onStart: (id) => { setDragId(id); setOverId(null) }, + onOver: (id) => setOverId(id), + onEnd: () => { setDragId(null); setOverId(null) }, + onDrop: handleReorderDrop, + } : undefined} /> ))} {/* Inline add item */} {canEdit && (showAddItem ? ( diff --git a/client/src/components/Packing/PackingListPanelItemRow.tsx b/client/src/components/Packing/PackingListPanelItemRow.tsx index 384ad4d5c..04d0ca67d 100644 --- a/client/src/components/Packing/PackingListPanelItemRow.tsx +++ b/client/src/components/Packing/PackingListPanelItemRow.tsx @@ -1,9 +1,10 @@ import { useState } from 'react' import { useTripStore } from '../../store/tripStore' +import { useAuthStore } from '../../store/authStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { - CheckSquare, Square, Trash2, Plus, Pencil, Package, + CheckSquare, Square, Trash2, Plus, Pencil, Package, GripVertical, Lock, Unlock, } from 'lucide-react' import type { PackingItem, PackingBag } from '../../types' import { katColor } from './packingListPanel.helpers' @@ -20,9 +21,18 @@ interface ArtikelZeileProps { bags?: PackingBag[] onCreateBag: (name: string) => Promise canEdit?: boolean + // Drag-to-reorder (#969) — wired by the category group, which owns the order. + drag?: { + isDragging: boolean + isOver: boolean + onStart: (id: number) => void + onOver: (id: number) => void + onEnd: () => void + onDrop: (targetId: number) => void + } } -export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) { +export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true, drag }: ArtikelZeileProps) { const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME const [editing, setEditing] = useState(false) const [editName, setEditName] = useState(isPlaceholder ? '' : item.name) @@ -32,11 +42,23 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel const [bagInlineCreate, setBagInlineCreate] = useState(false) const [bagInlineName, setBagInlineName] = useState('') const { togglePackingItem, updatePackingItem, deletePackingItem } = useTripStore() + const currentUserId = useAuthStore(s => s.user?.id) const toast = useToast() const { t } = useTranslation() + const isPrivate = !!item.is_private + // Only the owner may toggle privacy (#858). A private item is only ever rendered + // to its owner, so the toggle on those is always the owner's; for shared items + // it shows only to the member who created them — you don't privatize others' items. + const canTogglePrivacy = canEdit && !isPlaceholder && currentUserId != null && item.owner_id === currentUserId + const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked) + const handleTogglePrivacy = async () => { + try { await updatePackingItem(tripId, item.id, { is_private: isPrivate ? 0 : 1 }) } + catch { toast.error(t('packing.toast.saveError')) } + } + const handleSaveName = async () => { if (!editName.trim()) { setEditing(false); setEditName(isPlaceholder ? '' : item.name); return } try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) } @@ -58,18 +80,36 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel catch { toast.error(t('common.error')) } } + const canDrag = canEdit && !isPlaceholder && !!drag + return (
setHovered(true)} onMouseLeave={() => { setHovered(false); setShowCatPicker(false); setShowBagPicker(false) }} + onDragOver={canDrag ? (e => { if (drag!.isDragging || true) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; drag!.onOver(item.id) } }) : undefined} + onDragLeave={canDrag ? (e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) drag!.onOver(-1) }) : undefined} + onDrop={canDrag ? (e => { e.preventDefault(); e.stopPropagation(); drag!.onDrop(item.id) }) : undefined} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', borderRadius: 10, position: 'relative', background: hovered ? 'var(--bg-secondary)' : 'transparent', - transition: 'background 0.1s', + opacity: drag?.isDragging ? 0.4 : 1, + boxShadow: drag?.isOver ? 'inset 3px 0 0 0 var(--accent)' : 'none', + transition: 'background 0.1s, opacity 0.15s', }} > + {canDrag && ( +
{ e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; drag!.onStart(item.id) }} + onDragEnd={() => drag!.onEnd()} + title="" + style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0, opacity: hovered ? 1 : 0.35, transition: 'opacity 0.15s' }} + > + +
+ )}
+ {canTogglePrivacy && ( + + )} +