import { useState, useMemo, useRef, useEffect } from 'react' import { useTripStore } from '../../store/tripStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { packingApi, tripsApi, adminApi } from '../../api/client' import { CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight, X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, UserPlus, Package, FolderPlus, } from 'lucide-react' import type { PackingItem } from '../../types' const VORSCHLAEGE = [ { name: 'Passport', category: 'Documents' }, { name: 'Travel Insurance', category: 'Documents' }, { name: 'Visa Documents', category: 'Documents' }, { name: 'Flight Tickets', category: 'Documents' }, { name: 'Hotel Bookings', category: 'Documents' }, { name: 'Vaccination Card', category: 'Documents' }, { name: 'T-Shirts (5x)', category: 'Clothing' }, { name: 'Pants (2x)', category: 'Clothing' }, { name: 'Underwear (7x)', category: 'Clothing' }, { name: 'Socks (7x)', category: 'Clothing' }, { name: 'Jacket', category: 'Clothing' }, { name: 'Swimwear', category: 'Clothing' }, { name: 'Sport Shoes', category: 'Clothing' }, { name: 'Toothbrush', category: 'Toiletries' }, { name: 'Toothpaste', category: 'Toiletries' }, { name: 'Shampoo', category: 'Toiletries' }, { name: 'Sunscreen', category: 'Toiletries' }, { name: 'Deodorant', category: 'Toiletries' }, { name: 'Razor', category: 'Toiletries' }, { name: 'Phone Charger', category: 'Electronics' }, { name: 'Travel Adapter', category: 'Electronics' }, { name: 'Headphones', category: 'Electronics' }, { name: 'Camera', category: 'Electronics' }, { name: 'Power Bank', category: 'Electronics' }, { name: 'First Aid Kit', category: 'Health' }, { name: 'Prescription Medication', category: 'Health' }, { name: 'Pain Medication', category: 'Health' }, { name: 'Insect Repellent', category: 'Health' }, { name: 'Cash', category: 'Finances' }, { name: 'Credit Card', category: 'Finances' }, ] // Cycling color palette — works in light & dark mode const KAT_COLORS = [ '#3b82f6', // blue '#a855f7', // purple '#ec4899', // pink '#22c55e', // green '#f97316', // orange '#06b6d4', // cyan '#ef4444', // red '#eab308', // yellow '#8b5cf6', // violet '#14b8a6', // teal ] // Stable color assignment: category name → index via simple hash function katColor(kat, allCategories) { const idx = allCategories ? allCategories.indexOf(kat) : -1 if (idx >= 0) return KAT_COLORS[idx % KAT_COLORS.length] // Fallback: hash-based let h = 0 for (let i = 0; i < kat.length; i++) h = ((h << 5) - h + kat.charCodeAt(i)) | 0 return KAT_COLORS[Math.abs(h) % KAT_COLORS.length] } interface PackingBag { id: number; trip_id: number; name: string; color: string; weight_limit_grams: number | null } // ── Artikel-Zeile ────────────────────────────────────────────────────────── interface ArtikelZeileProps { item: PackingItem tripId: number categories: string[] onCategoryChange: () => void bagTrackingEnabled?: boolean bags?: PackingBag[] onCreateBag: (name: string) => Promise } function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag }: ArtikelZeileProps) { const [editing, setEditing] = useState(false) const [editName, setEditName] = useState(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(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) => { setShowCatPicker(false) if (cat === item.category) return try { await updatePackingItem(tripId, item.id, { category: cat }) } catch { toast.error(t('common.error')) } } return (
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', }} > {editing ? ( setEditName(e.target.value)} onBlur={handleSaveName} onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(item.name) } }} style={{ flex: 1, fontSize: 13.5, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }} /> ) : ( !item.checked && setEditing(true)} style={{ flex: 1, fontSize: 13.5, cursor: item.checked ? 'default' : 'text', color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)', textDecoration: item.checked ? 'line-through' : 'none', }} > {item.name} )} {/* Weight + Bag (when enabled) */} {bagTrackingEnabled && (
{ 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 {} }} placeholder="—" style={{ width: 36, border: 'none', fontSize: 12, 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 {} } 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' }} />
) : ( )}
)}
)}
{showCatPicker && (
{categories.map(cat => ( ))}
)}
) } // ── Kategorie-Gruppe ─────────────────────────────────────────────────────── interface TripMember { id: number username: string avatar?: string | null avatar_url?: string | null } interface CategoryAssignee { user_id: number username: string avatar?: string | null } interface KategorieGruppeProps { kategorie: string items: PackingItem[] tripId: number allCategories: string[] onRename: (oldName: string, newName: string) => Promise onDeleteAll: (items: PackingItem[]) => Promise onAddItem: (category: string, name: string) => Promise assignees: CategoryAssignee[] tripMembers: TripMember[] onSetAssignees: (category: string, userIds: number[]) => Promise bagTrackingEnabled?: boolean bags?: PackingBag[] onCreateBag: (name: string) => Promise } function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag }: KategorieGruppeProps) { const [offen, setOffen] = useState(true) const [editingName, setEditingName] = useState(false) const [editKatName, setEditKatName] = useState(kategorie) const [showMenu, setShowMenu] = useState(false) const [showAssigneeDropdown, setShowAssigneeDropdown] = useState(false) const [showAddItem, setShowAddItem] = useState(false) const [newItemName, setNewItemName] = useState('') const addItemRef = useRef(null) const assigneeDropdownRef = useRef(null) const { togglePackingItem } = useTripStore() const toast = useToast() const { t } = useTranslation() useEffect(() => { if (!showAssigneeDropdown) return const handleClickOutside = (e: MouseEvent) => { if (assigneeDropdownRef.current && !assigneeDropdownRef.current.contains(e.target as Node)) { setShowAssigneeDropdown(false) } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) }, [showAssigneeDropdown]) const abgehakt = items.filter(i => i.checked).length const alleAbgehakt = abgehakt === items.length const dot = katColor(kategorie, allCategories) const handleSaveKatName = async () => { const neu = editKatName.trim() if (!neu || neu === kategorie) { setEditingName(false); setEditKatName(kategorie); return } try { await onRename(kategorie, neu); setEditingName(false) } catch { toast.error(t('packing.toast.renameError')) } } const handleCheckAll = async () => { for (const item of Array.from(items)) { if (!item.checked) await togglePackingItem(tripId, item.id, true) } } const handleUncheckAll = async () => { for (const item of Array.from(items)) { if (item.checked) await togglePackingItem(tripId, item.id, false) } } const handleDeleteAll = async () => { await onDeleteAll(items) setShowMenu(false) } return (
{editingName ? ( setEditKatName(e.target.value)} onBlur={handleSaveKatName} onKeyDown={e => { if (e.key === 'Enter') handleSaveKatName(); if (e.key === 'Escape') { setEditingName(false); setEditKatName(kategorie) } }} style={{ flex: 1, fontSize: 12.5, fontWeight: 600, border: 'none', borderBottom: '2px solid var(--text-primary)', outline: 'none', background: 'transparent', fontFamily: 'inherit', color: 'var(--text-primary)', padding: '0 2px' }} /> ) : ( {kategorie} )} {/* Assignee chips */}
{assignees.map(a => (
{ e.stopPropagation(); onSetAssignees(kategorie, assignees.filter(x => x.user_id !== a.user_id).map(x => x.user_id)) }} >
{a.username[0]}
{a.username}
))}
{showAssigneeDropdown && (
{tripMembers.map(m => { const isAssigned = assignees.some(a => a.user_id === m.id) return ( ) })} {tripMembers.length === 0 && (
{t('packing.noMembers')}
)}
)}
{abgehakt}/{items.length}
{showMenu && (
setShowMenu(false)}> } label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} /> } label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} /> } label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
)}
{offen && (
{items.map(item => ( {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} /> ))} {/* Inline add item */} {showAddItem ? (
setNewItemName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && newItemName.trim()) { onAddItem(kategorie, newItemName.trim()) setNewItemName('') setTimeout(() => addItemRef.current?.focus(), 30) } if (e.key === 'Escape') { setShowAddItem(false); setNewItemName('') } }} placeholder={t('packing.addItemPlaceholder')} style={{ flex: 1, padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 12.5, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)', background: 'var(--bg-input)' }} />
) : ( )}
)}
) } interface MenuItemProps { icon: React.ReactNode label: string onClick: () => void danger: boolean } function MenuItem({ icon, label, onClick, danger }: MenuItemProps) { return ( ) } // ── Haupt-Panel ──────────────────────────────────────────────────────────── interface PackingListPanelProps { tripId: number items: PackingItem[] } export default function PackingListPanel({ tripId, items }: PackingListPanelProps) { const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt' const [addingCategory, setAddingCategory] = useState(false) const [newCatName, setNewCatName] = useState('') const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore() const toast = useToast() const { t } = useTranslation() // Trip members & category assignees const [tripMembers, setTripMembers] = useState([]) const [categoryAssignees, setCategoryAssignees] = useState>({}) useEffect(() => { tripsApi.getMembers(tripId).then(data => { const all: TripMember[] = [] if (data.owner) all.push({ id: data.owner.id, username: data.owner.username, avatar: data.owner.avatar_url }) if (data.members) all.push(...data.members.map((m: any) => ({ id: m.id, username: m.username, avatar: m.avatar_url }))) setTripMembers(all) }).catch(() => {}) packingApi.getCategoryAssignees(tripId).then(data => { setCategoryAssignees(data.assignees || {}) }).catch(() => {}) }, [tripId]) const handleSetAssignees = async (category: string, userIds: number[]) => { try { const data = await packingApi.setCategoryAssignees(tripId, category, userIds) setCategoryAssignees(prev => ({ ...prev, [category]: data.assignees || [] })) } catch { toast.error(t('packing.toast.saveError')) } } const allCategories = useMemo(() => { const seen: string[] = [] for (const item of items) { const cat = item.category || t('packing.defaultCategory') if (!seen.includes(cat)) seen.push(cat) } return seen }, [items, t]) const gruppiert = useMemo(() => { const filtered = items.filter(i => { if (filter === 'offen') return !i.checked if (filter === 'erledigt') return i.checked return true }) const groups = {} for (const item of filtered) { const kat = item.category || t('packing.defaultCategory') if (!groups[kat]) groups[kat] = [] groups[kat].push(item) } return groups }, [items, filter, t]) const abgehakt = items.filter(i => i.checked).length const fortschritt = items.length > 0 ? Math.round((abgehakt / items.length) * 100) : 0 const handleAddItemToCategory = async (category: string, name: string) => { try { await addPackingItem(tripId, { name, category }) } catch { toast.error(t('packing.toast.addError')) } } const handleAddNewCategory = async () => { if (!newCatName.trim()) return let catName = newCatName.trim() // Allow duplicate display names — append invisible zero-width spaces to make unique internally while (allCategories.includes(catName)) { catName += '\u200B' } try { await addPackingItem(tripId, { name: '...', category: catName }) setNewCatName('') setAddingCategory(false) } catch { toast.error(t('packing.toast.addError')) } } const handleRenameCategory = async (oldName, newName) => { const toUpdate = items.filter(i => (i.category || t('packing.defaultCategory')) === oldName) for (const item of toUpdate) { await updatePackingItem(tripId, item.id, { category: newName }) } } const handleDeleteCategory = async (catItems) => { for (const item of catItems) { try { await deletePackingItem(tripId, item.id) } catch {} } } const handleClearChecked = async () => { if (!confirm(t('packing.confirm.clearChecked', { count: abgehakt }))) return for (const item of items.filter(i => i.checked)) { try { await deletePackingItem(tripId, item.id) } catch {} } } // Bag tracking const [bagTrackingEnabled, setBagTrackingEnabled] = useState(false) const [bags, setBags] = useState([]) const [newBagName, setNewBagName] = useState('') const [showAddBag, setShowAddBag] = useState(false) const [showBagModal, setShowBagModal] = useState(false) useEffect(() => { adminApi.getBagTracking().then(d => { setBagTrackingEnabled(d.enabled) if (d.enabled) packingApi.listBags(tripId).then(r => setBags(r.bags || [])).catch(() => {}) }).catch(() => {}) }, [tripId]) const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b'] const handleCreateBag = async () => { if (!newBagName.trim()) return try { const data = await packingApi.createBag(tripId, { name: newBagName.trim(), color: BAG_COLORS[bags.length % BAG_COLORS.length] }) setBags(prev => [...prev, data.bag]) setNewBagName(''); setShowAddBag(false) } catch { toast.error(t('packing.toast.saveError')) } } const handleCreateBagByName = async (name: string): Promise => { try { const data = await packingApi.createBag(tripId, { name, color: BAG_COLORS[bags.length % BAG_COLORS.length] }) setBags(prev => [...prev, data.bag]) return data.bag } catch { toast.error(t('packing.toast.saveError')); return undefined } } const handleDeleteBag = async (bagId: number) => { try { await packingApi.deleteBag(tripId, bagId) setBags(prev => prev.filter(b => b.id !== bagId)) } catch { toast.error(t('packing.toast.deleteError')) } } // Templates const [availableTemplates, setAvailableTemplates] = useState<{ id: number; name: string; item_count: number }[]>([]) const [showTemplateDropdown, setShowTemplateDropdown] = useState(false) const [applyingTemplate, setApplyingTemplate] = useState(false) const templateDropdownRef = useRef(null) useEffect(() => { adminApi.packingTemplates().then(d => setAvailableTemplates(d.templates || [])).catch(() => {}) }, [tripId]) useEffect(() => { if (!showTemplateDropdown) return const handler = (e: MouseEvent) => { if (templateDropdownRef.current && !templateDropdownRef.current.contains(e.target as Node)) setShowTemplateDropdown(false) } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [showTemplateDropdown]) const handleApplyTemplate = async (templateId: number) => { setApplyingTemplate(true) try { const data = await packingApi.applyTemplate(tripId, templateId) toast.success(t('packing.templateApplied', { count: data.count })) setShowTemplateDropdown(false) // Reload packing items window.location.reload() } catch { toast.error(t('packing.templateError')) } finally { setApplyingTemplate(false) } } const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" } return (
{/* ── Header ── */}

{t('packing.title')}

{items.length === 0 ? t('packing.empty') : t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}

{abgehakt > 0 && ( )} {availableTemplates.length > 0 && (
{showTemplateDropdown && (
{availableTemplates.map(tmpl => ( ))}
)}
)} {bagTrackingEnabled && ( )}
{items.length > 0 && (
{fortschritt === 100 && (

{t('packing.allPacked')}

)}
)} {addingCategory ? (
setNewCatName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleAddNewCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }} placeholder={t('packing.newCategoryPlaceholder')} style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13.5, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)' }} />
) : ( )}
{/* ── Filter-Tabs ── */} {items.length > 0 && (
{[['alle', t('packing.filterAll')], ['offen', t('packing.filterOpen')], ['erledigt', t('packing.filterDone')]].map(([id, label]) => ( ))}
)} {/* ── Liste + Bags Sidebar ── */}
{items.length === 0 ? (

{t('packing.emptyTitle')}

{t('packing.emptyHint')}

) : Object.keys(gruppiert).length === 0 ? (

{t('packing.emptyFiltered')}

) : (
{Object.entries(gruppiert).map(([kat, katItems]) => ( ))}
)}
{/* ── Bag Weight Sidebar ── */} {bagTrackingEnabled && bags.length > 0 && (
{t('packing.bags')}
{bags.map(bag => { const bagItems = items.filter(i => i.bag_id === bag.id) const totalWeight = bagItems.reduce((sum, i) => sum + (i.weight_grams || 0), 0) const maxWeight = bag.weight_limit_grams || Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1) const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100)) return (
{bag.name} {totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
{bagItems.length} {t('admin.packingTemplates.items')}
) })} {/* Unassigned */} {(() => { const unassigned = items.filter(i => !i.bag_id) const unassignedWeight = unassigned.reduce((s, i) => s + (i.weight_grams || 0), 0) if (unassigned.length === 0) return null return (
{t('packing.noBag')} {unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`}
{unassigned.length} {t('admin.packingTemplates.items')}
) })()} {/* Total */}
{t('packing.totalWeight')} {(() => { const w = items.reduce((s, i) => s + (i.weight_grams || 0), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}
{/* Add bag */} {showAddBag ? (
setNewBagName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }} placeholder={t('packing.bagName')} style={{ flex: 1, padding: '5px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 11, fontFamily: 'inherit', outline: 'none' }} />
) : ( )}
)}
{/* ── Bag Modal (mobile + click) ── */} {showBagModal && bagTrackingEnabled && (
setShowBagModal(false)}>
e.stopPropagation()}>

{t('packing.bags')}

{bags.map(bag => { const bagItems = items.filter(i => i.bag_id === bag.id) const totalWeight = bagItems.reduce((sum, i) => sum + (i.weight_grams || 0), 0) const maxWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1) const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100)) return (
{bag.name} {totalWeight >= 1000 ? `${(totalWeight / 1000).toFixed(1)} kg` : `${totalWeight} g`}
{bagItems.length} {t('admin.packingTemplates.items')}
) })} {/* Unassigned */} {(() => { const unassigned = items.filter(i => !i.bag_id) const unassignedWeight = unassigned.reduce((s, i) => s + (i.weight_grams || 0), 0) if (unassigned.length === 0) return null return (
{t('packing.noBag')} {unassignedWeight >= 1000 ? `${(unassignedWeight / 1000).toFixed(1)} kg` : `${unassignedWeight} g`}
{unassigned.length} {t('admin.packingTemplates.items')}
) })()} {/* Total */}
{t('packing.totalWeight')} {(() => { const w = items.reduce((s, i) => s + (i.weight_grams || 0), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}
{/* Add bag */} {showAddBag ? (
setNewBagName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleCreateBag(); if (e.key === 'Escape') { setShowAddBag(false); setNewBagName('') } }} placeholder={t('packing.bagName')} style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none' }} />
) : ( )}
)}
) }