feat: add store catalog ui
This commit is contained in:
parent
86eebcc6f4
commit
033dd5dc33
55
frontend/src/api/availableItems.js
Normal file
55
frontend/src/api/availableItems.js
Normal 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`);
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
230
frontend/src/components/manage/StoreAvailableItemsManager.jsx
Normal file
230
frontend/src/components/manage/StoreAvailableItemsManager.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
frontend/src/components/modals/AvailableItemEditorModal.jsx
Normal file
165
frontend/src/components/modals/AvailableItemEditorModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
frontend/src/components/modals/AvailableItemsPickerModal.jsx
Normal file
92
frontend/src/components/modals/AvailableItemsPickerModal.jsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
109
frontend/src/styles/components/AvailableItemEditorModal.css
Normal file
109
frontend/src/styles/components/AvailableItemEditorModal.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
144
frontend/src/styles/components/AvailableItemsPickerModal.css
Normal file
144
frontend/src/styles/components/AvailableItemsPickerModal.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
335
frontend/tests/available-items-catalog.spec.ts
Normal file
335
frontend/tests/available-items-catalog.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user