mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
dc60f39554
The Costs tab opens with the same bar as Transports, Bookings, Lists and Files: the tab's name, the trip's dates and days in one pill, the travelers, and Settle up and Add expense on the right. The desktop packing list takes the phone's look without losing anything it shows. Category cards get a tinted head band, rows get hairlines and filled checkboxes, owner, quantity and weight read as small badges, and the bin and a menu with Move to List, Sharing, Rename and Delete replace the row of icons. What a row does not use stays dimmed until hover or focus, so nothing leaves a gap. A click on the name renames, ticked items too. Add item opens a composer shaped like the next row, Add list moved into the Lists bar, Add list and Save as template ask for the name in a dialog, and Remove checked sits beside the progress bar. The Bags panel becomes a card, and the item menu, the bag picker, the list menu and the bag member picker share one popover look and close on a click beside them. Help center pictures for Costs and Lists are re-rendered, the Lists guide texts follow the new controls in all 27 languages, and the wiki pages are updated. The phone screens are unchanged.
415 lines
18 KiB
TypeScript
415 lines
18 KiB
TypeScript
import { useState, useMemo, useRef, useEffect, useCallback } 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 { useNetworkMode } from '../../hooks/useNetworkMode'
|
||
import { useBagTotalsPing } from './useBagTotalsPing'
|
||
import type { PackingItem, PackingBag } from '../../types'
|
||
import { BAG_COLORS, PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'
|
||
import { parseImportLines } from './packingListPanel.helpers'
|
||
|
||
export interface TripMember {
|
||
id: number
|
||
username: string
|
||
avatar?: string | null
|
||
avatar_url?: string | null
|
||
is_guest?: boolean
|
||
}
|
||
|
||
export interface CategoryAssignee {
|
||
user_id: number
|
||
username: string
|
||
avatar?: 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'
|
||
// 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 [addingCategory, setAddingCategory] = useState(false)
|
||
const [newCatName, setNewCatName] = useState('')
|
||
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 } = useTranslation()
|
||
|
||
// Trip members & category assignees
|
||
const [tripMembers, setTripMembers] = useState<TripMember[]>([])
|
||
const [categoryAssignees, setCategoryAssignees] = useState<Record<string, CategoryAssignee[]>>({})
|
||
|
||
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(() => {})
|
||
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'))
|
||
}
|
||
}
|
||
|
||
// 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 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 gruppiert = useMemo(() => {
|
||
const filtered = viewItems.filter(i => {
|
||
if (filter === 'offen') return !i.checked
|
||
if (filter === 'erledigt') return i.checked
|
||
return true
|
||
})
|
||
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)
|
||
}
|
||
return groups
|
||
}, [viewItems, filter, t])
|
||
|
||
const abgehakt = viewItems.filter(i => i.checked).length
|
||
const fortschritt = viewItems.length > 0 ? Math.round((abgehakt / viewItems.length) * 100) : 0
|
||
|
||
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: Personal in "my list", Common otherwise.
|
||
await addPackingItem(tripId, { name, category, visibility: view === 'personal' ? 'personal' : 'common' } 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 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<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)
|
||
|
||
useEffect(() => {
|
||
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 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<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)
|
||
|
||
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])
|
||
|
||
useEffect(() => {
|
||
if (saveTemplateSignal !== lastHandledSaveSignal.current && saveTemplateSignal > 0) {
|
||
setShowSaveTemplate(true)
|
||
}
|
||
lastHandledSaveSignal.current = saveTemplateSignal
|
||
}, [saveTemplateSignal])
|
||
const csvInputRef = useRef<HTMLInputElement>(null)
|
||
const templateDropdownRef = useRef<HTMLDivElement>(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, 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 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, 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>
|