diff --git a/backend/controllers/location-maps.controller.js b/backend/controllers/location-maps.controller.js new file mode 100644 index 0000000..829ea4a --- /dev/null +++ b/backend/controllers/location-maps.controller.js @@ -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"); + } +}; diff --git a/backend/models/location-map.model.js b/backend/models/location-map.model.js new file mode 100644 index 0000000..3323402 --- /dev/null +++ b/backend/models/location-map.model.js @@ -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(); + } +}; diff --git a/backend/routes/households.routes.js b/backend/routes/households.routes.js index 970d647..f6c5fc5 100644 --- a/backend/routes/households.routes.js +++ b/backend/routes/households.routes.js @@ -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, diff --git a/backend/tests/store-locations.routes.test.js b/backend/tests/store-locations.routes.test.js index b331eae..665f7ac 100644 --- a/backend/tests/store-locations.routes.test.js +++ b/backend/tests/store-locations.routes.test.js @@ -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(); + }); }); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 012284b..a5af8d1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { } /> } /> } /> + } /> + 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 ( +
+
+ + + + + + + + {mode === "edit" ? ( + + ) : null} + {objects.map(renderMapObject)} + +
+
+ ); +} 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 ( +
+ +
+

{getStoreName(location)}

+ {getLocationName(location)} +
+ + {status} + +
+ ); +} 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 ( +
+

Loading map...

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