mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
4d4ef17de8
usePackingItemActions, usePackingTemplates and usePackingImport take the item, template and bulk import logic out of usePackingListPanel and MPackingListTab, and the phone listsModel keeps only its priority tokens. When adding to a category the desktop looks for the placeholder in the store and the phone in its rendered items. The desktop toasts on an empty import, the phone runs one import at a time. The effects run in the same order as before.
228 lines
11 KiB
TypeScript
228 lines
11 KiB
TypeScript
import { useState, useMemo, useRef, useEffect } 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 { tripsApi } from '../../api/client'
|
|
import { useAddonStore } from '../../store/addonStore'
|
|
import type { PackingItem } from '../../types'
|
|
import { parseImportLines, sortItemsByName } from './packingListPanel.helpers'
|
|
import { groupPackingItems, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel'
|
|
import { usePackingBags } from './usePackingBags'
|
|
import { usePackingCategoryAssignees } from './usePackingCategoryAssignees'
|
|
import { usePackingImport } from './usePackingImport'
|
|
import { usePackingItemActions } from './usePackingItemActions'
|
|
import { usePackingTemplates } from './usePackingTemplates'
|
|
|
|
export type { CategoryAssignee } from './usePackingCategoryAssignees'
|
|
|
|
const PACKING_SORT_KEY = 'trek:packing-sort'
|
|
|
|
export type PackingSort = 'manual' | 'name'
|
|
|
|
export interface TripMember {
|
|
id: number
|
|
username: string
|
|
avatar?: string | null
|
|
avatar_url?: string | null
|
|
is_guest?: boolean
|
|
}
|
|
|
|
export interface PackingListPanelProps {
|
|
tripId: number
|
|
items: PackingItem[]
|
|
openImportSignal?: number
|
|
// Raised by the Lists bar's Add list button, which opens the name field here.
|
|
addCategorySignal?: number
|
|
saveTemplateSignal?: number
|
|
inlineHeader?: boolean
|
|
// Lifted so an out-of-panel Apply Template button knows the active view (#1565).
|
|
view?: 'common' | 'personal'
|
|
onViewChange?: (view: 'common' | 'personal') => void
|
|
}
|
|
|
|
/**
|
|
* 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, addCategorySignal = 0, saveTemplateSignal = 0, inlineHeader = true, view: viewProp, onViewChange }: PackingListPanelProps) {
|
|
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
|
|
// A-Z only changes what is shown; the manual order stays stored underneath and
|
|
// comes back when the switch is turned off. Remembered per browser.
|
|
const [sort, setSortState] = useState<PackingSort>(() => {
|
|
try { return localStorage.getItem(PACKING_SORT_KEY) === 'name' ? 'name' : 'manual' } catch { return 'manual' }
|
|
})
|
|
const setSort = (next: PackingSort) => {
|
|
setSortState(next)
|
|
try { localStorage.setItem(PACKING_SORT_KEY, next) } catch { /* storage unavailable: the choice lasts until reload */ }
|
|
}
|
|
// Three-tier sharing (#858): 'common' = the group pool (where existing items
|
|
// live — non-breaking), 'personal' = my own list (private + shared-to-me).
|
|
const [ownView, setOwnView] = useState<'common' | 'personal'>('common')
|
|
const view = viewProp ?? ownView
|
|
const setView = onViewChange ?? setOwnView
|
|
const { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem, reorderPackingItems,
|
|
setPackingItemSharing, clonePackingItem, addPackingContributor, removePackingContributor } = 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 currentUserId = useAuthStore((s) => s.user?.id)
|
|
const toast = useToast()
|
|
const { t, locale } = useTranslation()
|
|
|
|
// Trip members & category assignees
|
|
const [tripMembers, setTripMembers] = useState<TripMember[]>([])
|
|
|
|
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, is_guest: false })
|
|
if (data.members) all.push(...data.members.map((m: any) => ({ id: m.id, username: m.username, avatar: m.avatar_url, is_guest: !!m.is_guest })))
|
|
setTripMembers(all)
|
|
}).catch(() => {})
|
|
}, [tripId])
|
|
|
|
const { categoryAssignees, setAssignees: handleSetAssignees } = usePackingCategoryAssignees({ tripId, t, toast })
|
|
|
|
// Split by the active view (#858): Common = group pool (is_private 0), Personal =
|
|
// my own + shared-to-me (is_private 1, already filtered to me by the server).
|
|
const viewItems = useMemo(() => packingViewItems(items, view), [items, view])
|
|
|
|
const allCategories = useMemo(() => packingCategoryOrder(viewItems, t('packing.defaultCategory')), [viewItems, t])
|
|
|
|
const gruppiert = useMemo(() => {
|
|
const status = filter === 'offen' ? 'open' : filter === 'erledigt' ? 'done' : 'all'
|
|
const groups: Record<string, PackingItem[]> = {}
|
|
for (const group of groupPackingItems(viewItems, status, t('packing.defaultCategory'))) {
|
|
groups[group.category] = sort === 'name' ? sortItemsByName(group.items, locale) : group.items
|
|
}
|
|
return groups
|
|
}, [viewItems, filter, sort, locale, t])
|
|
|
|
const { checked: abgehakt, pct: fortschritt } = packingProgress(viewItems)
|
|
|
|
const {
|
|
addingCategory, setAddingCategory, newCategoryName: newCatName, setNewCategoryName: setNewCatName,
|
|
addItemToCategory: handleAddItemToCategory, deleteItem: handleDeleteItem, addNewCategory: handleAddNewCategory,
|
|
renameCategory: handleRenameCategory, deleteCategoryItems: handleDeleteCategory, clearChecked,
|
|
} = usePackingItemActions({
|
|
tripId, items, view, currentUserId, categories: allCategories, defaultCategory: t('packing.defaultCategory'),
|
|
actions: { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem }, t, toast, placeholderFrom: 'store',
|
|
})
|
|
|
|
const handleClearChecked = async () => {
|
|
if (!confirm(t('packing.confirm.clearChecked', { count: abgehakt }))) return
|
|
await clearChecked()
|
|
}
|
|
|
|
// 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 [newBagName, setNewBagName] = useState('')
|
|
const [showAddBag, setShowAddBag] = useState(false)
|
|
const [showBagModal, setShowBagModal] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (!addonsLoaded) loadAddons()
|
|
}, [addonsLoaded, loadAddons])
|
|
|
|
const {
|
|
bags, unassignedWeightGrams, serverWeightsFresh, tryCreateBag, createBag: handleCreateBagByName,
|
|
deleteBag: handleDeleteBag, updateBag: handleUpdateBag, setBagMembers: handleSetBagMembers,
|
|
} = usePackingBags({ tripId, bagTrackingEnabled, t, toast })
|
|
|
|
const handleCreateBag = async () => {
|
|
if (!newBagName.trim()) return
|
|
if (await tryCreateBag(newBagName.trim())) {
|
|
setNewBagName(''); setShowAddBag(false)
|
|
}
|
|
}
|
|
|
|
// Templates
|
|
const [showTemplateDropdown, setShowTemplateDropdown] = useState(false)
|
|
const [showSaveTemplate, setShowSaveTemplate] = useState(false)
|
|
const [showImportModal, setShowImportModal] = useState(false)
|
|
const lastHandledImportSignal = useRef(openImportSignal)
|
|
const lastHandledAddCategorySignal = useRef(addCategorySignal)
|
|
const lastHandledSaveSignal = useRef(saveTemplateSignal)
|
|
|
|
useEffect(() => {
|
|
if (openImportSignal !== lastHandledImportSignal.current && openImportSignal > 0) {
|
|
setShowImportModal(true)
|
|
}
|
|
lastHandledImportSignal.current = openImportSignal
|
|
}, [openImportSignal])
|
|
|
|
useEffect(() => {
|
|
if (addCategorySignal !== lastHandledAddCategorySignal.current && addCategorySignal > 0) {
|
|
setAddingCategory(true)
|
|
}
|
|
lastHandledAddCategorySignal.current = addCategorySignal
|
|
}, [addCategorySignal, setAddingCategory])
|
|
|
|
useEffect(() => {
|
|
if (saveTemplateSignal !== lastHandledSaveSignal.current && saveTemplateSignal > 0) {
|
|
setShowSaveTemplate(true)
|
|
}
|
|
lastHandledSaveSignal.current = saveTemplateSignal
|
|
}, [saveTemplateSignal])
|
|
const csvInputRef = useRef<HTMLInputElement>(null)
|
|
const templateDropdownRef = useRef<HTMLDivElement>(null)
|
|
|
|
const {
|
|
templates: availableTemplates, applyingTemplate, applyTemplate: handleApplyTemplate,
|
|
saveTemplateName, setSaveTemplateName, saveAsTemplate: handleSaveAsTemplate,
|
|
} = usePackingTemplates({
|
|
tripId, view, t, toast,
|
|
onApplied: () => setShowTemplateDropdown(false),
|
|
onSaved: () => setShowSaveTemplate(false),
|
|
})
|
|
|
|
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 {
|
|
text: importText, setText: setImportText, readFile: handleCsvFile, runImport: handleBulkImport,
|
|
} = usePackingImport({ tripId, t, toast, onImported: () => setShowImportModal(false), reportEmpty: true })
|
|
|
|
const font = { fontFamily: "var(--font-system)" }
|
|
|
|
// ── Three-tier sharing handlers (#858) ──────────────────────────────────────
|
|
const handleSetSharing = (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) =>
|
|
setPackingItemSharing(tripId, id, visibility, recipientIds)
|
|
const handleCloneItem = (id: number) => clonePackingItem(tripId, id)
|
|
const handleJoinItem = (id: number) => addPackingContributor(tripId, id)
|
|
const handleLeaveItem = (id: number, userId: number) => removePackingContributor(tripId, id, userId)
|
|
|
|
return {
|
|
view, setView, currentUserId,
|
|
handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem,
|
|
tripId, items, inlineHeader, t, canEdit, isAdmin, font, reorderPackingItems,
|
|
filter, setFilter, sort, setSort, addingCategory, setAddingCategory, newCatName, setNewCatName,
|
|
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
|
|
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
|
|
bagTrackingEnabled, bags, unassignedWeightGrams, serverWeightsFresh, 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<typeof usePackingList>
|