diff --git a/backend/controllers/lists.controller.v2.js b/backend/controllers/lists.controller.v2.js
index 11b66b1..0293d94 100644
--- a/backend/controllers/lists.controller.v2.js
+++ b/backend/controllers/lists.controller.v2.js
@@ -1,4 +1,5 @@
const List = require("../models/list.model.v2");
+const householdModel = require("../models/household.model");
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
const { sendError } = require("../utils/http");
const { logError } = require("../utils/logger");
@@ -50,13 +51,29 @@ exports.getItemByName = async (req, res) => {
exports.addItem = async (req, res) => {
try {
const { householdId, storeId } = req.params;
- const { item_name, quantity, notes } = req.body;
+ const { item_name, quantity, notes, added_for_user_id } = req.body;
const userId = req.user.id;
+ let historyUserId = userId;
if (!item_name || item_name.trim() === "") {
return sendError(res, 400, "Item name is required");
}
+ if (added_for_user_id !== undefined && added_for_user_id !== null && String(added_for_user_id).trim() !== "") {
+ const parsedUserId = Number.parseInt(String(added_for_user_id), 10);
+
+ if (!Number.isInteger(parsedUserId) || parsedUserId <= 0) {
+ return sendError(res, 400, "Added-for user ID must be a positive integer");
+ }
+
+ const isMember = await householdModel.isHouseholdMember(householdId, parsedUserId);
+ if (!isMember) {
+ return sendError(res, 400, "Selected user is not a member of this household");
+ }
+
+ historyUserId = parsedUserId;
+ }
+
// Get processed image if uploaded
const imageBuffer = req.processedImage?.buffer || null;
const mimeType = req.processedImage?.mimeType || null;
@@ -73,7 +90,7 @@ exports.addItem = async (req, res) => {
);
// Add history record
- await List.addHistoryRecord(result.listId, quantity || "1", userId);
+ await List.addHistoryRecord(result.listId, quantity || "1", historyUserId);
res.json({
message: result.isNew ? "Item added" : "Item updated",
diff --git a/backend/tests/lists.controller.v2.test.js b/backend/tests/lists.controller.v2.test.js
new file mode 100644
index 0000000..b13ce603
--- /dev/null
+++ b/backend/tests/lists.controller.v2.test.js
@@ -0,0 +1,101 @@
+jest.mock("../models/list.model.v2", () => ({
+ addHistoryRecord: jest.fn(),
+ addOrUpdateItem: jest.fn(),
+}));
+
+jest.mock("../models/household.model", () => ({
+ isHouseholdMember: jest.fn(),
+}));
+
+jest.mock("../utils/logger", () => ({
+ logError: jest.fn(),
+}));
+
+const List = require("../models/list.model.v2");
+const householdModel = require("../models/household.model");
+const controller = require("../controllers/lists.controller.v2");
+
+function createResponse() {
+ const res = {};
+ res.status = jest.fn().mockReturnValue(res);
+ res.json = jest.fn().mockReturnValue(res);
+ return res;
+}
+
+describe("lists.controller.v2 addItem", () => {
+ beforeEach(() => {
+ List.addOrUpdateItem.mockResolvedValue({
+ listId: 42,
+ itemName: "milk",
+ isNew: true,
+ });
+ List.addHistoryRecord.mockResolvedValue(undefined);
+ householdModel.isHouseholdMember.mockResolvedValue(true);
+ });
+
+ test("records history for selected added_for_user_id when member is valid", async () => {
+ const req = {
+ params: { householdId: "1", storeId: "2" },
+ body: { item_name: "milk", quantity: "1", added_for_user_id: "9" },
+ user: { id: 7 },
+ processedImage: null,
+ };
+ const res = createResponse();
+
+ await controller.addItem(req, res);
+
+ expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 9);
+ expect(List.addOrUpdateItem).toHaveBeenCalled();
+ expect(List.addHistoryRecord).toHaveBeenCalledWith(42, "1", 9);
+ expect(res.status).not.toHaveBeenCalledWith(400);
+ });
+
+ test("rejects invalid added_for_user_id", async () => {
+ const req = {
+ params: { householdId: "1", storeId: "2" },
+ body: { item_name: "milk", quantity: "1", added_for_user_id: "abc" },
+ user: { id: 7 },
+ processedImage: null,
+ };
+ const res = createResponse();
+
+ await controller.addItem(req, res);
+
+ expect(List.addOrUpdateItem).not.toHaveBeenCalled();
+ expect(List.addHistoryRecord).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ error: expect.objectContaining({
+ message: "Added-for user ID must be a positive integer",
+ }),
+ })
+ );
+ });
+
+ test("rejects added_for_user_id when target user is not household member", async () => {
+ householdModel.isHouseholdMember.mockResolvedValue(false);
+
+ const req = {
+ params: { householdId: "1", storeId: "2" },
+ body: { item_name: "milk", quantity: "1", added_for_user_id: "11" },
+ user: { id: 7 },
+ processedImage: null,
+ };
+ const res = createResponse();
+
+ await controller.addItem(req, res);
+
+ expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 11);
+ expect(List.addOrUpdateItem).not.toHaveBeenCalled();
+ expect(List.addHistoryRecord).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ error: expect.objectContaining({
+ message: "Selected user is not a member of this household",
+ }),
+ })
+ );
+ });
+});
diff --git a/frontend/src/api/list.js b/frontend/src/api/list.js
index 9356365..e75d3fa 100644
--- a/frontend/src/api/list.js
+++ b/frontend/src/api/list.js
@@ -17,16 +17,27 @@ export const getItemByName = (householdId, storeId, itemName) =>
/**
* Add item to list
*/
-export const addItem = (householdId, storeId, itemName, quantity, imageFile = null, notes = null) => {
- const formData = new FormData();
- formData.append("item_name", itemName);
- formData.append("quantity", quantity);
- if (notes) {
- formData.append("notes", notes);
- }
- if (imageFile) {
- formData.append("image", imageFile);
- }
+export const addItem = (
+ householdId,
+ storeId,
+ itemName,
+ quantity,
+ imageFile = null,
+ notes = null,
+ addedForUserId = null
+) => {
+ const formData = new FormData();
+ formData.append("item_name", itemName);
+ formData.append("quantity", quantity);
+ if (notes) {
+ formData.append("notes", notes);
+ }
+ if (addedForUserId != null) {
+ formData.append("added_for_user_id", addedForUserId);
+ }
+ if (imageFile) {
+ formData.append("image", imageFile);
+ }
return api.post(`/households/${householdId}/stores/${storeId}/list/add`, formData, {
headers: {
@@ -108,15 +119,25 @@ export const getRecentlyBought = (householdId, storeId) =>
/**
* Update item image
*/
-export const updateItemImage = (householdId, storeId, itemName, quantity, imageFile) => {
- const formData = new FormData();
- formData.append("item_name", itemName);
- formData.append("quantity", quantity);
- formData.append("image", imageFile);
-
- return api.post(`/households/${householdId}/stores/${storeId}/list/update-image`, formData, {
- headers: {
- "Content-Type": "multipart/form-data",
- },
- });
-};
\ No newline at end of file
+export const updateItemImage = (
+ householdId,
+ storeId,
+ itemName,
+ quantity,
+ imageFile,
+ options = {}
+) => {
+ const formData = new FormData();
+ formData.append("item_name", itemName);
+ formData.append("quantity", quantity);
+ formData.append("image", imageFile);
+
+ return api.post(`/households/${householdId}/stores/${storeId}/list/update-image`, formData, {
+ headers: {
+ "Content-Type": "multipart/form-data",
+ },
+ onUploadProgress: options.onUploadProgress,
+ signal: options.signal,
+ timeout: options.timeoutMs,
+ });
+};
diff --git a/frontend/src/components/common/ToggleButtonGroup.jsx b/frontend/src/components/common/ToggleButtonGroup.jsx
new file mode 100644
index 0000000..761628f
--- /dev/null
+++ b/frontend/src/components/common/ToggleButtonGroup.jsx
@@ -0,0 +1,67 @@
+import "../../styles/components/ToggleButtonGroup.css";
+
+function joinClasses(parts) {
+ return parts.filter(Boolean).join(" ");
+}
+
+export default function ToggleButtonGroup({
+ value,
+ options,
+ onChange,
+ ariaLabel,
+ role = "group",
+ className = "tbg-group",
+ buttonBaseClassName = "tbg-button",
+ buttonClassName,
+ activeClassName = "is-active",
+ inactiveClassName = "is-inactive",
+ sizeClassName = "tbg-size-default"
+}) {
+ const optionCount = Math.max(options.length, 1);
+ const activeIndex =
+ value == null ? -1 : options.findIndex((option) => option.value === value);
+
+ const groupStyle = {
+ "--tbg-option-count": optionCount,
+ "--tbg-active-index": activeIndex >= 0 ? activeIndex : 0
+ };
+
+ return (
+
= 0 && "has-active"])}
+ role={role}
+ aria-label={ariaLabel}
+ style={groupStyle}
+ >
+
+ {options.map((option) => {
+ const isActive = value != null && option.value === value;
+ const handleClick = option.onClick
+ ? option.onClick
+ : onChange
+ ? () => onChange(option.value)
+ : undefined;
+
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/src/components/common/index.js b/frontend/src/components/common/index.js
index 0dc675e..d553900 100644
--- a/frontend/src/components/common/index.js
+++ b/frontend/src/components/common/index.js
@@ -3,5 +3,6 @@ export { default as ErrorMessage } from './ErrorMessage.jsx';
export { default as FloatingActionButton } from './FloatingActionButton.jsx';
export { default as FormInput } from './FormInput.jsx';
export { default as SortDropdown } from './SortDropdown.jsx';
+export { default as ToggleButtonGroup } from './ToggleButtonGroup.jsx';
export { default as UserRoleCard } from './UserRoleCard.jsx';
diff --git a/frontend/src/components/forms/AddItemForm.jsx b/frontend/src/components/forms/AddItemForm.jsx
index 6f889eb..5c55c9a 100644
--- a/frontend/src/components/forms/AddItemForm.jsx
+++ b/frontend/src/components/forms/AddItemForm.jsx
@@ -1,19 +1,49 @@
-import { useState } from "react";
+import { useMemo, useState } from "react";
+import { ToggleButtonGroup } from "../common";
+import AssignItemForModal from "../modals/AssignItemForModal";
import "../../styles/components/AddItemForm.css";
import SuggestionList from "../items/SuggestionList";
-export default function AddItemForm({ onAdd, onSuggest, suggestions, buttonText = "Add" }) {
+export default function AddItemForm({
+ onAdd,
+ onSuggest,
+ suggestions,
+ buttonText = "Add",
+ householdMembers = [],
+ currentUserId = null
+}) {
const [itemName, setItemName] = useState("");
const [quantity, setQuantity] = useState(1);
const [showSuggestions, setShowSuggestions] = useState(false);
+ const [assignmentMode, setAssignmentMode] = useState("me");
+ const [assignedUserId, setAssignedUserId] = useState(null);
+ const [showAssignModal, setShowAssignModal] = useState(false);
+
+ const numericCurrentUserId =
+ currentUserId == null ? null : Number.parseInt(String(currentUserId), 10);
+
+ const otherMembers = useMemo(
+ () => householdMembers.filter((member) => Number(member.id) !== numericCurrentUserId),
+ [householdMembers, numericCurrentUserId]
+ );
+
+ const assignedMemberLabel = useMemo(() => {
+ if (assignmentMode !== "others" || assignedUserId == null) return "";
+ const member = otherMembers.find((item) => Number(item.id) === Number(assignedUserId));
+ return member ? (member.display_name || member.name || member.username || `User ${member.id}`) : "";
+ }, [assignmentMode, assignedUserId, otherMembers]);
const handleSubmit = (e) => {
e.preventDefault();
if (!itemName.trim()) return;
- onAdd(itemName, quantity);
+ const targetUserId = assignmentMode === "others" ? assignedUserId : null;
+ onAdd(itemName, quantity, targetUserId);
setItemName("");
setQuantity(1);
+ setAssignmentMode("me");
+ setAssignedUserId(null);
+ setShowAssignModal(false);
};
const handleInputChange = (text) => {
@@ -35,30 +65,78 @@ export default function AddItemForm({ onAdd, onSuggest, suggestions, buttonText
setQuantity(prev => Math.max(1, prev - 1));
};
+ const handleAssignmentModeChange = (mode) => {
+ if (mode === "me") {
+ setAssignmentMode("me");
+ setAssignedUserId(null);
+ setShowAssignModal(false);
+ return;
+ }
+
+ if (otherMembers.length === 0) {
+ setAssignmentMode("me");
+ setAssignedUserId(null);
+ return;
+ }
+
+ setAssignmentMode("others");
+ setShowAssignModal(true);
+ };
+
+ const handleAssignCancel = () => {
+ setShowAssignModal(false);
+ setAssignmentMode("me");
+ setAssignedUserId(null);
+ };
+
+ const handleAssignConfirm = (memberId) => {
+ setShowAssignModal(false);
+ setAssignmentMode("others");
+ setAssignedUserId(Number(memberId));
+ };
+
const isDisabled = !itemName.trim();
return (
- {householdRole && householdRole !== 'viewer' && (
- setShowAddForm(!showAddForm)}
- />
- )}
-
- {showAddDetailsModal && pendingItem && (
-
);
-}
\ No newline at end of file
+}
diff --git a/frontend/src/styles/components/AddItemForm.css b/frontend/src/styles/components/AddItemForm.css
index acb5491..47be12b 100644
--- a/frontend/src/styles/components/AddItemForm.css
+++ b/frontend/src/styles/components/AddItemForm.css
@@ -1,7 +1,7 @@
/* Add Item Form Container */
.add-item-form-container {
background: var(--color-bg-surface);
- padding: var(--spacing-lg);
+ padding: var(--spacing-md);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-md);
margin-bottom: var(--spacing-xs);
@@ -11,7 +11,7 @@
.add-item-form {
display: flex;
flex-direction: column;
- gap: var(--spacing-sm);
+ gap: var(--spacing-xs);
}
/* Form Fields */
@@ -21,6 +21,28 @@
position: relative;
}
+.add-item-form-input-row {
+ display: flex;
+ align-items: stretch;
+ gap: var(--spacing-xs);
+}
+
+.add-item-form-input-row .add-item-form-field {
+ flex: 1;
+}
+
+.add-item-form-assignee-toggle {
+ flex: 0 0 auto;
+ width: 134px;
+ margin: 0;
+}
+
+.add-item-form-assignee-hint {
+ margin: 0;
+ font-size: var(--font-size-xs);
+ color: var(--color-text-secondary);
+}
+
.add-item-form-input {
padding: var(--input-padding-y) var(--input-padding-x);
border: var(--border-width-thin) solid var(--input-border-color);
@@ -58,7 +80,8 @@
display: flex;
align-items: center;
justify-content: space-between;
- gap: var(--spacing-md);
+ gap: var(--spacing-sm);
+ min-height: 40px;
}
/* Quantity Control */
@@ -66,11 +89,12 @@
display: flex;
align-items: center;
gap: var(--spacing-xs);
+ height: 40px;
}
.quantity-btn {
width: 40px;
- height: 40px;
+ height: 100%;
border: var(--border-width-thin) solid var(--color-border-medium);
background: var(--color-bg-surface);
color: var(--color-text-primary);
@@ -106,6 +130,8 @@
.add-item-form-quantity-input {
width: 40px;
max-width: 40px;
+ height: 100%;
+ box-sizing: border-box;
padding: var(--input-padding-y) var(--input-padding-x);
border: var(--border-width-thin) solid var(--input-border-color);
border-radius: var(--input-border-radius);
@@ -142,9 +168,9 @@
font-size: var(--font-size-base);
font-weight: var(--button-font-weight);
flex: 1;
- min-width: 120px
+ min-width: 120px;
transition: var(--transition-base);
- margin-top: var(--spacing-sm);
+ margin-top: 0;
}
.add-item-form-submit:hover:not(:disabled) {
@@ -173,10 +199,23 @@
.add-item-form-container {
padding: var(--spacing-md);
}
-
+
+ .add-item-form-assignee-toggle {
+ width: 120px;
+ }
+
+ .add-item-form-quantity-control {
+ height: 36px;
+ }
+
.quantity-btn {
width: 36px;
- height: 36px;
+ height: 100%;
font-size: var(--font-size-lg);
}
+
+ .add-item-form-quantity-input,
+ .add-item-form-submit {
+ height: 36px;
+ }
}
diff --git a/frontend/src/styles/components/AssignItemForModal.css b/frontend/src/styles/components/AssignItemForModal.css
new file mode 100644
index 0000000..479dc9c
--- /dev/null
+++ b/frontend/src/styles/components/AssignItemForModal.css
@@ -0,0 +1,19 @@
+.assign-item-for-modal {
+ max-width: 420px;
+}
+
+.assign-item-for-modal-description {
+ margin: 0 0 var(--spacing-md) 0;
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-sm);
+}
+
+.assign-item-for-modal-field {
+ margin-bottom: var(--spacing-sm);
+}
+
+.assign-item-for-modal-empty {
+ margin: 0 0 var(--spacing-sm) 0;
+ color: var(--color-text-secondary);
+ font-size: var(--font-size-sm);
+}
diff --git a/frontend/src/styles/components/ToggleButtonGroup.css b/frontend/src/styles/components/ToggleButtonGroup.css
new file mode 100644
index 0000000..9179a0f
--- /dev/null
+++ b/frontend/src/styles/components/ToggleButtonGroup.css
@@ -0,0 +1,81 @@
+.tbg-group {
+ position: relative;
+ display: grid;
+ grid-template-columns: repeat(var(--tbg-option-count, 1), minmax(0, 1fr));
+ align-items: stretch;
+ gap: 0;
+ padding: 2px;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ background: var(--background);
+ overflow: hidden;
+ isolation: isolate;
+}
+
+.tbg-indicator {
+ position: absolute;
+ top: 2px;
+ bottom: 2px;
+ left: 2px;
+ width: calc((100% - 4px) / var(--tbg-option-count, 1));
+ border-radius: 999px;
+ background: var(--primary);
+ transform: translateX(calc(var(--tbg-active-index, 0) * 100%));
+ transition: transform 0.22s ease, opacity 0.2s ease;
+ opacity: 0;
+ z-index: 0;
+}
+
+.tbg-group.has-active .tbg-indicator {
+ opacity: 1;
+}
+
+.tbg-button {
+ position: relative;
+ z-index: 1;
+ margin: 0;
+ width: 100%;
+ border: none;
+ border-radius: 999px;
+ background: transparent;
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: color var(--transition-fast), background-color var(--transition-fast);
+ white-space: nowrap;
+}
+
+.tbg-button.tbg-size-default {
+ padding: 0.5rem 0.8rem;
+ font-size: 0.9rem;
+ font-weight: 500;
+}
+
+.tbg-button.tbg-size-xs {
+ padding: 0.35rem 0.5rem;
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-semibold);
+}
+
+.tbg-button.is-active {
+ color: var(--color-text-inverse);
+ background: transparent;
+}
+
+.tbg-button.is-inactive:hover:not(:disabled) {
+ color: var(--text-primary);
+ background: rgba(0, 0, 0, 0.04);
+}
+
+[data-theme="dark"] .tbg-button.is-inactive:hover:not(:disabled) {
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.tbg-button:focus-visible {
+ outline: 2px solid var(--primary);
+ outline-offset: -2px;
+}
+
+.tbg-button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}