feat: add location map manager

This commit is contained in:
Nico 2026-06-02 01:59:20 -07:00
parent 23df814fb3
commit 78893545b2
18 changed files with 2662 additions and 4 deletions

View File

@ -0,0 +1,131 @@
const locationMapModel = require("../models/location-map.model");
const { sendError } = require("../utils/http");
const { logError } = require("../utils/logger");
function parsePositiveInteger(value) {
const parsed = Number.parseInt(String(value), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
function canManageMaps(req) {
return ["owner", "admin"].includes(req.household?.role);
}
function getScope(req) {
return {
householdId: parsePositiveInteger(req.params.householdId),
locationId: parsePositiveInteger(req.params.locationId),
};
}
function validateScope(res, scope) {
if (!scope.householdId || !scope.locationId) {
sendError(res, 400, "Household ID and location ID must be positive integers");
return false;
}
return true;
}
function sendMapState(res, state, req) {
if (!state) {
return sendError(res, 404, "Store location not found");
}
return res.json({
...state,
can_manage: canManageMaps(req),
});
}
exports.getLocationMap = async (req, res) => {
const scope = getScope(req);
if (!validateScope(res, scope)) return;
try {
const state = await locationMapModel.getMapState(
scope.householdId,
scope.locationId,
canManageMaps(req)
);
sendMapState(res, state, req);
} catch (error) {
logError(req, "locationMaps.getLocationMap", error);
sendError(res, 500, "Failed to load location map");
}
};
exports.createBlankMap = async (req, res) => {
const scope = getScope(req);
if (!validateScope(res, scope)) return;
try {
const state = await locationMapModel.saveBlankDraft(
scope.householdId,
scope.locationId,
req.user.id,
req.body?.map || req.body || {}
);
res.status(201);
sendMapState(res, state, req);
} catch (error) {
logError(req, "locationMaps.createBlankMap", error);
sendError(res, 500, "Failed to create blank map");
}
};
exports.createMapFromZones = async (req, res) => {
const scope = getScope(req);
if (!validateScope(res, scope)) return;
try {
const state = await locationMapModel.createDraftFromZones(
scope.householdId,
scope.locationId,
req.user.id,
req.body?.map || req.body || {}
);
res.status(201);
sendMapState(res, state, req);
} catch (error) {
logError(req, "locationMaps.createMapFromZones", error);
sendError(res, 500, "Failed to create map from zones");
}
};
exports.saveDraft = async (req, res) => {
const scope = getScope(req);
if (!validateScope(res, scope)) return;
try {
const state = await locationMapModel.saveDraft(
scope.householdId,
scope.locationId,
req.user.id,
req.body || {}
);
sendMapState(res, state, req);
} catch (error) {
logError(req, "locationMaps.saveDraft", error);
sendError(res, 500, "Failed to save map draft");
}
};
exports.publishDraft = async (req, res) => {
const scope = getScope(req);
if (!validateScope(res, scope)) return;
try {
const state = await locationMapModel.publishDraft(
scope.householdId,
scope.locationId,
req.user.id
);
if (!state) {
return sendError(res, 404, "No draft map to publish");
}
sendMapState(res, state, req);
} catch (error) {
logError(req, "locationMaps.publishDraft", error);
sendError(res, 500, "Failed to publish map");
}
};

View File

@ -0,0 +1,539 @@
const pool = require("../db/pool");
const storeModel = require("./store.model");
const DEFAULT_MAP_WIDTH = 1000;
const DEFAULT_MAP_HEIGHT = 700;
const DEFAULT_MAP_NAME = "Store Map";
const MAP_OBJECT_TYPES = new Set([
"zone",
"aisle",
"entrance",
"checkout",
"shelf",
"wall",
"department",
"anchor",
"other",
]);
function toNumber(value, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function toPositiveNumber(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function toPositiveInteger(value, fallback) {
const parsed = Number.parseInt(String(value), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function normalizeMapRow(row) {
if (!row) return null;
return {
...row,
width: toPositiveInteger(row.width, DEFAULT_MAP_WIDTH),
height: toPositiveInteger(row.height, DEFAULT_MAP_HEIGHT),
version: toPositiveInteger(row.version, 1),
};
}
function normalizeObjectRow(row) {
if (!row) return null;
return {
...row,
x: toNumber(row.x),
y: toNumber(row.y),
width: toPositiveNumber(row.width, 160),
height: toPositiveNumber(row.height, 100),
rotation: toNumber(row.rotation),
locked: Boolean(row.locked),
visible: row.visible !== false,
};
}
function sanitizeMapPayload(payload = {}) {
return {
name:
typeof payload.name === "string" && payload.name.trim()
? payload.name.trim().slice(0, 120)
: DEFAULT_MAP_NAME,
width: toPositiveInteger(payload.width, DEFAULT_MAP_WIDTH),
height: toPositiveInteger(payload.height, DEFAULT_MAP_HEIGHT),
};
}
function sanitizeObjectPayload(object = {}, index = 0) {
const type = MAP_OBJECT_TYPES.has(object.type) ? object.type : "zone";
const rawZoneId = object.zone_id ?? object.zoneId;
const zoneId = rawZoneId === null || rawZoneId === "" || rawZoneId === undefined
? null
: toPositiveInteger(rawZoneId, null);
return {
zone_id: zoneId,
type,
label:
typeof object.label === "string" && object.label.trim()
? object.label.trim().slice(0, 160)
: "",
x: toNumber(object.x),
y: toNumber(object.y),
width: toPositiveNumber(object.width, 160),
height: toPositiveNumber(object.height, 100),
rotation: toNumber(object.rotation),
locked: Boolean(object.locked),
visible: object.visible !== false,
sort_order: toPositiveInteger(object.sort_order ?? object.sortOrder, index + 1),
metadata_json:
object.metadata_json && typeof object.metadata_json === "object" && !Array.isArray(object.metadata_json)
? object.metadata_json
: {},
};
}
async function getMaps(db, householdId, locationId, includeDraft) {
const statuses = includeDraft ? ["draft", "published"] : ["published"];
const result = await db.query(
`SELECT
id,
household_id,
store_location_id,
name,
width,
height,
status,
version,
created_by,
updated_by,
published_at,
created_at,
updated_at
FROM location_maps
WHERE household_id = $1
AND store_location_id = $2
AND status = ANY($3::text[])
ORDER BY
CASE status WHEN 'draft' THEN 0 WHEN 'published' THEN 1 ELSE 2 END,
updated_at DESC`,
[householdId, locationId, statuses]
);
return result.rows.map(normalizeMapRow);
}
async function getObjects(db, mapId) {
if (!mapId) return [];
const result = await db.query(
`SELECT
lmo.id,
lmo.location_map_id,
lmo.zone_id,
lmo.type,
lmo.label,
lmo.x,
lmo.y,
lmo.width,
lmo.height,
lmo.rotation,
lmo.locked,
lmo.visible,
lmo.sort_order,
lmo.metadata_json,
slz.name AS zone_name,
lmo.created_at,
lmo.updated_at
FROM location_map_objects lmo
LEFT JOIN store_location_zones slz ON slz.id = lmo.zone_id
WHERE lmo.location_map_id = $1
ORDER BY lmo.sort_order ASC, lmo.id ASC`,
[mapId]
);
return result.rows.map(normalizeObjectRow);
}
async function getZoneSummaries(db, householdId, locationId) {
const result = await db.query(
`WITH map_items AS (
SELECT
hl.id,
resolved_zone.id AS zone_id
FROM household_lists hl
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
LEFT JOIN household_item_classifications hic
ON hic.household_id = hl.household_id
AND hic.store_location_id = hl.store_location_id
AND hic.household_store_item_id = hl.household_store_item_id
LEFT JOIN store_location_zones resolved_zone
ON resolved_zone.household_id = hl.household_id
AND resolved_zone.store_location_id = hl.store_location_id
AND resolved_zone.is_active = TRUE
AND (
resolved_zone.id = hic.zone_id
OR (
hic.zone_id IS NULL
AND hic.zone IS NOT NULL
AND resolved_zone.normalized_name = LOWER(BTRIM(hic.zone))
)
)
WHERE hl.household_id = $1
AND hl.store_location_id = $2
AND hl.bought = FALSE
),
item_counts AS (
SELECT zone_id, COUNT(*)::int AS item_count
FROM map_items
WHERE zone_id IS NOT NULL
GROUP BY zone_id
)
SELECT
slz.id,
slz.name,
slz.sort_order,
slz.color,
slz.map_metadata,
slz.is_active,
COALESCE(item_counts.item_count, 0)::int AS item_count
FROM store_location_zones slz
LEFT JOIN item_counts ON item_counts.zone_id = slz.id
WHERE slz.household_id = $1
AND slz.store_location_id = $2
AND slz.is_active = TRUE
ORDER BY slz.sort_order ASC, slz.name ASC`,
[householdId, locationId]
);
return result.rows;
}
async function getMapItems(db, householdId, locationId) {
const result = await db.query(
`SELECT
hl.id,
hl.household_store_item_id AS item_id,
hsi.name AS item_name,
hl.quantity,
hl.bought,
resolved_zone.id AS zone_id,
COALESCE(resolved_zone.name, hic.zone) AS zone,
(
SELECT ARRAY_AGG(active_added_by_users.user_label ORDER BY active_added_by_users.last_added_on DESC, active_added_by_users.user_label)
FROM (
SELECT
COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label,
MAX(active_history.added_on) AS last_added_on
FROM (
SELECT
hlh.*,
COALESCE(
SUM(hlh.quantity) OVER (
PARTITION BY hlh.household_list_id
ORDER BY hlh.added_on DESC, hlh.id DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
),
0
) AS newer_quantity
FROM household_list_history hlh
WHERE hlh.household_list_id = hl.id
) active_history
JOIN users u ON active_history.added_by = u.id
WHERE active_history.newer_quantity < GREATEST(hl.quantity, 0)
GROUP BY user_label
) active_added_by_users
) AS added_by_users
FROM household_lists hl
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
LEFT JOIN household_item_classifications hic
ON hic.household_id = hl.household_id
AND hic.store_location_id = hl.store_location_id
AND hic.household_store_item_id = hl.household_store_item_id
LEFT JOIN store_location_zones resolved_zone
ON resolved_zone.household_id = hl.household_id
AND resolved_zone.store_location_id = hl.store_location_id
AND resolved_zone.is_active = TRUE
AND (
resolved_zone.id = hic.zone_id
OR (
hic.zone_id IS NULL
AND hic.zone IS NOT NULL
AND resolved_zone.normalized_name = LOWER(BTRIM(hic.zone))
)
)
WHERE hl.household_id = $1
AND hl.store_location_id = $2
AND hl.bought = FALSE
ORDER BY resolved_zone.sort_order ASC NULLS LAST, hsi.name ASC`,
[householdId, locationId]
);
return result.rows.map((row) => ({
...row,
added_by_users: Array.isArray(row.added_by_users) ? row.added_by_users : [],
}));
}
async function insertObjects(db, mapId, objects) {
for (let index = 0; index < objects.length; index += 1) {
const object = sanitizeObjectPayload(objects[index], index);
await db.query(
`INSERT INTO location_map_objects
(
location_map_id,
zone_id,
type,
label,
x,
y,
width,
height,
rotation,
locked,
visible,
sort_order,
metadata_json
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)`,
[
mapId,
object.zone_id,
object.type,
object.label,
object.x,
object.y,
object.width,
object.height,
object.rotation,
object.locked,
object.visible,
object.sort_order,
JSON.stringify(object.metadata_json),
]
);
}
}
async function upsertDraftMap(db, householdId, locationId, userId, mapPayload) {
const map = sanitizeMapPayload(mapPayload);
const result = await db.query(
`INSERT INTO location_maps
(household_id, store_location_id, name, width, height, status, version, created_by, updated_by, updated_at)
VALUES ($1, $2, $3, $4, $5, 'draft', 1, $6, $6, NOW())
ON CONFLICT (store_location_id) WHERE status = 'draft'
DO UPDATE SET
name = EXCLUDED.name,
width = EXCLUDED.width,
height = EXCLUDED.height,
updated_by = EXCLUDED.updated_by,
updated_at = NOW()
RETURNING
id,
household_id,
store_location_id,
name,
width,
height,
status,
version,
created_by,
updated_by,
published_at,
created_at,
updated_at`,
[householdId, locationId, map.name, map.width, map.height, userId]
);
return normalizeMapRow(result.rows[0]);
}
function buildStarterObjectsFromZones(zones, width, height) {
if (!zones.length) return [];
const columnCount = zones.length <= 4 ? 2 : 3;
const rowCount = Math.ceil(zones.length / columnCount);
const gap = 28;
const cellWidth = (width - gap * (columnCount + 1)) / columnCount;
const cellHeight = (height - gap * (rowCount + 1)) / rowCount;
return zones.map((zone, index) => {
const column = index % columnCount;
const row = Math.floor(index / columnCount);
return {
zone_id: zone.id,
type: "zone",
label: zone.name,
x: Math.round((gap + column * (cellWidth + gap)) * 100) / 100,
y: Math.round((gap + row * (cellHeight + gap)) * 100) / 100,
width: Math.max(120, Math.round(cellWidth * 100) / 100),
height: Math.max(80, Math.round(cellHeight * 100) / 100),
rotation: 0,
locked: false,
visible: true,
sort_order: index + 1,
metadata_json: { starter: true },
};
});
}
exports.getMapState = async (householdId, locationId, includeDraft = false) => {
const location = await storeModel.getLocationById(householdId, locationId);
if (!location) return null;
const maps = await getMaps(pool, householdId, locationId, includeDraft);
const draftMap = includeDraft ? maps.find((map) => map.status === "draft") || null : null;
const publishedMap = maps.find((map) => map.status === "published") || null;
const activeMap = draftMap || publishedMap;
const [draftObjects, publishedObjects, zones, items] = await Promise.all([
getObjects(pool, draftMap?.id),
getObjects(pool, publishedMap?.id),
getZoneSummaries(pool, householdId, locationId),
getMapItems(pool, householdId, locationId),
]);
const objects = draftMap ? draftObjects : publishedObjects;
return {
location,
map: activeMap || null,
draft_map: draftMap,
published_map: publishedMap,
objects,
draft_objects: draftObjects,
published_objects: publishedObjects,
zones,
items,
unmapped_count: items.filter((item) => !item.zone_id).length,
};
};
exports.saveBlankDraft = async (householdId, locationId, userId, mapPayload = {}) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const map = await upsertDraftMap(client, householdId, locationId, userId, mapPayload);
await client.query("DELETE FROM location_map_objects WHERE location_map_id = $1", [map.id]);
await client.query("COMMIT");
return exports.getMapState(householdId, locationId, true);
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
};
exports.createDraftFromZones = async (householdId, locationId, userId, mapPayload = {}) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const zones = await getZoneSummaries(client, householdId, locationId);
const map = await upsertDraftMap(client, householdId, locationId, userId, mapPayload);
await client.query("DELETE FROM location_map_objects WHERE location_map_id = $1", [map.id]);
const starterHeight = Math.max(
map.height,
Math.ceil(Math.max(zones.length, 1) / (zones.length <= 4 ? 2 : 3)) * 210
);
if (starterHeight !== map.height) {
await client.query(
`UPDATE location_maps
SET height = $1, updated_at = NOW(), updated_by = $2
WHERE id = $3`,
[starterHeight, userId, map.id]
);
map.height = starterHeight;
}
await insertObjects(
client,
map.id,
buildStarterObjectsFromZones(zones, map.width, map.height)
);
await client.query("COMMIT");
return exports.getMapState(householdId, locationId, true);
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
};
exports.saveDraft = async (householdId, locationId, userId, payload = {}) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const map = await upsertDraftMap(client, householdId, locationId, userId, payload.map || {});
await client.query("DELETE FROM location_map_objects WHERE location_map_id = $1", [map.id]);
await insertObjects(client, map.id, Array.isArray(payload.objects) ? payload.objects : []);
await client.query("COMMIT");
return exports.getMapState(householdId, locationId, true);
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
};
exports.publishDraft = async (householdId, locationId, userId) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const draftResult = await client.query(
`SELECT id
FROM location_maps
WHERE household_id = $1
AND store_location_id = $2
AND status = 'draft'
FOR UPDATE`,
[householdId, locationId]
);
if (draftResult.rowCount === 0) {
await client.query("ROLLBACK");
return null;
}
const versionResult = await client.query(
`SELECT COALESCE(MAX(version), 0)::int AS version
FROM location_maps
WHERE household_id = $1
AND store_location_id = $2`,
[householdId, locationId]
);
const nextVersion = (versionResult.rows[0]?.version || 0) + 1;
await client.query(
`UPDATE location_maps
SET status = 'archived', updated_by = $3, updated_at = NOW()
WHERE household_id = $1
AND store_location_id = $2
AND status = 'published'`,
[householdId, locationId, userId]
);
await client.query(
`UPDATE location_maps
SET status = 'published',
version = $3,
published_at = NOW(),
updated_by = $4,
updated_at = NOW()
WHERE household_id = $1
AND store_location_id = $2
AND id = $5`,
[householdId, locationId, nextVersion, userId, draftResult.rows[0].id]
);
await client.query("COMMIT");
return exports.getMapState(householdId, locationId, true);
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
};

View File

@ -3,6 +3,7 @@ const router = express.Router();
const controller = require("../controllers/households.controller");
const listsController = require("../controllers/lists.controller.v2");
const availableItemsController = require("../controllers/available-items.controller");
const locationMapsController = require("../controllers/location-maps.controller");
const storesController = require("../controllers/stores.controller");
const auth = require("../middleware/auth");
const {
@ -134,6 +135,46 @@ router.delete(
storesController.deleteZone
);
router.get(
"/:householdId/locations/:locationId/map",
auth,
householdAccess,
locationAccess,
locationMapsController.getLocationMap
);
router.post(
"/:householdId/locations/:locationId/map/blank",
auth,
householdAccess,
locationAccess,
requireHouseholdAdmin,
locationMapsController.createBlankMap
);
router.post(
"/:householdId/locations/:locationId/map/from-zones",
auth,
householdAccess,
locationAccess,
requireHouseholdAdmin,
locationMapsController.createMapFromZones
);
router.put(
"/:householdId/locations/:locationId/map/draft",
auth,
householdAccess,
locationAccess,
requireHouseholdAdmin,
locationMapsController.saveDraft
);
router.post(
"/:householdId/locations/:locationId/map/publish",
auth,
householdAccess,
locationAccess,
requireHouseholdAdmin,
locationMapsController.publishDraft
);
router.get(
"/:householdId/locations/:locationId/available-items",
auth,

View File

@ -70,6 +70,14 @@ jest.mock("../controllers/available-items.controller", () => ({
updateAvailableItem: jest.fn(),
}));
jest.mock("../controllers/location-maps.controller", () => ({
createBlankMap: jest.fn((req, res) => res.status(201).json({ message: "blank map" })),
createMapFromZones: jest.fn((req, res) => res.status(201).json({ message: "zone map" })),
getLocationMap: jest.fn((req, res) => res.json({ map: null, objects: [] })),
publishDraft: jest.fn((req, res) => res.json({ message: "published map" })),
saveDraft: jest.fn((req, res) => res.json({ message: "saved draft" })),
}));
jest.mock("../controllers/stores.controller", () => ({
addLocationToStore: jest.fn((req, res) => res.status(201).json({ message: "location" })),
createHouseholdStore: jest.fn((req, res) => res.status(201).json({ message: "store" })),
@ -89,6 +97,7 @@ const express = require("express");
const request = require("supertest");
const router = require("../routes/households.routes");
const householdsController = require("../controllers/households.controller");
const locationMapsController = require("../controllers/location-maps.controller");
const storesController = require("../controllers/stores.controller");
describe("store location routes", () => {
@ -161,4 +170,51 @@ describe("store location routes", () => {
expect(response.status).toBe(200);
expect(storesController.updateZone).toHaveBeenCalled();
});
test("members can view a location map", async () => {
const response = await request(app)
.get("/households/1/locations/2/map")
.set("x-household-role", "member");
expect(response.status).toBe(200);
expect(locationMapsController.getLocationMap).toHaveBeenCalled();
});
test("members cannot create or publish location maps", async () => {
const createResponse = await request(app)
.post("/households/1/locations/2/map/from-zones")
.set("x-household-role", "member")
.send({});
const publishResponse = await request(app)
.post("/households/1/locations/2/map/publish")
.set("x-household-role", "member")
.send({});
expect(createResponse.status).toBe(403);
expect(publishResponse.status).toBe(403);
expect(locationMapsController.createMapFromZones).not.toHaveBeenCalled();
expect(locationMapsController.publishDraft).not.toHaveBeenCalled();
});
test("admins can create, save, and publish location maps", async () => {
const blankResponse = await request(app)
.post("/households/1/locations/2/map/blank")
.set("x-household-role", "admin")
.send({ map: { name: "Costco Map" } });
const saveResponse = await request(app)
.put("/households/1/locations/2/map/draft")
.set("x-household-role", "admin")
.send({ objects: [] });
const publishResponse = await request(app)
.post("/households/1/locations/2/map/publish")
.set("x-household-role", "admin")
.send({});
expect(blankResponse.status).toBe(201);
expect(saveResponse.status).toBe(200);
expect(publishResponse.status).toBe(200);
expect(locationMapsController.createBlankMap).toHaveBeenCalled();
expect(locationMapsController.saveDraft).toHaveBeenCalled();
expect(locationMapsController.publishDraft).toHaveBeenCalled();
});
});

View File

@ -9,7 +9,8 @@ import { SettingsProvider } from "./context/SettingsContext.jsx";
import { StoreProvider } from "./context/StoreContext.jsx";
import AdminPanel from "./pages/AdminPanel.jsx";
import GroceryList from "./pages/GroceryList.jsx";
import GroceryList from "./pages/GroceryList.jsx";
import LocationMapManager from "./pages/LocationMapManager.jsx";
import Login from "./pages/Login.jsx";
import Manage from "./pages/Manage.jsx";
import Register from "./pages/Register.jsx";
@ -48,6 +49,7 @@ function App() {
<Route path="/" element={<GroceryList />} />
<Route path="/manage" element={<Manage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/stores/:storeId/locations/:locationId/map" element={<LocationMapManager />} />
<Route
path="/admin"

View File

@ -0,0 +1,16 @@
import api from "./axios";
export const getLocationMap = (householdId, locationId) =>
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`);

View File

@ -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 (
<aside className="location-map-bottom-sheet">
<div className="location-map-sheet-header">
<strong>{mode === "edit" ? "Editor" : "Map Details"}</strong>
{filters.showUnmapped && visibleUnmappedItems.length > 0 ? (
<span>{visibleUnmappedItems.length} unmapped</span>
) : null}
</div>
<div className="location-map-display-controls">
{DISPLAY_CONTROLS.map(([key, label]) => (
<label key={key}>
<input
type="checkbox"
checked={filters[key]}
onChange={(event) =>
setFilters((current) => ({ ...current, [key]: event.target.checked }))
}
/>
{label}
</label>
))}
</div>
{mode === "edit" && canManage ? (
<div className="location-map-editor-actions">
<button type="button" onClick={onAddObject}>Add Zone Rectangle</button>
<button type="button" onClick={onUndo} disabled={history.length === 0}>Undo</button>
<button type="button" onClick={onRedo} disabled={future.length === 0}>Redo</button>
<button type="button" onClick={onSaveDraft} disabled={saving}>Save Draft</button>
<button type="button" onClick={onPublish} disabled={saving || !mapState?.draft_map}>Publish</button>
</div>
) : canManage && mapState?.published_map ? (
<div className="location-map-editor-actions">
<button type="button" onClick={onEditMode}>Edit Map</button>
</div>
) : null}
{mode === "edit" && selectedObject ? (
<div className="location-map-object-form">
<label>
Label
<input
value={selectedObject.label || ""}
onChange={(event) => onObjectField("label", event.target.value)}
/>
</label>
<label>
Type
<select
value={selectedObject.type}
onChange={(event) => onObjectField("type", event.target.value)}
>
{MAP_OBJECT_TYPES.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
</label>
<label>
Zone
<select
value={objectZoneId(selectedObject)}
onChange={(event) => onZoneLinkChange(event.target.value)}
>
<option value="">No linked zone</option>
{(mapState?.zones || []).map((zone) => (
<option key={zone.id} value={zone.id}>{zone.name}</option>
))}
</select>
</label>
<div className="location-map-object-grid">
<label>
X
<input
type="number"
value={Math.round(selectedObject.x)}
onChange={(event) => onObjectField("x", Number(event.target.value))}
/>
</label>
<label>
Y
<input
type="number"
value={Math.round(selectedObject.y)}
onChange={(event) => onObjectField("y", Number(event.target.value))}
/>
</label>
<label>
W
<input
type="number"
value={Math.round(selectedObject.width)}
onChange={(event) => onObjectField("width", Number(event.target.value))}
/>
</label>
<label>
H
<input
type="number"
value={Math.round(selectedObject.height)}
onChange={(event) => onObjectField("height", Number(event.target.value))}
/>
</label>
</div>
<div className="location-map-editor-actions">
<button type="button" onClick={onDuplicateObject}>Duplicate</button>
<button type="button" className="danger" onClick={onDeleteObject}>Delete Rectangle</button>
</div>
</div>
) : selectedObject ? (
<div className="location-map-zone-items">
<h2>{selectedObject.label || selectedObject.zone_name || "Map Area"}</h2>
{selectedZoneItems.length === 0 ? (
<p className="location-map-muted">No visible items assigned to this zone.</p>
) : (
<ul>
{selectedZoneItems.map((item) => (
<li key={item.id}>
<span>{item.item_name}</span>
<small>x{item.quantity}</small>
</li>
))}
</ul>
)}
</div>
) : (
<p className="location-map-muted">
{mode === "edit" ? "Tap a rectangle to edit it." : "Tap a zone to view assigned items."}
</p>
)}
{filters.showUnmapped && visibleUnmappedItems.length > 0 ? (
<div className="location-map-unmapped-list">
<strong>Unmapped Items</strong>
<ul>
{visibleUnmappedItems.slice(0, 8).map((item) => (
<li key={item.id}>{item.item_name}</li>
))}
</ul>
</div>
) : null}
</aside>
);
}

View File

@ -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 (
<circle
key={item.id}
className="location-map-pin"
cx={object.x + 24 + column * 18}
cy={object.y + 30 + row * 18}
r="5"
/>
);
})
: null;
return (
<g
key={key}
className={`location-map-object ${isSelected ? "is-selected" : ""}`}
transform={`rotate(${object.rotation || 0} ${object.x + object.width / 2} ${object.y + object.height / 2})`}
>
<rect
x={object.x}
y={object.y}
width={object.width}
height={object.height}
rx="12"
role="button"
aria-label={`Map area ${object.label || index + 1}`}
onPointerDown={(event) => startDrag(event, object, "move")}
onClick={() => setSelectedObjectKey(key)}
/>
{filters.showLabels ? (
<text x={object.x + 16} y={object.y + 28} className="location-map-label">
{object.label || object.zone_name || "Area"}
</text>
) : null}
{filters.showCounts ? (
<text x={object.x + 16} y={object.y + 52} className="location-map-count">
{count} item{count === 1 ? "" : "s"}
</text>
) : null}
{pinDots}
{mode === "edit" && isSelected ? (
<rect
className="location-map-resize-handle"
x={object.x + object.width - 24}
y={object.y + object.height - 24}
width="24"
height="24"
rx="6"
onPointerDown={(event) => startDrag(event, object, "resize")}
/>
) : null}
</g>
);
};
return (
<section className="location-map-canvas-shell">
<div className="location-map-scroll">
<svg
ref={svgRef}
className="location-map-svg"
style={{
width: `${mapSize.width * zoom}px`,
height: `${mapSize.height * zoom}px`,
}}
viewBox={`0 0 ${mapSize.width} ${mapSize.height}`}
role="img"
aria-label="Store location map"
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
>
<defs>
<pattern id="location-map-grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" />
</pattern>
</defs>
<rect className="location-map-background" width={mapSize.width} height={mapSize.height} />
{mode === "edit" ? (
<rect className="location-map-grid" width={mapSize.width} height={mapSize.height} />
) : null}
{objects.map(renderMapObject)}
</svg>
</div>
</section>
);
}

View File

@ -0,0 +1,47 @@
export default function LocationMapSetupPanel({
hasAnyMap,
canManage,
saving,
onContinue,
onPreview,
onPublish,
onCreateFromZones,
onCreateBlank,
}) {
return (
<section className="location-map-setup">
<h2>{hasAnyMap ? "Draft Map" : "No Map"}</h2>
<p>
{hasAnyMap
? "A draft map exists for this location."
: "Create a rough map for this store location."}
</p>
<div className="location-map-setup-actions">
{hasAnyMap ? (
<>
<button type="button" className="btn-primary" onClick={onContinue}>
Continue Editing
</button>
<button type="button" className="btn-secondary" onClick={onPreview}>
Preview Map
</button>
<button type="button" className="btn-primary" onClick={onPublish} disabled={saving || !canManage}>
Publish Map
</button>
</>
) : canManage ? (
<>
<button type="button" className="btn-primary" onClick={onCreateFromZones} disabled={saving}>
Create Map From Existing Zones
</button>
<button type="button" className="btn-secondary" onClick={onCreateBlank} disabled={saving}>
Create Blank Map
</button>
</>
) : (
<p className="location-map-muted">A household admin has not published a map yet.</p>
)}
</div>
</section>
);
}

View File

@ -0,0 +1,44 @@
export default function LocationMapToolbar({
mode,
hasAnyMap,
canManage,
zoom,
setZoom,
onView,
onEdit,
}) {
return (
<section className="location-map-toolbar" aria-label="Map controls">
<div className="location-map-mode-buttons">
<button
type="button"
className={mode === "view" ? "active" : ""}
disabled={!hasAnyMap}
onClick={onView}
>
View
</button>
{canManage ? (
<button
type="button"
className={mode === "edit" ? "active" : ""}
disabled={!hasAnyMap}
onClick={onEdit}
>
Edit
</button>
) : null}
</div>
<div className="location-map-zoom-controls">
<button type="button" onClick={() => setZoom((value) => Math.max(0.45, value - 0.15))}>
-
</button>
<span>{Math.round(zoom * 100)}%</span>
<button type="button" onClick={() => setZoom((value) => Math.min(1.6, value + 0.15))}>
+
</button>
</div>
</section>
);
}

View File

@ -0,0 +1,18 @@
import { getLocationName, getStoreName } from "../../lib/locationMapUtils";
export default function LocationMapTopbar({ location, status, onBack }) {
return (
<header className="location-map-topbar">
<button type="button" className="location-map-back" onClick={onBack}>
Back
</button>
<div className="location-map-title">
<h1>{getStoreName(location)}</h1>
<span>{getLocationName(location)}</span>
</div>
<span className={`location-map-status location-map-status-${status.toLowerCase().replace(" ", "-")}`}>
{status}
</span>
</header>
);
}

View File

@ -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 (

View File

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

View File

@ -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 (
<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>
);
}
const hasAnyMap = Boolean(mapState?.draft_map || mapState?.published_map);
const status = getMapStatus(mapState);
return (
<div className="location-map-page">
<LocationMapTopbar location={location} status={status} onBack={() => navigate(-1)} />
<main className="location-map-workspace">
<LocationMapToolbar
mode={mode}
hasAnyMap={hasAnyMap}
canManage={canManage}
zoom={zoom}
setZoom={setZoom}
onView={() => {
setMode("view");
setPreviewDraft(false);
}}
onEdit={() => {
setMode("edit");
setPreviewDraft(true);
}}
/>
{!hasAnyMap || (mode === "setup" && mapState?.draft_map && !mapState?.published_map) ? (
<LocationMapSetupPanel
hasAnyMap={hasAnyMap}
canManage={canManage}
saving={saving}
onContinue={() => setMode("edit")}
onPreview={() => {
setMode("view");
setPreviewDraft(true);
}}
onPublish={handlePublish}
onCreateFromZones={handleCreateFromZones}
onCreateBlank={handleCreateBlank}
/>
) : (
<>
<LocationMapCanvas
mode={mode}
objects={objects}
filters={filters}
mapSize={mapSize}
zoom={zoom}
selectedObjectKey={selectedObjectKey}
mapItems={mapState?.items || []}
username={username}
svgRef={svgRef}
dragState={dragState}
setDragState={setDragState}
setSelectedObjectKey={setSelectedObjectKey}
remember={remember}
updateObjects={updateObjects}
/>
<LocationMapBottomSheet
mode={mode}
canManage={canManage}
filters={filters}
setFilters={setFilters}
selectedObject={selectedObject}
selectedZoneItems={selectedZoneItems}
visibleUnmappedItems={visibleUnmappedItems}
history={history}
future={future}
saving={saving}
mapState={mapState}
onAddObject={handleAddObject}
onUndo={handleUndo}
onRedo={handleRedo}
onSaveDraft={handleSaveDraft}
onPublish={handlePublish}
onEditMode={() => {
setMode("edit");
setPreviewDraft(true);
}}
onObjectField={handleObjectField}
onZoneLinkChange={handleZoneLinkChange}
onDuplicateObject={handleDuplicateObject}
onDeleteObject={handleDeleteObject}
/>
</>
)}
</main>
</div>
);
}

View File

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

View File

@ -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<Record<string, unknown>>, 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<Record<string, unknown>>, 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<string, unknown> | 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<Record<string, unknown>>).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);
});

View File

@ -40,6 +40,26 @@ async function mockGroceryShell(page: Page, stores: Array<Record<string, unknown
});
});
await page.route("**/households/1/locations/*/map", async (route) => {
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 }) => {

View File

@ -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);