feat: support assigning grocery items to other household members
This commit is contained in:
parent
a1beb486cb
commit
9fa48e6eb3
@ -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",
|
||||||
|
|||||||
101
backend/tests/lists.controller.v2.test.js
Normal file
101
backend/tests/lists.controller.v2.test.js
Normal 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",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
67
frontend/src/components/common/ToggleButtonGroup.jsx
Normal file
67
frontend/src/components/common/ToggleButtonGroup.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
95
frontend/src/components/modals/AssignItemForModal.jsx
Normal file
95
frontend/src/components/modals/AssignItemForModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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';
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
frontend/src/styles/components/AssignItemForModal.css
Normal file
19
frontend/src/styles/components/AssignItemForModal.css
Normal 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);
|
||||||
|
}
|
||||||
81
frontend/src/styles/components/ToggleButtonGroup.css
Normal file
81
frontend/src/styles/components/ToggleButtonGroup.css
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user