From 033dd5dc33bedc7fa7bfe9a804f14499dadf8427 Mon Sep 17 00:00:00 2001 From: Nico Date: Sat, 28 Mar 2026 22:46:53 -0700 Subject: [PATCH] feat: add store catalog ui --- frontend/src/api/availableItems.js | 55 +++ frontend/src/components/forms/AddItemForm.jsx | 67 +++- .../src/components/manage/ManageStores.jsx | 7 + .../manage/StoreAvailableItemsManager.jsx | 230 ++++++++++++ .../modals/AvailableItemEditorModal.jsx | 165 +++++++++ .../modals/AvailableItemsPickerModal.jsx | 92 +++++ frontend/src/pages/GroceryList.jsx | 124 +++++-- .../src/styles/components/AddItemForm.css | 22 ++ .../components/AvailableItemEditorModal.css | 109 ++++++ .../components/AvailableItemsPickerModal.css | 144 ++++++++ .../manage/StoreAvailableItemsManager.css | 134 +++++++ .../tests/available-items-catalog.spec.ts | 335 ++++++++++++++++++ 12 files changed, 1451 insertions(+), 33 deletions(-) create mode 100644 frontend/src/api/availableItems.js create mode 100644 frontend/src/components/manage/StoreAvailableItemsManager.jsx create mode 100644 frontend/src/components/modals/AvailableItemEditorModal.jsx create mode 100644 frontend/src/components/modals/AvailableItemsPickerModal.jsx create mode 100644 frontend/src/styles/components/AvailableItemEditorModal.css create mode 100644 frontend/src/styles/components/AvailableItemsPickerModal.css create mode 100644 frontend/src/styles/components/manage/StoreAvailableItemsManager.css create mode 100644 frontend/tests/available-items-catalog.spec.ts diff --git a/frontend/src/api/availableItems.js b/frontend/src/api/availableItems.js new file mode 100644 index 0000000..bac18d9 --- /dev/null +++ b/frontend/src/api/availableItems.js @@ -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`); diff --git a/frontend/src/components/forms/AddItemForm.jsx b/frontend/src/components/forms/AddItemForm.jsx index 3e5b934..0aaf45b 100644 --- a/frontend/src/components/forms/AddItemForm.jsx +++ b/frontend/src/components/forms/AddItemForm.jsx @@ -6,6 +6,7 @@ import SuggestionList from "../items/SuggestionList"; export default function AddItemForm({ onAdd, + onOpenCatalog, onSuggest, suggestions, buttonText = "Add", @@ -18,6 +19,7 @@ export default function AddItemForm({ const [assignmentMode, setAssignmentMode] = useState("me"); const [assignedUserId, setAssignedUserId] = useState(null); const [showAssignModal, setShowAssignModal] = useState(false); + const [pendingAction, setPendingAction] = useState(null); const numericCurrentUserId = 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}`) : ""; }, [assignmentMode, assignedUserId, otherMembers]); + const resetForm = () => { + setItemName(""); + setQuantity(1); + setAssignmentMode("me"); + setAssignedUserId(null); + setShowAssignModal(false); + setPendingAction(null); + }; + const handleSubmit = (e) => { e.preventDefault(); if (!itemName.trim()) return; if (assignmentMode === "others" && assignedUserId == null) { if (otherMembers.length > 0) { + setPendingAction("submit"); setShowAssignModal(true); } return; } + setPendingAction(null); const targetUserId = assignmentMode === "others" ? Number(assignedUserId) : null; onAdd(itemName, quantity, targetUserId); - setItemName(""); - setQuantity(1); - setAssignmentMode("me"); - setAssignedUserId(null); - setShowAssignModal(false); + resetForm(); }; const handleInputChange = (text) => { @@ -94,12 +103,48 @@ export default function AddItemForm({ setShowAssignModal(false); setAssignmentMode("me"); setAssignedUserId(null); + setPendingAction(null); }; const handleAssignConfirm = (memberId) => { setShowAssignModal(false); 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(); @@ -127,6 +172,16 @@ export default function AddItemForm({ )} + {onOpenCatalog ? ( + + ) : null} + )} + ))} diff --git a/frontend/src/components/manage/StoreAvailableItemsManager.jsx b/frontend/src/components/manage/StoreAvailableItemsManager.jsx new file mode 100644 index 0000000..5d8bcdf --- /dev/null +++ b/frontend/src/components/manage/StoreAvailableItemsManager.jsx @@ -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 ( +
+
+
+

Available Items

+

Curate what members see for {store.name}.

+
+ +
+ + {expanded ? ( +
+
+ setQuery(event.target.value)} + placeholder="Search store items" + /> +
+ + +
+
+ + {loading ? ( +

Loading store items...

+ ) : items.length === 0 ? ( +

No available items saved for this store yet.

+ ) : ( +
+ {items.map((item) => { + const imageSrc = itemImageSource(item); + const details = [item.item_type, item.item_group, item.zone].filter(Boolean); + return ( +
+
+ {imageSrc ? ( + + ) : ( + + {item.item_name?.slice(0, 1).toUpperCase() || "?"} + + )} +
+ {item.item_name} + {details.join(" | ") || "No store defaults set"} +
+
+
+ + +
+
+ ); + })} +
+ )} +
+ ) : null} + + { + setShowEditor(false); + setEditorItem(null); + }} + onSave={editorItem ? handleUpdate : handleCreate} + /> +
+ ); +} diff --git a/frontend/src/components/modals/AvailableItemEditorModal.jsx b/frontend/src/components/modals/AvailableItemEditorModal.jsx new file mode 100644 index 0000000..1465857 --- /dev/null +++ b/frontend/src/components/modals/AvailableItemEditorModal.jsx @@ -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 ( +
+
event.stopPropagation()}> +

+ {item ? `Edit ${item.item_name}` : "Add Available Item"} +

+

+ Save store-specific item defaults for this household. +

+ +
+ + setItemName(event.target.value)} + placeholder="Enter item name" + /> +
+ +
+ +
+ +
+ +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/modals/AvailableItemsPickerModal.jsx b/frontend/src/components/modals/AvailableItemsPickerModal.jsx new file mode 100644 index 0000000..353dd21 --- /dev/null +++ b/frontend/src/components/modals/AvailableItemsPickerModal.jsx @@ -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 ( +
+
event.stopPropagation()}> +
+
+

Store Items

+

+ Pick from your household's available items for this store. +

+
+ +
+ + onQueryChange(event.target.value)} + placeholder="Search available items" + /> + +
+ {loading ? ( +

Loading store items...

+ ) : items.length === 0 ? ( +

No matching store items found.

+ ) : ( + items.map((item) => { + const imageSrc = itemImageSource(item); + return ( + + ); + }) + )} +
+
+
+ ); +} diff --git a/frontend/src/pages/GroceryList.jsx b/frontend/src/pages/GroceryList.jsx index ca44241..d0bfaf0 100644 --- a/frontend/src/pages/GroceryList.jsx +++ b/frontend/src/pages/GroceryList.jsx @@ -4,18 +4,20 @@ import { addItem, getClassification, getItemByName, - getList, + getList, getRecentlyBought, getSuggestions, markBought, updateItemWithClassification } from "../api/list"; +import { getAvailableItems } from "../api/availableItems"; import { getHouseholdMembers } from "../api/households"; import SortDropdown from "../components/common/SortDropdown"; import AddItemForm from "../components/forms/AddItemForm"; import GroceryListItem from "../components/items/GroceryListItem"; -import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal"; -import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal"; +import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal"; +import AvailableItemsPickerModal from "../components/modals/AvailableItemsPickerModal"; +import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal"; import EditItemModal from "../components/modals/EditItemModal"; import SimilarItemModal from "../components/modals/SimilarItemModal"; import StoreTabs from "../components/store/StoreTabs"; @@ -60,9 +62,14 @@ export default function GroceryList() { const [showAddDetailsModal, setShowAddDetailsModal] = useState(false); const [showSimilarModal, setShowSimilarModal] = useState(false); const [similarItemSuggestion, setSimilarItemSuggestion] = useState(null); - const [showEditModal, setShowEditModal] = useState(false); - const [editingItem, setEditingItem] = useState(null); - const [recentlyBoughtCollapsed, setRecentlyBoughtCollapsed] = useState(settings.recentlyBoughtCollapsed); + const [showEditModal, setShowEditModal] = useState(false); + const [editingItem, setEditingItem] = useState(null); + 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 [showConfirmAddExisting, setShowConfirmAddExisting] = useState(false); const [confirmAddExistingData, setConfirmAddExistingData] = useState(null); @@ -125,6 +132,34 @@ export default function GroceryList() { loadHouseholdMembers(); }, [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(() => { const handleUploadSuccess = async (event) => { const detail = event?.detail || {}; @@ -298,6 +333,25 @@ export default function GroceryList() { } }, [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 = {}) => { if (!activeHousehold?.id || !activeStore?.id) return; @@ -592,19 +646,19 @@ export default function GroceryList() { }, [activeHousehold?.id, activeStore?.id, enqueueImageUpload, toast]); - const handleLongPress = useCallback(async (item) => { - if (!householdRole || householdRole === 'viewer') return; - if (!activeHousehold?.id || !activeStore?.id) return; - - try { - const classificationResponse = await getClassification(activeHousehold.id, activeStore.id, item.id); - setEditingItem({ - ...item, - classification: classificationResponse.data - }); - setShowEditModal(true); - } catch (error) { - console.error("Failed to load classification:", error); + const handleLongPress = useCallback(async (item) => { + if (!householdRole || householdRole === 'viewer') return; + if (!activeHousehold?.id || !activeStore?.id) return; + + try { + const classificationResponse = await getClassification(activeHousehold.id, activeStore.id, item.item_name); + setEditingItem({ + ...item, + classification: classificationResponse.data?.classification || null + }); + setShowEditModal(true); + } catch (error) { + console.error("Failed to load classification:", error); setEditingItem({ ...item, classification: null }); setShowEditModal(true); } @@ -757,6 +811,7 @@ export default function GroceryList() { {householdRole && householdRole !== 'viewer' && ( )} - {showConfirmAddExisting && confirmAddExistingData && ( - { setShowConfirmAddExisting(false); setConfirmAddExistingData(null); - }} - /> - )} - - ); + }} + /> + )} + + { + setShowAvailableItemsPicker(false); + setAvailableItemsContext(null); + setAvailableItemsQuery(""); + setAvailableItems([]); + }} + onSelect={handleAvailableItemSelect} + /> + + ); } diff --git a/frontend/src/styles/components/AddItemForm.css b/frontend/src/styles/components/AddItemForm.css index 5c455e9..4a5bda6 100644 --- a/frontend/src/styles/components/AddItemForm.css +++ b/frontend/src/styles/components/AddItemForm.css @@ -37,6 +37,23 @@ 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 { margin: 0; font-size: var(--font-size-xs); @@ -204,6 +221,11 @@ width: 100px; } + .add-item-form-catalog-btn { + min-width: 96px; + font-size: var(--font-size-sm); + } + .add-item-form-quantity-control { height: 36px; } diff --git a/frontend/src/styles/components/AvailableItemEditorModal.css b/frontend/src/styles/components/AvailableItemEditorModal.css new file mode 100644 index 0000000..326a58e --- /dev/null +++ b/frontend/src/styles/components/AvailableItemEditorModal.css @@ -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; + } +} diff --git a/frontend/src/styles/components/AvailableItemsPickerModal.css b/frontend/src/styles/components/AvailableItemsPickerModal.css new file mode 100644 index 0000000..f5b5c05 --- /dev/null +++ b/frontend/src/styles/components/AvailableItemsPickerModal.css @@ -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); + } +} diff --git a/frontend/src/styles/components/manage/StoreAvailableItemsManager.css b/frontend/src/styles/components/manage/StoreAvailableItemsManager.css new file mode 100644 index 0000000..a85dd2c --- /dev/null +++ b/frontend/src/styles/components/manage/StoreAvailableItemsManager.css @@ -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; + } +} diff --git a/frontend/tests/available-items-catalog.spec.ts b/frontend/tests/available-items-catalog.spec.ts new file mode 100644 index 0000000..891b36e --- /dev/null +++ b/frontend/tests/available-items-catalog.spec.ts @@ -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"); +});