feat: support assigning grocery items to other household members

This commit is contained in:
Nico 2026-02-20 23:33:22 -08:00
parent a1beb486cb
commit 9fa48e6eb3
12 changed files with 885 additions and 228 deletions

View File

@ -1,4 +1,5 @@
const List = require("../models/list.model.v2"); const List = require("../models/list.model.v2");
const householdModel = require("../models/household.model");
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications"); const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
const { sendError } = require("../utils/http"); const { sendError } = require("../utils/http");
const { logError } = require("../utils/logger"); const { logError } = require("../utils/logger");
@ -50,13 +51,29 @@ exports.getItemByName = async (req, res) => {
exports.addItem = async (req, res) => { exports.addItem = async (req, res) => {
try { try {
const { householdId, storeId } = req.params; 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; const userId = req.user.id;
let historyUserId = userId;
if (!item_name || item_name.trim() === "") { if (!item_name || item_name.trim() === "") {
return sendError(res, 400, "Item name is required"); 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 // Get processed image if uploaded
const imageBuffer = req.processedImage?.buffer || null; const imageBuffer = req.processedImage?.buffer || null;
const mimeType = req.processedImage?.mimeType || null; const mimeType = req.processedImage?.mimeType || null;
@ -73,7 +90,7 @@ exports.addItem = async (req, res) => {
); );
// Add history record // Add history record
await List.addHistoryRecord(result.listId, quantity || "1", userId); await List.addHistoryRecord(result.listId, quantity || "1", historyUserId);
res.json({ res.json({
message: result.isNew ? "Item added" : "Item updated", message: result.isNew ? "Item added" : "Item updated",

View File

@ -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",
}),
})
);
});
});

View File

@ -17,16 +17,27 @@ export const getItemByName = (householdId, storeId, itemName) =>
/** /**
* Add item to list * Add item to list
*/ */
export const addItem = (householdId, storeId, itemName, quantity, imageFile = null, notes = null) => { export const addItem = (
const formData = new FormData(); householdId,
formData.append("item_name", itemName); storeId,
formData.append("quantity", quantity); itemName,
if (notes) { quantity,
formData.append("notes", notes); imageFile = null,
} notes = null,
if (imageFile) { addedForUserId = null
formData.append("image", imageFile); ) => {
} 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, { return api.post(`/households/${householdId}/stores/${storeId}/list/add`, formData, {
headers: { headers: {
@ -108,15 +119,25 @@ export const getRecentlyBought = (householdId, storeId) =>
/** /**
* Update item image * Update item image
*/ */
export const updateItemImage = (householdId, storeId, itemName, quantity, imageFile) => { export const updateItemImage = (
const formData = new FormData(); householdId,
formData.append("item_name", itemName); storeId,
formData.append("quantity", quantity); itemName,
formData.append("image", imageFile); quantity,
imageFile,
return api.post(`/households/${householdId}/stores/${storeId}/list/update-image`, formData, { options = {}
headers: { ) => {
"Content-Type": "multipart/form-data", 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,
});
};

View File

@ -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 (
<div
className={joinClasses([className, activeIndex >= 0 && "has-active"])}
role={role}
aria-label={ariaLabel}
style={groupStyle}
>
<span className="tbg-indicator" aria-hidden="true" />
{options.map((option) => {
const isActive = value != null && option.value === value;
const handleClick = option.onClick
? option.onClick
: onChange
? () => onChange(option.value)
: undefined;
return (
<button
key={option.value}
type="button"
className={joinClasses([
buttonBaseClassName,
sizeClassName,
buttonClassName,
isActive ? (option.activeClassName || activeClassName) : (option.inactiveClassName || inactiveClassName),
option.className
])}
onClick={handleClick}
disabled={option.disabled}
aria-pressed={value != null ? isActive : undefined}
aria-label={option.ariaLabel}
>
{option.label}
</button>
);
})}
</div>
);
}

View File

@ -3,5 +3,6 @@ export { default as ErrorMessage } from './ErrorMessage.jsx';
export { default as FloatingActionButton } from './FloatingActionButton.jsx'; export { default as FloatingActionButton } from './FloatingActionButton.jsx';
export { default as FormInput } from './FormInput.jsx'; export { default as FormInput } from './FormInput.jsx';
export { default as SortDropdown } from './SortDropdown.jsx'; export { default as SortDropdown } from './SortDropdown.jsx';
export { default as ToggleButtonGroup } from './ToggleButtonGroup.jsx';
export { default as UserRoleCard } from './UserRoleCard.jsx'; export { default as UserRoleCard } from './UserRoleCard.jsx';

View File

@ -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 "../../styles/components/AddItemForm.css";
import SuggestionList from "../items/SuggestionList"; 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 [itemName, setItemName] = useState("");
const [quantity, setQuantity] = useState(1); const [quantity, setQuantity] = useState(1);
const [showSuggestions, setShowSuggestions] = useState(false); 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) => { const handleSubmit = (e) => {
e.preventDefault(); e.preventDefault();
if (!itemName.trim()) return; if (!itemName.trim()) return;
onAdd(itemName, quantity); const targetUserId = assignmentMode === "others" ? assignedUserId : null;
onAdd(itemName, quantity, targetUserId);
setItemName(""); setItemName("");
setQuantity(1); setQuantity(1);
setAssignmentMode("me");
setAssignedUserId(null);
setShowAssignModal(false);
}; };
const handleInputChange = (text) => { const handleInputChange = (text) => {
@ -35,30 +65,78 @@ export default function AddItemForm({ onAdd, onSuggest, suggestions, buttonText
setQuantity(prev => Math.max(1, prev - 1)); 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(); const isDisabled = !itemName.trim();
return ( return (
<div className="add-item-form-container"> <div className="add-item-form-container">
<form onSubmit={handleSubmit} className="add-item-form"> <form onSubmit={handleSubmit} className="add-item-form">
<div className="add-item-form-field"> <div className="add-item-form-input-row">
<input <div className="add-item-form-field">
type="text" <input
className="add-item-form-input" type="text"
placeholder="Enter item name" className="add-item-form-input"
value={itemName} placeholder="Enter item name"
onChange={(e) => handleInputChange(e.target.value)} value={itemName}
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)} onChange={(e) => handleInputChange(e.target.value)}
onClick={() => setShowSuggestions(true)} onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
/> onClick={() => setShowSuggestions(true)}
{showSuggestions && suggestions.length > 0 && (
<SuggestionList
suggestions={suggestions}
onSelect={handleSuggestionSelect}
/> />
)}
{showSuggestions && suggestions.length > 0 && (
<SuggestionList
suggestions={suggestions}
onSelect={handleSuggestionSelect}
/>
)}
</div>
<ToggleButtonGroup
value={assignmentMode}
ariaLabel="Item assignment mode"
className="tbg-group add-item-form-assignee-toggle"
sizeClassName="tbg-size-xs"
options={[
{ value: "me", label: "Me" },
{ value: "others", label: "Others", disabled: otherMembers.length === 0 }
]}
onChange={handleAssignmentModeChange}
/>
</div> </div>
{assignmentMode === "others" && assignedMemberLabel ? (
<p className="add-item-form-assignee-hint">Adding for: {assignedMemberLabel}</p>
) : null}
<div className="add-item-form-actions"> <div className="add-item-form-actions">
<div className="add-item-form-quantity-control"> <div className="add-item-form-quantity-control">
<button <button
@ -94,6 +172,13 @@ export default function AddItemForm({ onAdd, onSuggest, suggestions, buttonText
</button> </button>
</div> </div>
</form> </form>
<AssignItemForModal
isOpen={showAssignModal}
members={otherMembers}
onCancel={handleAssignCancel}
onConfirm={handleAssignConfirm}
/>
</div> </div>
); );
} }

View File

@ -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 (
<div className="modal-overlay" onClick={onCancel}>
<div className="modal assign-item-for-modal" onClick={(event) => event.stopPropagation()}>
<h2 className="modal-title">Add Item For Someone Else</h2>
<p className="assign-item-for-modal-description">
Who should this item be assigned to?
</p>
{hasMembers ? (
<div className="assign-item-for-modal-field">
<label htmlFor="assign-item-for-member" className="form-label">
Household member
</label>
<select
id="assign-item-for-member"
className="form-select"
value={selectedUserId}
onChange={(event) => setSelectedUserId(event.target.value)}
>
{members.map((member) => (
<option key={member.id} value={member.id}>
{getMemberLabel(member)}
</option>
))}
</select>
</div>
) : (
<p className="assign-item-for-modal-empty">
No other household members are available.
</p>
)}
<div className="modal-actions">
<button type="button" className="btn btn-outline flex-1" onClick={onCancel}>
Cancel
</button>
<button
type="button"
className="btn btn-primary flex-1"
onClick={handleConfirm}
disabled={!selectedMember}
>
Confirm
</button>
</div>
</div>
</div>
);
}

View File

@ -1,7 +1,9 @@
// Barrel export for modal components // Barrel export for modal components
export { default as AddImageModal } from './AddImageModal.jsx'; export { default as AddImageModal } from './AddImageModal.jsx';
export { default as AddItemWithDetailsModal } from './AddItemWithDetailsModal.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 ConfirmBuyModal } from './ConfirmBuyModal.jsx';
export { default as ConfirmSlideModal } from './ConfirmSlideModal.jsx';
export { default as EditItemModal } from './EditItemModal.jsx'; export { default as EditItemModal } from './EditItemModal.jsx';
export { default as ImageModal } from './ImageModal.jsx'; export { default as ImageModal } from './ImageModal.jsx';
export { default as ImageUploadModal } from './ImageUploadModal.jsx'; export { default as ImageUploadModal } from './ImageUploadModal.jsx';

View File

@ -1,19 +1,18 @@
import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
addItem, addItem,
getClassification, getClassification,
getItemByName, getItemByName,
getList, getList,
getRecentlyBought, getRecentlyBought,
getSuggestions, getSuggestions,
markBought, markBought,
updateItemImage, updateItemWithClassification
updateItemWithClassification } from "../api/list";
} from "../api/list"; import { getHouseholdMembers } from "../api/households";
import FloatingActionButton from "../components/common/FloatingActionButton"; import SortDropdown from "../components/common/SortDropdown";
import SortDropdown from "../components/common/SortDropdown"; import AddItemForm from "../components/forms/AddItemForm";
import AddItemForm from "../components/forms/AddItemForm";
import GroceryListItem from "../components/items/GroceryListItem"; import GroceryListItem from "../components/items/GroceryListItem";
import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal"; import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal";
import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal"; import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal";
@ -23,33 +22,36 @@ import StoreTabs from "../components/store/StoreTabs";
import { ZONE_FLOW } from "../constants/classifications"; import { ZONE_FLOW } from "../constants/classifications";
import { ROLES } from "../constants/roles"; import { ROLES } from "../constants/roles";
import { AuthContext } from "../context/AuthContext"; import { AuthContext } from "../context/AuthContext";
import { HouseholdContext } from "../context/HouseholdContext"; import { HouseholdContext } from "../context/HouseholdContext";
import { SettingsContext } from "../context/SettingsContext"; import { IMAGE_UPLOAD_SUCCESS_EVENT } from "../context/UploadQueueContext";
import { StoreContext } from "../context/StoreContext"; import { SettingsContext } from "../context/SettingsContext";
import "../styles/pages/GroceryList.css"; import { StoreContext } from "../context/StoreContext";
import { findSimilarItems } from "../utils/stringSimilarity"; import useUploadQueue from "../hooks/useUploadQueue";
import "../styles/pages/GroceryList.css";
import { findSimilarItems } from "../utils/stringSimilarity";
export default function GroceryList() { export default function GroceryList() {
const pageTitle = "Grocery List"; const pageTitle = "Grocery List";
const { role: systemRole } = useContext(AuthContext); const { userId } = useContext(AuthContext);
const { activeHousehold } = useContext(HouseholdContext); const { activeHousehold } = useContext(HouseholdContext);
const { activeStore, stores, loading: storeLoading } = useContext(StoreContext); const { activeStore, stores, loading: storeLoading } = useContext(StoreContext);
const { settings } = useContext(SettingsContext); const { settings } = useContext(SettingsContext);
const navigate = useNavigate(); const { enqueueImageUpload } = useUploadQueue();
const navigate = useNavigate();
// Get household role for permissions // Get household role for permissions
const householdRole = activeHousehold?.role; const householdRole = activeHousehold?.role;
const isHouseholdAdmin = householdRole === "admin"; const isHouseholdAdmin = ["owner", "admin"].includes(householdRole);
// === State === // // === State === //
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]); const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]);
const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(settings.recentlyBoughtCount); const [householdMembers, setHouseholdMembers] = useState([]);
const [sortMode, setSortMode] = useState(settings.defaultSortMode); const [recentlyBoughtDisplayCount, setRecentlyBoughtDisplayCount] = useState(settings.recentlyBoughtCount);
const [suggestions, setSuggestions] = useState([]); const [sortMode, setSortMode] = useState(settings.defaultSortMode);
const [showAddForm, setShowAddForm] = useState(true); const [suggestions, setSuggestions] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [buttonText, setButtonText] = useState("Add Item"); const [buttonText, setButtonText] = useState("Add Item");
const [pendingItem, setPendingItem] = useState(null); const [pendingItem, setPendingItem] = useState(null);
const [showAddDetailsModal, setShowAddDetailsModal] = useState(false); const [showAddDetailsModal, setShowAddDetailsModal] = useState(false);
@ -96,10 +98,77 @@ export default function GroceryList() {
}; };
useEffect(() => { useEffect(() => {
loadItems(); loadItems();
loadRecentlyBought(); loadRecentlyBought();
}, [activeHousehold?.id, activeStore?.id]); }, [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 === // === Zone Collapse Handler ===
@ -185,77 +254,97 @@ export default function GroceryList() {
// === Item Addition Handlers === // === Item Addition Handlers ===
const handleAdd = useCallback(async (itemName, quantity) => { const handleAdd = useCallback(async (itemName, quantity, addedForUserId = null) => {
if (!itemName.trim()) return; const normalizedItemName = itemName.trim().toLowerCase();
if (!activeHousehold?.id || !activeStore?.id) return; if (!normalizedItemName) return;
if (!activeHousehold?.id || !activeStore?.id) return;
// Check if item already exists
let existingItem = null; const allItems = [...items, ...recentlyBoughtItems];
try { const existingLocalItem = allItems.find(
const response = await getItemByName(activeHousehold.id, activeStore.id, itemName); (item) => String(item.item_name || "").toLowerCase() === normalizedItemName
existingItem = response.data; );
} catch {
// Item doesn't exist, continue if (existingLocalItem) {
} await processItemAddition(itemName, quantity, {
existingItem: existingLocalItem,
if (existingItem) { addedForUserId
await processItemAddition(itemName, quantity); });
return; return;
} }
setItems(prevItems => { const similar = findSimilarItems(itemName, allItems, 70);
const allItems = [...prevItems, ...recentlyBoughtItems]; if (similar.length > 0) {
const similar = findSimilarItems(itemName, allItems, 70); setSimilarItemSuggestion({
if (similar.length > 0) { originalName: itemName,
setSimilarItemSuggestion({ originalName: itemName, suggestedItem: similar[0], quantity }); suggestedItem: similar[0],
setShowSimilarModal(true); quantity,
return prevItems; addedForUserId
} });
setShowSimilarModal(true);
processItemAddition(itemName, quantity); return;
return prevItems; }
});
}, [activeHousehold?.id, activeStore?.id, recentlyBoughtItems]); const shouldSkipLookup = buttonText === "Create + Add";
await processItemAddition(itemName, quantity, {
skipLookup: shouldSkipLookup,
const processItemAddition = useCallback(async (itemName, quantity) => { addedForUserId
if (!activeHousehold?.id || !activeStore?.id) return; });
}, [activeHousehold?.id, activeStore?.id, items, recentlyBoughtItems, buttonText]);
// Fetch current item state from backend
let existingItem = null;
try { const processItemAddition = useCallback(async (itemName, quantity, options = {}) => {
const response = await getItemByName(activeHousehold.id, activeStore.id, itemName); if (!activeHousehold?.id || !activeStore?.id) return;
existingItem = response.data; const {
} catch { existingItem: providedItem = null,
// Item doesn't exist, continue with add skipLookup = false,
} addedForUserId = null
} = options;
if (existingItem?.bought === false) {
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 currentQuantity = existingItem.quantity;
const newQuantity = currentQuantity + quantity; const newQuantity = currentQuantity + quantity;
// Show modal instead of window.confirm // Show modal instead of window.confirm
setConfirmAddExistingData({ setConfirmAddExistingData({
itemName, itemName,
currentQuantity, currentQuantity,
addingQuantity: quantity, addingQuantity: quantity,
newQuantity, newQuantity,
existingItem existingItem,
}); addedForUserId
setShowConfirmAddExisting(true); });
} else if (existingItem) { setShowConfirmAddExisting(true);
await addItem(activeHousehold.id, activeStore.id, itemName, quantity, null); } else if (existingItem) {
setSuggestions([]); await addItem(
setButtonText("Add Item"); activeHousehold.id,
activeStore.id,
// Reload lists to reflect the changes itemName,
await loadItems(); quantity,
await loadRecentlyBought(); null,
} else { null,
setPendingItem({ itemName, quantity }); addedForUserId
setShowAddDetailsModal(true); );
} setSuggestions([]);
}, [activeHousehold?.id, activeStore?.id, items, loadItems]); 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 === // === Similar Item Modal Handlers ===
@ -265,20 +354,25 @@ export default function GroceryList() {
}, []); }, []);
const handleSimilarNo = useCallback(async () => { const handleSimilarNo = useCallback(async () => {
if (!similarItemSuggestion) return; if (!similarItemSuggestion) return;
setShowSimilarModal(false); setShowSimilarModal(false);
await processItemAddition(similarItemSuggestion.originalName, similarItemSuggestion.quantity); await processItemAddition(similarItemSuggestion.originalName, similarItemSuggestion.quantity, {
setSimilarItemSuggestion(null); skipLookup: true,
}, [similarItemSuggestion, processItemAddition]); addedForUserId: similarItemSuggestion.addedForUserId || null
});
setSimilarItemSuggestion(null);
}, [similarItemSuggestion, processItemAddition]);
const handleSimilarYes = useCallback(async () => { const handleSimilarYes = useCallback(async () => {
if (!similarItemSuggestion) return; if (!similarItemSuggestion) return;
setShowSimilarModal(false); setShowSimilarModal(false);
await processItemAddition(similarItemSuggestion.suggestedItem.item_name, similarItemSuggestion.quantity); await processItemAddition(similarItemSuggestion.suggestedItem.item_name, similarItemSuggestion.quantity, {
setSimilarItemSuggestion(null); addedForUserId: similarItemSuggestion.addedForUserId || null
}, [similarItemSuggestion, processItemAddition]); });
setSimilarItemSuggestion(null);
}, [similarItemSuggestion, processItemAddition]);
// === Confirm Add Existing Modal Handlers === // === Confirm Add Existing Modal Handlers ===
@ -286,13 +380,21 @@ export default function GroceryList() {
if (!confirmAddExistingData) return; if (!confirmAddExistingData) return;
if (!activeHousehold?.id || !activeStore?.id) return; if (!activeHousehold?.id || !activeStore?.id) return;
const { itemName, newQuantity, existingItem } = confirmAddExistingData; const { itemName, newQuantity, existingItem, addedForUserId } = confirmAddExistingData;
setShowConfirmAddExisting(false); setShowConfirmAddExisting(false);
setConfirmAddExistingData(null); setConfirmAddExistingData(null);
try { 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 response = await getItemByName(activeHousehold.id, activeStore.id, itemName);
const updatedItem = response.data; const updatedItem = response.data;
@ -313,17 +415,26 @@ export default function GroceryList() {
// === Add Details Modal Handlers === // === Add Details Modal Handlers ===
const handleAddWithDetails = useCallback(async (imageFile, classification) => { const handleAddWithDetails = useCallback(async (imageFile, classification) => {
if (!pendingItem) return; if (!pendingItem) return;
if (!activeHousehold?.id || !activeStore?.id) return; if (!activeHousehold?.id || !activeStore?.id) return;
try { try {
await addItem(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, imageFile); // Create the list item first, upload image separately in background.
await addItem(
if (classification) { activeHousehold.id,
// Apply classification if provided activeStore.id,
await updateItemWithClassification(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, classification); 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 // Fetch the newly added item
const itemResponse = await getItemByName(activeHousehold.id, activeStore.id, pendingItem.itemName); const itemResponse = await getItemByName(activeHousehold.id, activeStore.id, pendingItem.itemName);
@ -335,21 +446,44 @@ export default function GroceryList() {
setButtonText("Add Item"); setButtonText("Add Item");
// Add to state // Add to state
if (newItem) { if (newItem) {
setItems(prevItems => [...prevItems, newItem]); setItems(prevItems => [...prevItems, newItem]);
}
} catch (error) { if (imageFile) {
console.error("Failed to add item:", error); enqueueImageUpload({
alert("Failed to add item. Please try again."); householdId: activeHousehold.id,
} storeId: activeStore.id,
}, [activeHousehold?.id, activeStore?.id, pendingItem]); 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 () => { const handleAddDetailsSkip = useCallback(async () => {
if (!pendingItem) return; if (!pendingItem) return;
if (!activeHousehold?.id || !activeStore?.id) return; if (!activeHousehold?.id || !activeStore?.id) return;
try { 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 // Fetch the newly added item
const itemResponse = await getItemByName(activeHousehold.id, activeStore.id, pendingItem.itemName); const itemResponse = await getItemByName(activeHousehold.id, activeStore.id, pendingItem.itemName);
@ -403,28 +537,28 @@ export default function GroceryList() {
loadRecentlyBought(); loadRecentlyBought();
}, [activeHousehold?.id, activeStore?.id, items]); }, [activeHousehold?.id, activeStore?.id, items]);
const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile) => { const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile, source = "add_image_modal") => {
if (!activeHousehold?.id || !activeStore?.id) return; if (!activeHousehold?.id || !activeStore?.id) return;
if (!imageFile) return;
try {
const response = await updateItemImage(activeHousehold.id, activeStore.id, id, itemName, quantity, imageFile); try {
enqueueImageUpload({
setItems(prevItems => householdId: activeHousehold.id,
prevItems.map(item => storeId: activeStore.id,
item.id === id ? { ...item, ...response.data } : item itemName,
) quantity,
); fileBlob: imageFile,
fileName: imageFile.name || "upload.jpg",
setRecentlyBoughtItems(prevItems => fileType: imageFile.type || "image/jpeg",
prevItems.map(item => fileSize: imageFile.size || 0,
item.id === id ? { ...item, ...response.data } : item source,
) localItemId: id,
); });
} catch (error) { } catch (error) {
console.error("Failed to add image:", error); console.error("Failed to add image:", error);
alert("Failed to add image. Please try again."); alert("Failed to add image. Please try again.");
} }
}, [activeHousehold?.id, activeStore?.id]); }, [activeHousehold?.id, activeStore?.id, enqueueImageUpload]);
const handleLongPress = useCallback(async (item) => { const handleLongPress = useCallback(async (item) => {
@ -586,14 +720,16 @@ export default function GroceryList() {
<StoreTabs /> <StoreTabs />
{householdRole && householdRole !== 'viewer' && showAddForm && ( {householdRole && householdRole !== 'viewer' && (
<AddItemForm <AddItemForm
onAdd={handleAdd} onAdd={handleAdd}
onSuggest={handleSuggest} onSuggest={handleSuggest}
suggestions={suggestions} suggestions={suggestions}
buttonText={buttonText} buttonText={buttonText}
/> householdMembers={householdMembers}
)} currentUserId={userId}
/>
)}
<SortDropdown value={sortMode} onChange={setSortMode} /> <SortDropdown value={sortMode} onChange={setSortMode} />
@ -711,15 +847,8 @@ export default function GroceryList() {
)} )}
</div> </div>
{householdRole && householdRole !== 'viewer' && ( {showAddDetailsModal && pendingItem && (
<FloatingActionButton <AddItemWithDetailsModal
isOpen={showAddForm}
onClick={() => setShowAddForm(!showAddForm)}
/>
)}
{showAddDetailsModal && pendingItem && (
<AddItemWithDetailsModal
itemName={pendingItem.itemName} itemName={pendingItem.itemName}
onConfirm={handleAddWithDetails} onConfirm={handleAddWithDetails}
onSkip={handleAddDetailsSkip} onSkip={handleAddDetailsSkip}
@ -760,4 +889,4 @@ export default function GroceryList() {
)} )}
</div> </div>
); );
} }

View File

@ -1,7 +1,7 @@
/* Add Item Form Container */ /* Add Item Form Container */
.add-item-form-container { .add-item-form-container {
background: var(--color-bg-surface); background: var(--color-bg-surface);
padding: var(--spacing-lg); padding: var(--spacing-md);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
margin-bottom: var(--spacing-xs); margin-bottom: var(--spacing-xs);
@ -11,7 +11,7 @@
.add-item-form { .add-item-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-sm); gap: var(--spacing-xs);
} }
/* Form Fields */ /* Form Fields */
@ -21,6 +21,28 @@
position: relative; 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 { .add-item-form-input {
padding: var(--input-padding-y) var(--input-padding-x); padding: var(--input-padding-y) var(--input-padding-x);
border: var(--border-width-thin) solid var(--input-border-color); border: var(--border-width-thin) solid var(--input-border-color);
@ -58,7 +80,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--spacing-md); gap: var(--spacing-sm);
min-height: 40px;
} }
/* Quantity Control */ /* Quantity Control */
@ -66,11 +89,12 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-xs); gap: var(--spacing-xs);
height: 40px;
} }
.quantity-btn { .quantity-btn {
width: 40px; width: 40px;
height: 40px; height: 100%;
border: var(--border-width-thin) solid var(--color-border-medium); border: var(--border-width-thin) solid var(--color-border-medium);
background: var(--color-bg-surface); background: var(--color-bg-surface);
color: var(--color-text-primary); color: var(--color-text-primary);
@ -106,6 +130,8 @@
.add-item-form-quantity-input { .add-item-form-quantity-input {
width: 40px; width: 40px;
max-width: 40px; max-width: 40px;
height: 100%;
box-sizing: border-box;
padding: var(--input-padding-y) var(--input-padding-x); padding: var(--input-padding-y) var(--input-padding-x);
border: var(--border-width-thin) solid var(--input-border-color); border: var(--border-width-thin) solid var(--input-border-color);
border-radius: var(--input-border-radius); border-radius: var(--input-border-radius);
@ -142,9 +168,9 @@
font-size: var(--font-size-base); font-size: var(--font-size-base);
font-weight: var(--button-font-weight); font-weight: var(--button-font-weight);
flex: 1; flex: 1;
min-width: 120px min-width: 120px;
transition: var(--transition-base); transition: var(--transition-base);
margin-top: var(--spacing-sm); margin-top: 0;
} }
.add-item-form-submit:hover:not(:disabled) { .add-item-form-submit:hover:not(:disabled) {
@ -173,10 +199,23 @@
.add-item-form-container { .add-item-form-container {
padding: var(--spacing-md); padding: var(--spacing-md);
} }
.add-item-form-assignee-toggle {
width: 120px;
}
.add-item-form-quantity-control {
height: 36px;
}
.quantity-btn { .quantity-btn {
width: 36px; width: 36px;
height: 36px; height: 100%;
font-size: var(--font-size-lg); font-size: var(--font-size-lg);
} }
.add-item-form-quantity-input,
.add-item-form-submit {
height: 36px;
}
} }

View File

@ -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);
}

View File

@ -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;
}