refactor: split location map manager

This commit is contained in:
Nico 2026-06-04 17:04:09 -07:00
parent 26b05d9729
commit f397f19e48
4 changed files with 328 additions and 204 deletions

View File

@ -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 ? (
<ConfirmBuyModal
item={buyModalItem}
allItems={buyModalItems}
onNavigate={onBuyNavigate}
onCancel={onBuyCancel}
onConfirm={onBuyConfirm}
/>
) : null}
<ConfirmSlideModal
isOpen={Boolean(pendingDeleteObject)}
title={`Delete ${getMapObjectDisplayLabel(pendingDeleteObject, "this map area")}?`}
description="This removes the area from the draft map. Save the draft after deleting to keep the change."
confirmLabel="Delete Area"
onClose={onDeleteClose}
onConfirm={onDeleteConfirm}
/>
<ConfirmSlideModal
isOpen={Boolean(pendingLeaveTarget)}
title="Leave with unsaved changes?"
description="Your draft edits are only on this screen right now. Save the draft before leaving if you want to keep them."
confirmLabel="Leave Map"
onClose={onLeaveClose}
onConfirm={onLeaveConfirm}
/>
</>
);
}

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -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() {
)}
</main>
{buyModalItem ? (
<ConfirmBuyModal
item={buyModalItem}
allItems={buyModalItems}
onNavigate={handleMapBuyNavigate}
onCancel={handleMapBuyCancel}
onConfirm={handleMapBuyConfirm}
/>
) : null}
<ConfirmSlideModal
isOpen={Boolean(pendingDeleteObject)}
title={`Delete ${getMapObjectDisplayLabel(pendingDeleteObject, "this map area")}?`}
description="This removes the area from the draft map. Save the draft after deleting to keep the change."
confirmLabel="Delete Area"
onClose={() => setPendingDeleteObject(null)}
onConfirm={handleDeleteObject}
/>
<ConfirmSlideModal
isOpen={Boolean(pendingLeaveTarget)}
title="Leave with unsaved changes?"
description="Your draft edits are only on this screen right now. Save the draft before leaving if you want to keep them."
confirmLabel="Leave Map"
onClose={clearPendingLeave}
onConfirm={confirmPendingLeave}
<LocationMapDialogs
buyModalItem={buyModalItem}
buyModalItems={buyModalItems}
onBuyNavigate={handleMapBuyNavigate}
onBuyCancel={handleMapBuyCancel}
onBuyConfirm={handleMapBuyConfirm}
pendingDeleteObject={pendingDeleteObject}
onDeleteClose={() => setPendingDeleteObject(null)}
onDeleteConfirm={handleDeleteObject}
pendingLeaveTarget={pendingLeaveTarget}
onLeaveClose={clearPendingLeave}
onLeaveConfirm={confirmPendingLeave}
/>
</div>
);