grocery-app/frontend/src/components/manage/StoreAvailableItemsManager.jsx
2026-05-31 19:21:08 -07:00

360 lines
13 KiB
JavaScript

import { useCallback, useEffect, useState } from "react";
import {
createAvailableItem,
deleteAvailableItem,
getAvailableItems,
updateAvailableItem,
} from "../../api/availableItems";
import { getLocationZones } from "../../api/stores";
import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage";
import AvailableItemEditorModal from "../modals/AvailableItemEditorModal";
import ConfirmSlideModal from "../modals/ConfirmSlideModal";
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 [isOpen, setIsOpen] = useState(false);
const [items, setItems] = useState([]);
const [zones, setZones] = useState([]);
const [catalogReady, setCatalogReady] = useState(true);
const [catalogMessage, setCatalogMessage] = useState("");
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(false);
const [editorItem, setEditorItem] = useState(null);
const [showEditor, setShowEditor] = useState(false);
const [deleteMode, setDeleteMode] = useState(false);
const [selectedDeleteIds, setSelectedDeleteIds] = useState(() => new Set());
const [pendingDeleteItems, setPendingDeleteItems] = useState([]);
const selectedDeleteItems = items.filter((item) => selectedDeleteIds.has(item.item_id));
const selectedDeleteCount = selectedDeleteItems.length;
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 || []);
setCatalogReady(response.data.catalog_ready !== false);
setCatalogMessage(response.data.message || "");
} catch (error) {
console.error("Failed to load store items:", error);
setCatalogReady(false);
setCatalogMessage("Store item management is unavailable right now.");
const message = getApiErrorMessage(error, "Failed to load store items");
toast.error("Load store items failed", `Load store items failed: ${message}`);
} finally {
setLoading(false);
}
}, [householdId, query, store?.id, toast]);
const loadZones = useCallback(async () => {
if (!householdId || !store?.id) {
setZones([]);
return;
}
try {
const response = await getLocationZones(householdId, store.id);
setZones(response.data?.zones || []);
} catch (error) {
console.error("Failed to load location zones:", error);
setZones([]);
}
}, [householdId, store?.id]);
useEffect(() => {
if (!isOpen) {
return;
}
loadItems(query);
loadZones();
}, [isOpen, query, loadItems, loadZones]);
const closeManager = () => {
setIsOpen(false);
setDeleteMode(false);
setSelectedDeleteIds(new Set());
setPendingDeleteItems([]);
};
const handleUpdate = async (payload) => {
if (!catalogReady) {
toast.info(
"Store item management unavailable",
catalogMessage || "Store item management is unavailable until the latest database migration is applied."
);
return;
}
try {
if (editorItem?.item_id) {
await updateAvailableItem(householdId, store.id, editorItem.item_id, payload);
toast.success("Updated store item", `Updated ${editorItem.item_name} for ${store.display_name || store.name}`);
} else {
const response = await createAvailableItem(householdId, store.id, payload);
toast.success(
"Created store item",
`Created ${response.data?.item?.item_name || payload.itemName} for ${store.display_name || store.name}`
);
}
setShowEditor(false);
setEditorItem(null);
await loadItems(query);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to update store item");
toast.error("Update store item failed", `Update store item failed: ${message}`);
throw error;
}
};
const openEditor = (item) => {
setEditorItem(item);
setShowEditor(true);
};
const toggleDeleteSelection = (itemId) => {
setSelectedDeleteIds((currentIds) => {
const nextIds = new Set(currentIds);
if (nextIds.has(itemId)) {
nextIds.delete(itemId);
} else {
nextIds.add(itemId);
}
return nextIds;
});
};
const startDeleteMode = () => {
setDeleteMode(true);
setSelectedDeleteIds(new Set());
};
const cancelDeleteMode = () => {
setDeleteMode(false);
setSelectedDeleteIds(new Set());
};
const confirmSelectedDelete = () => {
if (selectedDeleteCount === 0) {
return;
}
setPendingDeleteItems(selectedDeleteItems);
};
const handleDeleteConfirm = async () => {
if (pendingDeleteItems.length === 0) {
return;
}
try {
await Promise.all(
pendingDeleteItems.map((item) => deleteAvailableItem(householdId, store.id, item.item_id))
);
const count = pendingDeleteItems.length;
toast.success(
count === 1 ? "Deleted store item" : "Deleted store items",
`Deleted ${count} ${count === 1 ? "item" : "items"} from ${store.display_name || store.name}`
);
setPendingDeleteItems([]);
setDeleteMode(false);
setSelectedDeleteIds(new Set());
await loadItems(query);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to delete store item");
toast.error("Delete store items failed", `Delete store items failed: ${message}`);
}
};
return (
<>
<button
type="button"
className="btn-secondary btn-small store-available-items-trigger"
onClick={() => setIsOpen(true)}
>
Manage Items
</button>
{isOpen ? (
<div className="store-items-modal-overlay" onClick={closeManager}>
<div className="store-items-modal" onClick={(event) => event.stopPropagation()}>
<div className="store-items-modal-header">
<div>
<h3>{store.display_name || store.name} Items</h3>
<p>Manage location-specific items used for suggestions and defaults.</p>
</div>
<button
type="button"
className="store-items-modal-close"
onClick={closeManager}
aria-label="Close manage items modal"
>
x
</button>
</div>
{!catalogReady ? (
<p className="store-available-items-notice">
{catalogMessage || "Store item management is unavailable until the latest database migration is applied."}
</p>
) : null}
<div className="store-items-modal-toolbar">
<input
className="store-available-items-search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search household/store items"
disabled={!catalogReady}
/>
<button
type="button"
className="btn-primary btn-small"
disabled={!catalogReady}
onClick={() => {
setEditorItem(null);
setShowEditor(true);
}}
>
Add Item
</button>
</div>
{isAdmin && catalogReady && items.length > 0 ? (
<div className="store-items-bulk-toolbar">
<button
type="button"
className="btn-danger btn-small store-items-delete-toggle"
disabled={deleteMode ? selectedDeleteCount === 0 : loading}
onClick={deleteMode ? confirmSelectedDelete : startDeleteMode}
>
{deleteMode ? `Confirm Delete (${selectedDeleteCount})` : "Delete Items"}
</button>
{deleteMode ? (
<button
type="button"
className="btn-secondary btn-small store-items-delete-cancel"
onClick={cancelDeleteMode}
>
Cancel
</button>
) : null}
</div>
) : null}
<div className="store-items-modal-body">
{!catalogReady ? (
<p className="empty-message">Run the latest database migrations to enable store item management.</p>
) : loading ? (
<p className="empty-message">Loading store items...</p>
) : items.length === 0 ? (
<p className="empty-message">No household items found for this store yet.</p>
) : (
<div className="store-items-table">
<div className="store-items-table-body">
{items.map((item) => {
const imageSrc = itemImageSource(item);
const isSelectedForDelete = selectedDeleteIds.has(item.item_id);
return (
<button
key={item.item_id}
type="button"
className={`store-items-table-row store-items-table-row-button ${deleteMode ? "is-delete-selectable" : ""} ${isSelectedForDelete ? "is-selected" : ""}`}
aria-label={
deleteMode
? `${isSelectedForDelete ? "Deselect" : "Select"} ${item.item_name} for deletion`
: `Edit settings for ${item.item_name}`
}
aria-pressed={deleteMode ? isSelectedForDelete : undefined}
onClick={() => {
if (deleteMode) {
toggleDeleteSelection(item.item_id);
} else {
openEditor(item);
}
}}
>
<div className="store-items-table-cell store-items-table-item">
<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"
aria-hidden="true"
>
{"\uD83D\uDCE6"}
</span>
)}
<div className="store-available-items-copy">
<strong>{item.item_name}</strong>
</div>
</div>
</div>
{deleteMode ? (
<span className="store-items-delete-indicator" aria-hidden="true">
{isSelectedForDelete ? "✓" : ""}
</span>
) : null}
</button>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
) : null}
<AvailableItemEditorModal
isOpen={showEditor}
item={editorItem}
zones={zones}
onCancel={() => {
setShowEditor(false);
setEditorItem(null);
}}
onSave={handleUpdate}
/>
<ConfirmSlideModal
isOpen={pendingDeleteItems.length > 0}
title={
pendingDeleteItems.length === 1
? `Delete ${pendingDeleteItems[0].item_name}?`
: `Delete ${pendingDeleteItems.length} items?`
}
description={
pendingDeleteItems.length > 0
? `Slide to confirm. This permanently deletes ${pendingDeleteItems.length === 1 ? pendingDeleteItems[0].item_name : `${pendingDeleteItems.length} items`} from ${store.display_name || store.name} for this household, including current list entries and history.`
: ""
}
confirmLabel={pendingDeleteItems.length === 1 ? "Delete Item" : "Delete Items"}
onClose={() => setPendingDeleteItems([])}
onConfirm={handleDeleteConfirm}
/>
</>
);
}