chore: harden reliability checks #2

Merged
nalalangan merged 67 commits from main-new into main 2026-05-25 14:28:32 -09:00
12 changed files with 1451 additions and 33 deletions
Showing only changes of commit 033dd5dc33 - Show all commits

View File

@ -0,0 +1,55 @@
import api from "./axios";
function appendClassification(formData, classification) {
if (classification === undefined) {
return;
}
formData.append("classification", JSON.stringify(classification));
}
export const getAvailableItems = (householdId, storeId, query = "") =>
api.get(`/households/${householdId}/stores/${storeId}/available-items`, {
params: query ? { query } : undefined,
});
export const createAvailableItem = (householdId, storeId, payload) => {
const formData = new FormData();
formData.append("item_name", payload.itemName);
appendClassification(formData, payload.classification ?? undefined);
if (payload.imageFile) {
formData.append("image", payload.imageFile);
}
return api.post(`/households/${householdId}/stores/${storeId}/available-items`, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
};
export const updateAvailableItem = (householdId, storeId, itemId, payload) => {
const formData = new FormData();
if (payload.itemName !== undefined) {
formData.append("item_name", payload.itemName);
}
appendClassification(formData, payload.classification);
if (payload.removeImage) {
formData.append("remove_image", "true");
}
if (payload.imageFile) {
formData.append("image", payload.imageFile);
}
return api.patch(`/households/${householdId}/stores/${storeId}/available-items/${itemId}`, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
};
export const deleteAvailableItem = (householdId, storeId, itemId) =>
api.delete(`/households/${householdId}/stores/${storeId}/available-items/${itemId}`);
export const importCurrentAvailableItems = (householdId, storeId) =>
api.post(`/households/${householdId}/stores/${storeId}/available-items/import-current`);

View File

@ -6,6 +6,7 @@ import SuggestionList from "../items/SuggestionList";
export default function AddItemForm({ export default function AddItemForm({
onAdd, onAdd,
onOpenCatalog,
onSuggest, onSuggest,
suggestions, suggestions,
buttonText = "Add", buttonText = "Add",
@ -18,6 +19,7 @@ export default function AddItemForm({
const [assignmentMode, setAssignmentMode] = useState("me"); const [assignmentMode, setAssignmentMode] = useState("me");
const [assignedUserId, setAssignedUserId] = useState(null); const [assignedUserId, setAssignedUserId] = useState(null);
const [showAssignModal, setShowAssignModal] = useState(false); const [showAssignModal, setShowAssignModal] = useState(false);
const [pendingAction, setPendingAction] = useState(null);
const numericCurrentUserId = const numericCurrentUserId =
currentUserId == null ? null : Number.parseInt(String(currentUserId), 10); currentUserId == null ? null : Number.parseInt(String(currentUserId), 10);
@ -33,24 +35,31 @@ export default function AddItemForm({
return member ? (member.display_name || member.name || member.username || `User ${member.id}`) : ""; return member ? (member.display_name || member.name || member.username || `User ${member.id}`) : "";
}, [assignmentMode, assignedUserId, otherMembers]); }, [assignmentMode, assignedUserId, otherMembers]);
const resetForm = () => {
setItemName("");
setQuantity(1);
setAssignmentMode("me");
setAssignedUserId(null);
setShowAssignModal(false);
setPendingAction(null);
};
const handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault(); e.preventDefault();
if (!itemName.trim()) return; if (!itemName.trim()) return;
if (assignmentMode === "others" && assignedUserId == null) { if (assignmentMode === "others" && assignedUserId == null) {
if (otherMembers.length > 0) { if (otherMembers.length > 0) {
setPendingAction("submit");
setShowAssignModal(true); setShowAssignModal(true);
} }
return; return;
} }
setPendingAction(null);
const targetUserId = assignmentMode === "others" ? Number(assignedUserId) : null; const targetUserId = assignmentMode === "others" ? Number(assignedUserId) : null;
onAdd(itemName, quantity, targetUserId); onAdd(itemName, quantity, targetUserId);
setItemName(""); resetForm();
setQuantity(1);
setAssignmentMode("me");
setAssignedUserId(null);
setShowAssignModal(false);
}; };
const handleInputChange = (text) => { const handleInputChange = (text) => {
@ -94,12 +103,48 @@ export default function AddItemForm({
setShowAssignModal(false); setShowAssignModal(false);
setAssignmentMode("me"); setAssignmentMode("me");
setAssignedUserId(null); setAssignedUserId(null);
setPendingAction(null);
}; };
const handleAssignConfirm = (memberId) => { const handleAssignConfirm = (memberId) => {
setShowAssignModal(false); setShowAssignModal(false);
setAssignmentMode("others"); setAssignmentMode("others");
setAssignedUserId(Number(memberId)); const parsedMemberId = Number(memberId);
setAssignedUserId(parsedMemberId);
if (pendingAction === "submit" && itemName.trim()) {
onAdd(itemName, quantity, parsedMemberId);
resetForm();
return;
}
if (pendingAction === "catalog" && onOpenCatalog) {
onOpenCatalog({
quantity,
addedForUserId: parsedMemberId,
resetForm,
});
setPendingAction(null);
}
};
const handleCatalogOpen = () => {
if (!onOpenCatalog) return;
if (assignmentMode === "others" && assignedUserId == null) {
if (otherMembers.length > 0) {
setPendingAction("catalog");
setShowAssignModal(true);
}
return;
}
setPendingAction(null);
onOpenCatalog({
quantity,
addedForUserId: assignmentMode === "others" ? Number(assignedUserId) : null,
resetForm,
});
}; };
const isDisabled = !itemName.trim(); const isDisabled = !itemName.trim();
@ -127,6 +172,16 @@ export default function AddItemForm({
)} )}
</div> </div>
{onOpenCatalog ? (
<button
type="button"
className="add-item-form-catalog-btn"
onClick={handleCatalogOpen}
>
Store Items
</button>
) : null}
<ToggleButtonGroup <ToggleButtonGroup
value={assignmentMode} value={assignmentMode}
ariaLabel="Item assignment mode" ariaLabel="Item assignment mode"

View File

@ -5,11 +5,13 @@ import {
removeStoreFromHousehold, removeStoreFromHousehold,
setDefaultStore setDefaultStore
} from "../../api/stores"; } from "../../api/stores";
import StoreAvailableItemsManager from "./StoreAvailableItemsManager";
import { HouseholdContext } from "../../context/HouseholdContext"; import { HouseholdContext } from "../../context/HouseholdContext";
import { StoreContext } from "../../context/StoreContext"; import { StoreContext } from "../../context/StoreContext";
import useActionToast from "../../hooks/useActionToast"; import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage"; import getApiErrorMessage from "../../lib/getApiErrorMessage";
import "../../styles/components/manage/ManageStores.css"; import "../../styles/components/manage/ManageStores.css";
import "../../styles/components/manage/StoreAvailableItemsManager.css";
export default function ManageStores() { export default function ManageStores() {
const { activeHousehold } = useContext(HouseholdContext); const { activeHousehold } = useContext(HouseholdContext);
@ -119,6 +121,11 @@ export default function ManageStores() {
</button> </button>
</div> </div>
)} )}
<StoreAvailableItemsManager
householdId={activeHousehold.id}
store={store}
isAdmin={isAdmin}
/>
</div> </div>
))} ))}
</div> </div>

View File

@ -0,0 +1,230 @@
import { useCallback, useEffect, useState } from "react";
import {
createAvailableItem,
deleteAvailableItem,
getAvailableItems,
importCurrentAvailableItems,
updateAvailableItem,
} from "../../api/availableItems";
import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage";
import AvailableItemEditorModal from "../modals/AvailableItemEditorModal";
function itemImageSource(item) {
if (!item?.item_image) {
return null;
}
const mimeType = item.image_mime_type || "image/jpeg";
return `data:${mimeType};base64,${item.item_image}`;
}
export default function StoreAvailableItemsManager({ householdId, store, isAdmin }) {
const toast = useActionToast();
const [expanded, setExpanded] = useState(false);
const [items, setItems] = useState([]);
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(false);
const [editorItem, setEditorItem] = useState(null);
const [showEditor, setShowEditor] = useState(false);
const loadItems = useCallback(async (search = query) => {
if (!householdId || !store?.id) {
setItems([]);
return;
}
setLoading(true);
try {
const response = await getAvailableItems(householdId, store.id, search);
setItems(response.data.items || []);
} catch (error) {
console.error("Failed to load available items:", error);
const message = getApiErrorMessage(error, "Failed to load available items");
toast.error("Load store items failed", `Load store items failed: ${message}`);
} finally {
setLoading(false);
}
}, [householdId, query, store?.id, toast]);
useEffect(() => {
if (!expanded) {
return;
}
loadItems(query);
}, [expanded, query, loadItems]);
const handleCreate = async (payload) => {
try {
await createAvailableItem(householdId, store.id, payload);
toast.success("Added store item", `Added ${payload.itemName} to ${store.name}`);
setShowEditor(false);
setEditorItem(null);
await loadItems(query);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to add available item");
toast.error("Add store item failed", `Add store item failed: ${message}`);
throw error;
}
};
const handleUpdate = async (payload) => {
try {
await updateAvailableItem(householdId, store.id, editorItem.item_id, payload);
toast.success("Updated store item", `Updated ${payload.itemName} for ${store.name}`);
setShowEditor(false);
setEditorItem(null);
await loadItems(query);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to update available item");
toast.error("Update store item failed", `Update store item failed: ${message}`);
throw error;
}
};
const handleDelete = async (item) => {
if (!confirm(`Remove ${item.item_name} from ${store.name}'s available items?`)) {
return;
}
try {
await deleteAvailableItem(householdId, store.id, item.item_id);
toast.success("Removed store item", `Removed ${item.item_name} from ${store.name}`);
await loadItems(query);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to remove available item");
toast.error("Remove store item failed", `Remove store item failed: ${message}`);
}
};
const handleImport = async () => {
try {
const response = await importCurrentAvailableItems(householdId, store.id);
const importedCount = response.data.imported_count || 0;
toast.success(
"Imported current list items",
importedCount > 0
? `Imported ${importedCount} current list items into ${store.name}`
: `No current list items to import for ${store.name}`
);
await loadItems(query);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to import current list items");
toast.error("Import store items failed", `Import store items failed: ${message}`);
}
};
if (!isAdmin) {
return null;
}
return (
<div className="store-available-items">
<div className="store-available-items-header">
<div>
<h4>Available Items</h4>
<p>Curate what members see for {store.name}.</p>
</div>
<button
type="button"
className="btn-secondary btn-small"
onClick={() => setExpanded((value) => !value)}
>
{expanded ? "Hide" : "Manage"}
</button>
</div>
{expanded ? (
<div className="store-available-items-panel">
<div className="store-available-items-toolbar">
<input
className="store-available-items-search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search store items"
/>
<div className="store-available-items-toolbar-actions">
<button
type="button"
className="btn-secondary btn-small"
onClick={handleImport}
>
Import Current List
</button>
<button
type="button"
className="btn-primary btn-small"
onClick={() => {
setEditorItem(null);
setShowEditor(true);
}}
>
Add Item
</button>
</div>
</div>
{loading ? (
<p className="empty-message">Loading store items...</p>
) : items.length === 0 ? (
<p className="empty-message">No available items saved for this store yet.</p>
) : (
<div className="store-available-items-list">
{items.map((item) => {
const imageSrc = itemImageSource(item);
const details = [item.item_type, item.item_group, item.zone].filter(Boolean);
return (
<div key={item.item_id} className="store-available-items-card">
<div className="store-available-items-summary">
{imageSrc ? (
<img src={imageSrc} alt="" className="store-available-items-thumb" />
) : (
<span className="store-available-items-thumb store-available-items-thumb-placeholder">
{item.item_name?.slice(0, 1).toUpperCase() || "?"}
</span>
)}
<div className="store-available-items-copy">
<strong>{item.item_name}</strong>
<span>{details.join(" | ") || "No store defaults set"}</span>
</div>
</div>
<div className="store-available-items-actions">
<button
type="button"
className="btn-secondary btn-small"
onClick={() => {
setEditorItem(item);
setShowEditor(true);
}}
>
Edit
</button>
<button
type="button"
className="btn-danger btn-small"
onClick={() => handleDelete(item)}
>
Remove
</button>
</div>
</div>
);
})}
</div>
)}
</div>
) : null}
<AvailableItemEditorModal
isOpen={showEditor}
item={editorItem}
onCancel={() => {
setShowEditor(false);
setEditorItem(null);
}}
onSave={editorItem ? handleUpdate : handleCreate}
/>
</div>
);
}

View File

@ -0,0 +1,165 @@
import { useEffect, useState } from "react";
import ClassificationSection from "../forms/ClassificationSection";
import ImageUploadSection from "../forms/ImageUploadSection";
import useActionToast from "../../hooks/useActionToast";
import "../../styles/components/AvailableItemEditorModal.css";
function buildPreview(item) {
if (!item?.item_image) {
return null;
}
const mimeType = item.image_mime_type || "image/jpeg";
return `data:${mimeType};base64,${item.item_image}`;
}
export default function AvailableItemEditorModal({ isOpen, item = null, onCancel, onSave }) {
const toast = useActionToast();
const [itemName, setItemName] = useState("");
const [itemType, setItemType] = useState("");
const [itemGroup, setItemGroup] = useState("");
const [zone, setZone] = useState("");
const [selectedImage, setSelectedImage] = useState(null);
const [imagePreview, setImagePreview] = useState(null);
const [removeImage, setRemoveImage] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!isOpen) {
return;
}
setItemName(item?.item_name || "");
setItemType(item?.item_type || "");
setItemGroup(item?.item_group || "");
setZone(item?.zone || "");
setSelectedImage(null);
setImagePreview(buildPreview(item));
setRemoveImage(false);
}, [isOpen, item]);
if (!isOpen) {
return null;
}
const handleItemTypeChange = (nextType) => {
setItemType(nextType);
setItemGroup("");
};
const handleImageChange = (file) => {
setSelectedImage(file);
setRemoveImage(false);
const reader = new FileReader();
reader.onloadend = () => {
setImagePreview(reader.result);
};
reader.readAsDataURL(file);
};
const handleImageRemove = () => {
setSelectedImage(null);
setImagePreview(null);
setRemoveImage(Boolean(item?.item_image));
};
const handleSubmit = async () => {
if (!itemName.trim()) {
toast.error("Save available item failed", "Save available item failed: Item name is required");
return;
}
if (itemType && !itemGroup) {
toast.error(
"Save available item failed",
`Save available item failed: Select an item group for ${itemName.trim()}`
);
return;
}
setSaving(true);
try {
await onSave({
itemName: itemName.trim(),
classification: itemType || itemGroup || zone
? {
item_type: itemType || null,
item_group: itemGroup || null,
zone: zone || null,
}
: null,
imageFile: selectedImage,
removeImage,
});
} finally {
setSaving(false);
}
};
return (
<div className="available-item-editor-overlay" onClick={onCancel}>
<div className="available-item-editor-modal" onClick={(event) => event.stopPropagation()}>
<h2 className="available-item-editor-title">
{item ? `Edit ${item.item_name}` : "Add Available Item"}
</h2>
<p className="available-item-editor-subtitle">
Save store-specific item defaults for this household.
</p>
<div className="available-item-editor-field">
<label htmlFor="available-item-name">Item Name</label>
<input
id="available-item-name"
className="available-item-editor-input"
value={itemName}
onChange={(event) => setItemName(event.target.value)}
placeholder="Enter item name"
/>
</div>
<div className="available-item-editor-section">
<ImageUploadSection
imagePreview={imagePreview}
onImageChange={handleImageChange}
onImageRemove={handleImageRemove}
title="Store Image (Optional)"
/>
</div>
<div className="available-item-editor-section">
<ClassificationSection
itemType={itemType}
itemGroup={itemGroup}
zone={zone}
onItemTypeChange={handleItemTypeChange}
onItemGroupChange={setItemGroup}
onZoneChange={setZone}
fieldClass="available-item-editor-field"
selectClass="available-item-editor-select"
title="Store Classification (Optional)"
/>
</div>
<div className="available-item-editor-actions">
<button
type="button"
className="available-item-editor-btn available-item-editor-btn-cancel"
onClick={onCancel}
disabled={saving}
>
Cancel
</button>
<button
type="button"
className="available-item-editor-btn available-item-editor-btn-save"
onClick={handleSubmit}
disabled={saving}
>
{saving ? "Saving..." : item ? "Save Changes" : "Add Item"}
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,92 @@
import "../../styles/components/AvailableItemsPickerModal.css";
function itemImageSource(item) {
if (!item?.item_image) {
return null;
}
const mimeType = item.image_mime_type || "image/jpeg";
return `data:${mimeType};base64,${item.item_image}`;
}
export default function AvailableItemsPickerModal({
isOpen,
items,
loading,
query,
onClose,
onQueryChange,
onSelect,
}) {
if (!isOpen) {
return null;
}
return (
<div className="available-items-picker-overlay" onClick={onClose}>
<div className="available-items-picker-modal" onClick={(event) => event.stopPropagation()}>
<div className="available-items-picker-header">
<div>
<h2 className="available-items-picker-title">Store Items</h2>
<p className="available-items-picker-subtitle">
Pick from your household&apos;s available items for this store.
</p>
</div>
<button
type="button"
className="available-items-picker-close"
onClick={onClose}
aria-label="Close store items picker"
>
x
</button>
</div>
<input
className="available-items-picker-search"
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder="Search available items"
/>
<div className="available-items-picker-list">
{loading ? (
<p className="available-items-picker-empty">Loading store items...</p>
) : items.length === 0 ? (
<p className="available-items-picker-empty">No matching store items found.</p>
) : (
items.map((item) => {
const imageSrc = itemImageSource(item);
return (
<button
type="button"
key={item.item_id}
className="available-items-picker-item"
onClick={() => onSelect(item)}
>
{imageSrc ? (
<img
src={imageSrc}
alt=""
className="available-items-picker-thumb"
/>
) : (
<span className="available-items-picker-thumb available-items-picker-thumb-placeholder">
{item.item_name?.slice(0, 1).toUpperCase() || "?"}
</span>
)}
<span className="available-items-picker-copy">
<span className="available-items-picker-name">{item.item_name}</span>
<span className="available-items-picker-meta">
{[item.item_type, item.item_group, item.zone].filter(Boolean).join(" | ") || "No store defaults"}
</span>
</span>
</button>
);
})
)}
</div>
</div>
</div>
);
}

View File

@ -4,18 +4,20 @@ import {
addItem, addItem,
getClassification, getClassification,
getItemByName, getItemByName,
getList, getList,
getRecentlyBought, getRecentlyBought,
getSuggestions, getSuggestions,
markBought, markBought,
updateItemWithClassification updateItemWithClassification
} from "../api/list"; } from "../api/list";
import { getAvailableItems } from "../api/availableItems";
import { getHouseholdMembers } from "../api/households"; import { getHouseholdMembers } from "../api/households";
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 AvailableItemsPickerModal from "../components/modals/AvailableItemsPickerModal";
import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal";
import EditItemModal from "../components/modals/EditItemModal"; import EditItemModal from "../components/modals/EditItemModal";
import SimilarItemModal from "../components/modals/SimilarItemModal"; import SimilarItemModal from "../components/modals/SimilarItemModal";
import StoreTabs from "../components/store/StoreTabs"; import StoreTabs from "../components/store/StoreTabs";
@ -60,9 +62,14 @@ export default function GroceryList() {
const [showAddDetailsModal, setShowAddDetailsModal] = useState(false); const [showAddDetailsModal, setShowAddDetailsModal] = useState(false);
const [showSimilarModal, setShowSimilarModal] = useState(false); const [showSimilarModal, setShowSimilarModal] = useState(false);
const [similarItemSuggestion, setSimilarItemSuggestion] = useState(null); const [similarItemSuggestion, setSimilarItemSuggestion] = useState(null);
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const [editingItem, setEditingItem] = useState(null); const [editingItem, setEditingItem] = useState(null);
const [recentlyBoughtCollapsed, setRecentlyBoughtCollapsed] = useState(settings.recentlyBoughtCollapsed); const [showAvailableItemsPicker, setShowAvailableItemsPicker] = useState(false);
const [availableItemsQuery, setAvailableItemsQuery] = useState("");
const [availableItems, setAvailableItems] = useState([]);
const [availableItemsLoading, setAvailableItemsLoading] = useState(false);
const [availableItemsContext, setAvailableItemsContext] = useState(null);
const [recentlyBoughtCollapsed, setRecentlyBoughtCollapsed] = useState(settings.recentlyBoughtCollapsed);
const [collapsedZones, setCollapsedZones] = useState({}); const [collapsedZones, setCollapsedZones] = useState({});
const [showConfirmAddExisting, setShowConfirmAddExisting] = useState(false); const [showConfirmAddExisting, setShowConfirmAddExisting] = useState(false);
const [confirmAddExistingData, setConfirmAddExistingData] = useState(null); const [confirmAddExistingData, setConfirmAddExistingData] = useState(null);
@ -125,6 +132,34 @@ export default function GroceryList() {
loadHouseholdMembers(); loadHouseholdMembers();
}, [activeHousehold?.id]); }, [activeHousehold?.id]);
useEffect(() => {
const loadAvailableStoreItems = async () => {
if (!showAvailableItemsPicker) return;
if (!activeHousehold?.id || !activeStore?.id) return;
setAvailableItemsLoading(true);
try {
const response = await getAvailableItems(activeHousehold.id, activeStore.id, availableItemsQuery);
setAvailableItems(response.data.items || []);
} catch (error) {
console.error("Failed to load available store items:", error);
const message = getApiErrorMessage(error, "Failed to load store items");
toast.error("Load store items failed", `Load store items failed: ${message}`);
setAvailableItems([]);
} finally {
setAvailableItemsLoading(false);
}
};
loadAvailableStoreItems();
}, [
activeHousehold?.id,
activeStore?.id,
availableItemsQuery,
showAvailableItemsPicker,
toast,
]);
useEffect(() => { useEffect(() => {
const handleUploadSuccess = async (event) => { const handleUploadSuccess = async (event) => {
const detail = event?.detail || {}; const detail = event?.detail || {};
@ -298,6 +333,25 @@ export default function GroceryList() {
} }
}, [activeHousehold?.id, activeStore?.id, items, recentlyBoughtItems, buttonText]); }, [activeHousehold?.id, activeStore?.id, items, recentlyBoughtItems, buttonText]);
const handleOpenAvailableItemsPicker = useCallback((context) => {
setAvailableItemsContext(context);
setAvailableItemsQuery("");
setAvailableItems([]);
setShowAvailableItemsPicker(true);
}, []);
const handleAvailableItemSelect = useCallback(async (item) => {
setShowAvailableItemsPicker(false);
setAvailableItems([]);
setAvailableItemsQuery("");
const context = availableItemsContext || {};
context.resetForm?.();
setAvailableItemsContext(null);
await handleAdd(item.item_name, context.quantity || 1, context.addedForUserId || null);
}, [availableItemsContext, handleAdd]);
const processItemAddition = useCallback(async (itemName, quantity, options = {}) => { const processItemAddition = useCallback(async (itemName, quantity, options = {}) => {
if (!activeHousehold?.id || !activeStore?.id) return; if (!activeHousehold?.id || !activeStore?.id) return;
@ -592,19 +646,19 @@ export default function GroceryList() {
}, [activeHousehold?.id, activeStore?.id, enqueueImageUpload, toast]); }, [activeHousehold?.id, activeStore?.id, enqueueImageUpload, toast]);
const handleLongPress = useCallback(async (item) => { const handleLongPress = useCallback(async (item) => {
if (!householdRole || householdRole === 'viewer') return; if (!householdRole || householdRole === 'viewer') return;
if (!activeHousehold?.id || !activeStore?.id) return; if (!activeHousehold?.id || !activeStore?.id) return;
try { try {
const classificationResponse = await getClassification(activeHousehold.id, activeStore.id, item.id); const classificationResponse = await getClassification(activeHousehold.id, activeStore.id, item.item_name);
setEditingItem({ setEditingItem({
...item, ...item,
classification: classificationResponse.data classification: classificationResponse.data?.classification || null
}); });
setShowEditModal(true); setShowEditModal(true);
} catch (error) { } catch (error) {
console.error("Failed to load classification:", error); console.error("Failed to load classification:", error);
setEditingItem({ ...item, classification: null }); setEditingItem({ ...item, classification: null });
setShowEditModal(true); setShowEditModal(true);
} }
@ -757,6 +811,7 @@ export default function GroceryList() {
{householdRole && householdRole !== 'viewer' && ( {householdRole && householdRole !== 'viewer' && (
<AddItemForm <AddItemForm
onAdd={handleAdd} onAdd={handleAdd}
onOpenCatalog={handleOpenAvailableItemsPicker}
onSuggest={handleSuggest} onSuggest={handleSuggest}
suggestions={suggestions} suggestions={suggestions}
buttonText={buttonText} buttonText={buttonText}
@ -909,18 +964,33 @@ export default function GroceryList() {
/> />
)} )}
{showConfirmAddExisting && confirmAddExistingData && ( {showConfirmAddExisting && confirmAddExistingData && (
<ConfirmAddExistingModal <ConfirmAddExistingModal
itemName={confirmAddExistingData.itemName} itemName={confirmAddExistingData.itemName}
currentQuantity={confirmAddExistingData.currentQuantity} currentQuantity={confirmAddExistingData.currentQuantity}
addingQuantity={confirmAddExistingData.addingQuantity} addingQuantity={confirmAddExistingData.addingQuantity}
onConfirm={handleConfirmAddExisting} onConfirm={handleConfirmAddExisting}
onCancel={() => { onCancel={() => {
setShowConfirmAddExisting(false); setShowConfirmAddExisting(false);
setConfirmAddExistingData(null); setConfirmAddExistingData(null);
}} }}
/> />
)} )}
</div>
); <AvailableItemsPickerModal
isOpen={showAvailableItemsPicker}
items={availableItems}
loading={availableItemsLoading}
query={availableItemsQuery}
onQueryChange={setAvailableItemsQuery}
onClose={() => {
setShowAvailableItemsPicker(false);
setAvailableItemsContext(null);
setAvailableItemsQuery("");
setAvailableItems([]);
}}
onSelect={handleAvailableItemSelect}
/>
</div>
);
} }

View File

@ -37,6 +37,23 @@
margin: 0; margin: 0;
} }
.add-item-form-catalog-btn {
flex: 0 0 auto;
min-width: 108px;
border: var(--border-width-thin) solid var(--color-primary);
background: var(--color-bg-surface);
color: var(--color-primary);
border-radius: var(--button-border-radius);
padding: 0 var(--spacing-sm);
font-weight: var(--font-weight-semibold);
cursor: pointer;
transition: var(--transition-base);
}
.add-item-form-catalog-btn:hover {
background: var(--color-primary-light);
}
.add-item-form-assignee-hint { .add-item-form-assignee-hint {
margin: 0; margin: 0;
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
@ -204,6 +221,11 @@
width: 100px; width: 100px;
} }
.add-item-form-catalog-btn {
min-width: 96px;
font-size: var(--font-size-sm);
}
.add-item-form-quantity-control { .add-item-form-quantity-control {
height: 36px; height: 36px;
} }

View File

@ -0,0 +1,109 @@
.available-item-editor-overlay {
position: fixed;
inset: 0;
background: var(--modal-backdrop-bg);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal);
padding: var(--spacing-md);
}
.available-item-editor-modal {
width: min(560px, 100%);
max-height: 90vh;
overflow-y: auto;
background: var(--modal-bg);
border-radius: var(--border-radius-xl);
box-shadow: var(--shadow-xl);
padding: var(--spacing-lg);
}
.available-item-editor-title {
margin: 0;
color: var(--color-text-primary);
font-size: var(--font-size-xl);
}
.available-item-editor-subtitle {
margin: var(--spacing-xs) 0 var(--spacing-lg);
color: var(--color-text-secondary);
}
.available-item-editor-section {
margin-top: var(--spacing-lg);
}
.available-item-editor-field {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-md);
}
.available-item-editor-field label {
color: var(--color-text-primary);
font-weight: var(--font-weight-semibold);
}
.available-item-editor-input,
.available-item-editor-select {
width: 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);
background: var(--color-bg-surface);
color: var(--color-text-primary);
}
.available-item-editor-input:focus,
.available-item-editor-select:focus {
outline: none;
border-color: var(--input-focus-border-color);
box-shadow: var(--input-focus-shadow);
}
.available-item-editor-actions {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-lg);
}
.available-item-editor-btn {
flex: 1;
min-height: 42px;
border: none;
border-radius: var(--button-border-radius);
font-weight: var(--button-font-weight);
cursor: pointer;
transition: var(--transition-base);
}
.available-item-editor-btn-cancel {
background: var(--color-secondary);
color: var(--color-text-inverse);
}
.available-item-editor-btn-cancel:hover:not(:disabled) {
background: var(--color-secondary-hover);
}
.available-item-editor-btn-save {
background: var(--color-primary);
color: var(--color-text-inverse);
}
.available-item-editor-btn-save:hover:not(:disabled) {
background: var(--color-primary-hover);
}
@media (max-width: 640px) {
.available-item-editor-modal {
padding: var(--spacing-md);
}
.available-item-editor-actions {
flex-direction: column;
}
}

View File

@ -0,0 +1,144 @@
.available-items-picker-overlay {
position: fixed;
inset: 0;
background: var(--modal-backdrop-bg);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal);
padding: var(--spacing-md);
}
.available-items-picker-modal {
width: min(680px, 100%);
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
background: var(--modal-bg);
border-radius: var(--border-radius-xl);
box-shadow: var(--shadow-xl);
padding: var(--spacing-lg);
}
.available-items-picker-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-md);
}
.available-items-picker-title {
margin: 0;
color: var(--color-text-primary);
font-size: var(--font-size-xl);
}
.available-items-picker-subtitle {
margin: var(--spacing-xs) 0 0;
color: var(--color-text-secondary);
}
.available-items-picker-close {
border: none;
background: transparent;
color: var(--color-text-secondary);
font-size: var(--font-size-xl);
cursor: pointer;
}
.available-items-picker-search {
margin-top: var(--spacing-md);
width: 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);
background: var(--color-bg-surface);
color: var(--color-text-primary);
}
.available-items-picker-search:focus {
outline: none;
border-color: var(--input-focus-border-color);
box-shadow: var(--input-focus-shadow);
}
.available-items-picker-list {
margin-top: var(--spacing-md);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.available-items-picker-item {
display: flex;
align-items: center;
gap: var(--spacing-md);
width: 100%;
text-align: left;
background: var(--color-bg-surface);
border: var(--border-width-thin) solid var(--color-border-light);
border-radius: var(--border-radius-lg);
padding: var(--spacing-md);
cursor: pointer;
transition: var(--transition-base);
}
.available-items-picker-item:hover {
border-color: var(--color-primary);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.available-items-picker-thumb {
width: 56px;
height: 56px;
border-radius: var(--border-radius-md);
object-fit: cover;
flex-shrink: 0;
background: var(--color-bg-muted);
}
.available-items-picker-thumb-placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
font-weight: var(--font-weight-semibold);
}
.available-items-picker-copy {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
min-width: 0;
}
.available-items-picker-name {
color: var(--color-text-primary);
font-weight: var(--font-weight-semibold);
}
.available-items-picker-meta {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.available-items-picker-empty {
margin: 0;
padding: var(--spacing-lg) 0;
text-align: center;
color: var(--color-text-secondary);
}
@media (max-width: 640px) {
.available-items-picker-modal {
padding: var(--spacing-md);
}
.available-items-picker-item {
padding: var(--spacing-sm);
}
}

View File

@ -0,0 +1,134 @@
.store-available-items {
border-top: var(--border-width-thin) solid var(--color-border-light);
padding-top: var(--spacing-md);
}
.store-available-items-header {
display: flex;
justify-content: space-between;
gap: var(--spacing-md);
align-items: flex-start;
}
.store-available-items-header h4 {
margin: 0;
color: var(--color-text-primary);
font-size: var(--font-size-base);
}
.store-available-items-header p {
margin: var(--spacing-xs) 0 0;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.store-available-items-panel {
margin-top: var(--spacing-md);
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.store-available-items-toolbar {
display: flex;
gap: var(--spacing-sm);
align-items: center;
flex-wrap: wrap;
}
.store-available-items-search {
flex: 1 1 240px;
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);
background: var(--color-bg-surface);
color: var(--color-text-primary);
}
.store-available-items-toolbar-actions {
display: flex;
gap: var(--spacing-sm);
flex-wrap: wrap;
}
.store-available-items-list {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.store-available-items-card {
display: flex;
justify-content: space-between;
gap: var(--spacing-sm);
align-items: center;
padding: var(--spacing-sm);
border: var(--border-width-thin) solid var(--color-border-light);
border-radius: var(--border-radius-md);
background: var(--color-bg-surface);
}
.store-available-items-summary {
display: flex;
align-items: center;
gap: var(--spacing-sm);
min-width: 0;
}
.store-available-items-thumb {
width: 48px;
height: 48px;
border-radius: var(--border-radius-md);
object-fit: cover;
background: var(--color-bg-muted);
flex-shrink: 0;
}
.store-available-items-thumb-placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
font-weight: var(--font-weight-semibold);
}
.store-available-items-copy {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.store-available-items-copy strong {
color: var(--color-text-primary);
}
.store-available-items-copy span {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.store-available-items-actions {
display: flex;
gap: var(--spacing-xs);
flex-wrap: wrap;
}
@media (max-width: 640px) {
.store-available-items-header,
.store-available-items-card,
.store-available-items-toolbar {
flex-direction: column;
align-items: stretch;
}
.store-available-items-actions,
.store-available-items-toolbar-actions {
width: 100%;
}
.store-available-items-actions button,
.store-available-items-toolbar-actions button {
flex: 1;
}
}

View File

@ -0,0 +1,335 @@
import { expect, test } from "@playwright/test";
function seedAuthStorage(page: import("@playwright/test").Page) {
return page.addInitScript(() => {
localStorage.setItem("token", "test-token");
localStorage.setItem("userId", "1");
localStorage.setItem("role", "admin");
localStorage.setItem("username", "catalog-user");
});
}
async function mockConfig(page: import("@playwright/test").Page) {
await page.route("**/config", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
maxFileSizeMB: 20,
maxImageDimension: 800,
imageQuality: 85,
}),
});
});
}
async function mockHouseholdAndStoreShell(page: import("@playwright/test").Page) {
await page.route("**/households", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ id: 1, name: "Catalog House", role: "admin", invite_code: "ABCD1234" },
]),
});
});
await page.route("**/stores/household/1", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
]),
});
});
}
test("manage stores lets admins import and curate available items", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
await mockHouseholdAndStoreShell(page);
let availableItems = [
{
item_id: 501,
item_name: "milk",
item_image: null,
image_mime_type: null,
item_type: "dairy",
item_group: "Milk",
zone: "Dairy & Refrigerated",
},
];
await page.route("**/stores", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: 10, name: "Costco" }]),
});
});
await page.route("**/households/1/stores/10/available-items/import-current", async (route) => {
availableItems = [
...availableItems,
{
item_id: 777,
item_name: "granola",
item_image: null,
image_mime_type: null,
item_type: null,
item_group: null,
zone: null,
},
];
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
message: "Imported current list items",
imported_count: 1,
}),
});
});
await page.route("**/households/1/stores/10/available-items*", async (route) => {
const request = route.request();
const url = new URL(request.url());
const query = (url.searchParams.get("query") || "").toLowerCase();
if (request.method() === "GET") {
const filteredItems = availableItems.filter((item) => item.item_name.includes(query));
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: filteredItems }),
});
return;
}
if (request.method() === "POST") {
availableItems = [
...availableItems,
{
item_id: 888,
item_name: "trail mix",
item_image: null,
image_mime_type: null,
item_type: "snack",
item_group: "Trail Mix",
zone: "Snacks & Candy",
},
];
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({
message: "Available item added",
item: availableItems[availableItems.length - 1],
}),
});
return;
}
await route.fulfill({ status: 500 });
});
await page.route("**/households/1/stores/10/available-items/888", async (route) => {
if (route.request().method() === "DELETE") {
availableItems = availableItems.filter((item) => item.item_id !== 888);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ message: "Available item removed" }),
});
return;
}
await route.fulfill({ status: 500 });
});
await page.goto("/manage?tab=stores");
const storeCard = page.locator(".store-card").filter({ hasText: "Costco" });
await expect(storeCard).toBeVisible();
await storeCard.getByRole("button", { name: "Manage" }).click();
await expect(storeCard.getByText("milk")).toBeVisible();
await storeCard.getByRole("button", { name: "Import Current List" }).click();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Imported current list items");
await expect(storeCard.getByText("granola")).toBeVisible();
await storeCard.getByRole("button", { name: "Add Item" }).click();
const editorModal = page.locator(".available-item-editor-modal");
await expect(editorModal).toBeVisible();
await editorModal.getByLabel("Item Name").fill("trail mix");
await editorModal.locator(".available-item-editor-select").nth(0).selectOption("snack");
await editorModal.locator(".available-item-editor-select").nth(1).selectOption("Trail Mix");
await editorModal.locator(".available-item-editor-select").nth(2).selectOption("Snacks & Candy");
await editorModal.getByRole("button", { name: "Add Item" }).click();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Added store item");
await expect(storeCard.getByText("trail mix")).toBeVisible();
page.once("dialog", (dialog) => dialog.accept());
await storeCard.locator(".store-available-items-card").filter({ hasText: "trail mix" }).getByRole("button", { name: "Remove" }).click();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Removed store item");
});
test("grocery picker uses available items and preserves quantity and assignee", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
await mockHouseholdAndStoreShell(page);
const members = [
{ id: 1, username: "owner", name: "Owner User", display_name: "Owner User", role: "owner" },
{ id: 2, username: "casey", name: "Casey Client", display_name: "Casey Client", role: "member" },
];
let lastAddBody = "";
let currentItems: Array<{
id: number;
item_id: number;
item_name: string;
quantity: number;
bought: boolean;
item_image: string | null;
image_mime_type: string | null;
added_by_users: string[];
last_added_on: string;
item_type: string | null;
item_group: string | null;
zone: string | null;
}> = [];
await page.route("**/households/1/members", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(members),
});
});
await page.route("**/households/1/stores/10/available-items*", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
item_id: 600,
item_name: "bananas",
item_image: null,
image_mime_type: null,
item_type: "produce",
item_group: "Fresh Fruit",
zone: "Fresh Produce",
},
],
}),
});
});
await page.route("**/households/1/stores/10/list/recent", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/households/1/stores/10/list/suggestions**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ item_name: "bananas" }]),
});
});
await page.route("**/households/1/stores/10/list/item**", async (route) => {
const request = route.request();
const url = new URL(request.url());
const itemName = (url.searchParams.get("item_name") || "").toLowerCase();
const item = currentItems.find((candidate) => candidate.item_name === itemName);
if (request.method() === "GET") {
await route.fulfill({
status: item ? 200 : 404,
contentType: "application/json",
body: JSON.stringify(item || { message: "Item not found" }),
});
return;
}
await route.fulfill({ status: 500 });
});
await page.route("**/households/1/stores/10/list/add", async (route) => {
lastAddBody = route.request().postData() || "";
currentItems = [
{
id: 201,
item_id: 600,
item_name: "bananas",
quantity: 3,
bought: false,
item_image: null,
image_mime_type: null,
added_by_users: ["Casey Client"],
last_added_on: "2026-03-28T12:00:00.000Z",
item_type: "produce",
item_group: "Fresh Fruit",
zone: "Fresh Produce",
},
];
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
message: "Item added",
item: {
id: 201,
item_name: "bananas",
quantity: 3,
bought: false,
},
}),
});
});
await page.route("**/households/1/stores/10/list", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: currentItems }),
});
});
await page.goto("/");
await page.getByRole("button", { name: "Others" }).click();
const assignModal = page.locator(".assign-item-for-modal");
await assignModal.getByRole("button", { name: "Select member" }).click();
await page.locator("body > .assign-item-for-dropdown-menu").getByRole("option", { name: "Casey Client" }).click();
await assignModal.getByRole("button", { name: "Confirm" }).click();
await page.getByRole("button", { name: "+" }).click();
await page.getByRole("button", { name: "+" }).click();
await expect(page.locator(".add-item-form-quantity-input")).toHaveValue("3");
await page.getByRole("button", { name: "Store Items" }).click();
const pickerModal = page.locator(".available-items-picker-modal");
await expect(pickerModal).toBeVisible();
await pickerModal.getByRole("button", { name: /bananas/i }).click();
await page.getByRole("button", { name: "Skip All" }).click();
await expect(page.locator(".glist-li").filter({ hasText: "bananas" })).toContainText("Casey Client");
expect(lastAddBody).toContain('name="quantity"');
expect(lastAddBody).toContain("3");
expect(lastAddBody).toContain('name="added_for_user_id"');
expect(lastAddBody).toContain("2");
});