import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { addItem, getClassification, getItemByName, getList, getRecentlyBought, getSuggestions, markBought, updateItemWithClassification } from "../api/list"; import { getHouseholdMembers } from "../api/households"; import SortDropdown from "../components/common/SortDropdown"; import AddItemForm from "../components/forms/AddItemForm"; import GroceryListItem from "../components/items/GroceryListItem"; import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal"; import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal"; import EditItemModal from "../components/modals/EditItemModal"; import SimilarItemModal from "../components/modals/SimilarItemModal"; import StoreTabs from "../components/store/StoreTabs"; import { ZONE_FLOW } from "../constants/classifications"; import { ROLES } from "../constants/roles"; import { AuthContext } from "../context/AuthContext"; import { HouseholdContext } from "../context/HouseholdContext"; import { IMAGE_UPLOAD_SUCCESS_EVENT } from "../context/UploadQueueContext"; import { SettingsContext } from "../context/SettingsContext"; import { StoreContext } from "../context/StoreContext"; import useActionToast from "../hooks/useActionToast"; import useUploadQueue from "../hooks/useUploadQueue"; import getApiErrorMessage from "../lib/getApiErrorMessage"; import "../styles/pages/GroceryList.css"; import { findSimilarItems } from "../utils/stringSimilarity"; export default function GroceryList() { const pageTitle = "Grocery List"; const { userId } = useContext(AuthContext); const { activeHousehold } = useContext(HouseholdContext); const { activeStore, stores, loading: storeLoading } = useContext(StoreContext); const { settings } = useContext(SettingsContext); const toast = useActionToast(); const { enqueueImageUpload } = useUploadQueue(); const navigate = useNavigate(); // Get household role for permissions const householdRole = activeHousehold?.role; const isHouseholdAdmin = ["owner", "admin"].includes(householdRole); // === State === // const [items, setItems] = useState([]); const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]); const [householdMembers, setHouseholdMembers] = useState([]); const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(settings.recentlyBoughtCount); const [sortMode, setSortMode] = useState(settings.defaultSortMode); const [suggestions, setSuggestions] = useState([]); const [loading, setLoading] = useState(true); const [buttonText, setButtonText] = useState("Add Item"); const [pendingItem, setPendingItem] = useState(null); const [showAddDetailsModal, setShowAddDetailsModal] = useState(false); const [showSimilarModal, setShowSimilarModal] = useState(false); const [similarItemSuggestion, setSimilarItemSuggestion] = useState(null); const [showEditModal, setShowEditModal] = useState(false); const [editingItem, setEditingItem] = useState(null); const [recentlyBoughtCollapsed, setRecentlyBoughtCollapsed] = useState(settings.recentlyBoughtCollapsed); const [collapsedZones, setCollapsedZones] = useState({}); const [showConfirmAddExisting, setShowConfirmAddExisting] = useState(false); const [confirmAddExistingData, setConfirmAddExistingData] = useState(null); // === Data Loading === const loadItems = async () => { if (!activeHousehold?.id || !activeStore?.id) { setLoading(false); return; } setLoading(true); try { const res = await getList(activeHousehold.id, activeStore.id); console.log('[GroceryList] Items loaded:', res.data); setItems(res.data.items || res.data || []); } catch (error) { console.error('[GroceryList] Failed to load items:', error); setItems([]); } finally { setLoading(false); } }; const loadRecentlyBought = async () => { if (!activeHousehold?.id || !activeStore?.id) return; try { const res = await getRecentlyBought(activeHousehold.id, activeStore.id); setRecentlyBoughtItems(res.data); } catch (error) { console.error("Failed to load recently bought items:", error); setRecentlyBoughtItems([]); } }; useEffect(() => { loadItems(); loadRecentlyBought(); }, [activeHousehold?.id, activeStore?.id]); useEffect(() => { const loadHouseholdMembers = async () => { if (!activeHousehold?.id) { setHouseholdMembers([]); return; } try { const response = await getHouseholdMembers(activeHousehold.id); setHouseholdMembers(response.data || []); } catch (error) { console.error("Failed to load household members:", error); setHouseholdMembers([]); } }; loadHouseholdMembers(); }, [activeHousehold?.id]); useEffect(() => { const handleUploadSuccess = async (event) => { const detail = event?.detail || {}; if (!activeHousehold?.id || !activeStore?.id) return; if (String(detail.householdId) !== String(activeHousehold.id)) return; if (String(detail.storeId) !== String(activeStore.id)) return; if (!detail.itemName) return; try { const response = await getItemByName(activeHousehold.id, activeStore.id, detail.itemName); const refreshedItem = response.data; setItems((prev) => prev.map((item) => { const byId = detail.localItemId !== null && detail.localItemId !== undefined && item.id === detail.localItemId; const byName = String(item.item_name || "").toLowerCase() === String(detail.itemName || "").toLowerCase(); return byId || byName ? { ...item, ...refreshedItem } : item; }) ); setRecentlyBoughtItems((prev) => prev.map((item) => { const byId = detail.localItemId !== null && detail.localItemId !== undefined && item.id === detail.localItemId; const byName = String(item.item_name || "").toLowerCase() === String(detail.itemName || "").toLowerCase(); return byId || byName ? { ...item, ...refreshedItem } : item; }) ); } catch (error) { console.error("Failed to refresh item after upload success:", error); } }; window.addEventListener(IMAGE_UPLOAD_SUCCESS_EVENT, handleUploadSuccess); return () => { window.removeEventListener(IMAGE_UPLOAD_SUCCESS_EVENT, handleUploadSuccess); }; }, [activeHousehold?.id, activeStore?.id]); // === Zone Collapse Handler === const toggleZoneCollapse = (zone) => { setCollapsedZones(prev => ({ ...prev, [zone]: !prev[zone] })); }; // === Sorted Items Computation === const sortedItems = useMemo(() => { const sorted = [...items]; if (sortMode === "az") sorted.sort((a, b) => a.item_name.localeCompare(b.item_name)); if (sortMode === "za") sorted.sort((a, b) => b.item_name.localeCompare(a.item_name)); if (sortMode === "qty-high") sorted.sort((a, b) => b.quantity - a.quantity); if (sortMode === "qty-low") sorted.sort((a, b) => a.quantity - b.quantity); if (sortMode === "zone") { sorted.sort((a, b) => { // Items without classification go to the end if (!a.zone && b.zone) return 1; if (a.zone && !b.zone) return -1; if (!a.zone && !b.zone) return a.item_name.localeCompare(b.item_name); // Sort by ZONE_FLOW order const aZoneIndex = ZONE_FLOW.indexOf(a.zone); const bZoneIndex = ZONE_FLOW.indexOf(b.zone); // If zone not in ZONE_FLOW, put at end const aIndex = aZoneIndex === -1 ? ZONE_FLOW.length : aZoneIndex; const bIndex = bZoneIndex === -1 ? ZONE_FLOW.length : bZoneIndex; const zoneCompare = aIndex - bIndex; if (zoneCompare !== 0) return zoneCompare; // Then by item_type const typeCompare = (a.item_type || "").localeCompare(b.item_type || ""); if (typeCompare !== 0) return typeCompare; // Then by item_group const groupCompare = (a.item_group || "").localeCompare(b.item_group || ""); if (groupCompare !== 0) return groupCompare; // Finally by name return a.item_name.localeCompare(b.item_name); }); } return sorted; }, [items, sortMode]); // === Suggestion Handler === const handleSuggest = async (text) => { if (!text.trim()) { setSuggestions([]); setButtonText("Add Item"); return; } if (!activeHousehold?.id || !activeStore?.id) { setSuggestions([]); setButtonText("Create + Add"); return; } const lowerText = text.toLowerCase().trim(); try { const response = await getSuggestions(activeHousehold.id, activeStore.id, text); const suggestionList = response.data.map(s => s.item_name); setSuggestions(suggestionList); // All suggestions are now lowercase from DB, direct comparison const exactMatch = suggestionList.includes(lowerText); setButtonText(exactMatch ? "Add" : "Create + Add"); } catch { setSuggestions([]); setButtonText("Create + Add"); } }; // === Item Addition Handlers === const handleAdd = useCallback(async (itemName, quantity, addedForUserId = null) => { try { const normalizedItemName = itemName.trim().toLowerCase(); if (!normalizedItemName) return; if (!activeHousehold?.id || !activeStore?.id) return; const allItems = [...items, ...recentlyBoughtItems]; const existingLocalItem = allItems.find( (item) => String(item.item_name || "").toLowerCase() === normalizedItemName ); if (existingLocalItem) { await processItemAddition(itemName, quantity, { existingItem: existingLocalItem, addedForUserId }); return; } const similar = findSimilarItems(itemName, allItems, 70); if (similar.length > 0) { setSimilarItemSuggestion({ originalName: itemName, suggestedItem: similar[0], quantity, addedForUserId }); setShowSimilarModal(true); return; } const shouldSkipLookup = buttonText === "Create + Add"; await processItemAddition(itemName, quantity, { skipLookup: shouldSkipLookup, addedForUserId }); } catch (error) { console.error("Failed to process add item flow:", error); } }, [activeHousehold?.id, activeStore?.id, items, recentlyBoughtItems, buttonText]); const processItemAddition = useCallback(async (itemName, quantity, options = {}) => { if (!activeHousehold?.id || !activeStore?.id) return; const { existingItem: providedItem = null, skipLookup = false, addedForUserId = null } = options; let existingItem = providedItem; if (!existingItem && !skipLookup) { try { const response = await getItemByName(activeHousehold.id, activeStore.id, itemName); existingItem = response.data; } catch { // Item doesn't exist, continue with add } } if (existingItem?.bought === false) { const currentQuantity = existingItem.quantity; const newQuantity = currentQuantity + quantity; // Show modal instead of window.confirm setConfirmAddExistingData({ itemName, currentQuantity, addingQuantity: quantity, newQuantity, existingItem, addedForUserId }); setShowConfirmAddExisting(true); } else if (existingItem) { try { await addItem( activeHousehold.id, activeStore.id, itemName, quantity, null, null, addedForUserId ); setSuggestions([]); setButtonText("Add Item"); toast.success("Added item", `Added item ${itemName}`); // Reload lists to reflect the changes await loadItems(); await loadRecentlyBought(); } catch (error) { const message = getApiErrorMessage(error, "Failed to add item"); toast.error("Add item failed", `Add item failed: ${message}`); throw error; } } else { setPendingItem({ itemName, quantity, addedForUserId }); setShowAddDetailsModal(true); } }, [activeHousehold?.id, activeStore?.id, loadItems, loadRecentlyBought, toast]); // === Similar Item Modal Handlers === const handleSimilarCancel = useCallback(() => { setShowSimilarModal(false); setSimilarItemSuggestion(null); }, []); const handleSimilarNo = useCallback(async () => { if (!similarItemSuggestion) return; setShowSimilarModal(false); await processItemAddition(similarItemSuggestion.originalName, similarItemSuggestion.quantity, { skipLookup: true, addedForUserId: similarItemSuggestion.addedForUserId || null }); setSimilarItemSuggestion(null); }, [similarItemSuggestion, processItemAddition]); const handleSimilarYes = useCallback(async () => { if (!similarItemSuggestion) return; setShowSimilarModal(false); await processItemAddition(similarItemSuggestion.suggestedItem.item_name, similarItemSuggestion.quantity, { addedForUserId: similarItemSuggestion.addedForUserId || null }); setSimilarItemSuggestion(null); }, [similarItemSuggestion, processItemAddition]); // === Confirm Add Existing Modal Handlers === const handleConfirmAddExisting = useCallback(async () => { if (!confirmAddExistingData) return; if (!activeHousehold?.id || !activeStore?.id) return; const { itemName, newQuantity, existingItem, addedForUserId } = confirmAddExistingData; setShowConfirmAddExisting(false); setConfirmAddExistingData(null); try { await addItem( activeHousehold.id, activeStore.id, itemName, newQuantity, null, null, addedForUserId || null ); const response = await getItemByName(activeHousehold.id, activeStore.id, itemName); const updatedItem = response.data; setItems(prevItems => prevItems.map(item => item.id === existingItem.id ? updatedItem : item ) ); setSuggestions([]); setButtonText("Add Item"); toast.success("Updated item quantity", `Updated item ${itemName}`); } catch (error) { console.error("Failed to update item:", error); const message = getApiErrorMessage(error, "Failed to update item"); toast.error("Update item failed", `Update item failed: ${message}`); await loadItems(); } }, [activeHousehold?.id, activeStore?.id, confirmAddExistingData, loadItems, toast]); // === Add Details Modal Handlers === const handleAddWithDetails = useCallback(async (imageFile, classification) => { if (!pendingItem) return; if (!activeHousehold?.id || !activeStore?.id) return; try { // Create the list item first, upload image separately in background. await addItem( activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, null, null, pendingItem.addedForUserId || null ); if (classification) { // Apply classification if provided await updateItemWithClassification(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, classification); toast.success("Updated item classification", `Updated classification for ${pendingItem.itemName}`); } // Fetch the newly added item const itemResponse = await getItemByName(activeHousehold.id, activeStore.id, pendingItem.itemName); const newItem = itemResponse.data; setShowAddDetailsModal(false); setPendingItem(null); setSuggestions([]); setButtonText("Add Item"); // Add to state if (newItem) { setItems(prevItems => [...prevItems, newItem]); toast.success("Added item", `Added item ${newItem.item_name || pendingItem.itemName}`); if (imageFile) { enqueueImageUpload({ householdId: activeHousehold.id, storeId: activeStore.id, itemName: newItem.item_name || pendingItem.itemName, quantity: newItem.quantity || pendingItem.quantity, fileBlob: imageFile, fileName: imageFile.name || "upload.jpg", fileType: imageFile.type || "image/jpeg", fileSize: imageFile.size || 0, source: "add_details", localItemId: newItem.id, }); toast.info("Queued image upload", `Queued image upload for ${newItem.item_name || pendingItem.itemName}`); } } } catch (error) { console.error("Failed to add item:", error); const message = getApiErrorMessage(error, "Failed to add item"); toast.error("Add item failed", `Add item failed: ${message}`); } }, [activeHousehold?.id, activeStore?.id, pendingItem, enqueueImageUpload, toast]); const handleAddDetailsSkip = useCallback(async () => { if (!pendingItem) return; if (!activeHousehold?.id || !activeStore?.id) return; try { await addItem( activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, null, null, pendingItem.addedForUserId || null ); // Fetch the newly added item const itemResponse = await getItemByName(activeHousehold.id, activeStore.id, pendingItem.itemName); const newItem = itemResponse.data; setShowAddDetailsModal(false); setPendingItem(null); setSuggestions([]); setButtonText("Add Item"); if (newItem) { setItems(prevItems => [...prevItems, newItem]); toast.success("Added item", `Added item ${newItem.item_name || pendingItem.itemName}`); } } catch (error) { console.error("Failed to add item:", error); const message = getApiErrorMessage(error, "Failed to add item"); toast.error("Add item failed", `Add item failed: ${message}`); } }, [activeHousehold?.id, activeStore?.id, pendingItem, toast]); const handleAddDetailsCancel = useCallback(() => { setShowAddDetailsModal(false); setPendingItem(null); setSuggestions([]); setButtonText("Add Item"); }, []); // === Item Action Handlers === const handleBought = useCallback(async (id, quantity) => { if (!activeHousehold?.id || !activeStore?.id) return; const item = items.find(i => i.id === id); if (!item) return; try { await markBought(activeHousehold.id, activeStore.id, item.item_name, quantity, true); // If buying full quantity, remove from list if (quantity >= item.quantity) { setItems(prevItems => prevItems.filter((existingItem) => existingItem.id !== id)); } else { // If partial, fetch updated item const response = await getItemByName(activeHousehold.id, activeStore.id, item.item_name); const updatedItem = response.data; setItems((prevItems) => prevItems.map((existingItem) => (existingItem.id === id ? updatedItem : existingItem)) ); } toast.success("Marked item bought", `Marked item ${item.item_name} as bought`); loadRecentlyBought(); } catch (error) { const message = getApiErrorMessage(error, "Failed to mark item as bought"); toast.error("Mark item bought failed", `Mark item bought failed: ${message}`); } }, [activeHousehold?.id, activeStore?.id, items, toast]); const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile, source = "add_image_modal") => { if (!activeHousehold?.id || !activeStore?.id) return; if (!imageFile) return; try { enqueueImageUpload({ householdId: activeHousehold.id, storeId: activeStore.id, itemName, quantity, fileBlob: imageFile, fileName: imageFile.name || "upload.jpg", fileType: imageFile.type || "image/jpeg", fileSize: imageFile.size || 0, source, localItemId: id, }); toast.info("Queued image upload", `Queued image upload for ${itemName}`); } catch (error) { console.error("Failed to add image:", error); const message = getApiErrorMessage(error, "Failed to add image"); toast.error("Add image failed", `Add image failed: ${message}`); } }, [activeHousehold?.id, activeStore?.id, enqueueImageUpload, toast]); const handleLongPress = useCallback(async (item) => { if (!householdRole || householdRole === 'viewer') return; if (!activeHousehold?.id || !activeStore?.id) return; try { const classificationResponse = await getClassification(activeHousehold.id, activeStore.id, item.item_name); setEditingItem({ ...item, classification: classificationResponse.data?.classification || null }); setShowEditModal(true); } catch (error) { console.error("Failed to load classification:", error); setEditingItem({ ...item, classification: null }); setShowEditModal(true); } }, [activeHousehold?.id, activeStore?.id, householdRole]); // === Edit Modal Handlers === const handleEditSave = useCallback(async (id, itemName, quantity, classification) => { if (!activeHousehold?.id || !activeStore?.id) return; try { await updateItemWithClassification(activeHousehold.id, activeStore.id, itemName, quantity, classification); // Fetch the updated item const response = await getItemByName(activeHousehold.id, activeStore.id, itemName); const updatedItem = response.data; setShowEditModal(false); setEditingItem(null); setItems(prevItems => prevItems.map(item => item.id === id ? updatedItem : item ) ); setRecentlyBoughtItems(prevItems => prevItems.map(item => item.id === id ? { ...item, ...updatedItem } : item ) ); toast.success("Updated item", `Updated item ${itemName}`); } catch (error) { console.error("Failed to update item:", error); const message = getApiErrorMessage(error, "Failed to update item"); toast.error("Update item failed", `Update item failed: ${message}`); throw error; } }, [activeHousehold?.id, activeStore?.id, toast]); const handleEditCancel = useCallback(() => { setShowEditModal(false); setEditingItem(null); }, []); // === Helper Functions === const groupItemsByZone = (items) => { const groups = {}; items.forEach(item => { const zone = item.zone || 'unclassified'; if (!groups[zone]) { groups[zone] = []; } groups[zone].push(item); }); return groups; }; if (!activeHousehold) { return (

{pageTitle}

Loading households...

); } if (storeLoading) { return (

{pageTitle}

Loading stores...

); } if (!storeLoading && stores.length === 0) { return (

{pageTitle}

No stores found

This household doesn’t have any stores yet.

{isHouseholdAdmin ? ( ) : (

Please notify a household admin to add a store.

)}
); } if (!activeStore) { return (

{pageTitle}

Loading stores...

); } if (loading) { return (

{pageTitle}

Loading grocery list...

); } return (

{pageTitle}

{householdRole && householdRole !== 'viewer' && ( )} {sortMode === "zone" ? ( (() => { const grouped = groupItemsByZone(sortedItems); return Object.keys(grouped).map(zone => { const isCollapsed = collapsedZones[zone]; const itemCount = grouped[zone].length; return (

toggleZoneCollapse(zone)} > {zone === 'unclassified' ? 'Unclassified' : zone} ({itemCount}) {isCollapsed ? "▼" : "▲"}

{!isCollapsed && (
    {grouped[zone].map((item) => ( householdRole && householdRole !== 'viewer' && handleBought(id, quantity) } onImageAdded={ householdRole && householdRole !== 'viewer' ? handleImageAdded : null } onLongPress={ householdRole && householdRole !== 'viewer' ? handleLongPress : null } /> ))}
)}
); }); })() ) : (
    {sortedItems.map((item) => ( [ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(id, quantity) } onImageAdded={ [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null } onLongPress={ [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null } /> ))}
)} {recentlyBoughtItems.length > 0 && settings.showRecentlyBought && ( <>

setRecentlyBoughtCollapsed(!recentlyBoughtCollapsed)} > Recently Bought (24HR) {recentlyBoughtCollapsed ? "▼" : "▲"}

{!recentlyBoughtCollapsed && ( <>
    {recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount).map((item) => ( ))}
{recentlyBoughtDisplayCount < recentlyBoughtItems.length && (
)} )} )}
{showAddDetailsModal && pendingItem && ( )} {showSimilarModal && similarItemSuggestion && ( )} {showEditModal && editingItem && ( )} {showConfirmAddExisting && confirmAddExistingData && ( { setShowConfirmAddExisting(false); setConfirmAddExistingData(null); }} /> )}
); }