783 lines
26 KiB
JavaScript
783 lines
26 KiB
JavaScript
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useBeforeUnload, useLocation, useNavigate, useParams } from "react-router-dom";
|
|
import {
|
|
createBlankLocationMap,
|
|
createLocationMapFromZones,
|
|
getLocationMap,
|
|
publishLocationMapDraft,
|
|
saveLocationMapDraft,
|
|
} from "../api/locationMaps";
|
|
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 LocationMapToolbar from "../components/maps/LocationMapToolbar";
|
|
import LocationMapTopbar from "../components/maps/LocationMapTopbar";
|
|
import ConfirmSlideModal from "../components/modals/ConfirmSlideModal";
|
|
import useActionToast from "../hooks/useActionToast";
|
|
import getApiErrorMessage from "../lib/getApiErrorMessage";
|
|
import {
|
|
DEFAULT_MAP_FILTERS,
|
|
DEFAULT_MAP_SIZE,
|
|
clampMapZoom,
|
|
clampObjectToMap,
|
|
createClientObject,
|
|
getMapObjectDisplayLabel,
|
|
getMapStatus,
|
|
getObjectKey,
|
|
itemMatchesMapFilters,
|
|
itemsForZone,
|
|
mapForMode,
|
|
normalizeMapObject,
|
|
objectZoneId,
|
|
objectsForMode,
|
|
prepareObjectsForSave,
|
|
unmappedItems,
|
|
} from "../lib/locationMapUtils";
|
|
import "../styles/pages/LocationMapManager.css";
|
|
|
|
const EMPTY_MAP_ITEMS = [];
|
|
|
|
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 [mapState, setMapState] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadError, setLoadError] = useState(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const [savingAction, setSavingAction] = useState(null);
|
|
const [mode, setMode] = useState("setup");
|
|
const [editorTool, setEditorTool] = useState("pan");
|
|
const [previewDraft, setPreviewDraft] = useState(false);
|
|
const [mapDraft, setMapDraft] = useState({
|
|
name: "Store Map",
|
|
width: DEFAULT_MAP_SIZE.width,
|
|
height: DEFAULT_MAP_SIZE.height,
|
|
});
|
|
const [objects, setObjects] = useState([]);
|
|
const [selectedObjectKey, setSelectedObjectKey] = useState(null);
|
|
const [filters, setFilters] = useState(DEFAULT_MAP_FILTERS);
|
|
const [layersOpen, setLayersOpen] = useState(false);
|
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
const [zoom, setZoom] = useState(0.75);
|
|
const [history, setHistory] = useState([]);
|
|
const [future, setFuture] = useState([]);
|
|
const [dragState, setDragState] = useState(null);
|
|
const [pendingDeleteObject, setPendingDeleteObject] = useState(null);
|
|
const [pendingLeaveTarget, setPendingLeaveTarget] = useState(null);
|
|
const svgRef = useRef(null);
|
|
const toastRef = useRef(toast);
|
|
const loadRequestRef = useRef(0);
|
|
|
|
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 { 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 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]
|
|
);
|
|
|
|
useBeforeUnload(
|
|
useCallback((event) => {
|
|
if (!shouldGuardLeave) return;
|
|
event.preventDefault();
|
|
event.returnValue = "";
|
|
}, [shouldGuardLeave]),
|
|
{ capture: true }
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!shouldGuardLeave) {
|
|
setPendingLeaveTarget(null);
|
|
return undefined;
|
|
}
|
|
|
|
const handleDocumentClick = (event) => {
|
|
if (
|
|
event.defaultPrevented ||
|
|
event.button !== 0 ||
|
|
event.altKey ||
|
|
event.ctrlKey ||
|
|
event.metaKey ||
|
|
event.shiftKey
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const target = event.target instanceof Element ? event.target : null;
|
|
const anchor = target?.closest("a[href]");
|
|
if (!anchor || (anchor.target && anchor.target !== "_self") || anchor.hasAttribute("download")) {
|
|
return;
|
|
}
|
|
|
|
const nextUrl = new URL(anchor.href, window.location.href);
|
|
if (nextUrl.origin !== window.location.origin) return;
|
|
|
|
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
|
const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
|
|
if (nextPath === currentPath) return;
|
|
|
|
event.preventDefault();
|
|
setPendingLeaveTarget({ type: "path", path: nextPath });
|
|
};
|
|
|
|
document.addEventListener("click", handleDocumentClick, true);
|
|
return () => document.removeEventListener("click", handleDocumentClick, true);
|
|
}, [shouldGuardLeave]);
|
|
|
|
const syncMapDraftFromState = useCallback((nextState, nextMode, nextPreviewDraft) => {
|
|
const nextMap = mapForMode(nextState, nextMode, nextPreviewDraft);
|
|
const nextObjects = objectsForMode(nextState, nextMode, nextPreviewDraft);
|
|
if (!nextMap) return;
|
|
|
|
setMapDraft({
|
|
name: nextMap.name || "Store Map",
|
|
width: nextMap.width || DEFAULT_MAP_SIZE.width,
|
|
height: nextMap.height || DEFAULT_MAP_SIZE.height,
|
|
});
|
|
setObjects(nextObjects.map(normalizeMapObject));
|
|
setSelectedObjectKey(null);
|
|
setHistory([]);
|
|
setFuture([]);
|
|
setHasUnsavedChanges(false);
|
|
}, []);
|
|
|
|
const loadMap = useCallback(async () => {
|
|
const requestId = loadRequestRef.current + 1;
|
|
loadRequestRef.current = requestId;
|
|
|
|
if (!activeHousehold?.id || !locationId) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
setLoadError(null);
|
|
try {
|
|
const response = await getLocationMap(activeHousehold.id, locationId);
|
|
if (loadRequestRef.current !== requestId) return;
|
|
|
|
const nextState = response.data;
|
|
const nextCanManage = Boolean(
|
|
nextState.can_manage ?? ["owner", "admin"].includes(activeHousehold?.role)
|
|
);
|
|
setMapState(nextState);
|
|
|
|
if (!nextState.draft_map && !nextState.published_map) {
|
|
setMode("setup");
|
|
setEditorTool("pan");
|
|
setPreviewDraft(false);
|
|
setObjects([]);
|
|
setHasUnsavedChanges(false);
|
|
setMapDraft({
|
|
name: "Store Map",
|
|
width: DEFAULT_MAP_SIZE.width,
|
|
height: DEFAULT_MAP_SIZE.height,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (nextState.published_map) {
|
|
setMode("view");
|
|
setEditorTool("pan");
|
|
setPreviewDraft(false);
|
|
} else if (nextState.draft_map && nextCanManage) {
|
|
setMode("setup");
|
|
setEditorTool("pan");
|
|
setPreviewDraft(true);
|
|
} else {
|
|
setMode("setup");
|
|
setEditorTool("pan");
|
|
setPreviewDraft(false);
|
|
setObjects([]);
|
|
setSelectedObjectKey(null);
|
|
setHistory([]);
|
|
setFuture([]);
|
|
setHasUnsavedChanges(false);
|
|
setMapDraft({
|
|
name: "Store Map",
|
|
width: DEFAULT_MAP_SIZE.width,
|
|
height: DEFAULT_MAP_SIZE.height,
|
|
});
|
|
return;
|
|
}
|
|
|
|
syncMapDraftFromState(
|
|
nextState,
|
|
nextState.published_map ? "view" : "edit",
|
|
!nextState.published_map
|
|
);
|
|
} catch (error) {
|
|
if (loadRequestRef.current !== requestId) return;
|
|
|
|
const message = getApiErrorMessage(error, "Failed to load map");
|
|
toastRef.current.error("Load map failed", message);
|
|
setMapState(null);
|
|
setObjects([]);
|
|
setSelectedObjectKey(null);
|
|
setHistory([]);
|
|
setFuture([]);
|
|
setHasUnsavedChanges(false);
|
|
setLoadError(message);
|
|
} finally {
|
|
if (loadRequestRef.current === requestId) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
}, [activeHousehold?.id, activeHousehold?.role, locationId, syncMapDraftFromState]);
|
|
|
|
useEffect(() => {
|
|
toastRef.current = toast;
|
|
}, [toast]);
|
|
|
|
useEffect(() => {
|
|
if (!householdLoading && hasLoaded) {
|
|
loadMap();
|
|
}
|
|
}, [hasLoaded, householdLoading, loadMap]);
|
|
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
if (mode !== "edit") {
|
|
setEditorTool("pan");
|
|
return;
|
|
}
|
|
|
|
if (editorTool === "pan") {
|
|
setSelectedObjectKey(null);
|
|
}
|
|
}, [editorTool, mode]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedObjectKey) return;
|
|
|
|
const selectedMapObject = objects.find((object) => getObjectKey(object) === selectedObjectKey);
|
|
if (!selectedMapObject || !filters.showZones || selectedMapObject.visible === false) {
|
|
setSelectedObjectKey(null);
|
|
}
|
|
}, [filters.showZones, objects, selectedObjectKey]);
|
|
|
|
const remember = (currentObjects = objects) => {
|
|
setHistory((previous) => [...previous.slice(-19), currentObjects.map((object) => ({ ...object }))]);
|
|
setFuture([]);
|
|
};
|
|
|
|
const updateObjects = (updater) => {
|
|
setHasUnsavedChanges(true);
|
|
setObjects((currentObjects) => {
|
|
const nextObjects = updater(currentObjects).map((object) => clampObjectToMap(object, mapSize));
|
|
return nextObjects;
|
|
});
|
|
};
|
|
|
|
const beginSaving = (action) => {
|
|
setSavingAction(action);
|
|
setSaving(true);
|
|
};
|
|
|
|
const endSaving = () => {
|
|
setSaving(false);
|
|
setSavingAction(null);
|
|
};
|
|
|
|
const handleCreateBlank = async () => {
|
|
if (!activeHousehold?.id || !locationId) return;
|
|
beginSaving("create-blank");
|
|
try {
|
|
const response = await createBlankLocationMap(activeHousehold.id, locationId, mapDraft);
|
|
setMapState(response.data);
|
|
setMode("edit");
|
|
setEditorTool("edit");
|
|
setPreviewDraft(true);
|
|
syncMapDraftFromState(response.data, "edit", true);
|
|
toast.success("Created map", "Blank draft map created");
|
|
} catch (error) {
|
|
toast.error("Create map failed", getApiErrorMessage(error, "Failed to create map"));
|
|
} finally {
|
|
endSaving();
|
|
}
|
|
};
|
|
|
|
const handleCreateFromZones = async () => {
|
|
if (!activeHousehold?.id || !locationId) return;
|
|
beginSaving("create-zones");
|
|
try {
|
|
const response = await createLocationMapFromZones(activeHousehold.id, locationId, mapDraft);
|
|
setMapState(response.data);
|
|
setMode("edit");
|
|
setEditorTool("edit");
|
|
setPreviewDraft(true);
|
|
syncMapDraftFromState(response.data, "edit", true);
|
|
toast.success("Created starter map", "Zones were added as editable rectangles");
|
|
} catch (error) {
|
|
toast.error("Create map failed", getApiErrorMessage(error, "Failed to create map from zones"));
|
|
} finally {
|
|
endSaving();
|
|
}
|
|
};
|
|
|
|
const handleSaveDraft = async () => {
|
|
if (!activeHousehold?.id || !locationId) return;
|
|
beginSaving("save");
|
|
try {
|
|
const response = await saveLocationMapDraft(activeHousehold.id, locationId, {
|
|
map: mapDraft,
|
|
objects: prepareObjectsForSave(objects),
|
|
});
|
|
setMapState(response.data);
|
|
setMode("edit");
|
|
setPreviewDraft(true);
|
|
syncMapDraftFromState(response.data, "edit", true);
|
|
toast.success("Saved draft", "Map draft saved");
|
|
} catch (error) {
|
|
toast.error("Save draft failed", getApiErrorMessage(error, "Failed to save map draft"));
|
|
} finally {
|
|
endSaving();
|
|
}
|
|
};
|
|
|
|
const handlePublish = async () => {
|
|
if (!activeHousehold?.id || !locationId) return;
|
|
beginSaving("publish");
|
|
let savedPendingDraft = false;
|
|
try {
|
|
if (hasUnsavedChanges) {
|
|
const saveResponse = await saveLocationMapDraft(activeHousehold.id, locationId, {
|
|
map: mapDraft,
|
|
objects: prepareObjectsForSave(objects),
|
|
});
|
|
savedPendingDraft = true;
|
|
setMapState(saveResponse.data);
|
|
setMode("edit");
|
|
setPreviewDraft(true);
|
|
syncMapDraftFromState(saveResponse.data, "edit", true);
|
|
}
|
|
|
|
const response = await publishLocationMapDraft(activeHousehold.id, locationId);
|
|
setMapState(response.data);
|
|
setMode("view");
|
|
setEditorTool("pan");
|
|
setPreviewDraft(false);
|
|
syncMapDraftFromState(response.data, "view", false);
|
|
toast.success(
|
|
"Published map",
|
|
hasUnsavedChanges
|
|
? "Latest edits were saved and published"
|
|
: "Map is now visible to household members"
|
|
);
|
|
} catch (error) {
|
|
const message = getApiErrorMessage(error, "Failed to publish map");
|
|
toast.error(
|
|
"Publish failed",
|
|
savedPendingDraft ? `${message}. Draft changes were saved.` : message
|
|
);
|
|
} finally {
|
|
endSaving();
|
|
}
|
|
};
|
|
|
|
const handleAddObject = () => {
|
|
if (!canManage) return;
|
|
remember();
|
|
const usedZoneIds = new Set(objects.map((object) => objectZoneId(object)).filter(Boolean));
|
|
const nextZone = (mapState?.zones || []).find((zone) => !usedZoneIds.has(String(zone.id))) || mapState?.zones?.[0];
|
|
const nextObject = createClientObject(nextZone, objects.length);
|
|
setObjects((currentObjects) => [...currentObjects, nextObject]);
|
|
setSelectedObjectKey(getObjectKey(nextObject));
|
|
setEditorTool("edit");
|
|
setHasUnsavedChanges(true);
|
|
};
|
|
|
|
const handleDuplicateObject = () => {
|
|
if (!selectedObject) return;
|
|
remember();
|
|
const displayLabel = getMapObjectDisplayLabel(selectedObject, "Area");
|
|
const nextObject = {
|
|
...selectedObject,
|
|
id: undefined,
|
|
client_id: `copy-${Date.now()}`,
|
|
label: `${displayLabel} Copy`,
|
|
x: selectedObject.x + 28,
|
|
y: selectedObject.y + 28,
|
|
sort_order: objects.length + 1,
|
|
};
|
|
setObjects((currentObjects) => [...currentObjects, clampObjectToMap(nextObject, mapSize)]);
|
|
setSelectedObjectKey(getObjectKey(nextObject));
|
|
setEditorTool("edit");
|
|
setHasUnsavedChanges(true);
|
|
};
|
|
|
|
const requestDeleteObject = () => {
|
|
if (!selectedObject) return;
|
|
setPendingDeleteObject(selectedObject);
|
|
};
|
|
|
|
const handleDeleteObject = () => {
|
|
if (!pendingDeleteObject) return;
|
|
remember();
|
|
setObjects((currentObjects) =>
|
|
currentObjects.filter((object) => getObjectKey(object) !== getObjectKey(pendingDeleteObject))
|
|
);
|
|
setSelectedObjectKey(null);
|
|
setHasUnsavedChanges(true);
|
|
toast.success(
|
|
"Deleted map area",
|
|
"Save the draft to keep this change."
|
|
);
|
|
setPendingDeleteObject(null);
|
|
};
|
|
|
|
const handleShowHiddenAreas = () => {
|
|
if (!canManage || hiddenAreaCount === 0) return;
|
|
remember();
|
|
updateObjects((currentObjects) =>
|
|
currentObjects.map((object) =>
|
|
object.visible === false ? { ...object, visible: true } : object
|
|
)
|
|
);
|
|
toast.success("Showed hidden areas", "Save the draft to keep this change.");
|
|
};
|
|
|
|
const handleObjectField = (field, value) => {
|
|
if (!selectedObject) return;
|
|
remember();
|
|
updateObjects((currentObjects) =>
|
|
currentObjects.map((object) =>
|
|
getObjectKey(object) === getObjectKey(selectedObject)
|
|
? { ...object, [field]: value }
|
|
: object
|
|
)
|
|
);
|
|
};
|
|
|
|
const handleZoneLinkChange = (zoneId) => {
|
|
const zone = (mapState?.zones || []).find((candidate) => String(candidate.id) === String(zoneId));
|
|
handleObjectField("zone_id", zone?.id || null);
|
|
setObjects((currentObjects) =>
|
|
currentObjects.map((object) =>
|
|
getObjectKey(object) === selectedObjectKey
|
|
? {
|
|
...object,
|
|
zone_name: zone?.name || null,
|
|
label: object.label ?? zone?.name ?? "",
|
|
}
|
|
: object
|
|
)
|
|
);
|
|
};
|
|
|
|
const handleUndo = () => {
|
|
setHistory((previous) => {
|
|
if (previous.length === 0) return previous;
|
|
const priorObjects = previous[previous.length - 1];
|
|
setFuture((nextFuture) => [objects.map((object) => ({ ...object })), ...nextFuture.slice(0, 19)]);
|
|
setObjects(priorObjects);
|
|
setHasUnsavedChanges(true);
|
|
return previous.slice(0, -1);
|
|
});
|
|
};
|
|
|
|
const handleRedo = () => {
|
|
setFuture((previous) => {
|
|
if (previous.length === 0) return previous;
|
|
const nextObjects = previous[0];
|
|
setHistory((nextHistory) => [...nextHistory.slice(-19), objects.map((object) => ({ ...object }))]);
|
|
setObjects(nextObjects);
|
|
setHasUnsavedChanges(true);
|
|
return previous.slice(1);
|
|
});
|
|
};
|
|
|
|
const handlePreviewDraft = () => {
|
|
setMode("view");
|
|
setEditorTool("pan");
|
|
setPreviewDraft(true);
|
|
setSelectedObjectKey(null);
|
|
};
|
|
|
|
const handleViewMode = () => {
|
|
setMode("view");
|
|
setEditorTool("pan");
|
|
if (hasUnsavedChanges) {
|
|
setPreviewDraft(true);
|
|
setSelectedObjectKey(null);
|
|
return;
|
|
}
|
|
setPreviewDraft(false);
|
|
if (mapState) {
|
|
syncMapDraftFromState(mapState, "view", false);
|
|
}
|
|
};
|
|
|
|
const handleEditMode = () => {
|
|
setMode("edit");
|
|
setEditorTool("edit");
|
|
setPreviewDraft(true);
|
|
if (!previewDraft && mapState && !hasUnsavedChanges) {
|
|
syncMapDraftFromState(mapState, "edit", true);
|
|
}
|
|
};
|
|
|
|
const handleFitMap = () => {
|
|
const scrollElement = svgRef.current?.closest(".location-map-scroll");
|
|
const fallbackWidth = typeof window === "undefined" ? DEFAULT_MAP_SIZE.width : window.innerWidth;
|
|
const fallbackHeight = typeof window === "undefined" ? DEFAULT_MAP_SIZE.height : window.innerHeight;
|
|
const availableWidth = Math.max(280, (scrollElement?.clientWidth || fallbackWidth) - 24);
|
|
const availableHeight = Math.max(220, (scrollElement?.clientHeight || fallbackHeight) - 24);
|
|
const nextZoom = clampMapZoom(
|
|
Math.min(availableWidth / mapSize.width, availableHeight / mapSize.height)
|
|
);
|
|
setZoom(Number(nextZoom.toFixed(2)));
|
|
};
|
|
|
|
const performBackNavigation = useCallback(() => {
|
|
if (typeof returnPath === "string" && returnPath.startsWith("/")) {
|
|
navigate(returnPath, { replace: true });
|
|
return;
|
|
}
|
|
navigate("/");
|
|
}, [navigate, returnPath]);
|
|
|
|
const handleBack = useCallback(() => {
|
|
if (shouldGuardLeave) {
|
|
setPendingLeaveTarget({ type: "back" });
|
|
return;
|
|
}
|
|
performBackNavigation();
|
|
}, [performBackNavigation, shouldGuardLeave]);
|
|
|
|
const confirmPendingLeave = useCallback(() => {
|
|
const target = pendingLeaveTarget;
|
|
setPendingLeaveTarget(null);
|
|
if (target?.type === "path" && target.path) {
|
|
navigate(target.path);
|
|
return;
|
|
}
|
|
performBackNavigation();
|
|
}, [navigate, pendingLeaveTarget, performBackNavigation]);
|
|
|
|
if (!hasLoaded || householdLoading || loading) {
|
|
return (
|
|
<div className="location-map-page">
|
|
<p className="location-map-loading">Loading map...</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!activeHousehold) {
|
|
return (
|
|
<div className="location-map-page">
|
|
<p className="location-map-loading">Select a household to manage maps.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (loadError) {
|
|
return (
|
|
<div className="location-map-page">
|
|
<LocationMapTopbar location={location} status="Load Error" onBack={handleBack} />
|
|
<main className="location-map-workspace">
|
|
<section className="location-map-setup location-map-load-error" role="alert" aria-live="polite">
|
|
<h2>Map Unavailable</h2>
|
|
<p>{loadError}</p>
|
|
<div className="location-map-setup-actions">
|
|
<button type="button" className="btn-primary" onClick={loadMap}>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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={handleBack} />
|
|
|
|
<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}
|
|
/>
|
|
</>
|
|
)}
|
|
</main>
|
|
|
|
<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={() => setPendingLeaveTarget(null)}
|
|
onConfirm={confirmPendingLeave}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|