+ api.get(`/households/${householdId}/locations/${locationId}/map`);
+
+export const createBlankLocationMap = (householdId, locationId, map = {}) =>
+ api.post(`/households/${householdId}/locations/${locationId}/map/blank`, { map });
+
+export const createLocationMapFromZones = (householdId, locationId, map = {}) =>
+ api.post(`/households/${householdId}/locations/${locationId}/map/from-zones`, { map });
+
+export const saveLocationMapDraft = (householdId, locationId, payload) =>
+ api.put(`/households/${householdId}/locations/${locationId}/map/draft`, payload);
+
+export const publishLocationMapDraft = (householdId, locationId) =>
+ api.post(`/households/${householdId}/locations/${locationId}/map/publish`);
diff --git a/frontend/src/components/maps/LocationMapBottomSheet.jsx b/frontend/src/components/maps/LocationMapBottomSheet.jsx
new file mode 100644
index 0000000..f2b7b12
--- /dev/null
+++ b/frontend/src/components/maps/LocationMapBottomSheet.jsx
@@ -0,0 +1,184 @@
+import { MAP_OBJECT_TYPES } from "../../lib/locationMapUtils";
+
+function objectZoneId(object) {
+ return object?.zone_id ? String(object.zone_id) : "";
+}
+
+const DISPLAY_CONTROLS = [
+ ["showZones", "Zones"],
+ ["showLabels", "Labels"],
+ ["showCounts", "Counts"],
+ ["showPins", "Pins"],
+ ["showMyItems", "Mine"],
+ ["showOtherItems", "Others"],
+ ["showCompleted", "Done"],
+ ["showUnmapped", "Unmapped"],
+];
+
+export default function LocationMapBottomSheet({
+ mode,
+ canManage,
+ filters,
+ setFilters,
+ selectedObject,
+ selectedZoneItems,
+ visibleUnmappedItems,
+ history,
+ future,
+ saving,
+ mapState,
+ onAddObject,
+ onUndo,
+ onRedo,
+ onSaveDraft,
+ onPublish,
+ onEditMode,
+ onObjectField,
+ onZoneLinkChange,
+ onDuplicateObject,
+ onDeleteObject,
+}) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/maps/LocationMapCanvas.jsx b/frontend/src/components/maps/LocationMapCanvas.jsx
new file mode 100644
index 0000000..e23ae49
--- /dev/null
+++ b/frontend/src/components/maps/LocationMapCanvas.jsx
@@ -0,0 +1,162 @@
+import {
+ clientPointToMap,
+ getObjectKey,
+ itemsForZone,
+} from "../../lib/locationMapUtils";
+
+export default function LocationMapCanvas({
+ mode,
+ objects,
+ filters,
+ mapSize,
+ zoom,
+ selectedObjectKey,
+ mapItems,
+ username,
+ svgRef,
+ dragState,
+ setDragState,
+ setSelectedObjectKey,
+ remember,
+ updateObjects,
+}) {
+ const startDrag = (event, object, type) => {
+ if (mode !== "edit" || object.locked || !svgRef.current) return;
+ event.stopPropagation();
+ remember();
+ const point = clientPointToMap(event, svgRef.current, mapSize);
+ setSelectedObjectKey(getObjectKey(object));
+ setDragState({
+ type,
+ key: getObjectKey(object),
+ startPoint: point,
+ startObject: { ...object },
+ });
+ };
+
+ const handlePointerMove = (event) => {
+ if (!dragState || !svgRef.current) return;
+ const point = clientPointToMap(event, svgRef.current, mapSize);
+ const deltaX = point.x - dragState.startPoint.x;
+ const deltaY = point.y - dragState.startPoint.y;
+
+ updateObjects((currentObjects) =>
+ currentObjects.map((object) => {
+ if (getObjectKey(object) !== dragState.key) return object;
+ if (dragState.type === "resize") {
+ return {
+ ...object,
+ width: Math.max(40, dragState.startObject.width + deltaX),
+ height: Math.max(40, dragState.startObject.height + deltaY),
+ };
+ }
+ return {
+ ...object,
+ x: dragState.startObject.x + deltaX,
+ y: dragState.startObject.y + deltaY,
+ };
+ })
+ );
+ };
+
+ const handlePointerUp = () => {
+ setDragState(null);
+ };
+
+ const renderMapObject = (object, index) => {
+ if (!object.visible || !filters.showZones) return null;
+ const key = getObjectKey(object);
+ const isSelected = key === selectedObjectKey;
+ const zoneItems = itemsForZone(mapItems, object.zone_id, filters, username);
+ const count = zoneItems.length;
+ const pinDots = filters.showPins
+ ? zoneItems.slice(0, 12).map((item, itemIndex) => {
+ const column = itemIndex % 4;
+ const row = Math.floor(itemIndex / 4);
+ return (
+
+ );
+ })
+ : null;
+
+ return (
+
+ startDrag(event, object, "move")}
+ onClick={() => setSelectedObjectKey(key)}
+ />
+ {filters.showLabels ? (
+
+ {object.label || object.zone_name || "Area"}
+
+ ) : null}
+ {filters.showCounts ? (
+
+ {count} item{count === 1 ? "" : "s"}
+
+ ) : null}
+ {pinDots}
+ {mode === "edit" && isSelected ? (
+ startDrag(event, object, "resize")}
+ />
+ ) : null}
+
+ );
+ };
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/maps/LocationMapSetupPanel.jsx b/frontend/src/components/maps/LocationMapSetupPanel.jsx
new file mode 100644
index 0000000..9f598e1
--- /dev/null
+++ b/frontend/src/components/maps/LocationMapSetupPanel.jsx
@@ -0,0 +1,47 @@
+export default function LocationMapSetupPanel({
+ hasAnyMap,
+ canManage,
+ saving,
+ onContinue,
+ onPreview,
+ onPublish,
+ onCreateFromZones,
+ onCreateBlank,
+}) {
+ return (
+
+ {hasAnyMap ? "Draft Map" : "No Map"}
+
+ {hasAnyMap
+ ? "A draft map exists for this location."
+ : "Create a rough map for this store location."}
+
+
+ {hasAnyMap ? (
+ <>
+
+
+
+ >
+ ) : canManage ? (
+ <>
+
+
+ >
+ ) : (
+
A household admin has not published a map yet.
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/maps/LocationMapToolbar.jsx b/frontend/src/components/maps/LocationMapToolbar.jsx
new file mode 100644
index 0000000..e0b82cc
--- /dev/null
+++ b/frontend/src/components/maps/LocationMapToolbar.jsx
@@ -0,0 +1,44 @@
+export default function LocationMapToolbar({
+ mode,
+ hasAnyMap,
+ canManage,
+ zoom,
+ setZoom,
+ onView,
+ onEdit,
+}) {
+ return (
+
+
+
+ {canManage ? (
+
+ ) : null}
+
+
+
+
+ {Math.round(zoom * 100)}%
+
+
+
+ );
+}
diff --git a/frontend/src/components/maps/LocationMapTopbar.jsx b/frontend/src/components/maps/LocationMapTopbar.jsx
new file mode 100644
index 0000000..744c667
--- /dev/null
+++ b/frontend/src/components/maps/LocationMapTopbar.jsx
@@ -0,0 +1,18 @@
+import { getLocationName, getStoreName } from "../../lib/locationMapUtils";
+
+export default function LocationMapTopbar({ location, status, onBack }) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/store/StoreTabs.jsx b/frontend/src/components/store/StoreTabs.jsx
index 0884d48..51812fd 100644
--- a/frontend/src/components/store/StoreTabs.jsx
+++ b/frontend/src/components/store/StoreTabs.jsx
@@ -98,8 +98,8 @@ export default function StoreTabs() {
};
const handleManageMap = () => {
- const locationQuery = selectedLocation?.id ? `&locationId=${selectedLocation.id}` : "";
- navigate(`/manage?tab=stores${locationQuery}`);
+ if (!selectedLocation?.id || !selectedStoreOption?.key) return;
+ navigate(`/stores/${selectedStoreOption.key}/locations/${selectedLocation.id}/map`);
};
return (
diff --git a/frontend/src/lib/locationMapUtils.js b/frontend/src/lib/locationMapUtils.js
new file mode 100644
index 0000000..a56fc4c
--- /dev/null
+++ b/frontend/src/lib/locationMapUtils.js
@@ -0,0 +1,187 @@
+const DEFAULT_LOCATION_NAME = "Default Location";
+
+export const DEFAULT_MAP_SIZE = {
+ width: 1000,
+ height: 700,
+};
+
+export const DEFAULT_MAP_FILTERS = {
+ showZones: true,
+ showLabels: true,
+ showCounts: true,
+ showPins: false,
+ showMyItems: true,
+ showOtherItems: false,
+ showCompleted: false,
+ showUnmapped: false,
+};
+
+export const MAP_OBJECT_TYPES = [
+ "zone",
+ "aisle",
+ "entrance",
+ "checkout",
+ "shelf",
+ "wall",
+ "department",
+ "anchor",
+ "other",
+];
+
+export function getStoreName(location) {
+ return location?.name || "Store";
+}
+
+export function getLocationName(location) {
+ if (!location) return "Location";
+ if (!location.location_name || location.location_name === DEFAULT_LOCATION_NAME) {
+ return "Default";
+ }
+ return location.location_name;
+}
+
+export function getMapStatus(state) {
+ if (!state?.draft_map && !state?.published_map) return "No Map";
+ if (state?.draft_map) return "Draft";
+ return "Published";
+}
+
+export function objectZoneId(object) {
+ return object?.zone_id ? String(object.zone_id) : "";
+}
+
+export function mapForMode(mapState, mode, previewDraft) {
+ if (mode === "edit") {
+ return mapState?.draft_map || mapState?.published_map || null;
+ }
+
+ if (previewDraft && mapState?.draft_map) {
+ return mapState.draft_map;
+ }
+
+ return mapState?.published_map || mapState?.draft_map || null;
+}
+
+export function objectsForMode(mapState, mode, previewDraft) {
+ if (mode === "edit") {
+ return mapState?.draft_objects?.length
+ ? mapState.draft_objects
+ : mapState?.published_objects || [];
+ }
+
+ if (previewDraft && mapState?.draft_map) {
+ return mapState.draft_objects || [];
+ }
+
+ return mapState?.published_objects?.length
+ ? mapState.published_objects
+ : mapState?.draft_objects || [];
+}
+
+export function createClientObject(zone, index = 0) {
+ const offset = index * 24;
+ return {
+ client_id: `new-${Date.now()}-${index}`,
+ zone_id: zone?.id || null,
+ zone_name: zone?.name || null,
+ type: "zone",
+ label: zone?.name || "New Area",
+ x: 80 + offset,
+ y: 80 + offset,
+ width: 220,
+ height: 130,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: index + 1,
+ metadata_json: {},
+ };
+}
+
+export function normalizeMapObject(object, index = 0) {
+ return {
+ ...object,
+ client_id: object.client_id || `object-${object.id || Date.now()}-${index}`,
+ zone_id: object.zone_id ?? null,
+ zone_name: object.zone_name || null,
+ type: MAP_OBJECT_TYPES.includes(object.type) ? object.type : "zone",
+ label: object.label || object.zone_name || "Map Area",
+ x: Number(object.x) || 0,
+ y: Number(object.y) || 0,
+ width: Math.max(40, Number(object.width) || 160),
+ height: Math.max(40, Number(object.height) || 100),
+ rotation: Number(object.rotation) || 0,
+ locked: Boolean(object.locked),
+ visible: object.visible !== false,
+ sort_order: Number(object.sort_order ?? object.sortOrder ?? index + 1),
+ metadata_json: object.metadata_json || {},
+ };
+}
+
+export function getObjectKey(object) {
+ return String(object.id || object.client_id);
+}
+
+export function prepareObjectsForSave(objects) {
+ return objects.map((object, index) => ({
+ zone_id: object.zone_id || null,
+ type: object.type || "zone",
+ label: object.label || "",
+ x: Number(object.x) || 0,
+ y: Number(object.y) || 0,
+ width: Math.max(40, Number(object.width) || 160),
+ height: Math.max(40, Number(object.height) || 100),
+ rotation: Number(object.rotation) || 0,
+ locked: Boolean(object.locked),
+ visible: object.visible !== false,
+ sort_order: index + 1,
+ metadata_json: object.metadata_json || {},
+ }));
+}
+
+export function itemsForZone(items, zoneId, filters, username) {
+ return items.filter((item) => {
+ if (String(item.zone_id || "") !== String(zoneId || "")) return false;
+ if (!filters.showCompleted && item.bought) return false;
+
+ const addedBy = Array.isArray(item.added_by_users) ? item.added_by_users : [];
+ const hasOwnershipData = addedBy.length > 0 && username;
+ const isMine = hasOwnershipData ? addedBy.includes(username) : true;
+ if (isMine && !filters.showMyItems) return false;
+ if (!isMine && !filters.showOtherItems) return false;
+ return true;
+ });
+}
+
+export function unmappedItems(items, filters, username) {
+ return items.filter((item) => {
+ if (item.zone_id) return false;
+ if (!filters.showCompleted && item.bought) return false;
+
+ const addedBy = Array.isArray(item.added_by_users) ? item.added_by_users : [];
+ const hasOwnershipData = addedBy.length > 0 && username;
+ const isMine = hasOwnershipData ? addedBy.includes(username) : true;
+ if (isMine && !filters.showMyItems) return false;
+ if (!isMine && !filters.showOtherItems) return false;
+ return true;
+ });
+}
+
+export function clientPointToMap(event, svgElement, mapSize) {
+ const rect = svgElement.getBoundingClientRect();
+ const x = ((event.clientX - rect.left) / rect.width) * mapSize.width;
+ const y = ((event.clientY - rect.top) / rect.height) * mapSize.height;
+ return { x, y };
+}
+
+export function clampObjectToMap(object, mapSize) {
+ const width = Math.max(40, Math.min(object.width, mapSize.width));
+ const height = Math.max(40, Math.min(object.height, mapSize.height));
+ return {
+ ...object,
+ width,
+ height,
+ x: Math.max(0, Math.min(object.x, mapSize.width - width)),
+ y: Math.max(0, Math.min(object.y, mapSize.height - height)),
+ };
+}
diff --git a/frontend/src/pages/LocationMapManager.jsx b/frontend/src/pages/LocationMapManager.jsx
new file mode 100644
index 0000000..1a69125
--- /dev/null
+++ b/frontend/src/pages/LocationMapManager.jsx
@@ -0,0 +1,429 @@
+import { useCallback, useContext, useEffect, useRef, useState } from "react";
+import { 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 useActionToast from "../hooks/useActionToast";
+import getApiErrorMessage from "../lib/getApiErrorMessage";
+import {
+ DEFAULT_MAP_FILTERS,
+ DEFAULT_MAP_SIZE,
+ clampObjectToMap,
+ createClientObject,
+ getMapStatus,
+ getObjectKey,
+ itemsForZone,
+ mapForMode,
+ normalizeMapObject,
+ objectZoneId,
+ objectsForMode,
+ prepareObjectsForSave,
+ unmappedItems,
+} from "../lib/locationMapUtils";
+import "../styles/pages/LocationMapManager.css";
+
+export default function LocationMapManager() {
+ const { storeId, locationId } = useParams();
+ const navigate = useNavigate();
+ 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 [saving, setSaving] = useState(false);
+ const [mode, setMode] = useState("setup");
+ 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 [zoom, setZoom] = useState(0.75);
+ const [history, setHistory] = useState([]);
+ const [future, setFuture] = useState([]);
+ const [dragState, setDragState] = useState(null);
+ const svgRef = useRef(null);
+ const toastRef = useRef(toast);
+
+ 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 = mapForMode(mapState, mode, previewDraft);
+ const selectedObject = objects.find((object) => getObjectKey(object) === selectedObjectKey) || null;
+ const selectedZoneItems = selectedObject?.zone_id
+ ? itemsForZone(mapState?.items || [], selectedObject.zone_id, filters, username)
+ : [];
+ const visibleUnmappedItems = unmappedItems(mapState?.items || [], filters, username);
+
+ const mapSize = {
+ width: mapDraft.width || activeMap?.width || DEFAULT_MAP_SIZE.width,
+ height: mapDraft.height || activeMap?.height || DEFAULT_MAP_SIZE.height,
+ };
+
+ const loadMap = useCallback(async () => {
+ if (!activeHousehold?.id || !locationId) return;
+
+ setLoading(true);
+ try {
+ const response = await getLocationMap(activeHousehold.id, locationId);
+ const nextState = response.data;
+ setMapState(nextState);
+
+ if (!nextState.draft_map && !nextState.published_map) {
+ setMode("setup");
+ setPreviewDraft(false);
+ setObjects([]);
+ setMapDraft({
+ name: "Store Map",
+ width: DEFAULT_MAP_SIZE.width,
+ height: DEFAULT_MAP_SIZE.height,
+ });
+ return;
+ }
+
+ if (nextState.published_map) {
+ setMode("view");
+ setPreviewDraft(false);
+ } else if (nextState.draft_map) {
+ setMode("setup");
+ setPreviewDraft(true);
+ }
+
+ const nextMap = nextState.published_map || nextState.draft_map;
+ setMapDraft({
+ name: nextMap?.name || "Store Map",
+ width: nextMap?.width || DEFAULT_MAP_SIZE.width,
+ height: nextMap?.height || DEFAULT_MAP_SIZE.height,
+ });
+ setObjects(
+ objectsForMode(
+ nextState,
+ nextState.published_map ? "view" : "edit",
+ !nextState.published_map
+ ).map(normalizeMapObject)
+ );
+ } catch (error) {
+ const message = getApiErrorMessage(error, "Failed to load map");
+ toastRef.current.error("Load map failed", message);
+ setMapState(null);
+ } finally {
+ setLoading(false);
+ }
+ }, [activeHousehold?.id, locationId]);
+
+ 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 (!mapState) return;
+ const nextMap = mapForMode(mapState, mode, previewDraft);
+ const nextObjects = objectsForMode(mapState, mode, previewDraft);
+ 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([]);
+ }, [mapState, mode, previewDraft]);
+
+ const remember = (currentObjects = objects) => {
+ setHistory((previous) => [...previous.slice(-19), currentObjects.map((object) => ({ ...object }))]);
+ setFuture([]);
+ };
+
+ const updateObjects = (updater) => {
+ setObjects((currentObjects) => {
+ const nextObjects = updater(currentObjects).map((object) => clampObjectToMap(object, mapSize));
+ return nextObjects;
+ });
+ };
+
+ const handleCreateBlank = async () => {
+ if (!activeHousehold?.id || !locationId) return;
+ setSaving(true);
+ try {
+ const response = await createBlankLocationMap(activeHousehold.id, locationId, mapDraft);
+ setMapState(response.data);
+ setMode("edit");
+ setPreviewDraft(true);
+ toast.success("Created map", "Blank draft map created");
+ } catch (error) {
+ toast.error("Create map failed", getApiErrorMessage(error, "Failed to create map"));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleCreateFromZones = async () => {
+ if (!activeHousehold?.id || !locationId) return;
+ setSaving(true);
+ try {
+ const response = await createLocationMapFromZones(activeHousehold.id, locationId, mapDraft);
+ setMapState(response.data);
+ setMode("edit");
+ setPreviewDraft(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 {
+ setSaving(false);
+ }
+ };
+
+ const handleSaveDraft = async () => {
+ if (!activeHousehold?.id || !locationId) return;
+ setSaving(true);
+ try {
+ const response = await saveLocationMapDraft(activeHousehold.id, locationId, {
+ map: mapDraft,
+ objects: prepareObjectsForSave(objects),
+ });
+ setMapState(response.data);
+ setMode("edit");
+ setPreviewDraft(true);
+ toast.success("Saved draft", "Map draft saved");
+ } catch (error) {
+ toast.error("Save draft failed", getApiErrorMessage(error, "Failed to save map draft"));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handlePublish = async () => {
+ if (!activeHousehold?.id || !locationId) return;
+ setSaving(true);
+ try {
+ const response = await publishLocationMapDraft(activeHousehold.id, locationId);
+ setMapState(response.data);
+ setMode("view");
+ setPreviewDraft(false);
+ toast.success("Published map", "Map is now visible to household members");
+ } catch (error) {
+ toast.error("Publish failed", getApiErrorMessage(error, "Failed to publish map"));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ 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));
+ };
+
+ const handleDuplicateObject = () => {
+ if (!selectedObject) return;
+ remember();
+ const nextObject = {
+ ...selectedObject,
+ id: undefined,
+ client_id: `copy-${Date.now()}`,
+ label: `${selectedObject.label || "Area"} Copy`,
+ x: selectedObject.x + 28,
+ y: selectedObject.y + 28,
+ sort_order: objects.length + 1,
+ };
+ setObjects((currentObjects) => [...currentObjects, clampObjectToMap(nextObject, mapSize)]);
+ setSelectedObjectKey(getObjectKey(nextObject));
+ };
+
+ const handleDeleteObject = () => {
+ if (!selectedObject) return;
+ remember();
+ setObjects((currentObjects) =>
+ currentObjects.filter((object) => getObjectKey(object) !== getObjectKey(selectedObject))
+ );
+ setSelectedObjectKey(null);
+ };
+
+ 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);
+ 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);
+ return previous.slice(1);
+ });
+ };
+
+ if (!hasLoaded || householdLoading || loading) {
+ return (
+
+ );
+ }
+
+ if (!activeHousehold) {
+ return (
+
+
Select a household to manage maps.
+
+ );
+ }
+
+ const hasAnyMap = Boolean(mapState?.draft_map || mapState?.published_map);
+ const status = getMapStatus(mapState);
+
+ return (
+
+ navigate(-1)} />
+
+
+ {
+ setMode("view");
+ setPreviewDraft(false);
+ }}
+ onEdit={() => {
+ setMode("edit");
+ setPreviewDraft(true);
+ }}
+ />
+
+ {!hasAnyMap || (mode === "setup" && mapState?.draft_map && !mapState?.published_map) ? (
+ setMode("edit")}
+ onPreview={() => {
+ setMode("view");
+ setPreviewDraft(true);
+ }}
+ onPublish={handlePublish}
+ onCreateFromZones={handleCreateFromZones}
+ onCreateBlank={handleCreateBlank}
+ />
+ ) : (
+ <>
+
+ {
+ setMode("edit");
+ setPreviewDraft(true);
+ }}
+ onObjectField={handleObjectField}
+ onZoneLinkChange={handleZoneLinkChange}
+ onDuplicateObject={handleDuplicateObject}
+ onDeleteObject={handleDeleteObject}
+ />
+ >
+ )}
+
+
+ );
+}
diff --git a/frontend/src/styles/pages/LocationMapManager.css b/frontend/src/styles/pages/LocationMapManager.css
new file mode 100644
index 0000000..a21913f
--- /dev/null
+++ b/frontend/src/styles/pages/LocationMapManager.css
@@ -0,0 +1,414 @@
+.location-map-page {
+ min-height: calc(100vh - 56px);
+ background: var(--color-bg-body);
+ color: var(--color-text-primary);
+ display: flex;
+ flex-direction: column;
+}
+
+.location-map-topbar {
+ position: sticky;
+ top: 0;
+ z-index: 20;
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.75rem clamp(0.75rem, 2vw, 1.25rem);
+ border-bottom: 1px solid var(--color-border-light);
+ background: rgba(17, 28, 42, 0.94);
+ backdrop-filter: blur(14px);
+}
+
+.location-map-back,
+.location-map-topbar button {
+ min-height: 40px;
+ border: 1px solid var(--color-border-light);
+ border-radius: 8px;
+ background: var(--color-bg-elevated);
+ color: var(--color-text-primary);
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.location-map-back {
+ padding: 0 0.85rem;
+}
+
+.location-map-title {
+ min-width: 0;
+}
+
+.location-map-title h1 {
+ font-size: clamp(1rem, 3vw, 1.35rem);
+ color: var(--color-text-primary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.location-map-title span {
+ display: block;
+ margin-top: 0.15rem;
+ color: var(--color-text-secondary);
+ font-size: 0.86rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.location-map-status {
+ min-width: 78px;
+ padding: 0.35rem 0.6rem;
+ border: 1px solid var(--color-border-light);
+ border-radius: 999px;
+ color: var(--color-text-primary);
+ font-size: 0.78rem;
+ font-weight: 800;
+ text-align: center;
+}
+
+.location-map-status-no-map {
+ background: rgba(148, 163, 184, 0.16);
+}
+
+.location-map-status-draft {
+ background: rgba(245, 158, 11, 0.2);
+ border-color: rgba(245, 158, 11, 0.36);
+}
+
+.location-map-status-published {
+ background: rgba(20, 184, 166, 0.18);
+ border-color: rgba(20, 184, 166, 0.34);
+}
+
+.location-map-workspace {
+ flex: 1;
+ min-height: 0;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr) auto;
+}
+
+.location-map-toolbar {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 0.75rem clamp(0.75rem, 2vw, 1.25rem);
+ border-bottom: 1px solid var(--color-border-light);
+}
+
+.location-map-mode-buttons,
+.location-map-zoom-controls,
+.location-map-editor-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+}
+
+.location-map-mode-buttons button,
+.location-map-zoom-controls button,
+.location-map-editor-actions button,
+.location-map-setup-actions button {
+ min-height: 40px;
+ padding: 0.55rem 0.8rem;
+ border: 1px solid var(--color-border-light);
+ border-radius: 8px;
+ background: var(--color-bg-elevated);
+ color: var(--color-text-primary);
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.location-map-mode-buttons button.active,
+.location-map-editor-actions button:nth-last-child(2),
+.location-map-setup-actions .btn-primary {
+ border-color: var(--color-primary);
+ background: var(--color-primary);
+ color: var(--color-text-inverse);
+}
+
+.location-map-editor-actions button.danger {
+ border-color: rgba(248, 113, 113, 0.5);
+ background: rgba(248, 113, 113, 0.24);
+}
+
+.location-map-mode-buttons button:disabled,
+.location-map-zoom-controls button:disabled,
+.location-map-editor-actions button:disabled,
+.location-map-setup-actions button:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+
+.location-map-zoom-controls span {
+ min-width: 54px;
+ color: var(--color-text-secondary);
+ font-size: 0.9rem;
+ font-weight: 700;
+ text-align: center;
+}
+
+.location-map-canvas-shell {
+ min-height: 0;
+ padding: 0.75rem;
+}
+
+.location-map-scroll {
+ height: 100%;
+ min-height: 48vh;
+ overflow: auto;
+ border: 1px solid var(--color-border-light);
+ border-radius: 8px;
+ background:
+ linear-gradient(135deg, rgba(20, 184, 166, 0.08), transparent 40%),
+ linear-gradient(180deg, rgba(15, 23, 42, 0.2), rgba(15, 23, 42, 0.04));
+ touch-action: pan-x pan-y;
+}
+
+.location-map-svg {
+ min-width: 320px;
+ min-height: 260px;
+ display: block;
+}
+
+.location-map-background {
+ fill: rgba(15, 23, 34, 0.92);
+ stroke: rgba(148, 163, 184, 0.28);
+ stroke-width: 2;
+}
+
+.location-map-grid {
+ fill: url("#location-map-grid");
+ stroke: rgba(148, 163, 184, 0.18);
+ stroke-width: 1;
+}
+
+.location-map-object rect:first-child {
+ fill: rgba(30, 83, 124, 0.78);
+ stroke: rgba(96, 165, 250, 0.72);
+ stroke-width: 2;
+ cursor: pointer;
+}
+
+.location-map-object.is-selected rect:first-child {
+ fill: rgba(20, 184, 166, 0.26);
+ stroke: rgb(45, 212, 191);
+ stroke-width: 4;
+}
+
+.location-map-label {
+ fill: var(--color-text-primary);
+ font-size: 24px;
+ font-weight: 800;
+ pointer-events: none;
+}
+
+.location-map-count {
+ fill: rgba(226, 232, 240, 0.82);
+ font-size: 18px;
+ font-weight: 700;
+ pointer-events: none;
+}
+
+.location-map-pin {
+ fill: rgb(245, 158, 11);
+ stroke: rgba(15, 23, 42, 0.9);
+ stroke-width: 2;
+ pointer-events: none;
+}
+
+.location-map-resize-handle {
+ fill: rgb(245, 158, 11);
+ stroke: rgba(15, 23, 42, 0.9);
+ stroke-width: 2;
+ cursor: nwse-resize;
+}
+
+.location-map-bottom-sheet {
+ max-height: 43vh;
+ overflow-y: auto;
+ padding: 0.8rem clamp(0.75rem, 2vw, 1.25rem) 1rem;
+ border-top: 1px solid var(--color-border-light);
+ background: rgba(17, 28, 42, 0.96);
+ box-shadow: 0 -18px 40px rgba(0, 0, 0, 0.28);
+}
+
+.location-map-sheet-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.75rem;
+ margin-bottom: 0.65rem;
+}
+
+.location-map-sheet-header span,
+.location-map-muted {
+ color: var(--color-text-secondary);
+}
+
+.location-map-display-controls {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 0.4rem;
+ margin-bottom: 0.7rem;
+}
+
+.location-map-display-controls label {
+ min-height: 34px;
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ padding: 0.35rem 0.45rem;
+ border: 1px solid var(--color-border-light);
+ border-radius: 8px;
+ background: rgba(15, 23, 34, 0.58);
+ color: var(--color-text-primary);
+ font-size: 0.82rem;
+ font-weight: 700;
+}
+
+.location-map-display-controls input {
+ width: 16px;
+ height: 16px;
+}
+
+.location-map-editor-actions {
+ flex-wrap: wrap;
+ margin-bottom: 0.75rem;
+}
+
+.location-map-object-form {
+ display: grid;
+ gap: 0.6rem;
+}
+
+.location-map-object-form label {
+ display: grid;
+ gap: 0.3rem;
+ color: var(--color-text-secondary);
+ font-size: 0.78rem;
+ font-weight: 800;
+}
+
+.location-map-object-form input,
+.location-map-object-form select {
+ min-height: 40px;
+ padding: 0.55rem 0.65rem;
+ border: 1px solid var(--color-border-light);
+ border-radius: 8px;
+ background: var(--color-bg-elevated);
+ color: var(--color-text-primary);
+}
+
+.location-map-object-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 0.45rem;
+}
+
+.location-map-zone-items h2 {
+ margin-bottom: 0.5rem;
+ font-size: 1rem;
+}
+
+.location-map-zone-items ul,
+.location-map-unmapped-list ul {
+ display: grid;
+ gap: 0.35rem;
+}
+
+.location-map-zone-items li,
+.location-map-unmapped-list li {
+ min-height: 34px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 0.35rem 0.55rem;
+ border: 1px solid var(--color-border-light);
+ border-radius: 8px;
+ background: rgba(15, 23, 34, 0.56);
+}
+
+.location-map-zone-items small {
+ color: var(--color-text-secondary);
+ font-weight: 800;
+}
+
+.location-map-unmapped-list {
+ margin-top: 0.75rem;
+}
+
+.location-map-unmapped-list strong {
+ display: block;
+ margin-bottom: 0.45rem;
+}
+
+.location-map-setup {
+ align-self: center;
+ width: min(100% - 1.5rem, 560px);
+ margin: 3rem auto;
+ padding: 1.15rem;
+ border: 1px solid var(--color-border-light);
+ border-radius: 8px;
+ background: var(--color-bg-surface);
+}
+
+.location-map-setup h2 {
+ margin-bottom: 0.45rem;
+ font-size: 1.35rem;
+}
+
+.location-map-setup p {
+ margin-bottom: 1rem;
+ color: var(--color-text-secondary);
+}
+
+.location-map-setup-actions {
+ display: grid;
+ gap: 0.6rem;
+}
+
+.location-map-loading {
+ margin: 3rem auto;
+ color: var(--color-text-secondary);
+}
+
+@media (min-width: 840px) {
+ .location-map-workspace {
+ grid-template-columns: minmax(0, 1fr) 360px;
+ grid-template-rows: auto minmax(0, 1fr);
+ }
+
+ .location-map-toolbar {
+ grid-column: 1 / -1;
+ }
+
+ .location-map-bottom-sheet {
+ max-height: none;
+ border-top: none;
+ border-left: 1px solid var(--color-border-light);
+ }
+}
+
+@media (max-width: 520px) {
+ .location-map-topbar {
+ grid-template-columns: auto minmax(0, 1fr);
+ }
+
+ .location-map-status {
+ grid-column: 2;
+ justify-self: start;
+ }
+
+ .location-map-toolbar {
+ flex-direction: column;
+ }
+
+ .location-map-display-controls {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .location-map-object-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
diff --git a/frontend/tests/location-map-manager.spec.ts b/frontend/tests/location-map-manager.spec.ts
new file mode 100644
index 0000000..b5134a1
--- /dev/null
+++ b/frontend/tests/location-map-manager.spec.ts
@@ -0,0 +1,302 @@
+import { expect, test, type Page } from "@playwright/test";
+import { mockConfig, seedAuthStorage } from "./helpers/e2e";
+
+const adminHousehold = { id: 1, name: "Map House", role: "admin", invite_code: "ABCD1234" };
+const memberHousehold = { ...adminHousehold, role: "member" };
+const storeLocation = {
+ id: 10,
+ household_store_id: 100,
+ household_id: 1,
+ name: "Costco",
+ location_name: "Eastvale",
+ display_name: "Costco - Eastvale",
+ is_default: true,
+};
+const zones = [
+ { id: 501, name: "Bakery", sort_order: 10, item_count: 2 },
+ { id: 502, name: "Frozen Foods", sort_order: 20, item_count: 1 },
+];
+const items = [
+ {
+ id: 1,
+ item_name: "french bread",
+ quantity: 1,
+ bought: false,
+ zone_id: 501,
+ zone: "Bakery",
+ added_by_users: ["map-user"],
+ },
+ {
+ id: 2,
+ item_name: "sourdough",
+ quantity: 1,
+ bought: false,
+ zone_id: 501,
+ zone: "Bakery",
+ added_by_users: ["map-user"],
+ },
+ {
+ id: 3,
+ item_name: "frozen salmon",
+ quantity: 1,
+ bought: false,
+ zone_id: 502,
+ zone: "Frozen Foods",
+ added_by_users: ["other-user"],
+ },
+ {
+ id: 4,
+ item_name: "loose batteries",
+ quantity: 1,
+ bought: false,
+ zone_id: null,
+ zone: null,
+ added_by_users: ["map-user"],
+ },
+];
+
+function noMapState(canManage = true) {
+ return {
+ location: storeLocation,
+ map: null,
+ draft_map: null,
+ published_map: null,
+ objects: [],
+ draft_objects: [],
+ published_objects: [],
+ zones,
+ items,
+ unmapped_count: 1,
+ can_manage: canManage,
+ };
+}
+
+function draftMapState(objects: Array>, canManage = true) {
+ const draft = {
+ id: 900,
+ household_id: 1,
+ store_location_id: 10,
+ name: "Store Map",
+ width: 1000,
+ height: 700,
+ status: "draft",
+ version: 1,
+ };
+
+ return {
+ ...noMapState(canManage),
+ map: draft,
+ draft_map: draft,
+ objects,
+ draft_objects: objects,
+ };
+}
+
+function publishedMapState(objects: Array>, canManage = true) {
+ const published = {
+ id: 901,
+ household_id: 1,
+ store_location_id: 10,
+ name: "Store Map",
+ width: 1000,
+ height: 700,
+ status: "published",
+ version: 2,
+ };
+
+ return {
+ ...noMapState(canManage),
+ map: published,
+ published_map: published,
+ objects,
+ published_objects: objects,
+ };
+}
+
+async function mockMapShell(page: Page, household = adminHousehold) {
+ await seedAuthStorage(page, { username: "map-user", role: household.role });
+ await mockConfig(page);
+
+ await page.route("**/households", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify([household]),
+ });
+ });
+
+ await page.route("**/households/1/stores", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify([storeLocation]),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/zones", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ zones }),
+ });
+ });
+}
+
+test("admin creates a map from zones, saves a draft, and publishes it", async ({ page }) => {
+ await mockMapShell(page);
+
+ let mapState = noMapState(true);
+ let savedPayload: Record | null = null;
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/map/from-zones", async (route) => {
+ const objects = [
+ {
+ id: 1001,
+ location_map_id: 900,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 28,
+ y: 28,
+ width: 300,
+ height: 180,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ {
+ id: 1002,
+ location_map_id: 900,
+ zone_id: 502,
+ zone_name: "Frozen Foods",
+ type: "zone",
+ label: "Frozen Foods",
+ x: 356,
+ y: 28,
+ width: 300,
+ height: 180,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 2,
+ },
+ ];
+ mapState = draftMapState(objects, true);
+ await route.fulfill({
+ status: 201,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/map/draft", async (route) => {
+ savedPayload = await route.request().postDataJSON();
+ const savedObjects = (savedPayload.objects as Array>).map((object, index) => ({
+ id: 1100 + index,
+ location_map_id: 900,
+ zone_name: object.zone_id === 501 ? "Bakery" : "Frozen Foods",
+ ...object,
+ }));
+ mapState = draftMapState(savedObjects, true);
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.route("**/households/1/locations/10/map/publish", async (route) => {
+ mapState = publishedMapState(mapState.draft_objects, true);
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await expect(page.getByRole("heading", { name: "Costco" })).toBeVisible();
+ await expect(page.getByText("Eastvale")).toBeVisible();
+ await expect(page.getByRole("heading", { name: "No Map" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Create Map From Existing Zones" })).toBeVisible();
+
+ await page.getByRole("button", { name: "Create Map From Existing Zones" }).click();
+ await expect(page.getByText("Bakery")).toBeVisible();
+ await expect(page.getByText("Frozen Foods")).toBeVisible();
+ await expect(page.locator(".location-map-pin")).toHaveCount(0);
+
+ const bakeryObject = page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first();
+ await bakeryObject.click();
+ await page.getByRole("textbox", { name: "Label" }).fill("Bread Wall");
+ await page.getByRole("button", { name: "Save Draft" }).click();
+
+ expect(savedPayload).not.toBeNull();
+ expect(savedPayload?.objects).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ label: "Bread Wall", zone_id: 501 }),
+ ])
+ );
+
+ await page.getByRole("button", { name: "Publish" }).click();
+ await expect(page.locator(".location-map-status")).toHaveText("Published");
+ await expect(page.getByRole("button", { name: "Edit Map" })).toBeVisible();
+ await expect(page.getByText("loose batteries")).toHaveCount(0);
+
+ await page.locator(".location-map-object", { hasText: "Bread Wall" }).locator("rect").first().click();
+ await expect(page.getByText("french bread")).toBeVisible();
+ await expect(page.getByText("sourdough")).toBeVisible();
+ await expect(page.getByText("frozen salmon")).toHaveCount(0);
+
+ await page.getByLabel("Unmapped").check();
+ await expect(page.getByText("loose batteries")).toBeVisible();
+});
+
+test("members can view a published map but cannot edit it", async ({ page }) => {
+ await mockMapShell(page, memberHousehold);
+
+ const mapState = publishedMapState([
+ {
+ id: 1201,
+ location_map_id: 901,
+ zone_id: 501,
+ zone_name: "Bakery",
+ type: "zone",
+ label: "Bakery",
+ x: 40,
+ y: 40,
+ width: 260,
+ height: 160,
+ rotation: 0,
+ locked: false,
+ visible: true,
+ sort_order: 1,
+ },
+ ], false);
+
+ await page.route("**/households/1/locations/10/map", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(mapState),
+ });
+ });
+
+ await page.goto("/stores/100/locations/10/map");
+
+ await expect(page.getByText("Published")).toBeVisible();
+ await expect(page.getByText("Bakery")).toBeVisible();
+ await expect(page.getByRole("button", { name: "Edit Map" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Save Draft" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Publish" })).toHaveCount(0);
+});
diff --git a/frontend/tests/store-selector.spec.ts b/frontend/tests/store-selector.spec.ts
index f5102fb..0fc4e5c 100644
--- a/frontend/tests/store-selector.spec.ts
+++ b/frontend/tests/store-selector.spec.ts
@@ -40,6 +40,26 @@ async function mockGroceryShell(page: Page, stores: Array {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ location: stores.find((store) => String(store.id) === route.request().url().match(/locations\/(\d+)\/map/)?.[1]) || stores[0],
+ map: null,
+ draft_map: null,
+ published_map: null,
+ objects: [],
+ draft_objects: [],
+ published_objects: [],
+ zones: [],
+ items: [],
+ unmapped_count: 0,
+ can_manage: true,
+ }),
+ });
+ });
+
await page.route("**/households/1/locations/*/list/recent", async (route) => {
await route.fulfill({
status: 200,
@@ -161,7 +181,7 @@ test("grocery store selector shows one selected store and picks from a modal", a
await expect.poll(() => page.evaluate(() => localStorage.getItem("activeStoreLocationId_1_100"))).toBe("13");
await page.getByRole("button", { name: "Manage Map" }).click();
- await expect(page).toHaveURL(/\/manage\?tab=stores&locationId=13$/);
+ await expect(page).toHaveURL(/\/stores\/100\/locations\/13\/map$/);
});
test("single grocery store selector click does not open picker modal", async ({ page }) => {
diff --git a/packages/db/migrations/20260602_010000_location_maps.sql b/packages/db/migrations/20260602_010000_location_maps.sql
new file mode 100644
index 0000000..1611888
--- /dev/null
+++ b/packages/db/migrations/20260602_010000_location_maps.sql
@@ -0,0 +1,66 @@
+-- Location-specific store maps for the mobile map manager.
+-- Maps are scoped to a household-owned physical store location.
+
+CREATE TABLE IF NOT EXISTS location_maps (
+ id BIGSERIAL PRIMARY KEY,
+ household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
+ store_location_id INTEGER NOT NULL REFERENCES store_locations(id) ON DELETE CASCADE,
+ name VARCHAR(120) NOT NULL DEFAULT 'Store Map',
+ width INTEGER NOT NULL DEFAULT 1000 CHECK (width > 0),
+ height INTEGER NOT NULL DEFAULT 700 CHECK (height > 0),
+ status VARCHAR(20) NOT NULL DEFAULT 'draft'
+ CHECK (status IN ('draft', 'published', 'archived')),
+ version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0),
+ created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ published_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_location_maps_location_status
+ ON location_maps(household_id, store_location_id, status, updated_at DESC);
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_location_maps_draft_location
+ ON location_maps(store_location_id)
+ WHERE status = 'draft';
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_location_maps_published_location
+ ON location_maps(store_location_id)
+ WHERE status = 'published';
+
+CREATE TABLE IF NOT EXISTS location_map_objects (
+ id BIGSERIAL PRIMARY KEY,
+ location_map_id BIGINT NOT NULL REFERENCES location_maps(id) ON DELETE CASCADE,
+ zone_id INTEGER REFERENCES store_location_zones(id) ON DELETE SET NULL,
+ type VARCHAR(30) NOT NULL DEFAULT 'zone'
+ CHECK (type IN (
+ 'zone',
+ 'aisle',
+ 'entrance',
+ 'checkout',
+ 'shelf',
+ 'wall',
+ 'department',
+ 'anchor',
+ 'other'
+ )),
+ label VARCHAR(160) NOT NULL DEFAULT '',
+ x NUMERIC(10, 2) NOT NULL DEFAULT 0,
+ y NUMERIC(10, 2) NOT NULL DEFAULT 0,
+ width NUMERIC(10, 2) NOT NULL DEFAULT 160 CHECK (width > 0),
+ height NUMERIC(10, 2) NOT NULL DEFAULT 100 CHECK (height > 0),
+ rotation NUMERIC(7, 2) NOT NULL DEFAULT 0,
+ locked BOOLEAN NOT NULL DEFAULT FALSE,
+ visible BOOLEAN NOT NULL DEFAULT TRUE,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_location_map_objects_map_order
+ ON location_map_objects(location_map_id, sort_order, id);
+
+CREATE INDEX IF NOT EXISTS idx_location_map_objects_zone
+ ON location_map_objects(zone_id);