costco-grocery-list/frontend/src/pages/GroceryList.jsx

638 lines
21 KiB
JavaScript

import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import {
addItem,
getClassification,
getItemByName,
getList,
getRecentlyBought,
getSuggestions,
markBought,
updateItemImage,
updateItemWithClassification
} from "../api/list";
import FloatingActionButton from "../components/common/FloatingActionButton";
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 { ZONE_FLOW } from "../constants/classifications";
import { ROLES } from "../constants/roles";
import { AuthContext } from "../context/AuthContext";
import { SettingsContext } from "../context/SettingsContext";
import "../styles/pages/GroceryList.css";
import { findSimilarItems } from "../utils/stringSimilarity";
export default function GroceryList() {
const { role } = useContext(AuthContext);
const { settings } = useContext(SettingsContext);
// === State === //
const [items, setItems] = useState([]);
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(settings.recentlyBoughtCount);
const [sortMode, setSortMode] = useState(settings.defaultSortMode);
const [suggestions, setSuggestions] = useState([]);
const [showAddForm, setShowAddForm] = useState(true);
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 () => {
setLoading(true);
const res = await getList();
console.log(res.data);
setItems(res.data);
setLoading(false);
};
const loadRecentlyBought = async () => {
try {
const res = await getRecentlyBought();
setRecentlyBoughtItems(res.data);
} catch (error) {
console.error("Failed to load recently bought items:", error);
setRecentlyBoughtItems([]);
}
};
useEffect(() => {
loadItems();
loadRecentlyBought();
}, []);
// === 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;
}
const lowerText = text.toLowerCase().trim();
try {
const response = await getSuggestions(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) => {
if (!itemName.trim()) return;
let existingItem = null;
try {
const response = await getItemByName(itemName);
existingItem = response.data;
} catch {
existingItem = null;
}
if (existingItem) {
await processItemAddition(itemName, quantity);
return;
}
setItems(prevItems => {
const allItems = [...prevItems, ...recentlyBoughtItems];
const similar = findSimilarItems(itemName, allItems, 70);
if (similar.length > 0) {
setSimilarItemSuggestion({ originalName: itemName, suggestedItem: similar[0], quantity });
setShowSimilarModal(true);
return prevItems;
}
processItemAddition(itemName, quantity);
return prevItems;
});
}, [recentlyBoughtItems]);
const processItemAddition = useCallback(async (itemName, quantity) => {
let existingItem = null;
try {
const response = await getItemByName(itemName);
existingItem = response.data;
} catch {
existingItem = null;
}
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
});
setShowConfirmAddExisting(true);
} else if (existingItem) {
await addItem(itemName, quantity, null);
setSuggestions([]);
setButtonText("Add Item");
// Reload lists to reflect the changes
await loadItems();
await loadRecentlyBought();
} else {
setPendingItem({ itemName, quantity });
setShowAddDetailsModal(true);
}
}, []);
// === 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);
setSimilarItemSuggestion(null);
}, [similarItemSuggestion, processItemAddition]);
const handleSimilarYes = useCallback(async () => {
if (!similarItemSuggestion) return;
setShowSimilarModal(false);
await processItemAddition(similarItemSuggestion.suggestedItem.item_name, similarItemSuggestion.quantity);
setSimilarItemSuggestion(null);
}, [similarItemSuggestion, processItemAddition]);
// === Confirm Add Existing Modal Handlers ===
const handleConfirmAddExisting = useCallback(async () => {
if (!confirmAddExistingData) return;
const { itemName, newQuantity, existingItem } = confirmAddExistingData;
setShowConfirmAddExisting(false);
setConfirmAddExistingData(null);
try {
// Update the item
await addItem(itemName, newQuantity, null);
// Fetch the updated item with properly formatted data
const response = await getItemByName(itemName);
const updatedItem = response.data;
// Update state with the full item data
setItems(prevItems =>
prevItems.map(item =>
item.id === existingItem.id ? updatedItem : item
)
);
setSuggestions([]);
setButtonText("Add Item");
} catch (error) {
console.error("Failed to update item:", error);
// Fallback to full reload on error
await loadItems();
}
}, [confirmAddExistingData, loadItems]);
const handleCancelAddExisting = useCallback(() => {
setShowConfirmAddExisting(false);
setConfirmAddExistingData(null);
}, []);
// === Add Details Modal Handlers ===
const handleAddDetailsConfirm = useCallback(async (imageFile, classification) => {
if (!pendingItem) return;
try {
const addResponse = await addItem(pendingItem.itemName, pendingItem.quantity, imageFile);
let newItem = addResponse.data;
if (classification) {
const itemResponse = await getItemByName(pendingItem.itemName);
const itemId = itemResponse.data.id;
const updateResponse = await updateItemWithClassification(itemId, undefined, undefined, classification);
newItem = { ...newItem, ...updateResponse.data };
}
setShowAddDetailsModal(false);
setPendingItem(null);
setSuggestions([]);
setButtonText("Add Item");
if (newItem) {
setItems(prevItems => [...prevItems, newItem]);
}
} catch (error) {
console.error("Failed to add item:", error);
alert("Failed to add item. Please try again.");
}
}, [pendingItem]);
const handleAddDetailsSkip = useCallback(async () => {
if (!pendingItem) return;
try {
const response = await addItem(pendingItem.itemName, pendingItem.quantity, null);
setShowAddDetailsModal(false);
setPendingItem(null);
setSuggestions([]);
setButtonText("Add Item");
if (response.data) {
setItems(prevItems => [...prevItems, response.data]);
}
} catch (error) {
console.error("Failed to add item:", error);
alert("Failed to add item. Please try again.");
}
}, [pendingItem]);
const handleAddDetailsCancel = useCallback(() => {
setShowAddDetailsModal(false);
setPendingItem(null);
setSuggestions([]);
setButtonText("Add Item");
}, []);
// === Item Action Handlers ===
const handleBought = useCallback(async (id, quantity) => {
const item = items.find(i => i.id === id);
if (!item) return;
await markBought(id, quantity);
// If buying full quantity, remove from list
if (quantity >= item.quantity) {
setItems(prevItems => prevItems.filter(item => item.id !== id));
} else {
// If partial, update quantity
const response = await getItemByName(item.item_name);
if (response.data) {
setItems(prevItems =>
prevItems.map(item => item.id === id ? response.data : item)
);
}
}
loadRecentlyBought();
}, [items]);
const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile) => {
try {
const response = await updateItemImage(id, itemName, quantity, imageFile);
setItems(prevItems =>
prevItems.map(item =>
item.id === id ? { ...item, ...response.data } : item
)
);
setRecentlyBoughtItems(prevItems =>
prevItems.map(item =>
item.id === id ? { ...item, ...response.data } : item
)
);
} catch (error) {
console.error("Failed to add image:", error);
alert("Failed to add image. Please try again.");
}
}, []);
const handleLongPress = useCallback(async (item) => {
if (![ROLES.ADMIN, ROLES.EDITOR].includes(role)) return;
try {
const classificationResponse = await getClassification(item.id);
setEditingItem({
...item,
classification: classificationResponse.data
});
setShowEditModal(true);
} catch (error) {
console.error("Failed to load classification:", error);
setEditingItem({ ...item, classification: null });
setShowEditModal(true);
}
}, [role]);
// === Edit Modal Handlers ===
const handleEditSave = useCallback(async (id, itemName, quantity, classification) => {
try {
const response = await updateItemWithClassification(id, itemName, quantity, classification);
setShowEditModal(false);
setEditingItem(null);
const updatedItem = response.data;
setItems(prevItems =>
prevItems.map(item =>
item.id === id ? { ...item, ...updatedItem } : item
)
);
setRecentlyBoughtItems(prevItems =>
prevItems.map(item =>
item.id === id ? { ...item, ...updatedItem } : item
)
);
} catch (error) {
console.error("Failed to update item:", error);
throw error;
}
}, []);
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 (loading) return <p>Loading...</p>;
return (
<div className="glist-body">
<div className="glist-container">
<h1 className="glist-title">Costco Grocery List</h1>
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && showAddForm && (
<AddItemForm
onAdd={handleAdd}
onSuggest={handleSuggest}
suggestions={suggestions}
buttonText={buttonText}
/>
)}
<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) =>
[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>
)}
</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={
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
}
onLongPress={
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? 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>
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && (
<FloatingActionButton
isOpen={showAddForm}
onClick={() => setShowAddForm(!showAddForm)}
/>
)}
{showAddDetailsModal && pendingItem && (
<AddItemWithDetailsModal
itemName={pendingItem.itemName}
onConfirm={handleAddDetailsConfirm}
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={handleCancelAddExisting}
/>
)}
</div>
);
}