360 lines
13 KiB
JavaScript
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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|