diff --git a/backend/controllers/lists.controller.js b/backend/controllers/lists.controller.js
index 8f7a4a4..b33f7b7 100644
--- a/backend/controllers/lists.controller.js
+++ b/backend/controllers/lists.controller.js
@@ -32,7 +32,8 @@ exports.addItem = async (req, res) => {
exports.markBought = async (req, res) => {
const userId = req.user.id;
- await List.setBought(req.body.id, userId);
+ const { id, quantity } = req.body;
+ await List.setBought(id, userId, quantity);
res.json({ message: "Item marked bought" });
};
diff --git a/backend/models/list.model.js b/backend/models/list.model.js
index e265ae9..ff220ad 100644
--- a/backend/models/list.model.js
+++ b/backend/models/list.model.js
@@ -34,7 +34,30 @@ exports.getUnboughtItems = async () => {
exports.getItemByName = async (itemName) => {
const result = await pool.query(
- "SELECT * FROM grocery_list WHERE item_name ILIKE $1",
+ `SELECT
+ gl.id,
+ LOWER(gl.item_name) AS item_name,
+ gl.quantity,
+ gl.bought,
+ ENCODE(gl.item_image, 'base64') as item_image,
+ gl.image_mime_type,
+ (
+ SELECT ARRAY_AGG(DISTINCT u.name)
+ FROM (
+ SELECT DISTINCT gh.added_by
+ FROM grocery_history gh
+ WHERE gh.list_item_id = gl.id
+ ORDER BY gh.added_by
+ ) gh
+ JOIN users u ON gh.added_by = u.id
+ ) as added_by_users,
+ gl.modified_on as last_added_on,
+ ic.item_type,
+ ic.item_group,
+ ic.zone
+ FROM grocery_list gl
+ LEFT JOIN item_classification ic ON gl.id = ic.id
+ WHERE gl.item_name ILIKE $1`,
[itemName]
);
@@ -87,11 +110,31 @@ exports.addOrUpdateItem = async (itemName, quantity, userId, imageBuffer = null,
};
-exports.setBought = async (id, userId) => {
- await pool.query(
- "UPDATE grocery_list SET bought = TRUE, modified_on = NOW() WHERE id = $1",
+exports.setBought = async (id, userId, quantityBought) => {
+ // Get current item
+ const item = await pool.query(
+ "SELECT quantity FROM grocery_list WHERE id = $1",
[id]
);
+
+ if (!item.rows[0]) return;
+
+ const currentQuantity = item.rows[0].quantity;
+ const remainingQuantity = currentQuantity - quantityBought;
+
+ if (remainingQuantity <= 0) {
+ // Mark as bought if all quantity is purchased
+ await pool.query(
+ "UPDATE grocery_list SET bought = TRUE, modified_on = NOW() WHERE id = $1",
+ [id]
+ );
+ } else {
+ // Reduce quantity if partial purchase
+ await pool.query(
+ "UPDATE grocery_list SET quantity = $1, modified_on = NOW() WHERE id = $2",
+ [remainingQuantity, id]
+ );
+ }
};
@@ -106,7 +149,7 @@ exports.addHistoryRecord = async (itemId, quantity, userId) => {
exports.getSuggestions = async (query) => {
const result = await pool.query(
- `SELECT DISTINCT item_name
+ `SELECT DISTINCT LOWER(item_name) as item_name
FROM grocery_list
WHERE item_name ILIKE $1
LIMIT 10`,
diff --git a/frontend/src/api/list.js b/frontend/src/api/list.js
index 22b8ce6..f202292 100644
--- a/frontend/src/api/list.js
+++ b/frontend/src/api/list.js
@@ -27,7 +27,7 @@ export const updateItemWithClassification = (id, itemName, quantity, classificat
classification
});
};
-export const markBought = (id) => api.post("/list/mark-bought", { id });
+export const markBought = (id, quantity) => api.post("/list/mark-bought", { id, quantity });
export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } });
export const getRecentlyBought = () => api.get("/list/recently-bought");
diff --git a/frontend/src/components/items/GroceryListItem.jsx b/frontend/src/components/items/GroceryListItem.jsx
index 9e9d3d4..0d86042 100644
--- a/frontend/src/components/items/GroceryListItem.jsx
+++ b/frontend/src/components/items/GroceryListItem.jsx
@@ -2,7 +2,7 @@ import { memo, useRef, useState } from "react";
import AddImageModal from "../modals/AddImageModal";
import ConfirmBuyModal from "../modals/ConfirmBuyModal";
-function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems = [] }) {
+function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems = [], compact = false }) {
const [showAddImageModal, setShowAddImageModal] = useState(false);
const [showConfirmBuyModal, setShowConfirmBuyModal] = useState(false);
const [currentItem, setCurrentItem] = useState(item);
@@ -120,7 +120,7 @@ function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems =
return (
<>
+ e.stopPropagation()}>
+
+ {itemName} is already in your list
+
+
+
+
+
+ Current quantity:
+ {currentQuantity}
+
+
+ Adding:
+ +{addingQuantity}
+
+
+ New total:
+ {newQuantity}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/modals/ConfirmBuyModal.jsx b/frontend/src/components/modals/ConfirmBuyModal.jsx
index a8b888c..2029766 100644
--- a/frontend/src/components/modals/ConfirmBuyModal.jsx
+++ b/frontend/src/components/modals/ConfirmBuyModal.jsx
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useEffect, useState } from "react";
import "../../styles/ConfirmBuyModal.css";
export default function ConfirmBuyModal({
@@ -11,6 +11,11 @@ export default function ConfirmBuyModal({
const [quantity, setQuantity] = useState(item.quantity);
const maxQuantity = item.quantity;
+ // Update quantity when item changes (navigation)
+ useEffect(() => {
+ setQuantity(item.quantity);
+ }, [item.id, item.quantity]);
+
// Find current index and check for prev/next
const currentIndex = allItems.findIndex(i => i.id === item.id);
const hasPrev = currentIndex > 0;
diff --git a/frontend/src/pages/GroceryList.jsx b/frontend/src/pages/GroceryList.jsx
index a1430d6..293cd10 100644
--- a/frontend/src/pages/GroceryList.jsx
+++ b/frontend/src/pages/GroceryList.jsx
@@ -15,6 +15,7 @@ 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";
@@ -45,6 +46,9 @@ export default function GroceryList() {
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 ===
@@ -74,6 +78,14 @@ export default function GroceryList() {
}, []);
+ // === Zone Collapse Handler ===
+ const toggleZoneCollapse = (zone) => {
+ setCollapsedZones(prev => ({
+ ...prev,
+ [zone]: !prev[zone]
+ }));
+ };
+
// === Sorted Items Computation ===
const sortedItems = useMemo(() => {
const sorted = [...items];
@@ -125,17 +137,19 @@ export default function GroceryList() {
return;
}
- const allItems = [...items, ...recentlyBoughtItems];
const lowerText = text.toLowerCase().trim();
- const exactMatch = allItems.find(item => item.item_name.toLowerCase() === lowerText);
-
- setButtonText(exactMatch ? "Add" : "Create + Add");
try {
- const suggestions = await getSuggestions(text);
- setSuggestions(suggestions.data.map(s => s.item_name));
+ 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");
}
};
@@ -184,31 +198,24 @@ export default function GroceryList() {
if (existingItem?.bought === false) {
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;
- const response = await addItem(itemName, newQuantity, null);
- setSuggestions([]);
- setButtonText("Add Item");
-
- if (response.data) {
- setItems(prevItems =>
- prevItems.map(item =>
- item.id === existingItem.id ? { ...item, ...response.data } : item
- )
- );
- }
+ // Show modal instead of window.confirm
+ setConfirmAddExistingData({
+ itemName,
+ currentQuantity,
+ addingQuantity: quantity,
+ newQuantity,
+ existingItem
+ });
+ setShowConfirmAddExisting(true);
} else if (existingItem) {
- const response = await addItem(itemName, quantity, null);
+ await addItem(itemName, quantity, null);
setSuggestions([]);
setButtonText("Add Item");
- if (response.data) {
- setItems(prevItems => [...prevItems, response.data]);
- setRecentlyBoughtItems(prevItems => prevItems.filter(item => item.id !== existingItem.id));
- }
+ // Reload lists to reflect the changes
+ await loadItems();
+ await loadRecentlyBought();
} else {
setPendingItem({ itemName, quantity });
setShowAddDetailsModal(true);
@@ -239,6 +246,45 @@ export default function GroceryList() {
}, [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;
@@ -300,10 +346,26 @@ export default function GroceryList() {
// === Item Action Handlers ===
const handleBought = useCallback(async (id, quantity) => {
- await markBought(id);
- setItems(prevItems => prevItems.filter(item => item.id !== id));
+ 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) => {
@@ -415,39 +477,56 @@ export default function GroceryList() {
{sortMode === "zone" ? (
(() => {
const grouped = groupItemsByZone(sortedItems);
- return Object.keys(grouped).map(zone => (
-
-
- {zone === 'unclassified' ? 'Unclassified' : zone}
-
-
- {grouped[zone].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
- }
- />
- ))}
-
-
- ));
+ 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) => (
+
+ [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
+ }
+ />
+ ))}
+
+ )}
+
+ );
+ });
})()
) : (
-