From f397f19e48244abc09d6e871ea0870b9cbdf6e08 Mon Sep 17 00:00:00 2001 From: Nico Date: Thu, 4 Jun 2026 17:04:09 -0700 Subject: [PATCH] refactor: split location map manager --- .../components/maps/LocationMapDialogs.jsx | 48 ++++ frontend/src/hooks/useLocationMapBuying.js | 145 ++++++++++ .../src/hooks/useLocationMapDerivedState.js | 83 ++++++ frontend/src/pages/LocationMapManager.jsx | 256 ++++-------------- 4 files changed, 328 insertions(+), 204 deletions(-) create mode 100644 frontend/src/components/maps/LocationMapDialogs.jsx create mode 100644 frontend/src/hooks/useLocationMapBuying.js create mode 100644 frontend/src/hooks/useLocationMapDerivedState.js diff --git a/frontend/src/components/maps/LocationMapDialogs.jsx b/frontend/src/components/maps/LocationMapDialogs.jsx new file mode 100644 index 0000000..fbdb31c --- /dev/null +++ b/frontend/src/components/maps/LocationMapDialogs.jsx @@ -0,0 +1,48 @@ +import ConfirmBuyModal from "../modals/ConfirmBuyModal"; +import ConfirmSlideModal from "../modals/ConfirmSlideModal"; +import { getMapObjectDisplayLabel } from "../../lib/locationMapUtils"; + +export default function LocationMapDialogs({ + buyModalItem, + buyModalItems, + onBuyCancel, + onBuyConfirm, + onBuyNavigate, + pendingDeleteObject, + onDeleteClose, + onDeleteConfirm, + pendingLeaveTarget, + onLeaveClose, + onLeaveConfirm, +}) { + return ( + <> + {buyModalItem ? ( + + ) : null} + + + + + ); +} diff --git a/frontend/src/hooks/useLocationMapBuying.js b/frontend/src/hooks/useLocationMapBuying.js new file mode 100644 index 0000000..7f15b85 --- /dev/null +++ b/frontend/src/hooks/useLocationMapBuying.js @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { getItemByName, markBought } from "../api/list"; +import getApiErrorMessage from "../lib/getApiErrorMessage"; + +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)]; +} + +function getLocallyReducedMapItem(item, quantity) { + const currentQuantity = Number(item.quantity || 1); + const boughtQuantity = Math.max(1, Number(quantity || 1)); + return { + ...item, + quantity: Math.max(1, currentQuantity - boughtQuantity), + }; +} + +function getLocallyBoughtMapItem(item) { + return { + ...item, + bought: true, + }; +} + +export default function useLocationMapBuying({ + activeHouseholdId, + locationId, + selectedObject, + selectedZoneItems = EMPTY_MAP_ITEMS, + setMapState, + toast, + visibleUnmappedItems = EMPTY_MAP_ITEMS, +}) { + const [buyModalItem, setBuyModalItem] = useState(null); + const mapBuyScopeRef = useRef({ activeHouseholdId: null, locationId: null }); + const buyModalItems = useMemo( + () => (selectedObject ? selectedZoneItems : visibleUnmappedItems).filter((item) => !item.bought), + [selectedObject, selectedZoneItems, visibleUnmappedItems] + ); + + const updateMapItems = useCallback((updater) => { + setMapState((currentState) => { + if (!currentState) return currentState; + const currentItems = currentState.items || EMPTY_MAP_ITEMS; + return { ...currentState, items: updater(currentItems) }; + }); + }, [setMapState]); + + useEffect(() => { + mapBuyScopeRef.current = { + activeHouseholdId, + locationId, + }; + }, [activeHouseholdId, locationId]); + + useEffect(() => { + setBuyModalItem(null); + }, [activeHouseholdId, locationId]); + + const isCurrentMapBuyScope = useCallback((scopedHouseholdId, scopedLocationId) => ( + String(mapBuyScopeRef.current.activeHouseholdId || "") === String(scopedHouseholdId || "") && + String(mapBuyScopeRef.current.locationId || "") === String(scopedLocationId || "") + ), []); + + const handleMapBuyCancel = useCallback(() => { + setBuyModalItem(null); + }, []); + + const handleMapBuyNavigate = useCallback((item) => { + setBuyModalItem(item); + }, []); + + const handleMapBuyConfirm = useCallback(async (quantity) => { + if (!activeHouseholdId || !locationId || !buyModalItem) { + setBuyModalItem(null); + return; + } + + const scopedHouseholdId = activeHouseholdId; + const scopedLocationId = locationId; + const item = buyModalItems.find((candidate) => candidate.id === buyModalItem.id) || buyModalItem; + + try { + const currentIndex = buyModalItems.findIndex((candidate) => candidate.id === item.id); + const resolvedIndex = currentIndex >= 0 ? currentIndex : 0; + + await markBought(scopedHouseholdId, scopedLocationId, item.item_name, quantity, true); + if (!isCurrentMapBuyScope(scopedHouseholdId, scopedLocationId)) return; + + let nextBuyModalItems = buyModalItems; + if (quantity >= item.quantity) { + nextBuyModalItems = buyModalItems.filter((candidate) => candidate.id !== item.id); + updateMapItems((currentItems) => + currentItems.map((candidate) => ( + candidate.id === item.id ? getLocallyBoughtMapItem(candidate) : candidate + )) + ); + } else { + let updatedItem; + try { + const response = await getItemByName(scopedHouseholdId, scopedLocationId, item.item_name); + if (!isCurrentMapBuyScope(scopedHouseholdId, scopedLocationId)) return; + updatedItem = response.data || getLocallyReducedMapItem(item, quantity); + } catch { + if (!isCurrentMapBuyScope(scopedHouseholdId, scopedLocationId)) return; + updatedItem = getLocallyReducedMapItem(item, quantity); + toast.info("Updated item locally", "Latest quantity will sync when the map reloads."); + } + nextBuyModalItems = buyModalItems.map((candidate) => (candidate.id === item.id ? updatedItem : candidate)); + + updateMapItems((currentItems) => + currentItems.map((candidate) => (candidate.id === item.id ? updatedItem : candidate)) + ); + } + setBuyModalItem(getNextMapBuyItem(nextBuyModalItems, resolvedIndex, item.id)); + + toast.success("Marked item bought", `Marked item ${item.item_name} as bought`); + } catch (error) { + if (!isCurrentMapBuyScope(scopedHouseholdId, scopedLocationId)) return; + const message = getApiErrorMessage(error, "Failed to mark item as bought"); + toast.error("Mark item bought failed", `Mark item bought failed: ${message}`); + } + }, [ + activeHouseholdId, + buyModalItem, + buyModalItems, + isCurrentMapBuyScope, + locationId, + toast, + updateMapItems, + ]); + + return { + buyModalItem, + buyModalItems, + handleMapBuyCancel, + handleMapBuyConfirm, + handleMapBuyNavigate, + setBuyModalItem, + }; +} diff --git a/frontend/src/hooks/useLocationMapDerivedState.js b/frontend/src/hooks/useLocationMapDerivedState.js new file mode 100644 index 0000000..dd912df --- /dev/null +++ b/frontend/src/hooks/useLocationMapDerivedState.js @@ -0,0 +1,83 @@ +import { useMemo } from "react"; +import { + DEFAULT_MAP_SIZE, + getObjectKey, + itemMatchesMapFilters, + itemsForZone, + mapForMode, + unmappedItems, +} from "../lib/locationMapUtils"; + +const EMPTY_MAP_ITEMS = []; + +export default function useLocationMapDerivedState({ + filters, + mapDraft, + mapState, + mode, + objects, + previewDraft, + selectedObjectKey, + username, +}) { + const activeMap = useMemo( + () => mapForMode(mapState, mode, previewDraft), + [mapState, mode, previewDraft] + ); + const mapItems = mapState?.items || EMPTY_MAP_ITEMS; + const selectedObject = useMemo( + () => objects.find((object) => getObjectKey(object) === selectedObjectKey) || null, + [objects, selectedObjectKey] + ); + const selectedZoneItems = useMemo( + () => selectedObject?.zone_id + ? itemsForZone(mapItems, selectedObject.zone_id, filters, username) + : EMPTY_MAP_ITEMS, + [filters, mapItems, selectedObject?.zone_id, username] + ); + const visibleUnmappedItems = useMemo( + () => unmappedItems(mapItems, filters, username), + [filters, mapItems, username] + ); + const { visibleAssignedItemCount, unmappedItemCount } = useMemo(() => { + let nextVisibleAssignedItemCount = 0; + let nextUnmappedItemCount = 0; + + mapItems.forEach((item) => { + if (!item.zone_id) { + nextUnmappedItemCount += 1; + return; + } + if (itemMatchesMapFilters(item, filters, username)) { + nextVisibleAssignedItemCount += 1; + } + }); + + return { + visibleAssignedItemCount: nextVisibleAssignedItemCount, + unmappedItemCount: nextUnmappedItemCount, + }; + }, [filters, mapItems, username]); + const hiddenAreaCount = useMemo( + () => objects.filter((object) => object.visible === false).length, + [objects] + ); + const mapSize = useMemo( + () => ({ + width: mapDraft.width || activeMap?.width || DEFAULT_MAP_SIZE.width, + height: mapDraft.height || activeMap?.height || DEFAULT_MAP_SIZE.height, + }), + [activeMap?.height, activeMap?.width, mapDraft.height, mapDraft.width] + ); + + return { + hiddenAreaCount, + mapItems, + mapSize, + selectedObject, + selectedZoneItems, + unmappedItemCount, + visibleAssignedItemCount, + visibleUnmappedItems, + }; +} diff --git a/frontend/src/pages/LocationMapManager.jsx b/frontend/src/pages/LocationMapManager.jsx index fb56bea..dc6b749 100644 --- a/frontend/src/pages/LocationMapManager.jsx +++ b/frontend/src/pages/LocationMapManager.jsx @@ -1,11 +1,11 @@ -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useContext, useEffect, useRef } 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"; import LocationMapBottomSheet from "../components/maps/LocationMapBottomSheet"; import LocationMapCanvas from "../components/maps/LocationMapCanvas"; +import LocationMapDialogs from "../components/maps/LocationMapDialogs"; import LocationMapSetupPanel from "../components/maps/LocationMapSetupPanel"; import { LocationMapLoadErrorState, @@ -13,51 +13,17 @@ 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 useLocationMapDerivedState from "../hooks/useLocationMapDerivedState"; import useLocationMapDraftActions from "../hooks/useLocationMapDraftActions"; import useLocationMapDraftState from "../hooks/useLocationMapDraftState"; +import useLocationMapBuying from "../hooks/useLocationMapBuying"; import useLocationMapObjectActions from "../hooks/useLocationMapObjectActions"; import useLocationMapViewControls from "../hooks/useLocationMapViewControls"; import useUnsavedMapLeaveGuard from "../hooks/useUnsavedMapLeaveGuard"; -import { - DEFAULT_MAP_SIZE, - getMapObjectDisplayLabel, - getMapStatus, - getObjectKey, - itemMatchesMapFilters, - itemsForZone, - mapForMode, - unmappedItems, -} from "../lib/locationMapUtils"; -import getApiErrorMessage from "../lib/getApiErrorMessage"; +import { getMapStatus } from "../lib/locationMapUtils"; 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)]; -} - -function getLocallyReducedMapItem(item, quantity) { - const currentQuantity = Number(item.quantity || 1); - const boughtQuantity = Math.max(1, Number(quantity || 1)); - return { - ...item, - quantity: Math.max(1, currentQuantity - boughtQuantity), - }; -} - -function getLocallyBoughtMapItem(item) { - return { - ...item, - bought: true, - }; -} - export default function LocationMapManager() { const { storeId, locationId } = useParams(); const navigate = useNavigate(); @@ -67,8 +33,6 @@ 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 mapBuyScopeRef = useRef({ activeHouseholdId: null, locationId: null }); const { beginSaving, @@ -117,146 +81,43 @@ export default function LocationMapManager() { const location = mapState?.location || stores.find((store) => String(store.id) === String(locationId)); const canManage = Boolean(mapState?.can_manage ?? ["owner", "admin"].includes(activeHousehold?.role)); - const activeMap = useMemo( - () => mapForMode(mapState, mode, previewDraft), - [mapState, mode, previewDraft] - ); - const mapItems = mapState?.items || EMPTY_MAP_ITEMS; - const selectedObject = useMemo( - () => objects.find((object) => getObjectKey(object) === selectedObjectKey) || null, - [objects, selectedObjectKey] - ); - const selectedZoneItems = useMemo( - () => selectedObject?.zone_id - ? itemsForZone(mapItems, selectedObject.zone_id, filters, username) - : EMPTY_MAP_ITEMS, - [filters, mapItems, selectedObject?.zone_id, username] - ); - const visibleUnmappedItems = useMemo( - () => unmappedItems(mapItems, filters, username), - [filters, mapItems, username] - ); - const buyModalItems = useMemo( - () => (selectedObject ? selectedZoneItems : visibleUnmappedItems).filter((item) => !item.bought), - [selectedObject, selectedZoneItems, visibleUnmappedItems] - ); - const { visibleAssignedItemCount, unmappedItemCount } = useMemo(() => { - let nextVisibleAssignedItemCount = 0; - let nextUnmappedItemCount = 0; - - mapItems.forEach((item) => { - if (!item.zone_id) { - nextUnmappedItemCount += 1; - return; - } - if (itemMatchesMapFilters(item, filters, username)) { - nextVisibleAssignedItemCount += 1; - } - }); - - return { - visibleAssignedItemCount: nextVisibleAssignedItemCount, - unmappedItemCount: nextUnmappedItemCount, - }; - }, [filters, mapItems, username]); - const hiddenAreaCount = useMemo( - () => objects.filter((object) => object.visible === false).length, - [objects] - ); + const { + hiddenAreaCount, + mapItems, + mapSize, + selectedObject, + selectedZoneItems, + unmappedItemCount, + visibleAssignedItemCount, + visibleUnmappedItems, + } = useLocationMapDerivedState({ + filters, + mapDraft, + mapState, + mode, + objects, + previewDraft, + selectedObjectKey, + username, + }); + const { + buyModalItem, + buyModalItems, + handleMapBuyCancel, + handleMapBuyConfirm, + handleMapBuyNavigate, + setBuyModalItem, + } = useLocationMapBuying({ + activeHouseholdId: activeHousehold?.id, + locationId, + selectedObject, + selectedZoneItems, + setMapState, + toast, + visibleUnmappedItems, + }); 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]); - - useEffect(() => { - mapBuyScopeRef.current = { - activeHouseholdId: activeHousehold?.id, - locationId, - }; - }, [activeHousehold?.id, locationId]); - - const isCurrentMapBuyScope = useCallback((activeHouseholdId, scopedLocationId) => ( - String(mapBuyScopeRef.current.activeHouseholdId || "") === String(activeHouseholdId || "") && - String(mapBuyScopeRef.current.locationId || "") === String(scopedLocationId || "") - ), []); - - 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 activeHouseholdId = activeHousehold.id; - const scopedLocationId = locationId; - const item = buyModalItems.find((candidate) => candidate.id === buyModalItem.id) || buyModalItem; - - try { - const currentIndex = buyModalItems.findIndex((candidate) => candidate.id === item.id); - const resolvedIndex = currentIndex >= 0 ? currentIndex : 0; - - await markBought(activeHouseholdId, scopedLocationId, item.item_name, quantity, true); - if (!isCurrentMapBuyScope(activeHouseholdId, scopedLocationId)) return; - - let nextBuyModalItems = buyModalItems; - if (quantity >= item.quantity) { - nextBuyModalItems = buyModalItems.filter((candidate) => candidate.id !== item.id); - updateMapItems((currentItems) => - currentItems.map((candidate) => ( - candidate.id === item.id ? getLocallyBoughtMapItem(candidate) : candidate - )) - ); - } else { - let updatedItem; - try { - const response = await getItemByName(activeHouseholdId, scopedLocationId, item.item_name); - if (!isCurrentMapBuyScope(activeHouseholdId, scopedLocationId)) return; - updatedItem = response.data || getLocallyReducedMapItem(item, quantity); - } catch { - if (!isCurrentMapBuyScope(activeHouseholdId, scopedLocationId)) return; - updatedItem = getLocallyReducedMapItem(item, quantity); - toast.info("Updated item locally", "Latest quantity will sync when the map reloads."); - } - nextBuyModalItems = buyModalItems.map((candidate) => (candidate.id === item.id ? updatedItem : candidate)); - - updateMapItems((currentItems) => - currentItems.map((candidate) => (candidate.id === item.id ? updatedItem : candidate)) - ); - } - setBuyModalItem(getNextMapBuyItem(nextBuyModalItems, resolvedIndex, item.id)); - - toast.success("Marked item bought", `Marked item ${item.item_name} as bought`); - } catch (error) { - if (!isCurrentMapBuyScope(activeHouseholdId, scopedLocationId)) return; - const message = getApiErrorMessage(error, "Failed to mark item as bought"); - toast.error("Mark item bought failed", `Mark item bought failed: ${message}`); - } - }, [activeHousehold?.id, buyModalItem, buyModalItems, isCurrentMapBuyScope, locationId, toast, updateMapItems]); - - const mapSize = useMemo( - () => ({ - width: mapDraft.width || activeMap?.width || DEFAULT_MAP_SIZE.width, - height: mapDraft.height || activeMap?.height || DEFAULT_MAP_SIZE.height, - }), - [activeMap?.height, activeMap?.width, mapDraft.height, mapDraft.width] - ); - - useEffect(() => { - setBuyModalItem(null); - }, [activeHousehold?.id, locationId]); - useEffect(() => { const matchingLocation = stores.find((store) => String(store.id) === String(locationId)); if (matchingLocation && String(activeStore?.id) !== String(matchingLocation.id)) { @@ -512,31 +373,18 @@ export default function LocationMapManager() { )} - {buyModalItem ? ( - - ) : null} - - setPendingDeleteObject(null)} - onConfirm={handleDeleteObject} - /> - setPendingDeleteObject(null)} + onDeleteConfirm={handleDeleteObject} + pendingLeaveTarget={pendingLeaveTarget} + onLeaveClose={clearPendingLeave} + onLeaveConfirm={confirmPendingLeave} /> );