927 lines
32 KiB
JavaScript
927 lines
32 KiB
JavaScript
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 (
|
||
<div className="glist-body">
|
||
<div className="glist-container">
|
||
<h1 className="glist-title">{pageTitle}</h1>
|
||
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}>
|
||
Loading households...
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (storeLoading) {
|
||
return (
|
||
<div className="glist-body">
|
||
<div className="glist-container">
|
||
<h1 className="glist-title">{pageTitle}</h1>
|
||
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}>
|
||
Loading stores...
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!storeLoading && stores.length === 0) {
|
||
return (
|
||
<div className="glist-body">
|
||
<div className="glist-container">
|
||
<h1 className="glist-title">{pageTitle}</h1>
|
||
<div className="glist-empty-state">
|
||
<h2 className="glist-empty-title">No stores found</h2>
|
||
<p className="glist-empty-text">
|
||
This household doesn’t have any stores yet.
|
||
</p>
|
||
{isHouseholdAdmin ? (
|
||
<button
|
||
className="btn-primary"
|
||
onClick={() => navigate("/manage?tab=stores")}
|
||
>
|
||
Go to Manage Stores
|
||
</button>
|
||
) : (
|
||
<p className="glist-empty-text">
|
||
Please notify a household admin to add a store.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!activeStore) {
|
||
return (
|
||
<div className="glist-body">
|
||
<div className="glist-container">
|
||
<h1 className="glist-title">{pageTitle}</h1>
|
||
<StoreTabs />
|
||
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}>
|
||
Loading stores...
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="glist-body">
|
||
<div className="glist-container">
|
||
<h1 className="glist-title">{pageTitle}</h1>
|
||
<StoreTabs />
|
||
<p style={{ textAlign: 'center', marginTop: '2rem' }}>Loading grocery list...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
return (
|
||
<div className="glist-body">
|
||
<div className="glist-container">
|
||
<h1 className="glist-title">{pageTitle}</h1>
|
||
|
||
<StoreTabs />
|
||
|
||
{householdRole && householdRole !== 'viewer' && (
|
||
<AddItemForm
|
||
onAdd={handleAdd}
|
||
onSuggest={handleSuggest}
|
||
suggestions={suggestions}
|
||
buttonText={buttonText}
|
||
householdMembers={householdMembers}
|
||
currentUserId={userId}
|
||
/>
|
||
)}
|
||
|
||
<SortDropdown value={sortMode} onChange={setSortMode} />
|
||
|
||
{sortMode === "zone" ? (
|
||
(() => {
|
||
const grouped = groupItemsByZone(sortedItems);
|
||
return Object.keys(grouped).map(zone => {
|
||
const isCollapsed = collapsedZones[zone];
|
||
const itemCount = grouped[zone].length;
|
||
return (
|
||
<div key={zone} className="glist-classification-group">
|
||
<h3
|
||
className="glist-classification-header clickable"
|
||
onClick={() => toggleZoneCollapse(zone)}
|
||
>
|
||
<span>
|
||
{zone === 'unclassified' ? 'Unclassified' : zone}
|
||
<span className="glist-zone-count"> ({itemCount})</span>
|
||
</span>
|
||
<span className="glist-zone-indicator">
|
||
{isCollapsed ? "▼" : "▲"}
|
||
</span>
|
||
</h3>
|
||
{!isCollapsed && (
|
||
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
|
||
{grouped[zone].map((item) => (
|
||
<GroceryListItem
|
||
key={item.id}
|
||
item={item}
|
||
allItems={sortedItems}
|
||
compact={settings.compactView}
|
||
onClick={(id, quantity) =>
|
||
householdRole && householdRole !== 'viewer' && handleBought(id, quantity)
|
||
}
|
||
onImageAdded={
|
||
householdRole && householdRole !== 'viewer' ? handleImageAdded : null
|
||
}
|
||
onLongPress={
|
||
householdRole && householdRole !== 'viewer' ? handleLongPress : null
|
||
}
|
||
/>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
);
|
||
});
|
||
})()
|
||
) : (
|
||
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
|
||
{sortedItems.map((item) => (
|
||
<GroceryListItem
|
||
key={item.id}
|
||
item={item}
|
||
allItems={sortedItems}
|
||
compact={settings.compactView}
|
||
onClick={(id, quantity) =>
|
||
[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
|
||
}
|
||
/>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
{recentlyBoughtItems.length > 0 && settings.showRecentlyBought && (
|
||
<>
|
||
<h2
|
||
className="glist-section-title clickable"
|
||
onClick={() => setRecentlyBoughtCollapsed(!recentlyBoughtCollapsed)}
|
||
>
|
||
<span>Recently Bought (24HR)</span>
|
||
<span className="glist-section-indicator">
|
||
{recentlyBoughtCollapsed ? "▼" : "▲"}
|
||
</span>
|
||
</h2>
|
||
|
||
{!recentlyBoughtCollapsed && (
|
||
<>
|
||
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
|
||
{recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount).map((item) => (
|
||
<GroceryListItem
|
||
key={item.id}
|
||
item={item}
|
||
allItems={recentlyBoughtItems}
|
||
compact={settings.compactView}
|
||
onClick={null}
|
||
onImageAdded={
|
||
householdRole && householdRole !== 'viewer' ? handleImageAdded : null
|
||
}
|
||
onLongPress={
|
||
householdRole && householdRole !== 'viewer' ? handleLongPress : null
|
||
}
|
||
/>
|
||
))}
|
||
</ul>
|
||
{recentlyBoughtDisplayCount < recentlyBoughtItems.length && (
|
||
<div style={{ textAlign: 'center', marginTop: '1rem' }}>
|
||
<button
|
||
className="glist-show-more-btn"
|
||
onClick={() => setRecentlyBoughtDisplayCount(prev => prev + 10)}
|
||
>
|
||
Show More ({recentlyBoughtItems.length - recentlyBoughtDisplayCount} remaining)
|
||
</button>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{showAddDetailsModal && pendingItem && (
|
||
<AddItemWithDetailsModal
|
||
itemName={pendingItem.itemName}
|
||
onConfirm={handleAddWithDetails}
|
||
onSkip={handleAddDetailsSkip}
|
||
onCancel={handleAddDetailsCancel}
|
||
/>
|
||
)}
|
||
|
||
{showSimilarModal && similarItemSuggestion && (
|
||
<SimilarItemModal
|
||
originalName={similarItemSuggestion.originalName}
|
||
suggestedName={similarItemSuggestion.suggestedItem.item_name}
|
||
onCancel={handleSimilarCancel}
|
||
onNo={handleSimilarNo}
|
||
onYes={handleSimilarYes}
|
||
/>
|
||
)}
|
||
|
||
{showEditModal && editingItem && (
|
||
<EditItemModal
|
||
item={editingItem}
|
||
onSave={handleEditSave}
|
||
onCancel={handleEditCancel}
|
||
onImageUpdate={handleImageAdded}
|
||
/>
|
||
)}
|
||
|
||
{showConfirmAddExisting && confirmAddExistingData && (
|
||
<ConfirmAddExistingModal
|
||
itemName={confirmAddExistingData.itemName}
|
||
currentQuantity={confirmAddExistingData.currentQuantity}
|
||
addingQuantity={confirmAddExistingData.addingQuantity}
|
||
onConfirm={handleConfirmAddExisting}
|
||
onCancel={() => {
|
||
setShowConfirmAddExisting(false);
|
||
setConfirmAddExistingData(null);
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|