Files
TREK/client/src/components/Packing/PackingListPanelItemRow.tsx
T
Maurice c1fe1d2d6a fix(packing): keep a custom category when its last item is removed (#1289)
Removing the only item of a user-created category deleted the whole
category. Turn that row back into the existing ... placeholder in
place instead, so the category keeps its position and colour; adding an
item reuses the placeholder slot. Deleting the placeholder (or the
category menu) still removes an empty category.
2026-06-23 21:23:39 +02:00

262 lines
15 KiB
TypeScript

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
onDelete?: (item: PackingItem) => Promise<void>
bagTrackingEnabled?: boolean
bags?: PackingBag[]
onCreateBag: (name: string) => Promise<PackingBag | undefined>
canEdit?: boolean
}
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDelete, 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 () => {
// 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')) }
}
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>
)
}