484 lines
14 KiB
JavaScript
484 lines
14 KiB
JavaScript
import { useCallback, useContext, useEffect, useRef } from "react";
|
|
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
|
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,
|
|
LocationMapMessageState,
|
|
} from "../components/maps/LocationMapStateViews";
|
|
import LocationMapToolbar from "../components/maps/LocationMapToolbar";
|
|
import LocationMapTopbar from "../components/maps/LocationMapTopbar";
|
|
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 { getMapStatus } from "../lib/locationMapUtils";
|
|
import "../styles/pages/LocationMapManager.css";
|
|
|
|
function isTextEditingTarget(target) {
|
|
return Boolean(target?.closest?.("input, textarea, select, [contenteditable]"));
|
|
}
|
|
|
|
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 {
|
|
beginSaving,
|
|
dragState,
|
|
endSaving,
|
|
filters,
|
|
future,
|
|
hasUnsavedChanges,
|
|
history,
|
|
layersOpen,
|
|
loadError,
|
|
loadMap,
|
|
loading,
|
|
mapDraft,
|
|
mapState,
|
|
mode,
|
|
objects,
|
|
previewDraft,
|
|
saving,
|
|
savingAction,
|
|
selectedObjectKey,
|
|
setDragState,
|
|
setFilters,
|
|
setFuture,
|
|
setHasUnsavedChanges,
|
|
setHistory,
|
|
setLayersOpen,
|
|
setMapDraft,
|
|
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 {
|
|
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;
|
|
|
|
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,
|
|
setMapState,
|
|
setMode,
|
|
setPreviewDraft,
|
|
syncMapDraftFromState,
|
|
toast,
|
|
});
|
|
|
|
const {
|
|
handleAddObject,
|
|
handleDeleteObject,
|
|
handleDuplicateObject,
|
|
handleObjectField,
|
|
handleShowHiddenAreas,
|
|
handleZoneLinkChange,
|
|
expandMapToFitObject,
|
|
pendingDeleteObject,
|
|
remember,
|
|
requestDeleteObject,
|
|
setPendingDeleteObject,
|
|
updateObjects,
|
|
} = useLocationMapObjectActions({
|
|
canManage,
|
|
hiddenAreaCount,
|
|
mapSize,
|
|
mapState,
|
|
objects,
|
|
selectedObject,
|
|
selectedObjectKey,
|
|
setFuture,
|
|
setHasUnsavedChanges,
|
|
setHistory,
|
|
setMapDraft,
|
|
setObjects,
|
|
setSelectedObjectKey,
|
|
toast,
|
|
});
|
|
|
|
useEffect(() => {
|
|
setPendingDeleteObject(null);
|
|
}, [activeHousehold?.id, locationId, setPendingDeleteObject]);
|
|
|
|
const {
|
|
handleEditMode,
|
|
handleFitMap,
|
|
handlePreviewDraft,
|
|
handleRedo,
|
|
handleUndo,
|
|
handleViewMode,
|
|
} = useLocationMapViewControls({
|
|
filters,
|
|
future,
|
|
hasUnsavedChanges,
|
|
history,
|
|
mapSize,
|
|
mapState,
|
|
mode,
|
|
objects,
|
|
previewDraft,
|
|
saving,
|
|
selectedObjectKey,
|
|
setFuture,
|
|
setHasUnsavedChanges,
|
|
setHistory,
|
|
setLayersOpen,
|
|
setMode,
|
|
setObjects,
|
|
setPreviewDraft,
|
|
setSelectedObjectKey,
|
|
setZoom,
|
|
svgRef,
|
|
syncMapDraftFromState,
|
|
zoom,
|
|
});
|
|
|
|
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,
|
|
});
|
|
|
|
useEffect(() => {
|
|
const hasOpenModal = Boolean(buyModalItem || pendingDeleteObject || pendingLeaveTarget);
|
|
if (!selectedObjectKey || layersOpen || hasOpenModal || typeof window === "undefined") {
|
|
return undefined;
|
|
}
|
|
|
|
const handleKeyDown = (event) => {
|
|
if (
|
|
event.defaultPrevented ||
|
|
event.key !== "Escape" ||
|
|
isTextEditingTarget(event.target)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
setSelectedObjectKey(null);
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [buyModalItem, layersOpen, pendingDeleteObject, pendingLeaveTarget, selectedObjectKey, setSelectedObjectKey]);
|
|
|
|
useEffect(() => {
|
|
const hasOpenModal = Boolean(buyModalItem || pendingDeleteObject || pendingLeaveTarget);
|
|
if (mode !== "edit" || !canManage || hasOpenModal || typeof window === "undefined") {
|
|
return undefined;
|
|
}
|
|
|
|
const handleKeyDown = (event) => {
|
|
const wantsSave =
|
|
(event.ctrlKey || event.metaKey) &&
|
|
!event.altKey &&
|
|
event.key.toLowerCase() === "s";
|
|
|
|
if (!wantsSave) return;
|
|
event.preventDefault();
|
|
if (saving || !hasUnsavedChanges) return;
|
|
handleSaveDraft();
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown, true);
|
|
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
|
}, [
|
|
buyModalItem,
|
|
canManage,
|
|
handleSaveDraft,
|
|
hasUnsavedChanges,
|
|
mode,
|
|
pendingDeleteObject,
|
|
pendingLeaveTarget,
|
|
saving,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const hasOpenModal = Boolean(buyModalItem || pendingDeleteObject || pendingLeaveTarget);
|
|
if (
|
|
!selectedObjectKey ||
|
|
mode !== "edit" ||
|
|
!canManage ||
|
|
saving ||
|
|
layersOpen ||
|
|
hasOpenModal ||
|
|
typeof window === "undefined"
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
const handleKeyDown = (event) => {
|
|
if (
|
|
event.defaultPrevented ||
|
|
!["Backspace", "Delete"].includes(event.key) ||
|
|
isTextEditingTarget(event.target)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
requestDeleteObject();
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown, true);
|
|
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
|
}, [
|
|
buyModalItem,
|
|
canManage,
|
|
layersOpen,
|
|
mode,
|
|
pendingDeleteObject,
|
|
pendingLeaveTarget,
|
|
requestDeleteObject,
|
|
saving,
|
|
selectedObjectKey,
|
|
]);
|
|
|
|
if (!hasLoaded || householdLoading || loading) {
|
|
return (
|
|
<LocationMapMessageState
|
|
message="Loading map..."
|
|
location={location}
|
|
status="Loading"
|
|
onBack={requestBackNavigation}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (!activeHousehold) {
|
|
return (
|
|
<LocationMapMessageState
|
|
status="Select Household"
|
|
/>
|
|
);
|
|
}
|
|
|
|
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}
|
|
hasAnyMap={hasAnyMap}
|
|
canManage={canManage}
|
|
saving={saving}
|
|
history={history}
|
|
future={future}
|
|
zoom={zoom}
|
|
setZoom={setZoom}
|
|
onFit={handleFitMap}
|
|
onView={handleViewMode}
|
|
onEdit={handleEditMode}
|
|
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");
|
|
setPreviewDraft(true);
|
|
}}
|
|
onPreview={handlePreviewDraft}
|
|
onPublish={handlePublish}
|
|
onCreateFromZones={handleCreateFromZones}
|
|
onCreateBlank={handleCreateBlank}
|
|
/>
|
|
) : (
|
|
<>
|
|
<LocationMapCanvas
|
|
mode={mode}
|
|
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}
|
|
expandMapToFitObject={expandMapToFitObject}
|
|
onShowZones={() =>
|
|
setFilters((current) => ({
|
|
...current,
|
|
showZones: true,
|
|
}))
|
|
}
|
|
onShowHiddenAreas={handleShowHiddenAreas}
|
|
/>
|
|
<LocationMapBottomSheet
|
|
mode={mode}
|
|
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}
|
|
onObjectField={handleObjectField}
|
|
onZoneLinkChange={handleZoneLinkChange}
|
|
onDuplicateObject={handleDuplicateObject}
|
|
onDeleteObject={requestDeleteObject}
|
|
onShowHiddenAreas={handleShowHiddenAreas}
|
|
onClearSelection={() => setSelectedObjectKey(null)}
|
|
onSelectZoneItem={setBuyModalItem}
|
|
onSelectUnmappedItem={setBuyModalItem}
|
|
/>
|
|
</>
|
|
)}
|
|
</main>
|
|
|
|
<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>
|
|
);
|
|
}
|