638 lines
21 KiB
JavaScript
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>
|
|
);
|
|
}
|