diff --git a/client/src/components/Packing/PackingListPanelItemRow.tsx b/client/src/components/Packing/PackingListPanelItemRow.tsx index 0e4e7926f..61ff78c6a 100644 --- a/client/src/components/Packing/PackingListPanelItemRow.tsx +++ b/client/src/components/Packing/PackingListPanelItemRow.tsx @@ -1,9 +1,10 @@ -import { useState } from 'react' +import { useRef, useState, type ReactNode } from 'react' import { useTripStore } from '../../store/tripStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { CheckSquare, Square, Trash2, Plus, Pencil, Package, GripVertical, UserRound, Users, HandHelping, + MoreHorizontal, } from 'lucide-react' import type { PackingItem, PackingBag } from '../../types' import { katColor } from './packingListPanel.helpers' @@ -41,15 +42,18 @@ interface ArtikelZeileProps { } } -export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true, tripMembers = [], currentUserId, onSetSharing, onClone, onJoin, onLeave, drag }: ArtikelZeileProps) { +export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true, tripMembers = [], currentUserId, onSetSharing, onClone, onJoin, onLeave, drag }: ArtikelZeileProps) { const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME const [editing, setEditing] = useState(false) const [editName, setEditName] = useState(isPlaceholder ? '' : item.name) const [hovered, setHovered] = useState(false) const [showCatPicker, setShowCatPicker] = useState(false) const [showBagPicker, setShowBagPicker] = useState(false) + const [showItemMenu, setShowItemMenu] = useState(false) + const [showMenuCategories, setShowMenuCategories] = useState(false) const [bagInlineCreate, setBagInlineCreate] = useState(false) const [bagInlineName, setBagInlineName] = useState('') + const itemMenuBtnRef = useRef(null) const { togglePackingItem, updatePackingItem, deletePackingItem } = useTripStore() const toast = useToast() const { t } = useTranslation() @@ -80,16 +84,19 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel const handleCatChange = async (cat: string) => { setShowCatPicker(false) + setShowMenuCategories(false) + setShowItemMenu(false) if (cat === item.category) return try { await updatePackingItem(tripId, item.id, { category: cat }) } catch { toast.error(t('common.error')) } } const canDrag = canEdit && !isPlaceholder && !!drag + const selectedBag = bags.find(b => b.id === item.bag_id) return (
setHovered(true)} onMouseLeave={() => { setHovered(false); setShowCatPicker(false); setShowBagPicker(false) }} onDragOver={canDrag ? (e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; drag!.onOver(item.id) }) : undefined} @@ -162,30 +169,31 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel {/* Sharing badges (#858 three-tier) */} {!isPlaceholder && sharedToMe && ( - {t('packing.takenCareOf', { name: item.owner_username || '' })} )} {!isPlaceholder && sharedByMe && ( - r.username).join(', ')} + r.username).join(', ')} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', padding: '1px 7px', borderRadius: 99 }}> {t('packing.sharedWithCount', { count: recipients.length })} )} {!isPlaceholder && broughtBy && ( - {broughtBy}{contributors.length > 0 ? ` +${contributors.length}` : ''} )} - {/* Quantity */} - {canEdit && updatePackingItem(tripId, item.id, { quantity: qty })} />} +
+ {/* Quantity */} + {canEdit && updatePackingItem(tripId, item.id, { quantity: qty })} />} - {/* Weight + Bag (when enabled) */} - {bagTrackingEnabled && ( -
+ {/* Weight + Bag (when enabled) */} + {bagTrackingEnabled && ( + <>
canEdit && setShowBagPicker(p => !p)} style={{ width: 22, height: 22, borderRadius: '50%', cursor: canEdit ? 'pointer' : 'default', padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', - border: item.bag_id ? `2.5px solid ${bags.find(b => b.id === item.bag_id)?.color || 'var(--border-primary)'}` : '2px dashed var(--border-primary)', - background: item.bag_id ? `${bags.find(b => b.id === item.bag_id)?.color || 'var(--border-primary)'}30` : 'transparent', + border: item.bag_id ? `2.5px solid ${selectedBag?.color || 'var(--border-primary)'}` : '2px dashed var(--border-primary)', + background: item.bag_id ? `${selectedBag?.color || 'var(--border-primary)'}30` : 'transparent', }} > {!item.bag_id && } @@ -275,11 +283,12 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDel
)}
-
- )} + + )} +
{canEdit && ( -
+
)} + + {canEdit && ( +
+ + {showItemMenu && (() => { + const rect = itemMenuBtnRef.current?.getBoundingClientRect() + return ( + <> +
{ setShowItemMenu(false); setShowMenuCategories(false) }} /> +
+
+ Qty + updatePackingItem(tripId, item.id, { quantity: qty })} /> +
+ + {bagTrackingEnabled && ( + <> +
+
+ {t('packing.totalWeight')} +
+ { + const v = raw === '' ? null : parseInt(raw) + try { await updatePackingItem(tripId, item.id, { weight_grams: v }) } catch { toast.error(t('packing.toast.saveError')) } + }} + placeholder="—" + style={{ width: 42, border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'right', fontFamily: 'inherit', outline: 'none', color: 'var(--text-secondary)', background: 'transparent', padding: 0 }} + /> + g +
+
+
+ } label={selectedBag?.name || t('packing.noBag')} onClick={async () => { + if (item.bag_id) { + try { await updatePackingItem(tripId, item.id, { bag_id: null }) } catch { toast.error(t('packing.toast.saveError')) } + } + }} /> + {bags.map(b => ( + } label={b.name} active={item.bag_id === b.id} onClick={async () => { + setShowItemMenu(false) + try { await updatePackingItem(tripId, item.id, { bag_id: b.id }) } catch { toast.error(t('packing.toast.saveError')) } + }} /> + ))} +
+ + )} + +
+ } label={t('packing.changeCategory')} onClick={() => setShowMenuCategories(v => !v)} /> + {showMenuCategories && ( +
+ {categories.map(cat => ( + } label={cat} active={cat === (item.category || t('packing.defaultCategory'))} onClick={() => handleCatChange(cat)} /> + ))} +
+ )} + + {canShare && onClone && onJoin && onLeave && ( +
+ {t('packing.share')} + +
+ )} + +
+ } label={t('common.rename')} onClick={() => { setEditing(true); setShowItemMenu(false) }} /> + } label={t('common.delete')} danger onClick={() => { setShowItemMenu(false); handleDelete() }} /> +
+ + ) + })()} +
+ )}
) } + +interface OverflowMenuItemProps { + icon: ReactNode + label: string + onClick: () => void + active?: boolean + danger?: boolean +} + +function OverflowMenuItem({ icon, label, onClick, active = false, danger = false }: OverflowMenuItemProps) { + return ( + + ) +} diff --git a/client/src/index.css b/client/src/index.css index 8d2dfad28..6533c2dcd 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -188,6 +188,28 @@ html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color } } +/* Packing list rows: on phones, keep the item name readable and move the dense + per-item controls behind the overflow menu. */ +.packing-row-overflow { + display: none !important; +} +@media (max-width: 639px) { + .packing-item-row { + gap: 6px !important; + padding: 7px 8px !important; + } + .packing-item-row > span { + flex-basis: auto !important; + } + .packing-row-inline-actions, + .packing-row-badge { + display: none !important; + } + .packing-row-overflow { + display: flex !important; + } +} + /* Ladeanimation */ @keyframes spin { to { transform: rotate(360deg); }