import { useState, useMemo, useRef, useEffect } from 'react' import type { ChangeEvent } from 'react' import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' import { useAuthStore } from '../../store/authStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { packingApi, tripsApi } from '../../api/client' import { useAddonStore } from '../../store/addonStore' import type { PackingItem, PackingBag } from '../../types' import { BAG_COLORS } from './packingListPanel.constants' import { parseImportLines } from './packingListPanel.helpers' export interface TripMember { id: number username: string avatar?: string | null avatar_url?: string | null } export interface CategoryAssignee { user_id: number username: string avatar?: string | null } export interface PackingListPanelProps { tripId: number items: PackingItem[] openImportSignal?: number clearCheckedSignal?: number saveTemplateSignal?: number inlineHeader?: boolean } /** * Packing list state: trip members + per-category assignees, category grouping * and progress, item/category CRUD, bag tracking (weights + members) and the * template apply/save + bulk CSV import flows (driven by signal props). The * sections below render header, filters, the grouped list, the bag sidebar/ * modal and the import dialog. */ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheckedSignal = 0, saveTemplateSignal = 0, inlineHeader = true }: PackingListPanelProps) { const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt' const [addingCategory, setAddingCategory] = useState(false) const [newCatName, setNewCatName] = useState('') const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore() const can = useCanDo() const trip = useTripStore((s) => s.trip) const canEdit = can('packing_edit', trip) const isAdmin = useAuthStore((s) => s.user?.role === 'admin') 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: Record = {} 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 += '​' } try { await addPackingItem(tripId, { name: '...', category: catName }) setNewCatName('') setAddingCategory(false) } catch { toast.error(t('packing.toast.addError')) } } const handleRenameCategory = async (oldName: string, newName: string) => { 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: PackingItem[]) => { let failed = false for (const item of catItems) { try { await deletePackingItem(tripId, item.id) } catch { failed = true } } if (failed) toast.error(t('packing.toast.deleteError')) } const handleClearChecked = async () => { if (!confirm(t('packing.confirm.clearChecked', { count: abgehakt }))) return let failed = false for (const item of items.filter(i => i.checked)) { try { await deletePackingItem(tripId, item.id) } catch { failed = true } } if (failed) toast.error(t('packing.toast.deleteError')) } // Bag tracking — the global toggle is a packing sub-flag surfaced to every // authenticated user via the addon store (loaded on app start), not the // admin-only endpoint, so non-admin members see weights/bags too. const bagTrackingEnabled = useAddonStore(s => s.bagTracking) const addonsLoaded = useAddonStore(s => s.loaded) const loadAddons = useAddonStore(s => s.loadAddons) const [bags, setBags] = useState([]) const [newBagName, setNewBagName] = useState('') const [showAddBag, setShowAddBag] = useState(false) const [showBagModal, setShowBagModal] = useState(false) useEffect(() => { if (!addonsLoaded) loadAddons() }, [addonsLoaded, loadAddons]) useEffect(() => { if (bagTrackingEnabled) packingApi.listBags(tripId).then(r => setBags(r.bags || [])).catch(() => {}) }, [tripId, bagTrackingEnabled]) 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')) } } const handleUpdateBag = async (bagId: number, data: Record) => { try { const result = await packingApi.updateBag(tripId, bagId, data) setBags(prev => prev.map(b => b.id === bagId ? { ...b, ...result.bag } : b)) } catch { toast.error(t('common.error')) } } const handleSetBagMembers = async (bagId: number, userIds: number[]) => { try { const result = await packingApi.setBagMembers(tripId, bagId, userIds) setBags(prev => prev.map(b => b.id === bagId ? { ...b, members: result.members } : b)) } catch { toast.error(t('common.error')) } } // Templates const [availableTemplates, setAvailableTemplates] = useState<{ id: number; name: string; item_count: number }[]>([]) const [showTemplateDropdown, setShowTemplateDropdown] = useState(false) const [applyingTemplate, setApplyingTemplate] = useState(false) const [showSaveTemplate, setShowSaveTemplate] = useState(false) const [saveTemplateName, setSaveTemplateName] = useState('') const [showImportModal, setShowImportModal] = useState(false) const [importText, setImportText] = useState('') const lastHandledImportSignal = useRef(openImportSignal) const lastHandledClearSignal = useRef(clearCheckedSignal) const lastHandledSaveSignal = useRef(saveTemplateSignal) useEffect(() => { if (openImportSignal !== lastHandledImportSignal.current && openImportSignal > 0) { setShowImportModal(true) } lastHandledImportSignal.current = openImportSignal }, [openImportSignal]) useEffect(() => { if (clearCheckedSignal !== lastHandledClearSignal.current && clearCheckedSignal > 0) { handleClearChecked() } lastHandledClearSignal.current = clearCheckedSignal // eslint-disable-next-line react-hooks/exhaustive-deps }, [clearCheckedSignal]) useEffect(() => { if (saveTemplateSignal !== lastHandledSaveSignal.current && saveTemplateSignal > 0) { setShowSaveTemplate(true) } lastHandledSaveSignal.current = saveTemplateSignal }, [saveTemplateSignal]) const csvInputRef = useRef(null) const templateDropdownRef = useRef(null) useEffect(() => { packingApi.listTemplates(tripId).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) useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(data.items || [])] })) toast.success(t('packing.templateApplied', { count: data.count })) setShowTemplateDropdown(false) } catch { toast.error(t('packing.templateError')) } finally { setApplyingTemplate(false) } } const handleSaveAsTemplate = async () => { if (!saveTemplateName.trim()) return try { await packingApi.saveAsTemplate(tripId, saveTemplateName.trim()) toast.success(t('packing.templateSaved')) setShowSaveTemplate(false) setSaveTemplateName('') packingApi.listTemplates(tripId).then(d => setAvailableTemplates(d.templates || [])).catch(() => {}) } catch { toast.error(t('common.error')) } } const handleBulkImport = async () => { const parsed = parseImportLines(importText) if (parsed.length === 0) { toast.error(t('packing.importEmpty')); return } try { const result = await packingApi.bulkImport(tripId, parsed) useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(result.items || [])] })) toast.success(t('packing.importSuccess', { count: result.count })) setImportText('') setShowImportModal(false) } catch { toast.error(t('packing.importError')) } } const handleCsvFile = (e: ChangeEvent) => { const file = e.target.files?.[0] if (!file) return e.target.value = '' const reader = new FileReader() reader.onload = () => { if (typeof reader.result === 'string') setImportText(reader.result) } reader.readAsText(file) } const font = { fontFamily: "var(--font-system)" } return { tripId, items, inlineHeader, t, canEdit, isAdmin, font, filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName, tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt, handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleClearChecked, bagTrackingEnabled, bags, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal, handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers, availableTemplates, showTemplateDropdown, setShowTemplateDropdown, applyingTemplate, showSaveTemplate, setShowSaveTemplate, saveTemplateName, setSaveTemplateName, showImportModal, setShowImportModal, importText, setImportText, csvInputRef, templateDropdownRef, handleApplyTemplate, handleSaveAsTemplate, parseImportLines, handleBulkImport, handleCsvFile, } } export type PackingState = ReturnType