Spaces:
Sleeping
Sleeping
| 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, | |
| } 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' | |
| interface ArtikelZeileProps { | |
| item: PackingItem | |
| tripId: number | |
| categories: string[] | |
| onCategoryChange: () => void | |
| bagTrackingEnabled?: boolean | |
| bags?: PackingBag[] | |
| onCreateBag: (name: string) => Promise<PackingBag | undefined> | |
| canEdit?: boolean | |
| } | |
| export function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: 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() | |
| 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 () => { | |
| 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')) } | |
| } | |
| return ( | |
| <div | |
| className="group" | |
| onMouseEnter={() => setHovered(true)} | |
| onMouseLeave={() => { setHovered(false); setShowCatPicker(false); setShowBagPicker(false) }} | |
| style={{ | |
| display: 'flex', alignItems: 'center', gap: 8, | |
| padding: '6px 10px', borderRadius: 10, position: 'relative', | |
| background: hovered ? 'var(--bg-secondary)' : 'transparent', | |
| transition: 'background 0.1s', | |
| }} | |
| > | |
| <button onClick={handleToggle} style={{ | |
| flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, position: 'relative', | |
| width: 18, height: 18, | |
| color: item.checked ? '#10b981' : 'var(--text-faint)', | |
| transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)', | |
| }}> | |
| <Square size={18} style={{ | |
| position: 'absolute', inset: 0, | |
| opacity: item.checked ? 0 : 1, | |
| transform: item.checked ? 'scale(0.7)' : 'scale(1)', | |
| transition: 'opacity 180ms cubic-bezier(0.23,1,0.32,1), transform 180ms cubic-bezier(0.23,1,0.32,1)', | |
| }} /> | |
| <CheckSquare size={18} style={{ | |
| position: 'absolute', inset: 0, | |
| opacity: item.checked ? 1 : 0, | |
| transform: item.checked ? 'scale(1)' : 'scale(0.5)', | |
| transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 220ms cubic-bezier(0.34,1.56,0.64,1)', | |
| }} /> | |
| </button> | |
| {editing && canEdit ? ( | |
| <input | |
| type="text" value={editName} autoFocus | |
| placeholder={isPlaceholder ? '...' : undefined} | |
| onChange={e => 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: 13.5, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }} | |
| /> | |
| ) : ( | |
| <span | |
| onClick={() => canEdit && !item.checked && setEditing(true)} | |
| style={{ | |
| flex: 1, fontSize: 13.5, | |
| 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} | |
| </span> | |
| )} | |
| {/* Quantity */} | |
| {canEdit && <QuantityInput value={item.quantity || 1} onSave={qty => updatePackingItem(tripId, item.id, { quantity: qty })} />} | |
| {/* Weight + Bag (when enabled) */} | |
| {bagTrackingEnabled && ( | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 2, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '3px 6px', background: 'transparent' }}> | |
| <input | |
| type="text" inputMode="numeric" | |
| value={item.weight_grams ?? ''} | |
| readOnly={!canEdit} | |
| onChange={async e => { | |
| 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: 12, textAlign: 'right', fontFamily: 'inherit', outline: 'none', color: 'var(--text-secondary)', background: 'transparent', padding: 0 }} | |
| /> | |
| <span style={{ fontSize: 10, color: 'var(--text-faint)', userSelect: 'none' }}>g</span> | |
| </div> | |
| <div style={{ position: 'relative' }}> | |
| <button | |
| onClick={() => 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', | |
| }} | |
| > | |
| {!item.bag_id && <Package size={9} className="text-content-faint" />} | |
| </button> | |
| {showBagPicker && ( | |
| <div style={{ | |
| position: 'absolute', right: 0, top: '100%', marginTop: 4, zIndex: 50, | |
| background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, | |
| boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 160, | |
| }}> | |
| {item.bag_id && ( | |
| <button onClick={async () => { setShowBagPicker(false); try { await updatePackingItem(tripId, item.id, { bag_id: null }) } catch { toast.error(t('packing.toast.saveError')) } }} | |
| style={{ display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '6px 10px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, fontFamily: 'inherit', color: 'var(--text-faint)', borderRadius: 7 }}> | |
| <span style={{ width: 10, height: 10, borderRadius: '50%', border: '2px dashed var(--border-primary)' }} /> | |
| {t('packing.noBag')} | |
| </button> | |
| )} | |
| {bags.map(b => ( | |
| <button key={b.id} onClick={async () => { setShowBagPicker(false); try { await updatePackingItem(tripId, item.id, { bag_id: b.id }) } catch { toast.error(t('packing.toast.saveError')) } }} | |
| style={{ | |
| display: 'flex', alignItems: 'center', gap: 7, width: '100%', padding: '6px 10px', | |
| background: item.bag_id === b.id ? 'var(--bg-tertiary)' : 'none', | |
| border: 'none', cursor: 'pointer', fontSize: 12, fontFamily: 'inherit', color: 'var(--text-secondary)', borderRadius: 7, | |
| }} | |
| onMouseEnter={e => { if (item.bag_id !== b.id) e.currentTarget.style.background = 'var(--bg-tertiary)' }} | |
| onMouseLeave={e => { if (item.bag_id !== b.id) e.currentTarget.style.background = 'none' }}> | |
| <span style={{ width: 10, height: 10, borderRadius: '50%', background: b.color, flexShrink: 0 }} /> | |
| {b.name} | |
| </button> | |
| ))} | |
| {bags.length > 0 && <div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />} | |
| <div style={{ padding: '4px 6px' }}> | |
| {bagInlineCreate ? ( | |
| <div style={{ display: 'flex', gap: 4 }}> | |
| <input autoFocus value={bagInlineName} onChange={e => 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: 11, fontFamily: 'inherit', outline: 'none' }} /> | |
| <button onClick={async () => { | |
| if (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) | |
| } | |
| }} | |
| style={{ padding: '3px 6px', borderRadius: 6, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}> | |
| <Plus size={11} /> | |
| </button> | |
| </div> | |
| ) : ( | |
| <button onClick={() => setBagInlineCreate(true)} | |
| style={{ display: 'flex', alignItems: 'center', gap: 5, width: '100%', padding: '5px 6px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, fontFamily: 'inherit', color: 'var(--text-faint)', borderRadius: 7 }} | |
| onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} | |
| onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}> | |
| <Plus size={11} /> {t('packing.addBag')} | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {canEdit && ( | |
| <div className="sm:opacity-0 sm:group-hover:opacity-100" style={{ display: 'flex', gap: 2, alignItems: 'center', transition: 'opacity 0.12s', flexShrink: 0 }}> | |
| <div style={{ position: 'relative' }}> | |
| <button | |
| onClick={() => setShowCatPicker(p => !p)} | |
| title={t('packing.changeCategory')} | |
| style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 5px', borderRadius: 6, display: 'flex', alignItems: 'center', color: 'var(--text-faint)', fontSize: 10, gap: 2 }} | |
| > | |
| <span style={{ width: 7, height: 7, borderRadius: '50%', background: katColor(item.category || t('packing.defaultCategory'), categories), display: 'inline-block' }} /> | |
| </button> | |
| {showCatPicker && ( | |
| <div style={{ | |
| position: 'absolute', right: 0, top: '100%', zIndex: 50, background: 'var(--bg-card)', | |
| border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', | |
| padding: 4, minWidth: 140, | |
| }}> | |
| {categories.map(cat => ( | |
| <button key={cat} onClick={() => handleCatChange(cat)} style={{ | |
| display: 'flex', alignItems: 'center', gap: 7, width: '100%', | |
| padding: '6px 10px', background: cat === (item.category || t('packing.defaultCategory')) ? 'var(--bg-tertiary)' : 'none', | |
| border: 'none', cursor: 'pointer', fontSize: 12.5, fontFamily: 'inherit', | |
| color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left', | |
| }}> | |
| <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(cat, categories), flexShrink: 0 }} /> | |
| {cat} | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| <button onClick={() => setEditing(true)} title={t('common.rename')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }} | |
| onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}> | |
| <Pencil size={13} /> | |
| </button> | |
| <button onClick={handleDelete} title={t('common.delete')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }} | |
| onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}> | |
| <Trash2 size={13} /> | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |