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' import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants' import { QuantityInput } from './PackingListPanelQuantityInput' import PackingShareControl from './PackingShareControl' import type { TripMember } from './usePackingListPanel' import { NumericInput } from '../shared/NumericInput' interface ArtikelZeileProps { item: PackingItem tripId: number categories: string[] onCategoryChange: () => void onDelete?: (item: PackingItem) => Promise bagTrackingEnabled?: boolean bags?: PackingBag[] onCreateBag: (name: string) => Promise canEdit?: boolean // Three-tier sharing (#858): members + handlers for the per-item share control. tripMembers?: TripMember[] currentUserId?: number onSetSharing?: (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => void onClone?: (id: number) => void onJoin?: (id: number) => void onLeave?: (id: number, userId: number) => void // 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: _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() // Three-tier sharing display (#858). const sharedToMe = !!item.is_private && item.owner_id != null && item.owner_id !== currentUserId const recipients = item.recipients || [] const sharedByMe = !!item.is_private && item.owner_id === currentUserId && recipients.length > 0 const broughtBy = !item.is_private && item.owner_username ? item.owner_username : null const contributors = item.contributors || [] const canShare = canEdit && !isPlaceholder && !!onSetSharing const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked) const handleSaveName = async () => { if (!editName.trim()) { setEditing(false); setEditName(isPlaceholder ? '' : item.name); return } try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) } catch { toast.error(t('packing.toast.saveError')) } } const handleDelete = async () => { // The panel routes deletion through onDelete so an emptied custom category // keeps its placeholder; fall back to a plain delete when used standalone. if (onDelete) { await onDelete(item); return } try { await deletePackingItem(tripId, item.id) } catch { toast.error(t('packing.toast.deleteError')) } } 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} 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', 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' }} >
)} {editing && canEdit ? ( setEditName(e.target.value)} onBlur={handleSaveName} onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }} style={{ flex: 1, minWidth: 0, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }} /> ) : ( canEdit && !item.checked && setEditing(true)} style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', cursor: !canEdit || item.checked ? 'default' : 'text', color: isPlaceholder ? 'var(--text-faint)' : (item.checked ? 'var(--text-faint)' : 'var(--text-primary)'), transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)', textDecoration: item.checked ? 'line-through' : 'none', }} > {item.name} )} {/* Sharing badges (#858 three-tier) */} {!isPlaceholder && sharedToMe && ( {t('packing.takenCareOf', { name: item.owner_username || '' })} )} {!isPlaceholder && sharedByMe && ( 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 })} />} {/* Weight + Bag (when enabled) */} {bagTrackingEnabled && ( <>
{ if (!canEdit) return 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: 36, 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
{showBagPicker && (
{item.bag_id && ( )} {bags.map(b => ( ))} {bags.length > 0 &&
}
{bagInlineCreate ? (
setBagInlineName(e.target.value)} onKeyDown={async e => { if (e.key === 'Enter' && bagInlineName.trim()) { const newBag = await onCreateBag(bagInlineName.trim()) if (newBag) { try { await updatePackingItem(tripId, item.id, { bag_id: newBag.id }) } catch { toast.error(t('packing.toast.saveError')) } } setBagInlineName(''); setBagInlineCreate(false); setShowBagPicker(false) } if (e.key === 'Escape') { setBagInlineCreate(false); setBagInlineName('') } }} placeholder={t('packing.bagName')} style={{ flex: 1, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontFamily: 'inherit', outline: 'none' }} />
) : ( )}
)}
)}
{canEdit && (
{showCatPicker && (
{categories.map(cat => ( ))}
)}
{canShare && onClone && onJoin && onLeave && ( )}
)} {canEdit && (
{showItemMenu && (() => { const rect = itemMenuBtnRef.current?.getBoundingClientRect() return ( <>
{ setShowItemMenu(false); setShowMenuCategories(false) }} />
{t('packing.quantity')} 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 ( ) }