mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
refactor(packing): share the packing list logic between desktop and phone
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.
This commit is contained in:
@@ -1,17 +1,21 @@
|
||||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
||||
import type { ChangeEvent } from 'react'
|
||||
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 { packingApi, tripsApi } from '../../api/client'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import { useBagTotalsPing } from './useBagTotalsPing'
|
||||
import type { PackingItem, PackingBag } from '../../types'
|
||||
import { BAG_COLORS, PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'
|
||||
import { newItemSharing, parseImportLines, sortItemsByName } from './packingListPanel.helpers'
|
||||
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'
|
||||
|
||||
@@ -25,13 +29,6 @@ export interface TripMember {
|
||||
is_guest?: boolean
|
||||
}
|
||||
|
||||
export interface CategoryAssignee {
|
||||
user_id: number
|
||||
username: string
|
||||
avatar?: string | null
|
||||
is_guest?: boolean
|
||||
}
|
||||
|
||||
export interface PackingListPanelProps {
|
||||
tripId: number
|
||||
items: PackingItem[]
|
||||
@@ -68,8 +65,6 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
const [ownView, setOwnView] = useState<'common' | 'personal'>('common')
|
||||
const view = viewProp ?? ownView
|
||||
const setView = onViewChange ?? setOwnView
|
||||
const [addingCategory, setAddingCategory] = useState(false)
|
||||
const [newCatName, setNewCatName] = useState('')
|
||||
const { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem, reorderPackingItems,
|
||||
setPackingItemSharing, clonePackingItem, addPackingContributor, removePackingContributor } = useTripStore()
|
||||
const can = useCanDo()
|
||||
@@ -82,7 +77,6 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
|
||||
// Trip members & category assignees
|
||||
const [tripMembers, setTripMembers] = useState<TripMember[]>([])
|
||||
const [categoryAssignees, setCategoryAssignees] = useState<Record<string, CategoryAssignee[]>>({})
|
||||
|
||||
useEffect(() => {
|
||||
tripsApi.getMembers(tripId).then(data => {
|
||||
@@ -91,136 +85,39 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
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(() => {})
|
||||
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 { 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(
|
||||
() => items.filter(i => (view === 'common' ? !i.is_private : !!i.is_private)),
|
||||
[items, view],
|
||||
)
|
||||
const viewItems = useMemo(() => packingViewItems(items, view), [items, view])
|
||||
|
||||
const allCategories = useMemo(() => {
|
||||
const seen: string[] = []
|
||||
for (const item of viewItems) {
|
||||
const cat = item.category || t('packing.defaultCategory')
|
||||
if (!seen.includes(cat)) seen.push(cat)
|
||||
}
|
||||
return seen
|
||||
}, [viewItems, t])
|
||||
const allCategories = useMemo(() => packingCategoryOrder(viewItems, t('packing.defaultCategory')), [viewItems, t])
|
||||
|
||||
const gruppiert = useMemo(() => {
|
||||
const filtered = viewItems.filter(i => {
|
||||
if (filter === 'offen') return !i.checked
|
||||
if (filter === 'erledigt') return i.checked
|
||||
return true
|
||||
})
|
||||
const status = filter === 'offen' ? 'open' : filter === 'erledigt' ? 'done' : 'all'
|
||||
const groups: Record<string, PackingItem[]> = {}
|
||||
for (const item of filtered) {
|
||||
const kat = item.category || t('packing.defaultCategory')
|
||||
if (!groups[kat]) groups[kat] = []
|
||||
groups[kat].push(item)
|
||||
}
|
||||
if (sort === 'name') {
|
||||
for (const kat of Object.keys(groups)) groups[kat] = sortItemsByName(groups[kat], locale)
|
||||
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 abgehakt = viewItems.filter(i => i.checked).length
|
||||
const fortschritt = viewItems.length > 0 ? Math.round((abgehakt / viewItems.length) * 100) : 0
|
||||
const { checked: abgehakt, pct: fortschritt } = packingProgress(viewItems)
|
||||
|
||||
const handleAddItemToCategory = async (category: string, name: string) => {
|
||||
try {
|
||||
// Reuse the '...' placeholder slot when the category already has one, so a
|
||||
// freshly-emptied category keeps its position (and therefore its colour)
|
||||
// instead of the new item being appended to the end of the list.
|
||||
const placeholder = useTripStore.getState().packingItems.find(
|
||||
i => i.category === category && i.name === PACKING_PLACEHOLDER_NAME
|
||||
)
|
||||
if (placeholder) {
|
||||
await updatePackingItem(tripId, placeholder.id, { name })
|
||||
} else {
|
||||
// New items inherit the active view's tier, and in "my list" the sharing the
|
||||
// category's own items agree on (#2241).
|
||||
await addPackingItem(tripId, { name, category, ...newItemSharing(items, category, view, currentUserId) } as Parameters<typeof addPackingItem>[1])
|
||||
}
|
||||
} catch { toast.error(t('packing.toast.addError')) }
|
||||
}
|
||||
|
||||
// Deleting an item from a row. When it is the last item of a user-created
|
||||
// category, turn that row back into the '...' placeholder in place rather than
|
||||
// deleting it (#1289). Updating the row keeps its id, list position and colour,
|
||||
// so the category neither disappears nor jumps to the end. The default
|
||||
// (uncategorized) group and the placeholder row itself are deleted normally —
|
||||
// removing the placeholder is how an empty category is dismissed.
|
||||
const handleDeleteItem = async (item: PackingItem) => {
|
||||
const category = item.category
|
||||
const isLastInCategory = !!category
|
||||
&& item.name !== PACKING_PLACEHOLDER_NAME
|
||||
&& !items.some(i => i.id !== item.id && i.category === category)
|
||||
try {
|
||||
if (isLastInCategory) {
|
||||
if (item.checked) await togglePackingItem(tripId, item.id, false)
|
||||
await updatePackingItem(tripId, item.id, {
|
||||
name: PACKING_PLACEHOLDER_NAME, weight_grams: null, bag_id: null, quantity: 1,
|
||||
})
|
||||
} else {
|
||||
await deletePackingItem(tripId, item.id)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('packing.toast.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
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, visibility: view === 'personal' ? 'personal' : 'common' } as Parameters<typeof addPackingItem>[1])
|
||||
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 {
|
||||
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
|
||||
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'))
|
||||
await clearChecked()
|
||||
}
|
||||
|
||||
// Bag tracking — the global toggle is a packing sub-flag surfaced to every
|
||||
@@ -229,9 +126,6 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
const bagTrackingEnabled = useAddonStore(s => s.bagTracking)
|
||||
const addonsLoaded = useAddonStore(s => s.loaded)
|
||||
const loadAddons = useAddonStore(s => s.loadAddons)
|
||||
const [bags, setBags] = useState<PackingBag[]>([])
|
||||
/** Server-summed weight of everything in no bag (#2191); null until the first load. */
|
||||
const [unassignedWeightGrams, setUnassignedWeightGrams] = useState<number | null>(null)
|
||||
const [newBagName, setNewBagName] = useState('')
|
||||
const [showAddBag, setShowAddBag] = useState(false)
|
||||
const [showBagModal, setShowBagModal] = useState(false)
|
||||
@@ -240,81 +134,22 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
if (!addonsLoaded) loadAddons()
|
||||
}, [addonsLoaded, loadAddons])
|
||||
|
||||
const reloadBags = useCallback(async () => {
|
||||
if (!bagTrackingEnabled) return
|
||||
try {
|
||||
const r = await packingApi.listBags(tripId)
|
||||
setBags(r.bags || [])
|
||||
setUnassignedWeightGrams(r.unassigned_weight_grams ?? null)
|
||||
} catch {
|
||||
// Offline or a failed read: the surfaces fall back to the local sum
|
||||
// (see `serverWeightsFresh` below), so there is nothing to roll back.
|
||||
}
|
||||
}, [tripId, bagTrackingEnabled])
|
||||
|
||||
useEffect(() => { void reloadBags() }, [reloadBags])
|
||||
|
||||
// Bag weights are summed server-side across every member (#2191), so an item
|
||||
// this viewer may not even see still moves them. The item events cannot carry
|
||||
// that — a private item is delivered only to its owner, which is the very rule
|
||||
// that made the totals wrong — so the server pings the room content-free and
|
||||
// we re-read the numbers.
|
||||
useBagTotalsPing(bagTrackingEnabled, reloadBags)
|
||||
|
||||
// Bags are not part of the offline cache (no repo, no Dexie table), so while
|
||||
// offline the server totals are frozen at the last online read and cannot see
|
||||
// the optimistic item writes the mutation queue is holding. A stale absolute
|
||||
// number measured against an airline limit is worse than an honest partial
|
||||
// one, so offline the surfaces sum what they can see instead (#2191).
|
||||
const { offline } = useNetworkMode()
|
||||
const serverWeightsFresh = !offline
|
||||
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
|
||||
try {
|
||||
const data = await packingApi.createBag(tripId, { name: newBagName.trim(), color: BAG_COLORS[bags.length % BAG_COLORS.length] })
|
||||
setBags(prev => [...prev, data.bag])
|
||||
if (await tryCreateBag(newBagName.trim())) {
|
||||
setNewBagName(''); setShowAddBag(false)
|
||||
} catch { toast.error(t('packing.toast.saveError')) }
|
||||
}
|
||||
|
||||
const handleCreateBagByName = async (name: string): Promise<PackingBag | undefined> => {
|
||||
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<string, any>) => {
|
||||
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 lastHandledAddCategorySignal = useRef(addCategorySignal)
|
||||
const lastHandledSaveSignal = useRef(saveTemplateSignal)
|
||||
@@ -331,7 +166,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
setAddingCategory(true)
|
||||
}
|
||||
lastHandledAddCategorySignal.current = addCategorySignal
|
||||
}, [addCategorySignal])
|
||||
}, [addCategorySignal, setAddingCategory])
|
||||
|
||||
useEffect(() => {
|
||||
if (saveTemplateSignal !== lastHandledSaveSignal.current && saveTemplateSignal > 0) {
|
||||
@@ -342,9 +177,14 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
const csvInputRef = useRef<HTMLInputElement>(null)
|
||||
const templateDropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
packingApi.listTemplates(tripId).then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
|
||||
}, [tripId])
|
||||
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
|
||||
@@ -355,53 +195,9 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [showTemplateDropdown])
|
||||
|
||||
const handleApplyTemplate = async (templateId: number) => {
|
||||
setApplyingTemplate(true)
|
||||
try {
|
||||
const data = await packingApi.applyTemplate(tripId, templateId, view)
|
||||
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<HTMLInputElement>) => {
|
||||
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 {
|
||||
text: importText, setText: setImportText, readFile: handleCsvFile, runImport: handleBulkImport,
|
||||
} = usePackingImport({ tripId, t, toast, onImported: () => setShowImportModal(false), reportEmpty: true })
|
||||
|
||||
const font = { fontFamily: "var(--font-system)" }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user