feat: add location map manager
This commit is contained in:
parent
23df814fb3
commit
78893545b2
131
backend/controllers/location-maps.controller.js
Normal file
131
backend/controllers/location-maps.controller.js
Normal 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");
|
||||
}
|
||||
};
|
||||
539
backend/models/location-map.model.js
Normal file
539
backend/models/location-map.model.js
Normal 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();
|
||||
}
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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"
|
||||
|
||||
16
frontend/src/api/locationMaps.js
Normal file
16
frontend/src/api/locationMaps.js
Normal 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`);
|
||||
184
frontend/src/components/maps/LocationMapBottomSheet.jsx
Normal file
184
frontend/src/components/maps/LocationMapBottomSheet.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
frontend/src/components/maps/LocationMapCanvas.jsx
Normal file
162
frontend/src/components/maps/LocationMapCanvas.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
frontend/src/components/maps/LocationMapSetupPanel.jsx
Normal file
47
frontend/src/components/maps/LocationMapSetupPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
frontend/src/components/maps/LocationMapToolbar.jsx
Normal file
44
frontend/src/components/maps/LocationMapToolbar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
frontend/src/components/maps/LocationMapTopbar.jsx
Normal file
18
frontend/src/components/maps/LocationMapTopbar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
|
||||
187
frontend/src/lib/locationMapUtils.js
Normal file
187
frontend/src/lib/locationMapUtils.js
Normal 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)),
|
||||
};
|
||||
}
|
||||
429
frontend/src/pages/LocationMapManager.jsx
Normal file
429
frontend/src/pages/LocationMapManager.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
414
frontend/src/styles/pages/LocationMapManager.css
Normal file
414
frontend/src/styles/pages/LocationMapManager.css
Normal 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));
|
||||
}
|
||||
}
|
||||
302
frontend/tests/location-map-manager.spec.ts
Normal file
302
frontend/tests/location-map-manager.spec.ts
Normal 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);
|
||||
});
|
||||
@ -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 }) => {
|
||||
|
||||
66
packages/db/migrations/20260602_010000_location_maps.sql
Normal file
66
packages/db/migrations/20260602_010000_location_maps.sql
Normal 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);
|
||||
Loading…
Reference in New Issue
Block a user