530 lines
17 KiB
JavaScript
530 lines
17 KiB
JavaScript
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";
|
|
import LocationMapBottomSheet from "../components/maps/LocationMapBottomSheet";
|
|
import LocationMapCanvas from "../components/maps/LocationMapCanvas";
|
|
import LocationMapSetupPanel from "../components/maps/LocationMapSetupPanel";
|
|
import {
|
|
LocationMapLoadErrorState,
|
|
LocationMapMessageState,
|
|
} 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";
|
|
import useLocationMapDraftState from "../hooks/useLocationMapDraftState";
|
|
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 "../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();
|
|
const routeLocation = useLocation();
|
|
const returnPath = routeLocation.state?.returnTo;
|
|
const toast = useActionToast();
|
|
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,
|
|
dragState,
|
|
editorTool,
|
|
endSaving,
|
|
filters,
|
|
future,
|
|
hasUnsavedChanges,
|
|
history,
|
|
layersOpen,
|
|
loadError,
|
|
loadMap,
|
|
loading,
|
|
mapDraft,
|
|
mapState,
|
|
mode,
|
|
objects,
|
|
previewDraft,
|
|
saving,
|
|
savingAction,
|
|
selectedObjectKey,
|
|
setDragState,
|
|
setEditorTool,
|
|
setFilters,
|
|
setFuture,
|
|
setHasUnsavedChanges,
|
|
setHistory,
|
|
setLayersOpen,
|
|
setMapState,
|
|
setMode,
|
|
setObjects,
|
|
setPreviewDraft,
|
|
setSelectedObjectKey,
|
|
setZoom,
|
|
syncMapDraftFromState,
|
|
zoom,
|
|
} = useLocationMapDraftState({
|
|
activeHousehold,
|
|
hasLoaded,
|
|
householdLoading,
|
|
locationId,
|
|
toast,
|
|
});
|
|
const svgRef = useRef(null);
|
|
|
|
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 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)) {
|
|
setActiveStore(matchingLocation);
|
|
}
|
|
}, [activeStore?.id, locationId, setActiveStore, stores]);
|
|
|
|
const {
|
|
handleCreateBlank,
|
|
handleCreateFromZones,
|
|
handlePublish,
|
|
handleSaveDraft,
|
|
} = useLocationMapDraftActions({
|
|
activeHouseholdId: activeHousehold?.id,
|
|
beginSaving,
|
|
endSaving,
|
|
hasUnsavedChanges,
|
|
locationId,
|
|
mapDraft,
|
|
objects,
|
|
selectedObjectKey,
|
|
setEditorTool,
|
|
setMapState,
|
|
setMode,
|
|
setPreviewDraft,
|
|
syncMapDraftFromState,
|
|
toast,
|
|
});
|
|
|
|
const {
|
|
handleAddObject,
|
|
handleDeleteObject,
|
|
handleDuplicateObject,
|
|
handleObjectField,
|
|
handleShowHiddenAreas,
|
|
handleZoneLinkChange,
|
|
pendingDeleteObject,
|
|
remember,
|
|
requestDeleteObject,
|
|
setPendingDeleteObject,
|
|
updateObjects,
|
|
} = useLocationMapObjectActions({
|
|
canManage,
|
|
hiddenAreaCount,
|
|
mapSize,
|
|
mapState,
|
|
objects,
|
|
selectedObject,
|
|
selectedObjectKey,
|
|
setEditorTool,
|
|
setFuture,
|
|
setHasUnsavedChanges,
|
|
setHistory,
|
|
setObjects,
|
|
setSelectedObjectKey,
|
|
toast,
|
|
});
|
|
|
|
useEffect(() => {
|
|
setPendingDeleteObject(null);
|
|
}, [activeHousehold?.id, locationId, setPendingDeleteObject]);
|
|
|
|
const {
|
|
handleEditMode,
|
|
handleFitMap,
|
|
handlePreviewDraft,
|
|
handleRedo,
|
|
handleUndo,
|
|
handleViewMode,
|
|
} = useLocationMapViewControls({
|
|
editorTool,
|
|
filters,
|
|
hasUnsavedChanges,
|
|
mapSize,
|
|
mapState,
|
|
mode,
|
|
objects,
|
|
previewDraft,
|
|
selectedObjectKey,
|
|
setEditorTool,
|
|
setFuture,
|
|
setHasUnsavedChanges,
|
|
setHistory,
|
|
setMode,
|
|
setObjects,
|
|
setPreviewDraft,
|
|
setSelectedObjectKey,
|
|
setZoom,
|
|
svgRef,
|
|
syncMapDraftFromState,
|
|
});
|
|
|
|
const performBackNavigation = useCallback(() => {
|
|
if (typeof returnPath === "string" && returnPath.startsWith("/")) {
|
|
navigate(returnPath, { replace: true });
|
|
return;
|
|
}
|
|
navigate("/");
|
|
}, [navigate, returnPath]);
|
|
|
|
const {
|
|
pendingLeaveTarget,
|
|
requestBackNavigation,
|
|
clearPendingLeave,
|
|
confirmPendingLeave,
|
|
} = useUnsavedMapLeaveGuard({
|
|
shouldGuardLeave,
|
|
navigate,
|
|
performBackNavigation,
|
|
});
|
|
|
|
if (!hasLoaded || householdLoading || loading) {
|
|
return <LocationMapMessageState message="Loading map..." />;
|
|
}
|
|
|
|
if (!activeHousehold) {
|
|
return <LocationMapMessageState message="Select a household to manage maps." />;
|
|
}
|
|
|
|
if (loadError) {
|
|
return (
|
|
<LocationMapLoadErrorState
|
|
location={location}
|
|
loadError={loadError}
|
|
onBack={requestBackNavigation}
|
|
onRetry={loadMap}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const hasAnyMap = Boolean(mapState?.draft_map || mapState?.published_map);
|
|
const hasVisibleOrManageableMap = Boolean(mapState?.published_map || (mapState?.draft_map && canManage));
|
|
const status = getMapStatus(mapState, { mode, previewDraft, hasUnsavedChanges });
|
|
|
|
return (
|
|
<div className="location-map-page">
|
|
<LocationMapTopbar location={location} status={status} onBack={requestBackNavigation} />
|
|
|
|
<main className="location-map-workspace">
|
|
{hasAnyMap && mode !== "setup" ? (
|
|
<LocationMapToolbar
|
|
mode={mode}
|
|
editorTool={editorTool}
|
|
hasAnyMap={hasAnyMap}
|
|
canManage={canManage}
|
|
saving={saving}
|
|
history={history}
|
|
future={future}
|
|
zoom={zoom}
|
|
setZoom={setZoom}
|
|
onFit={handleFitMap}
|
|
onView={handleViewMode}
|
|
onEdit={handleEditMode}
|
|
onPanMode={() => setEditorTool("pan")}
|
|
onEditObjects={() => setEditorTool("edit")}
|
|
onUndo={handleUndo}
|
|
onRedo={handleRedo}
|
|
/>
|
|
) : null}
|
|
|
|
{!hasAnyMap || (mode === "setup" && mapState?.draft_map && !mapState?.published_map) ? (
|
|
<LocationMapSetupPanel
|
|
hasAnyMap={hasVisibleOrManageableMap}
|
|
canManage={canManage}
|
|
zoneCount={mapState?.zones?.length || 0}
|
|
saving={saving}
|
|
savingAction={savingAction}
|
|
onContinue={() => {
|
|
setMode("edit");
|
|
setEditorTool("edit");
|
|
setPreviewDraft(true);
|
|
}}
|
|
onPreview={handlePreviewDraft}
|
|
onPublish={handlePublish}
|
|
onCreateFromZones={handleCreateFromZones}
|
|
onCreateBlank={handleCreateBlank}
|
|
/>
|
|
) : (
|
|
<>
|
|
<LocationMapCanvas
|
|
mode={mode}
|
|
editorTool={editorTool}
|
|
objects={objects}
|
|
filters={filters}
|
|
mapSize={mapSize}
|
|
zoom={zoom}
|
|
selectedObjectKey={selectedObjectKey}
|
|
mapItems={mapItems}
|
|
username={username}
|
|
svgRef={svgRef}
|
|
dragState={dragState}
|
|
editingLocked={saving}
|
|
hiddenAreaCount={hiddenAreaCount}
|
|
onAddObject={handleAddObject}
|
|
setDragState={setDragState}
|
|
setSelectedObjectKey={setSelectedObjectKey}
|
|
remember={remember}
|
|
updateObjects={updateObjects}
|
|
onShowZones={() =>
|
|
setFilters((current) => ({
|
|
...current,
|
|
showZones: true,
|
|
}))
|
|
}
|
|
onShowHiddenAreas={handleShowHiddenAreas}
|
|
/>
|
|
<LocationMapBottomSheet
|
|
mode={mode}
|
|
editorTool={editorTool}
|
|
canManage={canManage}
|
|
filters={filters}
|
|
setFilters={setFilters}
|
|
layersOpen={layersOpen}
|
|
setLayersOpen={setLayersOpen}
|
|
selectedObject={selectedObject}
|
|
mapObjects={objects}
|
|
selectedZoneItems={selectedZoneItems}
|
|
visibleAssignedItemCount={visibleAssignedItemCount}
|
|
visibleUnmappedItems={visibleUnmappedItems}
|
|
unmappedItemCount={unmappedItemCount}
|
|
hiddenAreaCount={hiddenAreaCount}
|
|
saving={saving}
|
|
savingAction={savingAction}
|
|
hasUnsavedChanges={hasUnsavedChanges}
|
|
mapState={mapState}
|
|
onAddObject={handleAddObject}
|
|
onSaveDraft={handleSaveDraft}
|
|
onPublish={handlePublish}
|
|
onPreviewDraft={handlePreviewDraft}
|
|
onObjectField={handleObjectField}
|
|
onZoneLinkChange={handleZoneLinkChange}
|
|
onDuplicateObject={handleDuplicateObject}
|
|
onDeleteObject={requestDeleteObject}
|
|
onShowHiddenAreas={handleShowHiddenAreas}
|
|
onSelectZoneItem={setBuyModalItem}
|
|
onSelectUnmappedItem={setBuyModalItem}
|
|
/>
|
|
</>
|
|
)}
|
|
</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}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|