From 9fa48e6eb30294352dd1e5142ad26f0e78cb6272 Mon Sep 17 00:00:00 2001 From: Nico Date: Fri, 20 Feb 2026 23:33:22 -0800 Subject: [PATCH] feat: support assigning grocery items to other household members --- backend/controllers/lists.controller.v2.js | 21 +- backend/tests/lists.controller.v2.test.js | 101 ++++ frontend/src/api/list.js | 65 ++- .../components/common/ToggleButtonGroup.jsx | 67 +++ frontend/src/components/common/index.js | 1 + frontend/src/components/forms/AddItemForm.jsx | 123 ++++- .../components/modals/AssignItemForModal.jsx | 95 ++++ frontend/src/components/modals/index.js | 2 + frontend/src/pages/GroceryList.jsx | 483 +++++++++++------- .../src/styles/components/AddItemForm.css | 55 +- .../styles/components/AssignItemForModal.css | 19 + .../styles/components/ToggleButtonGroup.css | 81 +++ 12 files changed, 885 insertions(+), 228 deletions(-) create mode 100644 backend/tests/lists.controller.v2.test.js create mode 100644 frontend/src/components/common/ToggleButtonGroup.jsx create mode 100644 frontend/src/components/modals/AssignItemForModal.jsx create mode 100644 frontend/src/styles/components/AssignItemForModal.css create mode 100644 frontend/src/styles/components/ToggleButtonGroup.css diff --git a/backend/controllers/lists.controller.v2.js b/backend/controllers/lists.controller.v2.js index 11b66b1..0293d94 100644 --- a/backend/controllers/lists.controller.v2.js +++ b/backend/controllers/lists.controller.v2.js @@ -1,4 +1,5 @@ const List = require("../models/list.model.v2"); +const householdModel = require("../models/household.model"); const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications"); const { sendError } = require("../utils/http"); const { logError } = require("../utils/logger"); @@ -50,13 +51,29 @@ exports.getItemByName = async (req, res) => { exports.addItem = async (req, res) => { try { const { householdId, storeId } = req.params; - const { item_name, quantity, notes } = req.body; + const { item_name, quantity, notes, added_for_user_id } = req.body; const userId = req.user.id; + let historyUserId = userId; if (!item_name || item_name.trim() === "") { return sendError(res, 400, "Item name is required"); } + if (added_for_user_id !== undefined && added_for_user_id !== null && String(added_for_user_id).trim() !== "") { + const parsedUserId = Number.parseInt(String(added_for_user_id), 10); + + if (!Number.isInteger(parsedUserId) || parsedUserId <= 0) { + return sendError(res, 400, "Added-for user ID must be a positive integer"); + } + + const isMember = await householdModel.isHouseholdMember(householdId, parsedUserId); + if (!isMember) { + return sendError(res, 400, "Selected user is not a member of this household"); + } + + historyUserId = parsedUserId; + } + // Get processed image if uploaded const imageBuffer = req.processedImage?.buffer || null; const mimeType = req.processedImage?.mimeType || null; @@ -73,7 +90,7 @@ exports.addItem = async (req, res) => { ); // Add history record - await List.addHistoryRecord(result.listId, quantity || "1", userId); + await List.addHistoryRecord(result.listId, quantity || "1", historyUserId); res.json({ message: result.isNew ? "Item added" : "Item updated", diff --git a/backend/tests/lists.controller.v2.test.js b/backend/tests/lists.controller.v2.test.js new file mode 100644 index 0000000..b13ce603 --- /dev/null +++ b/backend/tests/lists.controller.v2.test.js @@ -0,0 +1,101 @@ +jest.mock("../models/list.model.v2", () => ({ + addHistoryRecord: jest.fn(), + addOrUpdateItem: jest.fn(), +})); + +jest.mock("../models/household.model", () => ({ + isHouseholdMember: jest.fn(), +})); + +jest.mock("../utils/logger", () => ({ + logError: jest.fn(), +})); + +const List = require("../models/list.model.v2"); +const householdModel = require("../models/household.model"); +const controller = require("../controllers/lists.controller.v2"); + +function createResponse() { + const res = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; +} + +describe("lists.controller.v2 addItem", () => { + beforeEach(() => { + List.addOrUpdateItem.mockResolvedValue({ + listId: 42, + itemName: "milk", + isNew: true, + }); + List.addHistoryRecord.mockResolvedValue(undefined); + householdModel.isHouseholdMember.mockResolvedValue(true); + }); + + test("records history for selected added_for_user_id when member is valid", async () => { + const req = { + params: { householdId: "1", storeId: "2" }, + body: { item_name: "milk", quantity: "1", added_for_user_id: "9" }, + user: { id: 7 }, + processedImage: null, + }; + const res = createResponse(); + + await controller.addItem(req, res); + + expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 9); + expect(List.addOrUpdateItem).toHaveBeenCalled(); + expect(List.addHistoryRecord).toHaveBeenCalledWith(42, "1", 9); + expect(res.status).not.toHaveBeenCalledWith(400); + }); + + test("rejects invalid added_for_user_id", async () => { + const req = { + params: { householdId: "1", storeId: "2" }, + body: { item_name: "milk", quantity: "1", added_for_user_id: "abc" }, + user: { id: 7 }, + processedImage: null, + }; + const res = createResponse(); + + await controller.addItem(req, res); + + expect(List.addOrUpdateItem).not.toHaveBeenCalled(); + expect(List.addHistoryRecord).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + message: "Added-for user ID must be a positive integer", + }), + }) + ); + }); + + test("rejects added_for_user_id when target user is not household member", async () => { + householdModel.isHouseholdMember.mockResolvedValue(false); + + const req = { + params: { householdId: "1", storeId: "2" }, + body: { item_name: "milk", quantity: "1", added_for_user_id: "11" }, + user: { id: 7 }, + processedImage: null, + }; + const res = createResponse(); + + await controller.addItem(req, res); + + expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 11); + expect(List.addOrUpdateItem).not.toHaveBeenCalled(); + expect(List.addHistoryRecord).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + message: "Selected user is not a member of this household", + }), + }) + ); + }); +}); diff --git a/frontend/src/api/list.js b/frontend/src/api/list.js index 9356365..e75d3fa 100644 --- a/frontend/src/api/list.js +++ b/frontend/src/api/list.js @@ -17,16 +17,27 @@ export const getItemByName = (householdId, storeId, itemName) => /** * Add item to list */ -export const addItem = (householdId, storeId, itemName, quantity, imageFile = null, notes = null) => { - const formData = new FormData(); - formData.append("item_name", itemName); - formData.append("quantity", quantity); - if (notes) { - formData.append("notes", notes); - } - if (imageFile) { - formData.append("image", imageFile); - } +export const addItem = ( + householdId, + storeId, + itemName, + quantity, + imageFile = null, + notes = null, + addedForUserId = null +) => { + const formData = new FormData(); + formData.append("item_name", itemName); + formData.append("quantity", quantity); + if (notes) { + formData.append("notes", notes); + } + if (addedForUserId != null) { + formData.append("added_for_user_id", addedForUserId); + } + if (imageFile) { + formData.append("image", imageFile); + } return api.post(`/households/${householdId}/stores/${storeId}/list/add`, formData, { headers: { @@ -108,15 +119,25 @@ export const getRecentlyBought = (householdId, storeId) => /** * Update item image */ -export const updateItemImage = (householdId, storeId, itemName, quantity, imageFile) => { - const formData = new FormData(); - formData.append("item_name", itemName); - formData.append("quantity", quantity); - formData.append("image", imageFile); - - return api.post(`/households/${householdId}/stores/${storeId}/list/update-image`, formData, { - headers: { - "Content-Type": "multipart/form-data", - }, - }); -}; \ No newline at end of file +export const updateItemImage = ( + householdId, + storeId, + itemName, + quantity, + imageFile, + options = {} +) => { + const formData = new FormData(); + formData.append("item_name", itemName); + formData.append("quantity", quantity); + formData.append("image", imageFile); + + return api.post(`/households/${householdId}/stores/${storeId}/list/update-image`, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + onUploadProgress: options.onUploadProgress, + signal: options.signal, + timeout: options.timeoutMs, + }); +}; diff --git a/frontend/src/components/common/ToggleButtonGroup.jsx b/frontend/src/components/common/ToggleButtonGroup.jsx new file mode 100644 index 0000000..761628f --- /dev/null +++ b/frontend/src/components/common/ToggleButtonGroup.jsx @@ -0,0 +1,67 @@ +import "../../styles/components/ToggleButtonGroup.css"; + +function joinClasses(parts) { + return parts.filter(Boolean).join(" "); +} + +export default function ToggleButtonGroup({ + value, + options, + onChange, + ariaLabel, + role = "group", + className = "tbg-group", + buttonBaseClassName = "tbg-button", + buttonClassName, + activeClassName = "is-active", + inactiveClassName = "is-inactive", + sizeClassName = "tbg-size-default" +}) { + const optionCount = Math.max(options.length, 1); + const activeIndex = + value == null ? -1 : options.findIndex((option) => option.value === value); + + const groupStyle = { + "--tbg-option-count": optionCount, + "--tbg-active-index": activeIndex >= 0 ? activeIndex : 0 + }; + + return ( +
= 0 && "has-active"])} + role={role} + aria-label={ariaLabel} + style={groupStyle} + > +
+ ); +} diff --git a/frontend/src/components/common/index.js b/frontend/src/components/common/index.js index 0dc675e..d553900 100644 --- a/frontend/src/components/common/index.js +++ b/frontend/src/components/common/index.js @@ -3,5 +3,6 @@ export { default as ErrorMessage } from './ErrorMessage.jsx'; export { default as FloatingActionButton } from './FloatingActionButton.jsx'; export { default as FormInput } from './FormInput.jsx'; export { default as SortDropdown } from './SortDropdown.jsx'; +export { default as ToggleButtonGroup } from './ToggleButtonGroup.jsx'; export { default as UserRoleCard } from './UserRoleCard.jsx'; diff --git a/frontend/src/components/forms/AddItemForm.jsx b/frontend/src/components/forms/AddItemForm.jsx index 6f889eb..5c55c9a 100644 --- a/frontend/src/components/forms/AddItemForm.jsx +++ b/frontend/src/components/forms/AddItemForm.jsx @@ -1,19 +1,49 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; +import { ToggleButtonGroup } from "../common"; +import AssignItemForModal from "../modals/AssignItemForModal"; import "../../styles/components/AddItemForm.css"; import SuggestionList from "../items/SuggestionList"; -export default function AddItemForm({ onAdd, onSuggest, suggestions, buttonText = "Add" }) { +export default function AddItemForm({ + onAdd, + onSuggest, + suggestions, + buttonText = "Add", + householdMembers = [], + currentUserId = null +}) { const [itemName, setItemName] = useState(""); const [quantity, setQuantity] = useState(1); const [showSuggestions, setShowSuggestions] = useState(false); + const [assignmentMode, setAssignmentMode] = useState("me"); + const [assignedUserId, setAssignedUserId] = useState(null); + const [showAssignModal, setShowAssignModal] = useState(false); + + const numericCurrentUserId = + currentUserId == null ? null : Number.parseInt(String(currentUserId), 10); + + const otherMembers = useMemo( + () => householdMembers.filter((member) => Number(member.id) !== numericCurrentUserId), + [householdMembers, numericCurrentUserId] + ); + + const assignedMemberLabel = useMemo(() => { + if (assignmentMode !== "others" || assignedUserId == null) return ""; + const member = otherMembers.find((item) => Number(item.id) === Number(assignedUserId)); + return member ? (member.display_name || member.name || member.username || `User ${member.id}`) : ""; + }, [assignmentMode, assignedUserId, otherMembers]); const handleSubmit = (e) => { e.preventDefault(); if (!itemName.trim()) return; - onAdd(itemName, quantity); + const targetUserId = assignmentMode === "others" ? assignedUserId : null; + onAdd(itemName, quantity, targetUserId); setItemName(""); setQuantity(1); + setAssignmentMode("me"); + setAssignedUserId(null); + setShowAssignModal(false); }; const handleInputChange = (text) => { @@ -35,30 +65,78 @@ export default function AddItemForm({ onAdd, onSuggest, suggestions, buttonText setQuantity(prev => Math.max(1, prev - 1)); }; + const handleAssignmentModeChange = (mode) => { + if (mode === "me") { + setAssignmentMode("me"); + setAssignedUserId(null); + setShowAssignModal(false); + return; + } + + if (otherMembers.length === 0) { + setAssignmentMode("me"); + setAssignedUserId(null); + return; + } + + setAssignmentMode("others"); + setShowAssignModal(true); + }; + + const handleAssignCancel = () => { + setShowAssignModal(false); + setAssignmentMode("me"); + setAssignedUserId(null); + }; + + const handleAssignConfirm = (memberId) => { + setShowAssignModal(false); + setAssignmentMode("others"); + setAssignedUserId(Number(memberId)); + }; + const isDisabled = !itemName.trim(); return (
-
- handleInputChange(e.target.value)} - onBlur={() => setTimeout(() => setShowSuggestions(false), 150)} - onClick={() => setShowSuggestions(true)} - /> - - {showSuggestions && suggestions.length > 0 && ( - +
+ handleInputChange(e.target.value)} + onBlur={() => setTimeout(() => setShowSuggestions(false), 150)} + onClick={() => setShowSuggestions(true)} /> - )} + + {showSuggestions && suggestions.length > 0 && ( + + )} +
+ +
+ {assignmentMode === "others" && assignedMemberLabel ? ( +

Adding for: {assignedMemberLabel}

+ ) : null} +
+ +
); } diff --git a/frontend/src/components/modals/AssignItemForModal.jsx b/frontend/src/components/modals/AssignItemForModal.jsx new file mode 100644 index 0000000..f23d928 --- /dev/null +++ b/frontend/src/components/modals/AssignItemForModal.jsx @@ -0,0 +1,95 @@ +import { useEffect, useMemo, useState } from "react"; +import "../../styles/components/AssignItemForModal.css"; + +function getMemberLabel(member) { + return member.display_name || member.name || member.username || `User ${member.id}`; +} + +export default function AssignItemForModal({ + isOpen, + members, + onCancel, + onConfirm +}) { + const [selectedUserId, setSelectedUserId] = useState(""); + + const hasMembers = members.length > 0; + const selectedMember = useMemo( + () => members.find((member) => String(member.id) === String(selectedUserId)) || null, + [members, selectedUserId] + ); + + useEffect(() => { + if (!isOpen) return; + setSelectedUserId(members[0] ? String(members[0].id) : ""); + }, [isOpen, members]); + + useEffect(() => { + if (!isOpen) return undefined; + + const handleEscape = (event) => { + if (event.key === "Escape") { + onCancel(); + } + }; + + window.addEventListener("keydown", handleEscape); + return () => window.removeEventListener("keydown", handleEscape); + }, [isOpen, onCancel]); + + if (!isOpen) return null; + + const handleConfirm = () => { + if (!selectedMember) return; + onConfirm(selectedMember.id); + }; + + return ( +
+
event.stopPropagation()}> +

Add Item For Someone Else

+

+ Who should this item be assigned to? +

+ + {hasMembers ? ( +
+ + +
+ ) : ( +

+ No other household members are available. +

+ )} + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/modals/index.js b/frontend/src/components/modals/index.js index ab72dac..aca8d4c 100644 --- a/frontend/src/components/modals/index.js +++ b/frontend/src/components/modals/index.js @@ -1,7 +1,9 @@ // Barrel export for modal components export { default as AddImageModal } from './AddImageModal.jsx'; export { default as AddItemWithDetailsModal } from './AddItemWithDetailsModal.jsx'; +export { default as AssignItemForModal } from './AssignItemForModal.jsx'; export { default as ConfirmBuyModal } from './ConfirmBuyModal.jsx'; +export { default as ConfirmSlideModal } from './ConfirmSlideModal.jsx'; export { default as EditItemModal } from './EditItemModal.jsx'; export { default as ImageModal } from './ImageModal.jsx'; export { default as ImageUploadModal } from './ImageUploadModal.jsx'; diff --git a/frontend/src/pages/GroceryList.jsx b/frontend/src/pages/GroceryList.jsx index f49786d..1620173 100644 --- a/frontend/src/pages/GroceryList.jsx +++ b/frontend/src/pages/GroceryList.jsx @@ -1,19 +1,18 @@ import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { - addItem, - getClassification, - getItemByName, +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"; + getSuggestions, + markBought, + updateItemWithClassification +} from "../api/list"; +import { getHouseholdMembers } from "../api/households"; +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"; @@ -23,33 +22,36 @@ import StoreTabs from "../components/store/StoreTabs"; import { ZONE_FLOW } from "../constants/classifications"; import { ROLES } from "../constants/roles"; import { AuthContext } from "../context/AuthContext"; -import { HouseholdContext } from "../context/HouseholdContext"; -import { SettingsContext } from "../context/SettingsContext"; -import { StoreContext } from "../context/StoreContext"; -import "../styles/pages/GroceryList.css"; -import { findSimilarItems } from "../utils/stringSimilarity"; +import { HouseholdContext } from "../context/HouseholdContext"; +import { IMAGE_UPLOAD_SUCCESS_EVENT } from "../context/UploadQueueContext"; +import { SettingsContext } from "../context/SettingsContext"; +import { StoreContext } from "../context/StoreContext"; +import useUploadQueue from "../hooks/useUploadQueue"; +import "../styles/pages/GroceryList.css"; +import { findSimilarItems } from "../utils/stringSimilarity"; -export default function GroceryList() { - const pageTitle = "Grocery List"; - const { role: systemRole } = useContext(AuthContext); - const { activeHousehold } = useContext(HouseholdContext); - const { activeStore, stores, loading: storeLoading } = useContext(StoreContext); - const { settings } = useContext(SettingsContext); - const navigate = useNavigate(); +export default function GroceryList() { + const pageTitle = "Grocery List"; + const { userId } = useContext(AuthContext); + const { activeHousehold } = useContext(HouseholdContext); + const { activeStore, stores, loading: storeLoading } = useContext(StoreContext); + const { settings } = useContext(SettingsContext); + const { enqueueImageUpload } = useUploadQueue(); + const navigate = useNavigate(); // Get household role for permissions const householdRole = activeHousehold?.role; - const isHouseholdAdmin = householdRole === "admin"; + const isHouseholdAdmin = ["owner", "admin"].includes(householdRole); // === 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 [items, setItems] = useState([]); + const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]); + const [householdMembers, setHouseholdMembers] = useState([]); + const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(settings.recentlyBoughtCount); + const [sortMode, setSortMode] = useState(settings.defaultSortMode); + const [suggestions, setSuggestions] = useState([]); + const [loading, setLoading] = useState(true); const [buttonText, setButtonText] = useState("Add Item"); const [pendingItem, setPendingItem] = useState(null); const [showAddDetailsModal, setShowAddDetailsModal] = useState(false); @@ -96,10 +98,77 @@ export default function GroceryList() { }; - useEffect(() => { - loadItems(); - loadRecentlyBought(); - }, [activeHousehold?.id, activeStore?.id]); + useEffect(() => { + loadItems(); + loadRecentlyBought(); + }, [activeHousehold?.id, activeStore?.id]); + + useEffect(() => { + const loadHouseholdMembers = async () => { + if (!activeHousehold?.id) { + setHouseholdMembers([]); + return; + } + + try { + const response = await getHouseholdMembers(activeHousehold.id); + setHouseholdMembers(response.data || []); + } catch (error) { + console.error("Failed to load household members:", error); + setHouseholdMembers([]); + } + }; + + loadHouseholdMembers(); + }, [activeHousehold?.id]); + + useEffect(() => { + const handleUploadSuccess = async (event) => { + const detail = event?.detail || {}; + if (!activeHousehold?.id || !activeStore?.id) return; + if (String(detail.householdId) !== String(activeHousehold.id)) return; + if (String(detail.storeId) !== String(activeStore.id)) return; + if (!detail.itemName) return; + + try { + const response = await getItemByName(activeHousehold.id, activeStore.id, detail.itemName); + const refreshedItem = response.data; + + setItems((prev) => + prev.map((item) => { + const byId = + detail.localItemId !== null && + detail.localItemId !== undefined && + item.id === detail.localItemId; + const byName = + String(item.item_name || "").toLowerCase() === + String(detail.itemName || "").toLowerCase(); + return byId || byName ? { ...item, ...refreshedItem } : item; + }) + ); + + setRecentlyBoughtItems((prev) => + prev.map((item) => { + const byId = + detail.localItemId !== null && + detail.localItemId !== undefined && + item.id === detail.localItemId; + const byName = + String(item.item_name || "").toLowerCase() === + String(detail.itemName || "").toLowerCase(); + return byId || byName ? { ...item, ...refreshedItem } : item; + }) + ); + } catch (error) { + console.error("Failed to refresh item after upload success:", error); + } + }; + + window.addEventListener(IMAGE_UPLOAD_SUCCESS_EVENT, handleUploadSuccess); + return () => { + window.removeEventListener(IMAGE_UPLOAD_SUCCESS_EVENT, handleUploadSuccess); + }; + }, [activeHousehold?.id, activeStore?.id]); // === Zone Collapse Handler === @@ -185,77 +254,97 @@ export default function GroceryList() { // === Item Addition Handlers === - const handleAdd = useCallback(async (itemName, quantity) => { - if (!itemName.trim()) return; - if (!activeHousehold?.id || !activeStore?.id) return; - - // Check if item already exists - let existingItem = null; - try { - const response = await getItemByName(activeHousehold.id, activeStore.id, itemName); - existingItem = response.data; - } catch { - // Item doesn't exist, continue - } - - 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; - }); - }, [activeHousehold?.id, activeStore?.id, recentlyBoughtItems]); - - - const processItemAddition = useCallback(async (itemName, quantity) => { - if (!activeHousehold?.id || !activeStore?.id) return; - - // Fetch current item state from backend - let existingItem = null; - try { - const response = await getItemByName(activeHousehold.id, activeStore.id, itemName); - existingItem = response.data; - } catch { - // Item doesn't exist, continue with add - } - - if (existingItem?.bought === false) { + const handleAdd = useCallback(async (itemName, quantity, addedForUserId = null) => { + const normalizedItemName = itemName.trim().toLowerCase(); + if (!normalizedItemName) return; + if (!activeHousehold?.id || !activeStore?.id) return; + + const allItems = [...items, ...recentlyBoughtItems]; + const existingLocalItem = allItems.find( + (item) => String(item.item_name || "").toLowerCase() === normalizedItemName + ); + + if (existingLocalItem) { + await processItemAddition(itemName, quantity, { + existingItem: existingLocalItem, + addedForUserId + }); + return; + } + + const similar = findSimilarItems(itemName, allItems, 70); + if (similar.length > 0) { + setSimilarItemSuggestion({ + originalName: itemName, + suggestedItem: similar[0], + quantity, + addedForUserId + }); + setShowSimilarModal(true); + return; + } + + const shouldSkipLookup = buttonText === "Create + Add"; + await processItemAddition(itemName, quantity, { + skipLookup: shouldSkipLookup, + addedForUserId + }); + }, [activeHousehold?.id, activeStore?.id, items, recentlyBoughtItems, buttonText]); + + + const processItemAddition = useCallback(async (itemName, quantity, options = {}) => { + if (!activeHousehold?.id || !activeStore?.id) return; + const { + existingItem: providedItem = null, + skipLookup = false, + addedForUserId = null + } = options; + + let existingItem = providedItem; + if (!existingItem && !skipLookup) { + try { + const response = await getItemByName(activeHousehold.id, activeStore.id, itemName); + existingItem = response.data; + } catch { + // Item doesn't exist, continue with add + } + } + + 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(activeHousehold.id, activeStore.id, itemName, quantity, null); - setSuggestions([]); - setButtonText("Add Item"); - - // Reload lists to reflect the changes - await loadItems(); - await loadRecentlyBought(); - } else { - setPendingItem({ itemName, quantity }); - setShowAddDetailsModal(true); - } - }, [activeHousehold?.id, activeStore?.id, items, loadItems]); + setConfirmAddExistingData({ + itemName, + currentQuantity, + addingQuantity: quantity, + newQuantity, + existingItem, + addedForUserId + }); + setShowConfirmAddExisting(true); + } else if (existingItem) { + await addItem( + activeHousehold.id, + activeStore.id, + itemName, + quantity, + null, + null, + addedForUserId + ); + setSuggestions([]); + setButtonText("Add Item"); + + // Reload lists to reflect the changes + await loadItems(); + await loadRecentlyBought(); + } else { + setPendingItem({ itemName, quantity, addedForUserId }); + setShowAddDetailsModal(true); + } + }, [activeHousehold?.id, activeStore?.id, loadItems]); // === Similar Item Modal Handlers === @@ -265,20 +354,25 @@ export default function GroceryList() { }, []); - const handleSimilarNo = useCallback(async () => { - if (!similarItemSuggestion) return; - setShowSimilarModal(false); - await processItemAddition(similarItemSuggestion.originalName, similarItemSuggestion.quantity); - setSimilarItemSuggestion(null); - }, [similarItemSuggestion, processItemAddition]); + const handleSimilarNo = useCallback(async () => { + if (!similarItemSuggestion) return; + setShowSimilarModal(false); + await processItemAddition(similarItemSuggestion.originalName, similarItemSuggestion.quantity, { + skipLookup: true, + addedForUserId: similarItemSuggestion.addedForUserId || null + }); + 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]); + const handleSimilarYes = useCallback(async () => { + if (!similarItemSuggestion) return; + setShowSimilarModal(false); + await processItemAddition(similarItemSuggestion.suggestedItem.item_name, similarItemSuggestion.quantity, { + addedForUserId: similarItemSuggestion.addedForUserId || null + }); + setSimilarItemSuggestion(null); + }, [similarItemSuggestion, processItemAddition]); // === Confirm Add Existing Modal Handlers === @@ -286,13 +380,21 @@ export default function GroceryList() { if (!confirmAddExistingData) return; if (!activeHousehold?.id || !activeStore?.id) return; - const { itemName, newQuantity, existingItem } = confirmAddExistingData; + const { itemName, newQuantity, existingItem, addedForUserId } = confirmAddExistingData; setShowConfirmAddExisting(false); setConfirmAddExistingData(null); try { - await addItem(activeHousehold.id, activeStore.id, itemName, newQuantity, null); + await addItem( + activeHousehold.id, + activeStore.id, + itemName, + newQuantity, + null, + null, + addedForUserId || null + ); const response = await getItemByName(activeHousehold.id, activeStore.id, itemName); const updatedItem = response.data; @@ -313,17 +415,26 @@ export default function GroceryList() { // === Add Details Modal Handlers === - const handleAddWithDetails = useCallback(async (imageFile, classification) => { - if (!pendingItem) return; - if (!activeHousehold?.id || !activeStore?.id) return; - - try { - await addItem(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, imageFile); - - if (classification) { - // Apply classification if provided - await updateItemWithClassification(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, classification); - } + const handleAddWithDetails = useCallback(async (imageFile, classification) => { + if (!pendingItem) return; + if (!activeHousehold?.id || !activeStore?.id) return; + + try { + // Create the list item first, upload image separately in background. + await addItem( + activeHousehold.id, + activeStore.id, + pendingItem.itemName, + pendingItem.quantity, + null, + null, + pendingItem.addedForUserId || null + ); + + if (classification) { + // Apply classification if provided + await updateItemWithClassification(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, classification); + } // Fetch the newly added item const itemResponse = await getItemByName(activeHousehold.id, activeStore.id, pendingItem.itemName); @@ -335,21 +446,44 @@ export default function GroceryList() { setButtonText("Add Item"); // Add to state - if (newItem) { - setItems(prevItems => [...prevItems, newItem]); - } - } catch (error) { - console.error("Failed to add item:", error); - alert("Failed to add item. Please try again."); - } - }, [activeHousehold?.id, activeStore?.id, pendingItem]); + if (newItem) { + setItems(prevItems => [...prevItems, newItem]); + + if (imageFile) { + enqueueImageUpload({ + householdId: activeHousehold.id, + storeId: activeStore.id, + itemName: newItem.item_name || pendingItem.itemName, + quantity: newItem.quantity || pendingItem.quantity, + fileBlob: imageFile, + fileName: imageFile.name || "upload.jpg", + fileType: imageFile.type || "image/jpeg", + fileSize: imageFile.size || 0, + source: "add_details", + localItemId: newItem.id, + }); + } + } + } catch (error) { + console.error("Failed to add item:", error); + alert("Failed to add item. Please try again."); + } + }, [activeHousehold?.id, activeStore?.id, pendingItem, enqueueImageUpload]); const handleAddDetailsSkip = useCallback(async () => { if (!pendingItem) return; if (!activeHousehold?.id || !activeStore?.id) return; try { - await addItem(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, null); + await addItem( + activeHousehold.id, + activeStore.id, + pendingItem.itemName, + pendingItem.quantity, + null, + null, + pendingItem.addedForUserId || null + ); // Fetch the newly added item const itemResponse = await getItemByName(activeHousehold.id, activeStore.id, pendingItem.itemName); @@ -403,28 +537,28 @@ export default function GroceryList() { loadRecentlyBought(); }, [activeHousehold?.id, activeStore?.id, items]); - const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile) => { - if (!activeHousehold?.id || !activeStore?.id) return; - - try { - const response = await updateItemImage(activeHousehold.id, activeStore.id, 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."); - } - }, [activeHousehold?.id, activeStore?.id]); + const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile, source = "add_image_modal") => { + if (!activeHousehold?.id || !activeStore?.id) return; + if (!imageFile) return; + + try { + enqueueImageUpload({ + householdId: activeHousehold.id, + storeId: activeStore.id, + itemName, + quantity, + fileBlob: imageFile, + fileName: imageFile.name || "upload.jpg", + fileType: imageFile.type || "image/jpeg", + fileSize: imageFile.size || 0, + source, + localItemId: id, + }); + } catch (error) { + console.error("Failed to add image:", error); + alert("Failed to add image. Please try again."); + } + }, [activeHousehold?.id, activeStore?.id, enqueueImageUpload]); const handleLongPress = useCallback(async (item) => { @@ -586,14 +720,16 @@ export default function GroceryList() { - {householdRole && householdRole !== 'viewer' && showAddForm && ( - - )} + {householdRole && householdRole !== 'viewer' && ( + + )} @@ -711,15 +847,8 @@ export default function GroceryList() { )}
- {householdRole && householdRole !== 'viewer' && ( - setShowAddForm(!showAddForm)} - /> - )} - - {showAddDetailsModal && pendingItem && ( - ); -} \ No newline at end of file +} diff --git a/frontend/src/styles/components/AddItemForm.css b/frontend/src/styles/components/AddItemForm.css index acb5491..47be12b 100644 --- a/frontend/src/styles/components/AddItemForm.css +++ b/frontend/src/styles/components/AddItemForm.css @@ -1,7 +1,7 @@ /* Add Item Form Container */ .add-item-form-container { background: var(--color-bg-surface); - padding: var(--spacing-lg); + padding: var(--spacing-md); border-radius: var(--border-radius-lg); box-shadow: var(--shadow-md); margin-bottom: var(--spacing-xs); @@ -11,7 +11,7 @@ .add-item-form { display: flex; flex-direction: column; - gap: var(--spacing-sm); + gap: var(--spacing-xs); } /* Form Fields */ @@ -21,6 +21,28 @@ position: relative; } +.add-item-form-input-row { + display: flex; + align-items: stretch; + gap: var(--spacing-xs); +} + +.add-item-form-input-row .add-item-form-field { + flex: 1; +} + +.add-item-form-assignee-toggle { + flex: 0 0 auto; + width: 134px; + margin: 0; +} + +.add-item-form-assignee-hint { + margin: 0; + font-size: var(--font-size-xs); + color: var(--color-text-secondary); +} + .add-item-form-input { padding: var(--input-padding-y) var(--input-padding-x); border: var(--border-width-thin) solid var(--input-border-color); @@ -58,7 +80,8 @@ display: flex; align-items: center; justify-content: space-between; - gap: var(--spacing-md); + gap: var(--spacing-sm); + min-height: 40px; } /* Quantity Control */ @@ -66,11 +89,12 @@ display: flex; align-items: center; gap: var(--spacing-xs); + height: 40px; } .quantity-btn { width: 40px; - height: 40px; + height: 100%; border: var(--border-width-thin) solid var(--color-border-medium); background: var(--color-bg-surface); color: var(--color-text-primary); @@ -106,6 +130,8 @@ .add-item-form-quantity-input { width: 40px; max-width: 40px; + height: 100%; + box-sizing: border-box; padding: var(--input-padding-y) var(--input-padding-x); border: var(--border-width-thin) solid var(--input-border-color); border-radius: var(--input-border-radius); @@ -142,9 +168,9 @@ font-size: var(--font-size-base); font-weight: var(--button-font-weight); flex: 1; - min-width: 120px + min-width: 120px; transition: var(--transition-base); - margin-top: var(--spacing-sm); + margin-top: 0; } .add-item-form-submit:hover:not(:disabled) { @@ -173,10 +199,23 @@ .add-item-form-container { padding: var(--spacing-md); } - + + .add-item-form-assignee-toggle { + width: 120px; + } + + .add-item-form-quantity-control { + height: 36px; + } + .quantity-btn { width: 36px; - height: 36px; + height: 100%; font-size: var(--font-size-lg); } + + .add-item-form-quantity-input, + .add-item-form-submit { + height: 36px; + } } diff --git a/frontend/src/styles/components/AssignItemForModal.css b/frontend/src/styles/components/AssignItemForModal.css new file mode 100644 index 0000000..479dc9c --- /dev/null +++ b/frontend/src/styles/components/AssignItemForModal.css @@ -0,0 +1,19 @@ +.assign-item-for-modal { + max-width: 420px; +} + +.assign-item-for-modal-description { + margin: 0 0 var(--spacing-md) 0; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} + +.assign-item-for-modal-field { + margin-bottom: var(--spacing-sm); +} + +.assign-item-for-modal-empty { + margin: 0 0 var(--spacing-sm) 0; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} diff --git a/frontend/src/styles/components/ToggleButtonGroup.css b/frontend/src/styles/components/ToggleButtonGroup.css new file mode 100644 index 0000000..9179a0f --- /dev/null +++ b/frontend/src/styles/components/ToggleButtonGroup.css @@ -0,0 +1,81 @@ +.tbg-group { + position: relative; + display: grid; + grid-template-columns: repeat(var(--tbg-option-count, 1), minmax(0, 1fr)); + align-items: stretch; + gap: 0; + padding: 2px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--background); + overflow: hidden; + isolation: isolate; +} + +.tbg-indicator { + position: absolute; + top: 2px; + bottom: 2px; + left: 2px; + width: calc((100% - 4px) / var(--tbg-option-count, 1)); + border-radius: 999px; + background: var(--primary); + transform: translateX(calc(var(--tbg-active-index, 0) * 100%)); + transition: transform 0.22s ease, opacity 0.2s ease; + opacity: 0; + z-index: 0; +} + +.tbg-group.has-active .tbg-indicator { + opacity: 1; +} + +.tbg-button { + position: relative; + z-index: 1; + margin: 0; + width: 100%; + border: none; + border-radius: 999px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: color var(--transition-fast), background-color var(--transition-fast); + white-space: nowrap; +} + +.tbg-button.tbg-size-default { + padding: 0.5rem 0.8rem; + font-size: 0.9rem; + font-weight: 500; +} + +.tbg-button.tbg-size-xs { + padding: 0.35rem 0.5rem; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); +} + +.tbg-button.is-active { + color: var(--color-text-inverse); + background: transparent; +} + +.tbg-button.is-inactive:hover:not(:disabled) { + color: var(--text-primary); + background: rgba(0, 0, 0, 0.04); +} + +[data-theme="dark"] .tbg-button.is-inactive:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); +} + +.tbg-button:focus-visible { + outline: 2px solid var(--primary); + outline-offset: -2px; +} + +.tbg-button:disabled { + opacity: 0.6; + cursor: not-allowed; +}