From 35b514911b659a2066c2d34c5334e49473230f6a Mon Sep 17 00:00:00 2001 From: Nico Date: Thu, 4 Jun 2026 10:48:06 -0700 Subject: [PATCH] fix: buy map zone items in place --- .../maps/LocationMapBottomSheet.jsx | 2 + .../maps/LocationMapBottomSheetSections.jsx | 12 ++- frontend/src/pages/LocationMapManager.jsx | 76 ++++++++++++++++- .../src/styles/pages/LocationMapManager.css | 36 ++++++++ frontend/tests/location-map-manager.spec.ts | 83 +++++++++++++++++++ 5 files changed, 206 insertions(+), 3 deletions(-) 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({ 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);