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 && ( + + )} +