diff --git a/frontend/src/components/maps/LocationMapBottomSheet.jsx b/frontend/src/components/maps/LocationMapBottomSheet.jsx
index 4215665..0d5c095 100644
--- a/frontend/src/components/maps/LocationMapBottomSheet.jsx
+++ b/frontend/src/components/maps/LocationMapBottomSheet.jsx
@@ -43,6 +43,7 @@ export default function LocationMapBottomSheet({
onDuplicateObject,
onDeleteObject,
onShowHiddenAreas,
+ onSelectZoneItem,
}) {
const selectedTitle = getMapObjectDisplayLabel(selectedObject);
const selectedZoneId = objectZoneId(selectedObject);
@@ -271,6 +272,7 @@ export default function LocationMapBottomSheet({
hasHiddenSelectedItems={hasHiddenSelectedItems}
hiddenSelectedItemCount={hiddenSelectedItemCount}
onShowSelectedZoneItems={showSelectedZoneItems}
+ onSelectZoneItem={onSelectZoneItem}
/>
) : null}
diff --git a/frontend/src/components/maps/LocationMapBottomSheetSections.jsx b/frontend/src/components/maps/LocationMapBottomSheetSections.jsx
index e300aa7..354c902 100644
--- a/frontend/src/components/maps/LocationMapBottomSheetSections.jsx
+++ b/frontend/src/components/maps/LocationMapBottomSheetSections.jsx
@@ -114,6 +114,7 @@ export function SelectedZoneItemsPanel({
hasHiddenSelectedItems,
hiddenSelectedItemCount,
onShowSelectedZoneItems,
+ onSelectZoneItem,
}) {
if (selectedZoneItems.length === 0) {
return (
@@ -139,8 +140,15 @@ export function SelectedZoneItemsPanel({
{selectedZoneItems.map((item) => (
-
- {item.item_name}
- x{item.quantity}
+
))}
diff --git a/frontend/src/pages/LocationMapManager.jsx b/frontend/src/pages/LocationMapManager.jsx
index c020e1b..88ef1a6 100644
--- a/frontend/src/pages/LocationMapManager.jsx
+++ b/frontend/src/pages/LocationMapManager.jsx
@@ -1,5 +1,6 @@
-import { useCallback, useContext, useEffect, useMemo, useRef } from "react";
+import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
+import { getItemByName, markBought } from "../api/list";
import { AuthContext } from "../context/AuthContext";
import { HouseholdContext } from "../context/HouseholdContext";
import { StoreContext } from "../context/StoreContext";
@@ -12,6 +13,7 @@ import {
} from "../components/maps/LocationMapStateViews";
import LocationMapToolbar from "../components/maps/LocationMapToolbar";
import LocationMapTopbar from "../components/maps/LocationMapTopbar";
+import ConfirmBuyModal from "../components/modals/ConfirmBuyModal";
import ConfirmSlideModal from "../components/modals/ConfirmSlideModal";
import useActionToast from "../hooks/useActionToast";
import useLocationMapDraftActions from "../hooks/useLocationMapDraftActions";
@@ -29,10 +31,17 @@ import {
mapForMode,
unmappedItems,
} from "../lib/locationMapUtils";
+import getApiErrorMessage from "../lib/getApiErrorMessage";
import "../styles/pages/LocationMapManager.css";
const EMPTY_MAP_ITEMS = [];
+function getNextMapBuyItem(items, currentIndex, excludedItemId) {
+ const remainingItems = items.filter((item) => item.id !== excludedItemId);
+ if (!remainingItems.length) return null;
+ return remainingItems[Math.min(Math.max(currentIndex, 0), remainingItems.length - 1)];
+}
+
export default function LocationMapManager() {
const { storeId, locationId } = useParams();
const navigate = useNavigate();
@@ -42,6 +51,7 @@ export default function LocationMapManager() {
const { username } = useContext(AuthContext);
const { activeHousehold, loading: householdLoading, hasLoaded } = useContext(HouseholdContext);
const { stores, activeStore, setActiveStore } = useContext(StoreContext);
+ const [buyModalItem, setBuyModalItem] = useState(null);
const {
beginSaving,
@@ -134,6 +144,59 @@ export default function LocationMapManager() {
);
const shouldGuardLeave = canManage && hasUnsavedChanges;
+ const updateMapItems = useCallback((updater) => {
+ setMapState((currentState) => {
+ if (!currentState) return currentState;
+ const currentItems = currentState.items || EMPTY_MAP_ITEMS;
+ return { ...currentState, items: updater(currentItems) };
+ });
+ }, [setMapState]);
+
+ const handleMapBuyCancel = useCallback(() => {
+ setBuyModalItem(null);
+ }, []);
+
+ const handleMapBuyNavigate = useCallback((item) => {
+ setBuyModalItem(item);
+ }, []);
+
+ const handleMapBuyConfirm = useCallback(async (quantity) => {
+ if (!activeHousehold?.id || !locationId || !buyModalItem) {
+ setBuyModalItem(null);
+ return;
+ }
+
+ const item = selectedZoneItems.find((candidate) => candidate.id === buyModalItem.id) || buyModalItem;
+
+ try {
+ const currentIndex = selectedZoneItems.findIndex((candidate) => candidate.id === item.id);
+ const resolvedIndex = currentIndex >= 0 ? currentIndex : 0;
+
+ await markBought(activeHousehold.id, locationId, item.item_name, quantity, true);
+
+ if (quantity >= item.quantity) {
+ updateMapItems((currentItems) => currentItems.filter((candidate) => candidate.id !== item.id));
+ setBuyModalItem(getNextMapBuyItem(selectedZoneItems, resolvedIndex, item.id));
+ } else {
+ const response = await getItemByName(activeHousehold.id, locationId, item.item_name);
+ const updatedItem = response.data || {
+ ...item,
+ quantity: Math.max(1, Number(item.quantity || 1) - Number(quantity || 1)),
+ };
+
+ updateMapItems((currentItems) =>
+ currentItems.map((candidate) => (candidate.id === item.id ? updatedItem : candidate))
+ );
+ setBuyModalItem(updatedItem);
+ }
+
+ toast.success("Marked item bought", `Marked item ${item.item_name} as bought`);
+ } catch (error) {
+ const message = getApiErrorMessage(error, "Failed to mark item as bought");
+ toast.error("Mark item bought failed", `Mark item bought failed: ${message}`);
+ }
+ }, [activeHousehold?.id, buyModalItem, locationId, selectedZoneItems, toast, updateMapItems]);
+
const mapSize = useMemo(
() => ({
width: mapDraft.width || activeMap?.width || DEFAULT_MAP_SIZE.width,
@@ -371,11 +434,22 @@ export default function LocationMapManager() {
onDuplicateObject={handleDuplicateObject}
onDeleteObject={requestDeleteObject}
onShowHiddenAreas={handleShowHiddenAreas}
+ onSelectZoneItem={setBuyModalItem}
/>
>
)}
+ {buyModalItem ? (
+
+ ) : null}
+
{
+ await mockMapShell(page);
+
+ const mapState = publishedMapState([
+ {
+ id: 1532,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], true);
+ const boughtPayloads: Array> = [];
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/list/item", async (route) => {
+ if (route.request().method() !== "PATCH") {
+ await route.fulfill({ status: 404, contentType: "application/json", body: "{}" });
+ return;
+ }
+
+ boughtPayloads.push(await route.request().postDataJSON());
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ success: true }),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+ const bakeryArea = page.locator('[data-object-key="1532"]');
+ await bakeryArea.locator("rect").first().click();
+
+ await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Bakery");
+ await expect(page.locator(".location-map-sheet-count")).toHaveText("2 items");
+ await expect(bakeryArea.locator(".location-map-count")).toHaveText("2 items");
+
+ await page.getByRole("button", { name: "Mark french bread bought" }).click();
+ await expect(page.locator(".confirm-buy-modal")).toBeVisible();
+ await expect(page.locator(".confirm-buy-item-name")).toHaveText("french bread");
+ await page.getByRole("button", { name: "Mark as Bought" }).click();
+
+ await expect.poll(() => boughtPayloads.length).toBe(1);
+ expect(boughtPayloads[0]).toMatchObject({
+ item_name: "french bread",
+ bought: true,
+ quantity_bought: 1,
+ });
+ await expect(page.locator(".confirm-buy-item-name")).toHaveText("sourdough");
+ await expect(page.locator(".location-map-sheet-count")).toHaveText("1 item");
+ await expect(bakeryArea.locator(".location-map-count")).toHaveText("1 item");
+ await expect(page.locator(".location-map-zone-items")).not.toContainText("french bread");
+
+ await page.getByRole("button", { name: "Mark as Bought" }).click();
+
+ await expect.poll(() => boughtPayloads.length).toBe(2);
+ expect(boughtPayloads[1]).toMatchObject({
+ item_name: "sourdough",
+ bought: true,
+ quantity_bought: 1,
+ });
+ await expect(page.locator(".confirm-buy-modal")).toHaveCount(0);
+ await expect(page.locator(".location-map-sheet-count")).toHaveText("0 items");
+ await expect(bakeryArea.locator(".location-map-count")).toHaveText("0 items");
+ await expect(page.getByText("No items assigned to this zone yet.")).toBeVisible();
+});
+
test("viewer explains when unmapped items are hidden by filters", async ({ page }) => {
await mockMapShell(page);