466 lines
16 KiB
JavaScript
466 lines
16 KiB
JavaScript
import { useContext, useEffect, 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 EditItemModal from "../components/modals/EditItemModal";
|
|
import SimilarItemModal from "../components/modals/SimilarItemModal";
|
|
import { ROLES } from "../constants/roles";
|
|
import { AuthContext } from "../context/AuthContext";
|
|
import "../styles/pages/GroceryList.css";
|
|
import { findSimilarItems } from "../utils/stringSimilarity";
|
|
|
|
export default function GroceryList() {
|
|
const { role } = useContext(AuthContext);
|
|
|
|
const [items, setItems] = useState([]);
|
|
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
|
|
const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(10);
|
|
const [sortedItems, setSortedItems] = useState([]);
|
|
const [sortMode, setSortMode] = useState("zone");
|
|
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 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();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let 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.item_type && b.item_type) return 1;
|
|
if (a.item_type && !b.item_type) return -1;
|
|
if (!a.item_type && !b.item_type) return a.item_name.localeCompare(b.item_name);
|
|
|
|
// Sort 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;
|
|
|
|
// Then by zone
|
|
const zoneCompare = (a.zone || "").localeCompare(b.zone || "");
|
|
if (zoneCompare !== 0) return zoneCompare;
|
|
|
|
// Finally by name
|
|
return a.item_name.localeCompare(b.item_name);
|
|
});
|
|
}
|
|
|
|
setSortedItems(sorted);
|
|
}, [items, sortMode]);
|
|
|
|
const handleSuggest = async (text) => {
|
|
if (!text.trim()) {
|
|
setSuggestions([]);
|
|
setButtonText("Add Item");
|
|
return;
|
|
}
|
|
|
|
// Combine both unbought and recently bought items for similarity checking
|
|
const allItems = [...items, ...recentlyBoughtItems];
|
|
|
|
// Check if exact match exists (case-insensitive)
|
|
const lowerText = text.toLowerCase().trim();
|
|
const exactMatch = allItems.find(item => item.item_name.toLowerCase() === lowerText);
|
|
|
|
if (exactMatch) {
|
|
setButtonText("Add");
|
|
} else {
|
|
setButtonText("Create + Add");
|
|
}
|
|
|
|
try {
|
|
let suggestions = await getSuggestions(text);
|
|
suggestions = suggestions.data.map(s => s.item_name);
|
|
setSuggestions(suggestions);
|
|
} catch {
|
|
setSuggestions([]);
|
|
}
|
|
};
|
|
|
|
const handleAdd = async (itemName, quantity) => {
|
|
if (!itemName.trim()) return;
|
|
|
|
const lowerItemName = itemName.toLowerCase().trim();
|
|
|
|
// First check if exact item exists in database (case-insensitive)
|
|
let existingItem = null;
|
|
try {
|
|
const response = await getItemByName(itemName);
|
|
existingItem = response.data;
|
|
} catch {
|
|
existingItem = null;
|
|
}
|
|
|
|
// If exact item exists, skip similarity check and process directly
|
|
if (existingItem) {
|
|
await processItemAddition(itemName, quantity);
|
|
return;
|
|
}
|
|
|
|
// Only check for similar items if exact item doesn't exist
|
|
const allItems = [...items, ...recentlyBoughtItems];
|
|
const similar = findSimilarItems(itemName, allItems, 80);
|
|
if (similar.length > 0) {
|
|
// Show modal and wait for user decision
|
|
setSimilarItemSuggestion({ originalName: itemName, suggestedItem: similar[0], quantity });
|
|
setShowSimilarModal(true);
|
|
return;
|
|
}
|
|
|
|
// Continue with normal flow for new items
|
|
await processItemAddition(itemName, quantity);
|
|
};
|
|
|
|
const processItemAddition = async (itemName, quantity) => {
|
|
|
|
// Check if item exists in database (case-insensitive)
|
|
let existingItem = null;
|
|
try {
|
|
const response = await getItemByName(itemName);
|
|
existingItem = response.data;
|
|
} catch {
|
|
existingItem = null;
|
|
}
|
|
|
|
if (existingItem && existingItem.bought === false) {
|
|
// Item exists and is unbought - update quantity
|
|
const currentQuantity = existingItem.quantity;
|
|
const newQuantity = currentQuantity + quantity;
|
|
const yes = window.confirm(
|
|
`Item "${itemName}" already exists in the list. Do you want to update its quantity from ${currentQuantity} to ${newQuantity}?`
|
|
);
|
|
if (!yes) return;
|
|
|
|
await addItem(itemName, newQuantity, null);
|
|
setSuggestions([]);
|
|
setButtonText("Add Item");
|
|
loadItems();
|
|
} else if (existingItem) {
|
|
// Item exists in database (was previously bought) - just add quantity
|
|
await addItem(itemName, quantity, null);
|
|
setSuggestions([]);
|
|
setButtonText("Add Item");
|
|
loadItems();
|
|
} else {
|
|
// NEW ITEM - show combined add details modal
|
|
setPendingItem({ itemName, quantity });
|
|
setShowAddDetailsModal(true);
|
|
}
|
|
};
|
|
|
|
const handleSimilarCancel = () => {
|
|
setShowSimilarModal(false);
|
|
setSimilarItemSuggestion(null);
|
|
};
|
|
|
|
const handleSimilarNo = async () => {
|
|
if (!similarItemSuggestion) return;
|
|
setShowSimilarModal(false);
|
|
// Create new item with original name
|
|
await processItemAddition(similarItemSuggestion.originalName, similarItemSuggestion.quantity);
|
|
setSimilarItemSuggestion(null);
|
|
};
|
|
|
|
const handleSimilarYes = async () => {
|
|
if (!similarItemSuggestion) return;
|
|
setShowSimilarModal(false);
|
|
// Use suggested item name
|
|
await processItemAddition(similarItemSuggestion.suggestedItem.item_name, similarItemSuggestion.quantity);
|
|
setSimilarItemSuggestion(null);
|
|
};
|
|
|
|
const handleAddDetailsConfirm = async (imageFile, classification) => {
|
|
if (!pendingItem) return;
|
|
|
|
try {
|
|
// Add item to grocery_list with image
|
|
await addItem(pendingItem.itemName, pendingItem.quantity, imageFile);
|
|
|
|
// If classification provided, add it
|
|
if (classification) {
|
|
const itemResponse = await getItemByName(pendingItem.itemName);
|
|
const itemId = itemResponse.data.id;
|
|
await updateItemWithClassification(itemId, undefined, undefined, classification);
|
|
}
|
|
|
|
setShowAddDetailsModal(false);
|
|
setPendingItem(null);
|
|
setSuggestions([]);
|
|
setButtonText("Add Item");
|
|
loadItems();
|
|
} catch (error) {
|
|
console.error("Failed to add item:", error);
|
|
alert("Failed to add item. Please try again.");
|
|
}
|
|
};
|
|
|
|
const handleAddDetailsSkip = async () => {
|
|
if (!pendingItem) return;
|
|
|
|
try {
|
|
// Add item without image or classification
|
|
await addItem(pendingItem.itemName, pendingItem.quantity, null);
|
|
|
|
setShowAddDetailsModal(false);
|
|
setPendingItem(null);
|
|
setSuggestions([]);
|
|
setButtonText("Add Item");
|
|
loadItems();
|
|
} catch (error) {
|
|
console.error("Failed to add item:", error);
|
|
alert("Failed to add item. Please try again.");
|
|
}
|
|
};
|
|
|
|
const handleAddDetailsCancel = () => {
|
|
setShowAddDetailsModal(false);
|
|
setPendingItem(null);
|
|
setSuggestions([]);
|
|
setButtonText("Add Item");
|
|
};
|
|
|
|
|
|
|
|
const handleBought = async (id, quantity) => {
|
|
await markBought(id);
|
|
loadItems();
|
|
loadRecentlyBought();
|
|
};
|
|
|
|
const handleImageAdded = async (id, itemName, quantity, imageFile) => {
|
|
try {
|
|
await updateItemImage(id, itemName, quantity, imageFile);
|
|
loadItems(); // Reload to show new image
|
|
} catch (error) {
|
|
console.error("Failed to add image:", error);
|
|
alert("Failed to add image. Please try again.");
|
|
}
|
|
};
|
|
|
|
const handleLongPress = async (item) => {
|
|
if (![ROLES.ADMIN, ROLES.EDITOR].includes(role)) return;
|
|
|
|
try {
|
|
// Fetch existing classification
|
|
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);
|
|
}
|
|
};
|
|
|
|
const handleEditSave = async (id, itemName, quantity, classification) => {
|
|
try {
|
|
await updateItemWithClassification(id, itemName, quantity, classification);
|
|
setShowEditModal(false);
|
|
setEditingItem(null);
|
|
loadItems();
|
|
loadRecentlyBought();
|
|
} catch (error) {
|
|
console.error("Failed to update item:", error);
|
|
throw error; // Re-throw to let modal handle it
|
|
}
|
|
};
|
|
|
|
const handleEditCancel = () => {
|
|
setShowEditModal(false);
|
|
setEditingItem(null);
|
|
};
|
|
|
|
// Group items by zone for classification view
|
|
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" ? (
|
|
// Grouped view by zone
|
|
(() => {
|
|
const grouped = groupItemsByZone(sortedItems);
|
|
return Object.keys(grouped).map(zone => (
|
|
<div key={zone} className="glist-classification-group">
|
|
<h3 className="glist-classification-header">
|
|
{zone === 'unclassified' ? 'Unclassified' : zone}
|
|
</h3>
|
|
<ul className="glist-ul">
|
|
{grouped[zone].map((item) => (
|
|
<GroceryListItem
|
|
key={item.id}
|
|
item={item}
|
|
onClick={(quantity) =>
|
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id, quantity)
|
|
}
|
|
onImageAdded={
|
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
|
}
|
|
onLongPress={
|
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
|
|
}
|
|
/>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
));
|
|
})()
|
|
) : (
|
|
// Regular flat list view
|
|
<ul className="glist-ul">
|
|
{sortedItems.map((item) => (
|
|
<GroceryListItem
|
|
key={item.id}
|
|
item={item}
|
|
onClick={(quantity) =>
|
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id, quantity)
|
|
}
|
|
onImageAdded={
|
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
|
}
|
|
onLongPress={
|
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
|
|
}
|
|
/>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{recentlyBoughtItems.length > 0 && (
|
|
<>
|
|
<h2 className="glist-section-title">Recently Bought (24HR)</h2>
|
|
<ul className="glist-ul">
|
|
{recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount).map((item) => (
|
|
<GroceryListItem
|
|
key={item.id}
|
|
item={item}
|
|
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}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|