import { useState } 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, } 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' 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, 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 [bagInlineCreate, setBagInlineCreate] = useState(false) const [bagInlineName, setBagInlineName] = useState('') 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) if (cat === item.category) return try { await updatePackingItem(tripId, item.id, { category: cat }) } catch { toast.error(t('common.error')) } } const canDrag = canEdit && !isPlaceholder && !!drag 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, 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, 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 raw = e.target.value.replace(/[^0-9]/g, '') 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 && ( )}
)}
) }