// src/screens/ActivePickListScreen.tsx import { Alert, AlertColor, Button, Checkbox, Container, FormControlLabel, InputAdornment, Stack, TextField, Typography, FormControl, FormLabel, RadioGroup, Radio, Snackbar, } from '@mui/material'; import SearchIcon from '@mui/icons-material/Search'; import { useParams, useNavigate } from 'react-router-dom'; import { useEffect, useMemo, useState } from 'react'; import { v4 as uuidv4 } from 'uuid'; import { useAreas, useCategories, usePickItems, usePickList, useProducts, } from '../hooks/dataHooks'; import { useDatabase } from '../context/DBProvider'; import { PickItemRow } from '../components/PickItemRow'; import { PickItem } from '../models/PickItem'; import { Product } from '../models/Product'; import { ProductAutocomplete } from '../components/ProductAutocomplete'; import { AddProductDialog } from '../components/AddProductDialog'; const normalizeName = (name: string) => name.trim().toLowerCase(); const ActivePickListScreen = () => { const { id } = useParams(); const pickList = usePickList(id); const items = usePickItems(id); const products = useProducts(); const areas = useAreas(); const categoriesList = useCategories(); const db = useDatabase(); const navigate = useNavigate(); const [query, setQuery] = useState(''); const [showPicked, setShowPicked] = useState(true); const [itemState, setItemState] = useState(items ?? []); const [isBatchUpdating, setIsBatchUpdating] = useState(false); const [packagingFilter, setPackagingFilter] = useState<'all' | 'units' | 'cartons'>('all'); const [addProductDialogOpen, setAddProductDialogOpen] = useState(false); const [feedback, setFeedback] = useState<{ text: string; severity: AlertColor } | null>(null); // NEW: search within the list and category filter for the visible list const [listSearch, setListSearch] = useState(''); const [categoryFilter, setCategoryFilter] = useState<'all' | string>('all'); // Keep local item state in sync with DB-driven `items` useEffect(() => { setItemState((current: PickItem[]) => { const currentById = new Map(current.map((item: PickItem) => [item.id, item])); return (items ?? []).map((incoming: PickItem) => { const local = currentById.get(incoming.id); if (!local) return incoming; const localUpdatedAt = local.updated_at ?? 0; const incomingUpdatedAt = incoming.updated_at ?? 0; if (localUpdatedAt > incomingUpdatedAt) { return local; } return incoming; }); }); }, [items]); // Reworked visible logic: const itemsAfterShowPicked = useMemo( () => (showPicked ? itemState : itemState.filter((item) => item.status !== 'picked')), [itemState, showPicked], ); const packagingInfo = useMemo(() => { const visible = itemsAfterShowPicked ?? []; const uniqueValues = new Set(visible.map((it) => !!it.is_carton)); return { visibleCount: visible.length, uniquePackagingCount: uniqueValues.size, }; }, [itemsAfterShowPicked]); useEffect(() => { if (packagingInfo.visibleCount === 0 || packagingInfo.uniquePackagingCount === 1) { setPackagingFilter('all'); } }, [packagingInfo.visibleCount, packagingInfo.uniquePackagingCount]); const allItemsPicked = useMemo( () => itemState.length > 0 && itemState.every((item) => item.status === 'picked'), [itemState], ); const productMap = useMemo(() => { const map = new Map(); products.forEach((product: Product) => { map.set(product.id, product); }); return map; }, [products]); const areaName = useMemo( () => areas.find((area) => area.id === pickList?.area_id)?.name ?? 'Area', [areas, pickList?.area_id], ); const sortedProducts = useMemo(() => { const dedupedById = new Map(); products.forEach((product: Product) => { const existing = dedupedById.get(product.id); if (!existing || product.updated_at > existing.updated_at) { dedupedById.set(product.id, product); } }); const dedupedByName = new Map(); dedupedById.forEach((product: Product) => { const normalizedName = product.name.trim().toLowerCase(); const existing = dedupedByName.get(normalizedName); if (!existing || product.updated_at > existing.updated_at) { dedupedByName.set(normalizedName, product); } }); return Array.from(dedupedByName.values()).sort((a, b) => { const normalizedNameA = normalizeName(a.name); const normalizedNameB = normalizeName(b.name); const nameComparison = normalizedNameA.localeCompare(normalizedNameB, undefined, { sensitivity: 'base', }); if (nameComparison !== 0) { return nameComparison; } return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); }); }, [products]); // Build a category id -> name map for display and name -> id map for resolution const categoriesById = useMemo(() => new Map(categoriesList.map((c) => [c.id, c.name])), [categoriesList]); const categoryNameToId = useMemo(() => new Map(categoriesList.map((c) => [c.name.trim().toLowerCase(), c.id])), [categoriesList]); const addProductCategoryOptions = useMemo(() => { const categoryNames = categoriesList.map((item) => item.name); const productCategories = products.map( (product) => categoriesById.get(product.category) ?? product.category ?? '', ); return Array.from(new Set([...categoryNames, ...productCategories].filter(Boolean))); }, [categoriesList, products, categoriesById]); // Filter products by pickList.categories (now stored as ids). Fallback: resolve name -> id const categoryFilteredProducts = useMemo(() => { if (!pickList?.categories || pickList.categories.length === 0) { return sortedProducts; } const categoryIdsKnown = new Set(categoriesList.map((c) => c.id)); const allowedCategoryIds = new Set(); pickList.categories.forEach((entry: string) => { if (!entry) return; if (categoryIdsKnown.has(entry)) { // entry already an id allowedCategoryIds.add(entry); } else { // maybe it's a name (legacy) — resolve const resolved = categoryNameToId.get(entry.trim().toLowerCase()); if (resolved) allowedCategoryIds.add(resolved); } }); if (allowedCategoryIds.size === 0) return sortedProducts; return sortedProducts.filter((product: Product) => allowedCategoryIds.has(product.category)); }, [pickList?.categories, sortedProducts, categoriesList, categoryNameToId]); const productIdsInList = useMemo(() => new Set(itemState.map((item) => item.product_id)), [itemState]); const filteredProducts = useMemo(() => { const normalizedQuery = query.trim().toLowerCase(); const availableProducts = categoryFilteredProducts.filter((product: Product) => !productIdsInList.has(product.id)); if (!normalizedQuery) return availableProducts; return availableProducts.filter((product: Product) => { const catName = categoriesById.get(product.category) ?? product.category ?? ''; const searchableText = `${product.name} ${catName} ${product.barcode ?? ''}`.toLowerCase(); return searchableText.includes(normalizedQuery); }); }, [categoryFilteredProducts, productIdsInList, query, categoriesById]); // If a filtered list leaves a previously-selected product missing, callers (ProductAutocomplete) will handle clear. // --- Handlers (restored) --- const handleIncrementQuantity = async (itemId: string) => { const existing = await db.pickItems.get(itemId); if (!existing) return; const nextQuantity = existing.quantity + 1; setItemState((current) => current.map((item) => (item.id === itemId ? { ...item, quantity: nextQuantity, updated_at: Date.now() } : item))); await db.pickItems.update(itemId, { quantity: nextQuantity, updated_at: Date.now(), }); }; const handleDecrementQuantity = async (itemId: string) => { const existing = await db.pickItems.get(itemId); if (!existing) return; const nextQuantity = Math.max(1, (existing.quantity || 1) - 1); setItemState((current) => current.map((item) => (item.id === itemId ? { ...item, quantity: nextQuantity, updated_at: Date.now() } : item))); await db.pickItems.update(itemId, { quantity: nextQuantity, updated_at: Date.now(), }); }; const handleToggleCarton = async (itemId: string) => { const existing = await db.pickItems.get(itemId); if (!existing) return; const nextCartonFlag = !existing.is_carton; const nextQuantity = existing.quantity || 1; setItemState((current) => current.map((item) => (item.id === itemId ? { ...item, is_carton: nextCartonFlag, quantity: nextQuantity, updated_at: Date.now() } : item))); await db.pickItems.update(itemId, { is_carton: nextCartonFlag, quantity: nextQuantity, updated_at: Date.now(), }); }; const handleStatusChange = async (itemId: string, status: PickItem['status']) => { const nextStatus = status === 'picked' ? 'picked' : 'pending'; setItemState((current) => current.map((item) => (item.id === itemId ? { ...item, status: nextStatus, updated_at: Date.now() } : item))); await db.pickItems.update(itemId, { status: nextStatus, updated_at: Date.now() }); }; const handleDeleteItem = async (itemId: string) => { setItemState((current) => current.filter((item) => item.id !== itemId)); await db.pickItems.delete(itemId); }; const handleMarkAllPicked = async () => { if (!id) return; setShowPicked(true); const timestamp = Date.now(); setItemState((current) => current.map((item) => ({ ...item, status: 'picked', updated_at: timestamp }))); setIsBatchUpdating(true); // if itemState is empty at invocation, fall back to DB items const itemsToUpdate = itemState.length > 0 ? itemState : (items ?? []); try { await Promise.all(itemsToUpdate.map((item: PickItem) => db.pickItems.update(item.id, { status: 'picked', updated_at: timestamp }))); const refreshedItems = await db.pickItems.where('pick_list_id').equals(id as string).toArray(); setItemState(refreshedItems); } finally { setIsBatchUpdating(false); } }; const addOrUpdateItem = async (product: Product) => { if (!id) return; const timestamp = Date.now(); const existing = itemState.find((item) => item.product_id === product.id && item.is_carton === false); if (existing) { setItemState((current) => current.map((item) => item.id === existing.id ? { ...item, quantity: item.quantity + 1, updated_at: timestamp } : item, ), ); await db.pickItems.update(existing.id, { quantity: existing.quantity + 1, updated_at: timestamp, }); } else { const newItem: PickItem = { id: uuidv4(), pick_list_id: id as string, product_id: product.id, quantity: 1, is_carton: false, status: 'pending', created_at: timestamp, updated_at: timestamp, }; setItemState((current) => [...current, newItem]); await db.pickItems.add({ ...newItem, }); } setQuery(''); }; const returnToLists = () => { navigate('/pick-lists'); }; const handleAddProductClose = () => { setAddProductDialogOpen(false); }; // Sort the items that are actually visible (after showPicked and packaging filter) const visibleItems = useMemo(() => { let arr = [...itemsAfterShowPicked]; if (packagingFilter === 'units') { arr = arr.filter((item) => !item.is_carton); } else if (packagingFilter === 'cartons') { arr = arr.filter((item) => item.is_carton); } arr.sort((a, b) => { const nameA = normalizeName(productMap.get(a.product_id)?.name ?? ''); const nameB = normalizeName(productMap.get(b.product_id)?.name ?? ''); const nameComparison = nameA.localeCompare(nameB, undefined, { sensitivity: 'base' }); if (nameComparison !== 0) return nameComparison; const timeA = a.created_at ?? a.updated_at ?? 0; const timeB = b.created_at ?? b.updated_at ?? 0; return timeA - timeB; }); return arr; }, [itemsAfterShowPicked, productMap, packagingFilter]); // --- NEW: build category options from pickList.categories (fallback to all categories) --- const categoryOptions = useMemo(() => { // helper maps const idToName = new Map(categoriesList.map((c) => [c.id, c.name])); const nameToId = new Map(categoriesList.map((c) => [c.name.trim().toLowerCase(), c.id])); if (!pickList?.categories || pickList.categories.length === 0) { // fallback: show all categories return categoriesList .map((c) => ({ id: c.id, name: c.name })) .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); } // Build from pickList.categories while resolving legacy names -> ids const seen = new Map(); pickList.categories.forEach((entry: string) => { if (!entry) return; if (idToName.has(entry)) { seen.set(entry, idToName.get(entry)!); } else { const resolved = nameToId.get(entry.trim().toLowerCase()); if (resolved) { seen.set(resolved, idToName.get(resolved)!); } } }); // Convert to array and sort by name return Array.from(seen.entries()) .map(([id, name]) => ({ id, name })) .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); }, [pickList?.categories, categoriesList]); // --- NEW: apply categoryFilter and listSearch to visibleItems --- const visibleItemsFiltered = useMemo(() => { const q = listSearch.trim().toLowerCase(); let arr = visibleItems; if (categoryFilter !== 'all') { arr = arr.filter((item) => { const product = productMap.get(item.product_id); return (product?.category ?? '') === categoryFilter; }); } if (!q) return arr; return arr.filter((item) => { const product = productMap.get(item.product_id); const catName = categoriesById.get(product?.category ?? '') ?? product?.category ?? ''; const searchableText = `${product?.name ?? ''} ${catName} ${product?.barcode ?? ''}`.toLowerCase(); return searchableText.includes(q); }); }, [visibleItems, productMap, listSearch, categoryFilter, categoriesById]); return ( {areaName} List Add products to this list { void addOrUpdateItem(product); }} onQueryChange={(q: string) => setQuery(q)} onAddProduct={() => setAddProductDialogOpen(true)} /> {filteredProducts.length === 0 ? ( No available products ) : null} Selecting a product immediately adds it to the pick list. Packaging setPackagingFilter(value as 'all' | 'units' | 'cartons')} > } label="All" data-testid="packaging-filter-all" /> } label="Units" disabled={packagingInfo.visibleCount === 0 || packagingInfo.uniquePackagingCount === 1} data-testid="packaging-filter-units" /> } label="Cartons" disabled={packagingInfo.visibleCount === 0 || packagingInfo.uniquePackagingCount === 1} data-testid="packaging-filter-cartons" /> setShowPicked(event.target.checked)} disabled={allItemsPicked} /> } label="Show picked" /> {/* NEW: Search List (left) and Category dropdown (right) on the same row */} setListSearch(e.target.value)} sx={{ flexGrow: 1, minWidth: 160 }} InputProps={{ startAdornment: ( ), }} /> setCategoryFilter((e.target.value as any) ?? 'all')} sx={{ width: { xs: '100%', sm: 240 }, ml: { xs: 0, sm: 2 }, mt: { xs: 1, sm: 0 } }} > {categoryOptions.map((opt) => ( ))} {pickList?.notes ? ( {pickList.notes} ) : null} {/* NEW: empty-filter message */} {visibleItemsFiltered.length === 0 ? ( visibleItems.length === 0 ? ( No items in this pick list ) : ( No items match the filter ) ) : ( visibleItemsFiltered.map((item) => ( handleIncrementQuantity(item.id)} onDecrementQuantity={() => handleDecrementQuantity(item.id)} onToggleCarton={() => handleToggleCarton(item.id)} onStatusChange={(status) => handleStatusChange(item.id, status)} onDelete={() => handleDeleteItem(item.id)} /> )) )} setFeedback(null)}> {feedback ? {feedback.text} : undefined} ); }; export default ActivePickListScreen;