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}
/>
);