From 68976a7683e0e2dcfab91a0699267406f52a394b Mon Sep 17 00:00:00 2001 From: Nico Date: Fri, 23 Jan 2026 00:25:11 -0800 Subject: [PATCH] frontend refresh and update backend for frontend's new behavior --- backend/controllers/lists.controller.js | 3 +- backend/models/list.model.js | 53 ++++- frontend/src/api/list.js | 2 +- .../src/components/items/GroceryListItem.jsx | 4 +- .../modals/ConfirmAddExistingModal.jsx | 47 ++++ .../src/components/modals/ConfirmBuyModal.jsx | 7 +- frontend/src/pages/GroceryList.jsx | 220 ++++++++++++------ .../components/ConfirmAddExistingModal.css | 144 ++++++++++++ frontend/src/styles/pages/GroceryList.css | 86 ++++++- 9 files changed, 486 insertions(+), 80 deletions(-) create mode 100644 frontend/src/components/modals/ConfirmAddExistingModal.jsx create mode 100644 frontend/src/styles/components/ConfirmAddExistingModal.css 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 + } + /> + ))} +
    + )} +
    + ); + }); })() ) : ( -