Add store location map manager #20
@ -24,7 +24,7 @@ If anything conflicts, follow **this** doc.
|
||||
- `docker compose -f docker-compose.dev.yml up -d --build backend`
|
||||
- After backend env/CORS changes, recreate the backend service so `backend/.env` is reloaded:
|
||||
- `docker compose -f docker-compose.dev.yml up -d --force-recreate --no-deps backend`
|
||||
- For the Docker frontend on port `3010`, `ALLOWED_ORIGINS` must include the exact browser origin, for example `http://localhost:3010` and `http://127.0.0.1:3010`.
|
||||
- For the Docker frontend on port `3010`, `ALLOWED_ORIGINS` must include exact loopback browser origins such as `http://localhost:3010` and `http://127.0.0.1:3010`; private LAN origins such as `http://192.168.7.45:3010` are allowed by the backend CORS helper for local device testing.
|
||||
- Verify the restarted API with `GET http://127.0.0.1:5000/` and `GET http://127.0.0.1:5000/config`.
|
||||
- Do not print or commit real `.env` values while checking or updating local Docker env.
|
||||
|
||||
|
||||
@ -6,6 +6,6 @@ DB_PORT=5432
|
||||
DB_NAME=
|
||||
PORT=5000
|
||||
JWT_SECRET=change-me
|
||||
ALLOWED_ORIGINS=http://localhost:3000
|
||||
ALLOWED_ORIGINS=http://localhost:3010,http://127.0.0.1:3010,http://192.168.7.45:3010
|
||||
SESSION_COOKIE_NAME=sid
|
||||
SESSION_TTL_DAYS=30
|
||||
|
||||
@ -4,6 +4,7 @@ const path = require("path");
|
||||
const User = require("./models/user.model");
|
||||
const requestIdMiddleware = require("./middleware/request-id");
|
||||
const { sendError } = require("./utils/http");
|
||||
const { isAllowedCorsOrigin, parseAllowedOrigins } = require("./utils/cors-origins");
|
||||
|
||||
const app = express();
|
||||
app.use(requestIdMiddleware);
|
||||
@ -14,17 +15,11 @@ if (process.env.NODE_ENV !== "production") {
|
||||
app.use("/test", express.static(path.join(__dirname, "public")));
|
||||
}
|
||||
|
||||
const allowedOrigins = (process.env.ALLOWED_ORIGINS || "")
|
||||
.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
const allowedOrigins = parseAllowedOrigins(process.env.ALLOWED_ORIGINS || "");
|
||||
app.use(
|
||||
cors({
|
||||
origin: function (origin, callback) {
|
||||
if (!origin) return callback(null, true);
|
||||
if (allowedOrigins.includes(origin)) return callback(null, true);
|
||||
if (/^http:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
|
||||
if (/^https:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
|
||||
if (isAllowedCorsOrigin(origin, allowedOrigins)) return callback(null, true);
|
||||
console.error(`CORS blocked origin: ${origin}`);
|
||||
callback(new Error(`CORS blocked: ${origin}. Add this origin to ALLOWED_ORIGINS environment variable.`));
|
||||
},
|
||||
|
||||
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");
|
||||
}
|
||||
};
|
||||
536
backend/models/location-map.model.js
Normal file
536
backend/models/location-map.model.js
Normal file
@ -0,0 +1,536 @@
|
||||
const pool = require("../db/pool");
|
||||
const storeModel = require("./store.model");
|
||||
const {
|
||||
buildStarterObjectsFromZones,
|
||||
getStarterMapSize,
|
||||
} = require("../services/location-map-layout.service");
|
||||
|
||||
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
|
||||
OR hl.modified_on >= NOW() - INTERVAL '24 hours'
|
||||
)
|
||||
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 getActiveZoneIds(db, householdId, locationId) {
|
||||
const result = await db.query(
|
||||
`SELECT id
|
||||
FROM store_location_zones
|
||||
WHERE household_id = $1
|
||||
AND store_location_id = $2
|
||||
AND is_active = TRUE`,
|
||||
[householdId, locationId]
|
||||
);
|
||||
|
||||
return new Set(result.rows.map((row) => Number(row.id)));
|
||||
}
|
||||
|
||||
async function insertObjects(db, mapId, objects, validZoneIds = null) {
|
||||
for (let index = 0; index < objects.length; index += 1) {
|
||||
const object = sanitizeObjectPayload(objects[index], index);
|
||||
const scopedZoneId =
|
||||
object.zone_id && validZoneIds && !validZoneIds.has(Number(object.zone_id))
|
||||
? null
|
||||
: object.zone_id;
|
||||
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,
|
||||
scopedZoneId,
|
||||
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]);
|
||||
}
|
||||
|
||||
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 starterSize = getStarterMapSize(zones, map);
|
||||
if (starterSize.width !== map.width || starterSize.height !== map.height) {
|
||||
await client.query(
|
||||
`UPDATE location_maps
|
||||
SET width = $1,
|
||||
height = $2,
|
||||
updated_at = NOW(),
|
||||
updated_by = $3
|
||||
WHERE id = $4`,
|
||||
[starterSize.width, starterSize.height, userId, map.id]
|
||||
);
|
||||
map.width = starterSize.width;
|
||||
map.height = starterSize.height;
|
||||
}
|
||||
|
||||
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 || {});
|
||||
const validZoneIds = await getActiveZoneIds(client, householdId, locationId);
|
||||
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 : [], validZoneIds);
|
||||
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,
|
||||
|
||||
71
backend/services/location-map-layout.service.js
Normal file
71
backend/services/location-map-layout.service.js
Normal file
@ -0,0 +1,71 @@
|
||||
const DEFAULT_MAP_WIDTH = 1000;
|
||||
const DEFAULT_MAP_HEIGHT = 700;
|
||||
const STARTER_GAP = 28;
|
||||
const STARTER_MIN_OBJECT_WIDTH = 120;
|
||||
const STARTER_MIN_OBJECT_HEIGHT = 80;
|
||||
const STARTER_TARGET_ROW_HEIGHT = 210;
|
||||
|
||||
function toPositiveInteger(value, fallback) {
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function roundToTwo(value) {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function getStarterGrid(zones) {
|
||||
const zoneCount = Array.isArray(zones) ? zones.length : 0;
|
||||
const columnCount = zoneCount <= 4 ? 2 : 3;
|
||||
const rowCount = Math.ceil(Math.max(zoneCount, 1) / columnCount);
|
||||
return { columnCount, rowCount };
|
||||
}
|
||||
|
||||
function getStarterMapSize(zones, mapSize = {}) {
|
||||
const { columnCount, rowCount } = getStarterGrid(zones);
|
||||
const currentWidth = toPositiveInteger(mapSize.width, DEFAULT_MAP_WIDTH);
|
||||
const currentHeight = toPositiveInteger(mapSize.height, DEFAULT_MAP_HEIGHT);
|
||||
const requiredWidth =
|
||||
columnCount * STARTER_MIN_OBJECT_WIDTH + STARTER_GAP * (columnCount + 1);
|
||||
const minimumGridHeight =
|
||||
rowCount * STARTER_MIN_OBJECT_HEIGHT + STARTER_GAP * (rowCount + 1);
|
||||
const preferredGridHeight = rowCount * STARTER_TARGET_ROW_HEIGHT;
|
||||
|
||||
return {
|
||||
width: Math.max(currentWidth, requiredWidth),
|
||||
height: Math.max(currentHeight, minimumGridHeight, preferredGridHeight),
|
||||
};
|
||||
}
|
||||
|
||||
function buildStarterObjectsFromZones(zones, width, height) {
|
||||
if (!Array.isArray(zones) || !zones.length) return [];
|
||||
|
||||
const mapSize = getStarterMapSize(zones, { width, height });
|
||||
const { columnCount, rowCount } = getStarterGrid(zones);
|
||||
const cellWidth = (mapSize.width - STARTER_GAP * (columnCount + 1)) / columnCount;
|
||||
const cellHeight = (mapSize.height - STARTER_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: roundToTwo(STARTER_GAP + column * (cellWidth + STARTER_GAP)),
|
||||
y: roundToTwo(STARTER_GAP + row * (cellHeight + STARTER_GAP)),
|
||||
width: Math.max(STARTER_MIN_OBJECT_WIDTH, roundToTwo(cellWidth)),
|
||||
height: Math.max(STARTER_MIN_OBJECT_HEIGHT, roundToTwo(cellHeight)),
|
||||
rotation: 0,
|
||||
locked: false,
|
||||
visible: true,
|
||||
sort_order: index + 1,
|
||||
metadata_json: { starter: true },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildStarterObjectsFromZones,
|
||||
getStarterMapSize,
|
||||
};
|
||||
37
backend/tests/cors-origins.test.js
Normal file
37
backend/tests/cors-origins.test.js
Normal file
@ -0,0 +1,37 @@
|
||||
const {
|
||||
isAllowedCorsOrigin,
|
||||
isPrivateLanHostname,
|
||||
parseAllowedOrigins,
|
||||
} = require("../utils/cors-origins");
|
||||
|
||||
describe("CORS origin helpers", () => {
|
||||
test("parses configured allowed origins", () => {
|
||||
expect(parseAllowedOrigins(" http://localhost:3010, http://127.0.0.1:3010 ,,")).toEqual([
|
||||
"http://localhost:3010",
|
||||
"http://127.0.0.1:3010",
|
||||
]);
|
||||
});
|
||||
|
||||
test("allows exact configured origins", () => {
|
||||
const allowedOrigins = ["http://localhost:3010"];
|
||||
|
||||
expect(isAllowedCorsOrigin("http://localhost:3010", allowedOrigins)).toBe(true);
|
||||
});
|
||||
|
||||
test("allows browser access from the local private network", () => {
|
||||
expect(isAllowedCorsOrigin("http://192.168.7.45:3010", [])).toBe(true);
|
||||
});
|
||||
|
||||
test("recognizes RFC1918 private IPv4 hostnames", () => {
|
||||
expect(isPrivateLanHostname("10.1.2.3")).toBe(true);
|
||||
expect(isPrivateLanHostname("172.16.0.1")).toBe(true);
|
||||
expect(isPrivateLanHostname("172.31.255.254")).toBe(true);
|
||||
expect(isPrivateLanHostname("192.168.7.45")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects public and lookalike hostnames", () => {
|
||||
expect(isAllowedCorsOrigin("http://192.168.7.45.example.test:3010", [])).toBe(false);
|
||||
expect(isAllowedCorsOrigin("http://203.0.113.10:3010", [])).toBe(false);
|
||||
expect(isAllowedCorsOrigin("ftp://192.168.7.45:3010", [])).toBe(false);
|
||||
});
|
||||
});
|
||||
78
backend/tests/location-map-layout.service.test.js
Normal file
78
backend/tests/location-map-layout.service.test.js
Normal file
@ -0,0 +1,78 @@
|
||||
const {
|
||||
buildStarterObjectsFromZones,
|
||||
getStarterMapSize,
|
||||
} = require("../services/location-map-layout.service");
|
||||
|
||||
function makeZones(count) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: index + 1,
|
||||
name: `Zone ${index + 1}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function overlaps(first, second) {
|
||||
return !(
|
||||
first.x + first.width <= second.x ||
|
||||
second.x + second.width <= first.x ||
|
||||
first.y + first.height <= second.y ||
|
||||
second.y + second.height <= first.y
|
||||
);
|
||||
}
|
||||
|
||||
describe("location map starter layout", () => {
|
||||
test("creates bounded starter rectangles for many zones", () => {
|
||||
const zones = makeZones(13);
|
||||
const mapSize = getStarterMapSize(zones, { width: 1000, height: 700 });
|
||||
const objects = buildStarterObjectsFromZones(zones, mapSize.width, mapSize.height);
|
||||
|
||||
expect(mapSize).toEqual({ width: 1000, height: 1050 });
|
||||
expect(objects).toHaveLength(13);
|
||||
|
||||
objects.forEach((object, index) => {
|
||||
expect(object).toEqual(
|
||||
expect.objectContaining({
|
||||
zone_id: zones[index].id,
|
||||
type: "zone",
|
||||
label: zones[index].name,
|
||||
locked: false,
|
||||
visible: true,
|
||||
sort_order: index + 1,
|
||||
metadata_json: { starter: true },
|
||||
})
|
||||
);
|
||||
expect(object.x).toBeGreaterThanOrEqual(0);
|
||||
expect(object.y).toBeGreaterThanOrEqual(0);
|
||||
expect(object.width).toBeGreaterThanOrEqual(120);
|
||||
expect(object.height).toBeGreaterThanOrEqual(80);
|
||||
expect(object.x + object.width).toBeLessThanOrEqual(mapSize.width);
|
||||
expect(object.y + object.height).toBeLessThanOrEqual(mapSize.height);
|
||||
});
|
||||
|
||||
for (let firstIndex = 0; firstIndex < objects.length; firstIndex += 1) {
|
||||
for (let secondIndex = firstIndex + 1; secondIndex < objects.length; secondIndex += 1) {
|
||||
expect(overlaps(objects[firstIndex], objects[secondIndex])).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("expands cramped map dimensions before placing starter rectangles", () => {
|
||||
const zones = makeZones(5);
|
||||
const mapSize = getStarterMapSize(zones, { width: 300, height: 180 });
|
||||
const objects = buildStarterObjectsFromZones(zones, 300, 180);
|
||||
|
||||
expect(mapSize).toEqual({ width: 472, height: 420 });
|
||||
expect(objects).toHaveLength(5);
|
||||
objects.forEach((object) => {
|
||||
expect(object.x + object.width).toBeLessThanOrEqual(mapSize.width);
|
||||
expect(object.y + object.height).toBeLessThanOrEqual(mapSize.height);
|
||||
});
|
||||
});
|
||||
|
||||
test("returns no objects for locations with no zones", () => {
|
||||
expect(buildStarterObjectsFromZones([], 1000, 700)).toEqual([]);
|
||||
expect(getStarterMapSize([], { width: 1000, height: 700 })).toEqual({
|
||||
width: 1000,
|
||||
height: 700,
|
||||
});
|
||||
});
|
||||
});
|
||||
235
backend/tests/location-map.model.test.js
Normal file
235
backend/tests/location-map.model.test.js
Normal file
@ -0,0 +1,235 @@
|
||||
jest.mock("../db/pool", () => ({
|
||||
query: jest.fn(),
|
||||
connect: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../models/store.model", () => ({
|
||||
getLocationById: jest.fn(),
|
||||
}));
|
||||
|
||||
const pool = require("../db/pool");
|
||||
const storeModel = require("../models/store.model");
|
||||
const LocationMap = require("../models/location-map.model");
|
||||
|
||||
function createClient() {
|
||||
return {
|
||||
query: jest.fn(),
|
||||
release: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("location-map.model", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function mockMapStateQueries() {
|
||||
const location = {
|
||||
id: 10,
|
||||
household_id: 1,
|
||||
name: "Costco",
|
||||
location_name: "Eastvale",
|
||||
};
|
||||
const draftMap = {
|
||||
id: 900,
|
||||
household_id: 1,
|
||||
store_location_id: 10,
|
||||
name: "Draft Map",
|
||||
width: 1000,
|
||||
height: 700,
|
||||
status: "draft",
|
||||
version: 3,
|
||||
};
|
||||
const publishedMap = {
|
||||
id: 901,
|
||||
household_id: 1,
|
||||
store_location_id: 10,
|
||||
name: "Published Map",
|
||||
width: 1000,
|
||||
height: 700,
|
||||
status: "published",
|
||||
version: 2,
|
||||
};
|
||||
const draftObject = {
|
||||
id: 1100,
|
||||
location_map_id: 900,
|
||||
zone_id: 501,
|
||||
type: "zone",
|
||||
label: "Draft Bakery",
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 200,
|
||||
height: 120,
|
||||
rotation: 0,
|
||||
locked: false,
|
||||
visible: true,
|
||||
sort_order: 1,
|
||||
};
|
||||
const publishedObject = {
|
||||
...draftObject,
|
||||
id: 1200,
|
||||
location_map_id: 901,
|
||||
label: "Published Bakery",
|
||||
};
|
||||
const zones = [{ id: 501, name: "Bakery", sort_order: 10, item_count: 1 }];
|
||||
const items = [
|
||||
{
|
||||
id: 3001,
|
||||
item_id: 2001,
|
||||
item_name: "sourdough",
|
||||
quantity: 1,
|
||||
bought: false,
|
||||
zone_id: 501,
|
||||
zone: "Bakery",
|
||||
added_by_users: ["Owner"],
|
||||
},
|
||||
{
|
||||
id: 3002,
|
||||
item_id: 2002,
|
||||
item_name: "bagels",
|
||||
quantity: 1,
|
||||
bought: true,
|
||||
zone_id: 501,
|
||||
zone: "Bakery",
|
||||
added_by_users: ["Owner"],
|
||||
},
|
||||
];
|
||||
|
||||
storeModel.getLocationById.mockResolvedValue(location);
|
||||
pool.query.mockImplementation(async (sql, params = []) => {
|
||||
const query = String(sql);
|
||||
if (query.includes("FROM location_maps") && query.includes("status = ANY")) {
|
||||
const statuses = params[2] || [];
|
||||
return {
|
||||
rows: [draftMap, publishedMap].filter((map) => statuses.includes(map.status)),
|
||||
};
|
||||
}
|
||||
if (query.includes("FROM location_map_objects")) {
|
||||
const mapId = params[0];
|
||||
return {
|
||||
rows: mapId === 900 ? [draftObject] : mapId === 901 ? [publishedObject] : [],
|
||||
};
|
||||
}
|
||||
if (query.includes("FROM store_location_zones")) {
|
||||
return { rows: zones };
|
||||
}
|
||||
if (query.includes("FROM household_lists hl")) {
|
||||
return { rows: items };
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
});
|
||||
|
||||
return { draftMap, publishedMap, draftObject, publishedObject, location, zones, items };
|
||||
}
|
||||
|
||||
test("hides draft map state from non-managers", async () => {
|
||||
const { publishedMap, publishedObject, location, zones, items } = mockMapStateQueries();
|
||||
|
||||
const state = await LocationMap.getMapState(1, 10, false);
|
||||
|
||||
expect(storeModel.getLocationById).toHaveBeenCalledWith(1, 10);
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("FROM location_maps"),
|
||||
[1, 10, ["published"]]
|
||||
);
|
||||
expect(state).toEqual(expect.objectContaining({
|
||||
location,
|
||||
map: expect.objectContaining({ id: publishedMap.id, status: "published" }),
|
||||
draft_map: null,
|
||||
published_map: expect.objectContaining({ id: publishedMap.id, status: "published" }),
|
||||
objects: [expect.objectContaining({ id: publishedObject.id, label: "Published Bakery" })],
|
||||
draft_objects: [],
|
||||
published_objects: [expect.objectContaining({ id: publishedObject.id })],
|
||||
zones,
|
||||
items,
|
||||
unmapped_count: 0,
|
||||
}));
|
||||
});
|
||||
|
||||
test("includes draft and published map state for managers", async () => {
|
||||
const { draftMap, publishedMap, draftObject, publishedObject } = mockMapStateQueries();
|
||||
|
||||
const state = await LocationMap.getMapState(1, 10, true);
|
||||
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("FROM location_maps"),
|
||||
[1, 10, ["draft", "published"]]
|
||||
);
|
||||
expect(state.map).toEqual(expect.objectContaining({ id: draftMap.id, status: "draft" }));
|
||||
expect(state.draft_map).toEqual(expect.objectContaining({ id: draftMap.id, status: "draft" }));
|
||||
expect(state.published_map).toEqual(expect.objectContaining({ id: publishedMap.id, status: "published" }));
|
||||
expect(state.objects).toEqual([expect.objectContaining({ id: draftObject.id, label: "Draft Bakery" })]);
|
||||
expect(state.draft_objects).toEqual([expect.objectContaining({ id: draftObject.id })]);
|
||||
expect(state.published_objects).toEqual([expect.objectContaining({ id: publishedObject.id })]);
|
||||
});
|
||||
|
||||
test("loads active and recent bought rows for map item layers", async () => {
|
||||
const { items } = mockMapStateQueries();
|
||||
|
||||
const state = await LocationMap.getMapState(1, 10, true);
|
||||
|
||||
expect(state.items).toEqual(items);
|
||||
expect(state.items.some((item) => item.bought)).toBe(true);
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("OR hl.modified_on >= NOW() - INTERVAL '24 hours'"),
|
||||
[1, 10]
|
||||
);
|
||||
});
|
||||
|
||||
test("unlinks saved map objects from zones outside the current location", async () => {
|
||||
const client = createClient();
|
||||
pool.connect.mockResolvedValue(client);
|
||||
const getMapStateSpy = jest.spyOn(LocationMap, "getMapState").mockResolvedValue({
|
||||
draft_map: { id: 900 },
|
||||
draft_objects: [],
|
||||
});
|
||||
|
||||
client.query.mockImplementation(async (sql) => {
|
||||
if (sql === "BEGIN" || sql === "COMMIT") {
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
if (String(sql).includes("INSERT INTO location_maps")) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
id: 900,
|
||||
household_id: 1,
|
||||
store_location_id: 10,
|
||||
name: "Store Map",
|
||||
width: 1000,
|
||||
height: 700,
|
||||
status: "draft",
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (String(sql).includes("FROM store_location_zones")) {
|
||||
return { rows: [{ id: 501 }] };
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
});
|
||||
|
||||
await LocationMap.saveDraft(1, 10, 42, {
|
||||
objects: [
|
||||
{ zone_id: 501, label: "Valid zone" },
|
||||
{ zone_id: 999, label: "Foreign zone" },
|
||||
],
|
||||
});
|
||||
|
||||
const insertCalls = client.query.mock.calls.filter(([sql]) =>
|
||||
String(sql).includes("INSERT INTO location_map_objects")
|
||||
);
|
||||
|
||||
expect(insertCalls).toHaveLength(2);
|
||||
expect(insertCalls[0][1][1]).toBe(501);
|
||||
expect(insertCalls[1][1][1]).toBeNull();
|
||||
expect(client.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("FROM store_location_zones"),
|
||||
[1, 10]
|
||||
);
|
||||
expect(getMapStateSpy).toHaveBeenCalledWith(1, 10, true);
|
||||
expect(client.query).toHaveBeenCalledWith("COMMIT");
|
||||
expect(client.release).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
54
backend/utils/cors-origins.js
Normal file
54
backend/utils/cors-origins.js
Normal file
@ -0,0 +1,54 @@
|
||||
function parseAllowedOrigins(value = "") {
|
||||
return String(value)
|
||||
.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseIpv4(hostname) {
|
||||
const parts = hostname.split(".");
|
||||
if (parts.length !== 4) return null;
|
||||
|
||||
const octets = parts.map((part) => {
|
||||
if (!/^\d{1,3}$/.test(part)) return null;
|
||||
|
||||
const value = Number(part);
|
||||
return value >= 0 && value <= 255 ? value : null;
|
||||
});
|
||||
|
||||
return octets.some((octet) => octet === null) ? null : octets;
|
||||
}
|
||||
|
||||
function isPrivateLanHostname(hostname) {
|
||||
const octets = parseIpv4(hostname);
|
||||
if (!octets) return false;
|
||||
|
||||
const [first, second] = octets;
|
||||
return (
|
||||
first === 10 ||
|
||||
(first === 172 && second >= 16 && second <= 31) ||
|
||||
(first === 192 && second === 168)
|
||||
);
|
||||
}
|
||||
|
||||
function isAllowedCorsOrigin(origin, allowedOrigins = []) {
|
||||
if (!origin) return true;
|
||||
if (allowedOrigins.includes(origin)) return true;
|
||||
|
||||
try {
|
||||
const url = new URL(origin);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isPrivateLanHostname(url.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isAllowedCorsOrigin,
|
||||
isPrivateLanHostname,
|
||||
parseAllowedOrigins,
|
||||
};
|
||||
@ -1,2 +1,3 @@
|
||||
VITE_API_URL=http://localhost:5000
|
||||
VITE_ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
# Leave blank to infer the API host from the browser URL, e.g. http://192.168.7.45:5000.
|
||||
VITE_API_URL=
|
||||
VITE_ALLOWED_HOSTS=localhost,127.0.0.1,192.168.7.45
|
||||
|
||||
@ -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`);
|
||||
323
frontend/src/components/maps/LocationMapBottomSheet.jsx
Normal file
323
frontend/src/components/maps/LocationMapBottomSheet.jsx
Normal file
@ -0,0 +1,323 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import {
|
||||
DEFAULT_MAP_FILTERS,
|
||||
getMapObjectDisplayLabel,
|
||||
objectZoneId,
|
||||
} from "../../lib/locationMapUtils";
|
||||
import {
|
||||
LocationMapLayerPanel,
|
||||
LocationMapOverview,
|
||||
MAP_DISPLAY_CONTROLS,
|
||||
SelectedMapObjectForm,
|
||||
SelectedZoneItemsPanel,
|
||||
UnmappedItemsPanel,
|
||||
} from "./LocationMapBottomSheetSections";
|
||||
|
||||
const EMPTY_ITEMS = [];
|
||||
|
||||
export default function LocationMapBottomSheet({
|
||||
mode,
|
||||
canManage,
|
||||
filters,
|
||||
setFilters,
|
||||
layersOpen,
|
||||
setLayersOpen,
|
||||
selectedObject,
|
||||
mapObjects = [],
|
||||
selectedZoneItems = EMPTY_ITEMS,
|
||||
visibleAssignedItemCount = 0,
|
||||
visibleUnmappedItems = EMPTY_ITEMS,
|
||||
unmappedItemCount = 0,
|
||||
hiddenAreaCount,
|
||||
saving,
|
||||
savingAction,
|
||||
hasUnsavedChanges,
|
||||
mapState,
|
||||
onAddObject,
|
||||
onSaveDraft,
|
||||
onPublish,
|
||||
onObjectField,
|
||||
onZoneLinkChange,
|
||||
onDuplicateObject,
|
||||
onDeleteObject,
|
||||
onShowHiddenAreas,
|
||||
onClearSelection,
|
||||
onSelectZoneItem,
|
||||
onSelectUnmappedItem,
|
||||
}) {
|
||||
const selectedTitle = getMapObjectDisplayLabel(selectedObject);
|
||||
const selectedZoneId = objectZoneId(selectedObject);
|
||||
const mapItems = mapState?.items || EMPTY_ITEMS;
|
||||
const { assignedItemCount, selectedAssignedItems } = useMemo(() => {
|
||||
const nextSelectedAssignedItems = [];
|
||||
let nextAssignedItemCount = 0;
|
||||
|
||||
mapItems.forEach((item) => {
|
||||
if (!item.zone_id) return;
|
||||
nextAssignedItemCount += 1;
|
||||
if (selectedZoneId && String(item.zone_id) === selectedZoneId) {
|
||||
nextSelectedAssignedItems.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
assignedItemCount: nextAssignedItemCount,
|
||||
selectedAssignedItems: nextSelectedAssignedItems,
|
||||
};
|
||||
}, [mapItems, selectedZoneId]);
|
||||
const hiddenSelectedItemCount = Math.max(0, selectedAssignedItems.length - selectedZoneItems.length);
|
||||
const hasHiddenSelectedItems = hiddenSelectedItemCount > 0;
|
||||
const selectedItemCountLabel = hasHiddenSelectedItems
|
||||
? `${selectedZoneItems.length} shown`
|
||||
: `${selectedZoneItems.length} item${selectedZoneItems.length === 1 ? "" : "s"}`;
|
||||
const sheetTitle = mode === "edit"
|
||||
? selectedObject
|
||||
? selectedTitle
|
||||
: "Edit Areas"
|
||||
: selectedObject
|
||||
? selectedTitle
|
||||
: "Map Details";
|
||||
const showHeaderItemCount = mode !== "edit" && Boolean(selectedObject);
|
||||
const showEditorControls = mode === "edit" && canManage;
|
||||
const showSelectedObjectForm = Boolean(mode === "edit" && selectedObject);
|
||||
const showUnmappedPanel = filters.showUnmapped && !selectedObject;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedObject) {
|
||||
setLayersOpen(false);
|
||||
}
|
||||
}, [selectedObject, setLayersOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!layersOpen || typeof window === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === "Escape") {
|
||||
setLayersOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [layersOpen, setLayersOpen]);
|
||||
|
||||
const changedLayerCount = useMemo(
|
||||
() => MAP_DISPLAY_CONTROLS.filter(([key]) => filters[key] !== DEFAULT_MAP_FILTERS[key]).length,
|
||||
[filters]
|
||||
);
|
||||
const totalAreaCount = mapObjects.length;
|
||||
const visibleAreaCount = filters.showZones
|
||||
? Math.max(0, totalAreaCount - hiddenAreaCount)
|
||||
: 0;
|
||||
const hasNoVisibleAreas =
|
||||
filters.showZones && totalAreaCount > 0 && visibleAreaCount === 0 && hiddenAreaCount > 0;
|
||||
const showMapOverview = mode !== "edit" && !selectedObject;
|
||||
const unlinkedAreaCount = useMemo(
|
||||
() => mapObjects.filter((object) => !objectZoneId(object)).length,
|
||||
[mapObjects]
|
||||
);
|
||||
const showDraftOverview = mode === "edit" && !selectedObject;
|
||||
const draftOverviewRows = [
|
||||
{ label: "Areas", value: totalAreaCount },
|
||||
...(hiddenAreaCount > 0 ? [{ label: "Hidden", value: hiddenAreaCount }] : []),
|
||||
...(unlinkedAreaCount > 0 ? [{ label: "Unlinked", value: unlinkedAreaCount }] : []),
|
||||
];
|
||||
const hiddenUnmappedItemCount = Math.max(0, unmappedItemCount - visibleUnmappedItems.length);
|
||||
const hasHiddenUnmappedItems =
|
||||
filters.showUnmapped && unmappedItemCount > 0 && visibleUnmappedItems.length === 0;
|
||||
const hiddenAreaLabel = `Show Hidden Areas (${hiddenAreaCount})`;
|
||||
const showHiddenAreaAction =
|
||||
showEditorControls && hiddenAreaCount > 0 && filters.showZones && !hasNoVisibleAreas;
|
||||
const saveDraftLabel = savingAction === "save" ? "Saving..." : "Save Draft";
|
||||
const publishLabel = savingAction === "publish" ? "Publishing..." : "Publish";
|
||||
const canPublishDraft = Boolean(mapState?.draft_map || hasUnsavedChanges);
|
||||
const showSaveDraftAction = Boolean(savingAction === "save" || hasUnsavedChanges);
|
||||
const showPublishAction = Boolean(savingAction === "publish" || canPublishDraft);
|
||||
const mappedItemSummary = assignedItemCount === visibleAssignedItemCount
|
||||
? String(assignedItemCount)
|
||||
: `${visibleAssignedItemCount} shown`;
|
||||
const unmappedItemSummary = !filters.showUnmapped && unmappedItemCount > 0
|
||||
? `${unmappedItemCount} hidden`
|
||||
: visibleUnmappedItems.length === unmappedItemCount
|
||||
? String(unmappedItemCount)
|
||||
: `${visibleUnmappedItems.length} shown`;
|
||||
const mapOverviewRows = [
|
||||
{ label: "Areas", value: filters.showZones ? `${visibleAreaCount} shown` : "Hidden" },
|
||||
...(assignedItemCount > 0 ? [{ label: "Mapped items", value: mappedItemSummary }] : []),
|
||||
...(unmappedItemCount > 0 ? [{ label: "Unmapped", value: unmappedItemSummary }] : []),
|
||||
];
|
||||
const resetLayerFilters = () => setFilters({ ...DEFAULT_MAP_FILTERS });
|
||||
const showSelectedZoneItems = () => {
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
showMyItems: true,
|
||||
showOtherItems: true,
|
||||
showCompleted: true,
|
||||
}));
|
||||
};
|
||||
const showUnmappedItems = () => {
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
showUnmapped: true,
|
||||
showMyItems: true,
|
||||
showOtherItems: true,
|
||||
showCompleted: true,
|
||||
}));
|
||||
};
|
||||
const primaryDraftActions = showEditorControls && (showSaveDraftAction || showPublishAction) ? (
|
||||
<div className="location-map-editor-actions location-map-primary-actions">
|
||||
{showSaveDraftAction ? (
|
||||
<button type="button" className="primary" onClick={onSaveDraft} disabled={saving || !hasUnsavedChanges}>
|
||||
{saveDraftLabel}
|
||||
</button>
|
||||
) : null}
|
||||
{showPublishAction ? (
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
onClick={onPublish}
|
||||
disabled={saving || !canPublishDraft}
|
||||
>
|
||||
{publishLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
const secondaryDraftActions = showEditorControls ? (
|
||||
<div className="location-map-editor-actions location-map-secondary-actions">
|
||||
<button type="button" onClick={onAddObject} disabled={saving}>Add Area</button>
|
||||
</div>
|
||||
) : null;
|
||||
const editorActions = showEditorControls ? (
|
||||
<div className="location-map-editor-action-stack" role="group" aria-label="Map draft actions">
|
||||
{primaryDraftActions}
|
||||
{secondaryDraftActions}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<aside className="location-map-bottom-sheet">
|
||||
<div className="location-map-bottom-sheet-handle" aria-hidden="true" />
|
||||
<div className="location-map-sheet-header">
|
||||
<div>
|
||||
<div className="location-map-sheet-heading">
|
||||
<strong>{sheetTitle}</strong>
|
||||
{showHeaderItemCount ? (
|
||||
<span className="location-map-sheet-count">{selectedItemCountLabel}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="location-map-layer-actions">
|
||||
{selectedObject ? (
|
||||
<button
|
||||
type="button"
|
||||
className="location-map-sheet-close"
|
||||
onClick={onClearSelection}
|
||||
aria-label="Close selected map area"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
) : null}
|
||||
{changedLayerCount ? (
|
||||
<button
|
||||
type="button"
|
||||
className="location-map-layer-reset"
|
||||
onClick={resetLayerFilters}
|
||||
aria-label="Reset layer filters"
|
||||
title="Reset layer filters"
|
||||
>
|
||||
<svg className="location-map-layer-reset-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 7v5h5" />
|
||||
<path d="M5.2 12a7 7 0 1 0 2.1-5" />
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`location-map-layer-toggle ${layersOpen ? "active" : ""}`}
|
||||
onClick={() => setLayersOpen((current) => !current)}
|
||||
aria-expanded={layersOpen}
|
||||
aria-label={changedLayerCount ? `Layers, ${changedLayerCount} changed` : "Layers"}
|
||||
>
|
||||
Layers
|
||||
{changedLayerCount ? (
|
||||
<span className="location-map-layer-count" aria-hidden="true">{changedLayerCount}</span>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{layersOpen ? (
|
||||
<LocationMapLayerPanel filters={filters} setFilters={setFilters} />
|
||||
) : null}
|
||||
|
||||
{showSelectedObjectForm ? (
|
||||
primaryDraftActions ? (
|
||||
<div
|
||||
className="location-map-editor-action-stack location-map-selected-primary-actions"
|
||||
role="group"
|
||||
aria-label="Map draft actions"
|
||||
>
|
||||
{primaryDraftActions}
|
||||
</div>
|
||||
) : null
|
||||
) : editorActions}
|
||||
|
||||
{showHiddenAreaAction ? (
|
||||
<div className="location-map-hidden-areas">
|
||||
<button type="button" onClick={onShowHiddenAreas} disabled={saving}>
|
||||
{hiddenAreaLabel}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showMapOverview ? (
|
||||
<LocationMapOverview
|
||||
ariaLabel="Map summary"
|
||||
rows={mapOverviewRows}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showDraftOverview ? (
|
||||
<LocationMapOverview
|
||||
ariaLabel="Draft map summary"
|
||||
rows={draftOverviewRows}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{mode === "edit" && selectedObject ? (
|
||||
<SelectedMapObjectForm
|
||||
selectedObject={selectedObject}
|
||||
zones={mapState?.zones || EMPTY_ITEMS}
|
||||
saving={saving}
|
||||
onObjectField={onObjectField}
|
||||
onZoneLinkChange={onZoneLinkChange}
|
||||
onDuplicateObject={onDuplicateObject}
|
||||
onDeleteObject={onDeleteObject}
|
||||
/>
|
||||
) : mode === "edit" ? (
|
||||
null
|
||||
) : selectedObject ? (
|
||||
<SelectedZoneItemsPanel
|
||||
selectedZoneItems={selectedZoneItems}
|
||||
hasHiddenSelectedItems={hasHiddenSelectedItems}
|
||||
hiddenSelectedItemCount={hiddenSelectedItemCount}
|
||||
onShowSelectedZoneItems={showSelectedZoneItems}
|
||||
onSelectZoneItem={onSelectZoneItem}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showUnmappedPanel ? (
|
||||
<UnmappedItemsPanel
|
||||
visibleUnmappedItems={visibleUnmappedItems}
|
||||
hiddenUnmappedItemCount={hiddenUnmappedItemCount}
|
||||
hasHiddenUnmappedItems={hasHiddenUnmappedItems}
|
||||
onShowUnmappedItems={showUnmappedItems}
|
||||
onSelectUnmappedItem={onSelectUnmappedItem}
|
||||
/>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
306
frontend/src/components/maps/LocationMapBottomSheetSections.jsx
Normal file
306
frontend/src/components/maps/LocationMapBottomSheetSections.jsx
Normal file
@ -0,0 +1,306 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getMapObjectDisplayLabel, objectZoneId } from "../../lib/locationMapUtils";
|
||||
|
||||
export const MAP_DISPLAY_CONTROLS = [
|
||||
["showZones", "Zones"],
|
||||
["showLabels", "Labels"],
|
||||
["showCounts", "Counts"],
|
||||
["showPins", "Pins"],
|
||||
["showMyItems", "Mine"],
|
||||
["showOtherItems", "Others"],
|
||||
["showCompleted", "Bought"],
|
||||
["showUnmapped", "Unmapped"],
|
||||
];
|
||||
|
||||
const MAP_ITEM_PREVIEW_LIMIT = 8;
|
||||
|
||||
function MapItemActionRow({ item, onSelectItem }) {
|
||||
const isBought = Boolean(item.bought);
|
||||
|
||||
return (
|
||||
<li className={`location-map-item-row ${isBought ? "is-bought" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="location-map-item-button"
|
||||
onClick={() => {
|
||||
if (!isBought) onSelectItem?.(item);
|
||||
}}
|
||||
disabled={isBought}
|
||||
aria-label={isBought ? `${item.item_name} bought` : `Mark ${item.item_name} bought`}
|
||||
>
|
||||
<span>{item.item_name}</span>
|
||||
<small>{isBought ? "Bought" : `x${item.quantity}`}</small>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function MapItemActionList({ items, onSelectItem, itemGroupLabel }) {
|
||||
const [showAllItems, setShowAllItems] = useState(false);
|
||||
const itemListKey = items.map((item) => item.id).join(":");
|
||||
|
||||
useEffect(() => {
|
||||
setShowAllItems(false);
|
||||
}, [itemListKey]);
|
||||
|
||||
const visibleItems = showAllItems ? items : items.slice(0, MAP_ITEM_PREVIEW_LIMIT);
|
||||
const overflowItemCount = items.length - visibleItems.length;
|
||||
const hasOverflow = items.length > MAP_ITEM_PREVIEW_LIMIT;
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{visibleItems.map((item) => (
|
||||
<MapItemActionRow key={item.id} item={item} onSelectItem={onSelectItem} />
|
||||
))}
|
||||
{hasOverflow ? (
|
||||
<li className="location-map-list-more">
|
||||
<button
|
||||
type="button"
|
||||
className="location-map-list-more-button"
|
||||
onClick={() => setShowAllItems((current) => !current)}
|
||||
aria-label={
|
||||
showAllItems
|
||||
? `Show fewer ${itemGroupLabel}`
|
||||
: `Show ${overflowItemCount} more ${itemGroupLabel}`
|
||||
}
|
||||
>
|
||||
{showAllItems ? "Show fewer" : `+${overflowItemCount} more`}
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function LocationMapLayerPanel({ filters, setFilters }) {
|
||||
return (
|
||||
<div className="location-map-display-panel">
|
||||
<div className="location-map-display-controls" role="group" aria-label="Layer filters">
|
||||
{MAP_DISPLAY_CONTROLS.map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={filters[key] ? "active" : ""}
|
||||
aria-pressed={filters[key]}
|
||||
onClick={() =>
|
||||
setFilters((current) => ({ ...current, [key]: !current[key] }))
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`location-map-layer-state ${filters[key] ? "is-on" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="location-map-layer-label">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LocationMapOverview({ ariaLabel, rows }) {
|
||||
return (
|
||||
<div className="location-map-overview" role="group" aria-label={ariaLabel}>
|
||||
{rows.map((row) => (
|
||||
<div className="location-map-overview-row" key={row.label}>
|
||||
<span>{row.label}</span>
|
||||
<strong>{row.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineStateAction({ label, value, actionLabel, actionText = "Show", onAction }) {
|
||||
return (
|
||||
<div className="location-map-inline-state">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
{onAction ? (
|
||||
<button type="button" onClick={onAction} aria-label={actionLabel}>
|
||||
{actionText}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectedMapObjectForm({
|
||||
selectedObject,
|
||||
zones = [],
|
||||
saving,
|
||||
onObjectField,
|
||||
onZoneLinkChange,
|
||||
onDuplicateObject,
|
||||
onDeleteObject,
|
||||
}) {
|
||||
const selectedObjectLabel = getMapObjectDisplayLabel(selectedObject);
|
||||
|
||||
return (
|
||||
<div className="location-map-object-form">
|
||||
<label className="location-map-field-row">
|
||||
<span>Label</span>
|
||||
<input
|
||||
value={selectedObject.label || ""}
|
||||
disabled={saving}
|
||||
onChange={(event) => onObjectField("label", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="location-map-field-row">
|
||||
<span>Zone</span>
|
||||
<select
|
||||
aria-label="Linked zone"
|
||||
value={objectZoneId(selectedObject)}
|
||||
disabled={saving}
|
||||
onChange={(event) => onZoneLinkChange(event.target.value)}
|
||||
>
|
||||
<option value="">No linked zone</option>
|
||||
{zones.map((zone) => (
|
||||
<option key={zone.id} value={zone.id}>{zone.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="location-map-object-flags">
|
||||
<button
|
||||
type="button"
|
||||
className={selectedObject.visible !== false ? "active" : ""}
|
||||
aria-pressed={selectedObject.visible !== false}
|
||||
disabled={saving}
|
||||
onClick={() => onObjectField("visible", selectedObject.visible === false)}
|
||||
>
|
||||
<span
|
||||
className={`location-map-object-flag-state ${selectedObject.visible !== false ? "is-on" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="location-map-object-flag-label">Visible</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={selectedObject.locked ? "active" : ""}
|
||||
aria-pressed={Boolean(selectedObject.locked)}
|
||||
disabled={saving}
|
||||
onClick={() => onObjectField("locked", !selectedObject.locked)}
|
||||
>
|
||||
<span
|
||||
className={`location-map-object-flag-state ${selectedObject.locked ? "is-on" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="location-map-object-flag-label">Locked</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="location-map-editor-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDuplicateObject}
|
||||
disabled={saving}
|
||||
aria-label={`Duplicate area ${selectedObjectLabel}`}
|
||||
>
|
||||
Duplicate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={onDeleteObject}
|
||||
disabled={saving}
|
||||
aria-label={`Delete area ${selectedObjectLabel}`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectedZoneItemsPanel({
|
||||
selectedZoneItems,
|
||||
hasHiddenSelectedItems,
|
||||
hiddenSelectedItemCount,
|
||||
onShowSelectedZoneItems,
|
||||
onSelectZoneItem,
|
||||
}) {
|
||||
if (selectedZoneItems.length === 0) {
|
||||
if (!hasHiddenSelectedItems) return null;
|
||||
|
||||
return (
|
||||
<div className="location-map-zone-items" role="group" aria-label="Zone items">
|
||||
<div className="location-map-zone-empty">
|
||||
<InlineStateAction
|
||||
label="Hidden"
|
||||
value={hiddenSelectedItemCount}
|
||||
actionLabel="Show Zone Items"
|
||||
onAction={onShowSelectedZoneItems}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="location-map-zone-items" role="group" aria-label="Zone items">
|
||||
<MapItemActionList
|
||||
items={selectedZoneItems}
|
||||
onSelectItem={onSelectZoneItem}
|
||||
itemGroupLabel="zone items"
|
||||
/>
|
||||
{hasHiddenSelectedItems ? (
|
||||
<div className="location-map-zone-hidden-note">
|
||||
<InlineStateAction
|
||||
label="Hidden"
|
||||
value={hiddenSelectedItemCount}
|
||||
actionLabel="Show All Zone Items"
|
||||
actionText="Show All"
|
||||
onAction={onShowSelectedZoneItems}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UnmappedItemsPanel({
|
||||
visibleUnmappedItems,
|
||||
hiddenUnmappedItemCount,
|
||||
hasHiddenUnmappedItems,
|
||||
onShowUnmappedItems,
|
||||
onSelectUnmappedItem,
|
||||
}) {
|
||||
if (visibleUnmappedItems.length === 0 && hasHiddenUnmappedItems) {
|
||||
return (
|
||||
<div className="location-map-unmapped-list" role="group" aria-label="Unmapped items">
|
||||
<div className="location-map-zone-empty">
|
||||
<InlineStateAction
|
||||
label="Hidden"
|
||||
value={hiddenUnmappedItemCount}
|
||||
actionLabel="Show Unmapped Items"
|
||||
onAction={onShowUnmappedItems}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (visibleUnmappedItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="location-map-unmapped-list" role="group" aria-label="Unmapped items">
|
||||
<MapItemActionList
|
||||
items={visibleUnmappedItems}
|
||||
onSelectItem={onSelectUnmappedItem}
|
||||
itemGroupLabel="unmapped items"
|
||||
/>
|
||||
{hiddenUnmappedItemCount > 0 ? (
|
||||
<div className="location-map-zone-hidden-note">
|
||||
<InlineStateAction
|
||||
label="Hidden"
|
||||
value={hiddenUnmappedItemCount}
|
||||
actionLabel="Show All Unmapped Items"
|
||||
actionText="Show All"
|
||||
onAction={onShowUnmappedItems}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
644
frontend/src/components/maps/LocationMapCanvas.jsx
Normal file
644
frontend/src/components/maps/LocationMapCanvas.jsx
Normal file
@ -0,0 +1,644 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
clampObjectToMap,
|
||||
clientPointToMap,
|
||||
getObjectKey,
|
||||
itemMatchesMapFilters,
|
||||
} from "../../lib/locationMapUtils";
|
||||
import LocationMapObject from "./LocationMapObject";
|
||||
|
||||
const DRAG_CHANGE_THRESHOLD = 2;
|
||||
const NUDGE_KEYS = new Set(["ArrowUp", "ArrowRight", "ArrowDown", "ArrowLeft"]);
|
||||
const SELECTED_OBJECT_SCROLL_PADDING = 24;
|
||||
const EDGE_PAN_ZONE = 56;
|
||||
const MAX_EDGE_PAN_STEP = 18;
|
||||
|
||||
function clampScrollPosition(value, maxValue) {
|
||||
return Math.min(Math.max(0, value), Math.max(0, maxValue));
|
||||
}
|
||||
|
||||
function getEdgePanStep(pointerPosition, start, end) {
|
||||
if (pointerPosition < start + EDGE_PAN_ZONE) {
|
||||
const intensity = (start + EDGE_PAN_ZONE - pointerPosition) / EDGE_PAN_ZONE;
|
||||
return -Math.ceil(MAX_EDGE_PAN_STEP * intensity);
|
||||
}
|
||||
|
||||
if (pointerPosition > end - EDGE_PAN_ZONE) {
|
||||
const intensity = (pointerPosition - (end - EDGE_PAN_ZONE)) / EDGE_PAN_ZONE;
|
||||
return Math.ceil(MAX_EDGE_PAN_STEP * intensity);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function panScrollElement(scrollElement, deltaX, deltaY) {
|
||||
if (!scrollElement || (!deltaX && !deltaY)) return;
|
||||
|
||||
scrollElement.scrollLeft = clampScrollPosition(
|
||||
scrollElement.scrollLeft + deltaX,
|
||||
scrollElement.scrollWidth - scrollElement.clientWidth
|
||||
);
|
||||
scrollElement.scrollTop = clampScrollPosition(
|
||||
scrollElement.scrollTop + deltaY,
|
||||
scrollElement.scrollHeight - scrollElement.clientHeight
|
||||
);
|
||||
}
|
||||
|
||||
function shiftMapObject(object, shiftX, shiftY) {
|
||||
if (!shiftX && !shiftY) return object;
|
||||
|
||||
return {
|
||||
...object,
|
||||
x: Number(object.x || 0) + shiftX,
|
||||
y: Number(object.y || 0) + shiftY,
|
||||
};
|
||||
}
|
||||
|
||||
function getSelectedObjectScrollKey(object, selectedObjectKey, zoom) {
|
||||
return [
|
||||
selectedObjectKey,
|
||||
zoom.toFixed(2),
|
||||
object.x,
|
||||
object.y,
|
||||
object.width,
|
||||
object.height,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function clampResizedObjectToMap(object, mapSize) {
|
||||
const x = Math.max(0, Math.min(object.x, mapSize.width - 40));
|
||||
const y = Math.max(0, Math.min(object.y, mapSize.height - 40));
|
||||
return {
|
||||
...object,
|
||||
x,
|
||||
y,
|
||||
width: Math.max(40, Math.min(object.width, mapSize.width - x)),
|
||||
height: Math.max(40, Math.min(object.height, mapSize.height - y)),
|
||||
};
|
||||
}
|
||||
|
||||
function getDraggedObjectCandidate(object, dragState, deltaX, deltaY) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function hasObjectBoundsChanged(object, nextObject) {
|
||||
return (
|
||||
object.x !== nextObject.x ||
|
||||
object.y !== nextObject.y ||
|
||||
object.width !== nextObject.width ||
|
||||
object.height !== nextObject.height
|
||||
);
|
||||
}
|
||||
|
||||
export default function LocationMapCanvas({
|
||||
mode,
|
||||
objects,
|
||||
filters,
|
||||
mapSize,
|
||||
zoom,
|
||||
selectedObjectKey,
|
||||
mapItems,
|
||||
username,
|
||||
svgRef,
|
||||
dragState,
|
||||
editingLocked = false,
|
||||
hiddenAreaCount = 0,
|
||||
onAddObject,
|
||||
onShowZones,
|
||||
onShowHiddenAreas,
|
||||
setDragState,
|
||||
setSelectedObjectKey,
|
||||
remember,
|
||||
updateObjects,
|
||||
expandMapToFitObject,
|
||||
}) {
|
||||
const canEditObjects = !editingLocked && mode === "edit";
|
||||
const canDragPan = mode === "view" || mode === "edit";
|
||||
const hiddenByZonesLayer = objects.length > 0 && !filters.showZones;
|
||||
const visibleObjectCount = filters.showZones
|
||||
? objects.filter((object) => object.visible !== false).length
|
||||
: 0;
|
||||
const hasNoVisibleObjects = objects.length > 0 && filters.showZones && visibleObjectCount === 0;
|
||||
const canAddFirstArea =
|
||||
mode === "edit" && objects.length === 0 && typeof onAddObject === "function";
|
||||
const canRecoverHiddenAreas =
|
||||
mode === "edit" && hasNoVisibleObjects && hiddenAreaCount > 0 && typeof onShowHiddenAreas === "function";
|
||||
const hasEmptyCanvasAction = canAddFirstArea || hiddenByZonesLayer || canRecoverHiddenAreas;
|
||||
const emptyCanvasMessage = objects.length === 0
|
||||
? "No map areas yet"
|
||||
: hiddenByZonesLayer
|
||||
? "Zones hidden in Layers"
|
||||
: hasNoVisibleObjects
|
||||
? "No visible map areas"
|
||||
: "";
|
||||
const scrollRef = useRef(null);
|
||||
const panDragRef = useRef(null);
|
||||
const pendingMapShiftScrollRef = useRef({ x: 0, y: 0 });
|
||||
const scheduledMapShiftScrollRef = useRef(null);
|
||||
const objectDragHistoryCapturedRef = useRef(false);
|
||||
const suppressPanClickRef = useRef(false);
|
||||
const autoScrolledObjectViewRef = useRef(null);
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const orderedObjects = useMemo(() => {
|
||||
if (!selectedObjectKey) return objects;
|
||||
|
||||
const selectedObjects = [];
|
||||
const remainingObjects = [];
|
||||
objects.forEach((object) => {
|
||||
if (getObjectKey(object) === selectedObjectKey) {
|
||||
selectedObjects.push(object);
|
||||
return;
|
||||
}
|
||||
remainingObjects.push(object);
|
||||
});
|
||||
|
||||
return [...remainingObjects, ...selectedObjects];
|
||||
}, [objects, selectedObjectKey]);
|
||||
const zoneItemSummary = useMemo(() => {
|
||||
const assignedCounts = new Map();
|
||||
const visibleItemsByZone = new Map();
|
||||
|
||||
(mapItems || []).forEach((item) => {
|
||||
const zoneKey = String(item.zone_id || "");
|
||||
if (!zoneKey) return;
|
||||
|
||||
assignedCounts.set(zoneKey, (assignedCounts.get(zoneKey) || 0) + 1);
|
||||
if (!itemMatchesMapFilters(item, filters, username)) return;
|
||||
|
||||
const existingItems = visibleItemsByZone.get(zoneKey) || [];
|
||||
existingItems.push(item);
|
||||
visibleItemsByZone.set(zoneKey, existingItems);
|
||||
});
|
||||
|
||||
return { assignedCounts, visibleItemsByZone };
|
||||
}, [filters, mapItems, username]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (scheduledMapShiftScrollRef.current && typeof window !== "undefined") {
|
||||
window.clearTimeout(scheduledMapShiftScrollRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const flushQueuedMapShiftScroll = () => {
|
||||
const queuedShift = pendingMapShiftScrollRef.current;
|
||||
if (!queuedShift.x && !queuedShift.y) return;
|
||||
|
||||
pendingMapShiftScrollRef.current = { x: 0, y: 0 };
|
||||
const scrollElement = scrollRef.current;
|
||||
panScrollElement(scrollElement, queuedShift.x * zoom, queuedShift.y * zoom);
|
||||
|
||||
if (!scrollElement || typeof window === "undefined") return;
|
||||
|
||||
const targetLeft = scrollElement.scrollLeft;
|
||||
const targetTop = scrollElement.scrollTop;
|
||||
const restoreScrollTarget = () => {
|
||||
const currentScrollElement = scrollRef.current;
|
||||
if (!currentScrollElement) return;
|
||||
|
||||
currentScrollElement.scrollLeft = Math.max(currentScrollElement.scrollLeft, targetLeft);
|
||||
currentScrollElement.scrollTop = Math.max(currentScrollElement.scrollTop, targetTop);
|
||||
};
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
restoreScrollTarget();
|
||||
window.setTimeout(restoreScrollTarget, 80);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const queueMapShiftScroll = (shiftX, shiftY) => {
|
||||
if (!shiftX && !shiftY) return;
|
||||
|
||||
pendingMapShiftScrollRef.current = {
|
||||
x: pendingMapShiftScrollRef.current.x + shiftX,
|
||||
y: pendingMapShiftScrollRef.current.y + shiftY,
|
||||
};
|
||||
|
||||
if (typeof window === "undefined" || scheduledMapShiftScrollRef.current) return;
|
||||
|
||||
scheduledMapShiftScrollRef.current = window.setTimeout(() => {
|
||||
scheduledMapShiftScrollRef.current = null;
|
||||
window.requestAnimationFrame(flushQueuedMapShiftScroll);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedObjectKey) {
|
||||
autoScrolledObjectViewRef.current = null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (mode !== "edit" || dragState) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const selectedObject = objects.find((object) => getObjectKey(object) === selectedObjectKey);
|
||||
if (!selectedObject || !scrollRef.current || typeof window === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const scrollKey = getSelectedObjectScrollKey(selectedObject, selectedObjectKey, zoom);
|
||||
if (autoScrolledObjectViewRef.current === scrollKey) {
|
||||
return undefined;
|
||||
}
|
||||
autoScrolledObjectViewRef.current = scrollKey;
|
||||
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
const scrollElement = scrollRef.current;
|
||||
if (!scrollElement) return;
|
||||
|
||||
const objectLeft = selectedObject.x * zoom - SELECTED_OBJECT_SCROLL_PADDING;
|
||||
const objectTop = selectedObject.y * zoom - SELECTED_OBJECT_SCROLL_PADDING;
|
||||
const objectRight = (selectedObject.x + selectedObject.width) * zoom + SELECTED_OBJECT_SCROLL_PADDING;
|
||||
const objectBottom = (selectedObject.y + selectedObject.height) * zoom + SELECTED_OBJECT_SCROLL_PADDING;
|
||||
let nextLeft = scrollElement.scrollLeft;
|
||||
let nextTop = scrollElement.scrollTop;
|
||||
|
||||
if (objectLeft < nextLeft) {
|
||||
nextLeft = objectLeft;
|
||||
} else if (objectRight > nextLeft + scrollElement.clientWidth) {
|
||||
nextLeft = objectRight - scrollElement.clientWidth;
|
||||
}
|
||||
|
||||
if (objectTop < nextTop) {
|
||||
nextTop = objectTop;
|
||||
} else if (objectBottom > nextTop + scrollElement.clientHeight) {
|
||||
nextTop = objectBottom - scrollElement.clientHeight;
|
||||
}
|
||||
|
||||
scrollElement.scrollTo({
|
||||
left: clampScrollPosition(nextLeft, scrollElement.scrollWidth - scrollElement.clientWidth),
|
||||
top: clampScrollPosition(nextTop, scrollElement.scrollHeight - scrollElement.clientHeight),
|
||||
});
|
||||
});
|
||||
|
||||
return () => window.cancelAnimationFrame(frameId);
|
||||
}, [dragState, mode, objects, selectedObjectKey, zoom]);
|
||||
|
||||
const startDrag = (event, object, type) => {
|
||||
if (!canEditObjects || object.locked || !svgRef.current) return;
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
objectDragHistoryCapturedRef.current = false;
|
||||
const point = clientPointToMap(event, svgRef.current, mapSize);
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
setSelectedObjectKey(getObjectKey(object));
|
||||
setDragState({
|
||||
type,
|
||||
key: getObjectKey(object),
|
||||
startPoint: point,
|
||||
startObject: { ...object },
|
||||
});
|
||||
};
|
||||
|
||||
const handlePointerMove = (event) => {
|
||||
if (!dragState || !svgRef.current) return;
|
||||
event.preventDefault();
|
||||
const scrollElement = scrollRef.current;
|
||||
if (scrollElement) {
|
||||
const scrollBounds = scrollElement.getBoundingClientRect();
|
||||
panScrollElement(
|
||||
scrollElement,
|
||||
getEdgePanStep(event.clientX, scrollBounds.left, scrollBounds.right),
|
||||
getEdgePanStep(event.clientY, scrollBounds.top, scrollBounds.bottom)
|
||||
);
|
||||
}
|
||||
|
||||
const point = clientPointToMap(event, svgRef.current, mapSize);
|
||||
const deltaX = point.x - dragState.startPoint.x;
|
||||
const deltaY = point.y - dragState.startPoint.y;
|
||||
const hasMeaningfulChange =
|
||||
Math.abs(deltaX) >= DRAG_CHANGE_THRESHOLD ||
|
||||
Math.abs(deltaY) >= DRAG_CHANGE_THRESHOLD;
|
||||
|
||||
if (!hasMeaningfulChange) return;
|
||||
const draggedObject = objects.find((object) => getObjectKey(object) === dragState.key);
|
||||
if (!draggedObject) return;
|
||||
const nextDraggedObjectCandidate = getDraggedObjectCandidate(draggedObject, dragState, deltaX, deltaY);
|
||||
const expansion = expandMapToFitObject?.(nextDraggedObjectCandidate) || {
|
||||
mapSize,
|
||||
shiftX: 0,
|
||||
shiftY: 0,
|
||||
};
|
||||
const nextMapSize = expansion.mapSize;
|
||||
const shiftedDraggedObjectCandidate = shiftMapObject(
|
||||
nextDraggedObjectCandidate,
|
||||
expansion.shiftX,
|
||||
expansion.shiftY
|
||||
);
|
||||
const nextDraggedObject = dragState.type === "resize"
|
||||
? clampResizedObjectToMap(shiftedDraggedObjectCandidate, nextMapSize)
|
||||
: clampObjectToMap(shiftedDraggedObjectCandidate, nextMapSize);
|
||||
if (!hasObjectBoundsChanged(draggedObject, nextDraggedObject)) return;
|
||||
|
||||
if (!objectDragHistoryCapturedRef.current) {
|
||||
remember(undefined, dragState.key);
|
||||
objectDragHistoryCapturedRef.current = true;
|
||||
}
|
||||
|
||||
if (expansion.shiftX || expansion.shiftY) {
|
||||
queueMapShiftScroll(expansion.shiftX, expansion.shiftY);
|
||||
setDragState((currentDragState) => {
|
||||
if (!currentDragState || currentDragState.key !== dragState.key) return currentDragState;
|
||||
|
||||
return {
|
||||
...currentDragState,
|
||||
startObject: shiftMapObject(currentDragState.startObject, expansion.shiftX, expansion.shiftY),
|
||||
startPoint: {
|
||||
x: currentDragState.startPoint.x + expansion.shiftX,
|
||||
y: currentDragState.startPoint.y + expansion.shiftY,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
updateObjects(
|
||||
(currentObjects) => currentObjects.map((object) => {
|
||||
if (getObjectKey(object) !== dragState.key) {
|
||||
return shiftMapObject(object, expansion.shiftX, expansion.shiftY);
|
||||
}
|
||||
const candidate = getDraggedObjectCandidate(object, dragState, deltaX, deltaY);
|
||||
const shiftedCandidate = shiftMapObject(candidate, expansion.shiftX, expansion.shiftY);
|
||||
return dragState.type === "resize"
|
||||
? clampResizedObjectToMap(shiftedCandidate, nextMapSize)
|
||||
: clampObjectToMap(shiftedCandidate, nextMapSize);
|
||||
}),
|
||||
nextMapSize
|
||||
);
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
objectDragHistoryCapturedRef.current = false;
|
||||
setDragState(null);
|
||||
};
|
||||
|
||||
const selectObject = (object) => {
|
||||
if (mode === "view" || canEditObjects) {
|
||||
setSelectedObjectKey(getObjectKey(object));
|
||||
}
|
||||
};
|
||||
|
||||
const handleObjectClick = (event, object) => {
|
||||
event.stopPropagation();
|
||||
if (suppressPanClickRef.current) {
|
||||
suppressPanClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
selectObject(object);
|
||||
};
|
||||
|
||||
const handleObjectKeyDown = (event, object) => {
|
||||
if (event.key === "Escape" && (mode === "view" || canEditObjects)) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
setSelectedObjectKey(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (["Enter", " ", "Spacebar"].includes(event.key)) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
selectObject(object);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canEditObjects || object.locked || !NUDGE_KEYS.has(event.key)) return;
|
||||
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
const nudgeAmount = event.shiftKey ? 25 : 10;
|
||||
const deltaX = event.key === "ArrowLeft" ? -nudgeAmount : event.key === "ArrowRight" ? nudgeAmount : 0;
|
||||
const deltaY = event.key === "ArrowUp" ? -nudgeAmount : event.key === "ArrowDown" ? nudgeAmount : 0;
|
||||
const nextObjectCandidate = {
|
||||
...object,
|
||||
x: object.x + deltaX,
|
||||
y: object.y + deltaY,
|
||||
};
|
||||
const expansion = expandMapToFitObject?.(nextObjectCandidate) || {
|
||||
mapSize,
|
||||
shiftX: 0,
|
||||
shiftY: 0,
|
||||
};
|
||||
const nextMapSize = expansion.mapSize;
|
||||
const nextObject = clampObjectToMap(
|
||||
shiftMapObject(nextObjectCandidate, expansion.shiftX, expansion.shiftY),
|
||||
nextMapSize
|
||||
);
|
||||
|
||||
setSelectedObjectKey(getObjectKey(object));
|
||||
if (!hasObjectBoundsChanged(object, nextObject)) return;
|
||||
|
||||
remember(undefined, getObjectKey(object));
|
||||
queueMapShiftScroll(expansion.shiftX, expansion.shiftY);
|
||||
updateObjects(
|
||||
(currentObjects) => currentObjects.map((candidate) =>
|
||||
getObjectKey(candidate) === getObjectKey(object)
|
||||
? nextObject
|
||||
: shiftMapObject(candidate, expansion.shiftX, expansion.shiftY)
|
||||
),
|
||||
nextMapSize
|
||||
);
|
||||
};
|
||||
|
||||
const handleShowZones = (event) => {
|
||||
event.stopPropagation();
|
||||
onShowZones?.();
|
||||
};
|
||||
|
||||
const handleAddObject = (event) => {
|
||||
event.stopPropagation();
|
||||
if (editingLocked) return;
|
||||
onAddObject?.();
|
||||
};
|
||||
|
||||
const handleShowHiddenAreas = (event) => {
|
||||
event.stopPropagation();
|
||||
if (editingLocked) return;
|
||||
onShowHiddenAreas?.();
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
if (suppressPanClickRef.current) {
|
||||
suppressPanClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!dragState) {
|
||||
setSelectedObjectKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const startPan = (event) => {
|
||||
if (!canDragPan || !scrollRef.current || event.button !== 0) return;
|
||||
const objectElement = event.target.closest?.(".location-map-object");
|
||||
if (canEditObjects && objectElement) return;
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
panDragRef.current = {
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
scrollLeft: scrollRef.current.scrollLeft,
|
||||
scrollTop: scrollRef.current.scrollTop,
|
||||
objectKey: objectElement?.getAttribute("data-object-key") || null,
|
||||
moved: false,
|
||||
};
|
||||
setIsPanning(true);
|
||||
};
|
||||
|
||||
const movePan = (event) => {
|
||||
if (!panDragRef.current || !scrollRef.current) return;
|
||||
const deltaX = event.clientX - panDragRef.current.startX;
|
||||
const deltaY = event.clientY - panDragRef.current.startY;
|
||||
if (Math.abs(deltaX) > 4 || Math.abs(deltaY) > 4) {
|
||||
panDragRef.current.moved = true;
|
||||
event.preventDefault();
|
||||
}
|
||||
scrollRef.current.scrollLeft = clampScrollPosition(
|
||||
panDragRef.current.scrollLeft - deltaX,
|
||||
scrollRef.current.scrollWidth - scrollRef.current.clientWidth
|
||||
);
|
||||
scrollRef.current.scrollTop = clampScrollPosition(
|
||||
panDragRef.current.scrollTop - deltaY,
|
||||
scrollRef.current.scrollHeight - scrollRef.current.clientHeight
|
||||
);
|
||||
};
|
||||
|
||||
const endPan = (event) => {
|
||||
if (!panDragRef.current) return;
|
||||
const wasMoved = panDragRef.current.moved;
|
||||
const objectKey = panDragRef.current.objectKey;
|
||||
if (!wasMoved && mode === "view" && objectKey) {
|
||||
setSelectedObjectKey(objectKey);
|
||||
suppressPanClickRef.current = true;
|
||||
} else {
|
||||
suppressPanClickRef.current = wasMoved;
|
||||
}
|
||||
panDragRef.current = null;
|
||||
setIsPanning(false);
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
};
|
||||
|
||||
const renderMapObject = (object, index) => {
|
||||
const key = getObjectKey(object);
|
||||
const zoneKey = String(object.zone_id || "");
|
||||
const zoneItems = zoneKey ? zoneItemSummary.visibleItemsByZone.get(zoneKey) || [] : [];
|
||||
const assignedZoneItemCount = zoneKey ? zoneItemSummary.assignedCounts.get(zoneKey) || 0 : 0;
|
||||
return (
|
||||
<LocationMapObject
|
||||
key={key}
|
||||
object={object}
|
||||
index={index}
|
||||
filters={filters}
|
||||
zoneItems={zoneItems}
|
||||
assignedZoneItemCount={assignedZoneItemCount}
|
||||
isSelected={key === selectedObjectKey}
|
||||
canEditObjects={canEditObjects}
|
||||
canSelect={mode === "view" || canEditObjects}
|
||||
zoom={zoom}
|
||||
mapSize={mapSize}
|
||||
onStartDrag={startDrag}
|
||||
onObjectClick={handleObjectClick}
|
||||
onObjectKeyDown={handleObjectKeyDown}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className={[
|
||||
"location-map-canvas-shell",
|
||||
mode === "edit" ? "is-edit-mode" : "is-view-mode",
|
||||
mode === "edit" ? "is-object-tool" : "is-pan-tool",
|
||||
isPanning ? "is-panning" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="location-map-scroll"
|
||||
onPointerDown={startPan}
|
||||
onPointerMove={movePan}
|
||||
onPointerUp={endPan}
|
||||
onPointerCancel={endPan}
|
||||
onPointerLeave={endPan}
|
||||
onClick={clearSelection}
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className={[
|
||||
"location-map-svg",
|
||||
canEditObjects ? "is-editable" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
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}
|
||||
onClick={clearSelection}
|
||||
>
|
||||
<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}
|
||||
{orderedObjects.map(renderMapObject)}
|
||||
</svg>
|
||||
{emptyCanvasMessage ? (
|
||||
<div
|
||||
className={[
|
||||
"location-map-empty-canvas",
|
||||
hasEmptyCanvasAction ? "has-action" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
>
|
||||
<span>{emptyCanvasMessage}</span>
|
||||
{canAddFirstArea ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={editingLocked}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={handleAddObject}
|
||||
>
|
||||
Add Area
|
||||
</button>
|
||||
) : hiddenByZonesLayer ? (
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={handleShowZones}
|
||||
>
|
||||
Show Zones
|
||||
</button>
|
||||
) : canRecoverHiddenAreas ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={editingLocked}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={handleShowHiddenAreas}
|
||||
>
|
||||
Show Hidden
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/maps/LocationMapDialogs.jsx
Normal file
46
frontend/src/components/maps/LocationMapDialogs.jsx
Normal file
@ -0,0 +1,46 @@
|
||||
import ConfirmBuyModal from "../modals/ConfirmBuyModal";
|
||||
import ConfirmSlideModal from "../modals/ConfirmSlideModal";
|
||||
import { getMapObjectDisplayLabel } from "../../lib/locationMapUtils";
|
||||
|
||||
export default function LocationMapDialogs({
|
||||
buyModalItem,
|
||||
buyModalItems,
|
||||
onBuyCancel,
|
||||
onBuyConfirm,
|
||||
onBuyNavigate,
|
||||
pendingDeleteObject,
|
||||
onDeleteClose,
|
||||
onDeleteConfirm,
|
||||
pendingLeaveTarget,
|
||||
onLeaveClose,
|
||||
onLeaveConfirm,
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{buyModalItem ? (
|
||||
<ConfirmBuyModal
|
||||
item={buyModalItem}
|
||||
allItems={buyModalItems}
|
||||
onNavigate={onBuyNavigate}
|
||||
onCancel={onBuyCancel}
|
||||
onConfirm={onBuyConfirm}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ConfirmSlideModal
|
||||
isOpen={Boolean(pendingDeleteObject)}
|
||||
title={`Delete ${getMapObjectDisplayLabel(pendingDeleteObject, "this map area")}?`}
|
||||
confirmLabel="Delete Area"
|
||||
onClose={onDeleteClose}
|
||||
onConfirm={onDeleteConfirm}
|
||||
/>
|
||||
<ConfirmSlideModal
|
||||
isOpen={Boolean(pendingLeaveTarget)}
|
||||
title="Leave with unsaved changes?"
|
||||
confirmLabel="Leave Map"
|
||||
onClose={onLeaveClose}
|
||||
onConfirm={onLeaveConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
193
frontend/src/components/maps/LocationMapObject.jsx
Normal file
193
frontend/src/components/maps/LocationMapObject.jsx
Normal file
@ -0,0 +1,193 @@
|
||||
import {
|
||||
getMapObjectDisplayLabel,
|
||||
getObjectKey,
|
||||
} from "../../lib/locationMapUtils";
|
||||
|
||||
const PIN_PREVIEW_LIMIT = 12;
|
||||
|
||||
function truncateLabel(value, maxCharacters) {
|
||||
if (value.length <= maxCharacters) return value;
|
||||
return `${value.slice(0, Math.max(5, maxCharacters - 3)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function fittedLabelLines(object, fallback) {
|
||||
const rawLabel = getMapObjectDisplayLabel(object, fallback);
|
||||
const maxCharacters = Math.max(8, Math.floor((Number(object.width) - 28) / 13));
|
||||
const canUseTwoLines = Number(object.height) >= 92 && rawLabel.length > maxCharacters;
|
||||
if (!canUseTwoLines) return [truncateLabel(rawLabel, maxCharacters)];
|
||||
|
||||
const words = rawLabel.split(/\s+/);
|
||||
let firstLine = "";
|
||||
let splitIndex = 0;
|
||||
for (let index = 0; index < words.length; index += 1) {
|
||||
const candidate = firstLine ? `${firstLine} ${words[index]}` : words[index];
|
||||
if (candidate.length > maxCharacters && firstLine) break;
|
||||
firstLine = candidate;
|
||||
splitIndex = index + 1;
|
||||
if (candidate.length >= maxCharacters) break;
|
||||
}
|
||||
|
||||
const secondLine = words.slice(splitIndex).join(" ");
|
||||
if (!secondLine) return [truncateLabel(firstLine, maxCharacters)];
|
||||
return [
|
||||
truncateLabel(firstLine, maxCharacters),
|
||||
truncateLabel(secondLine, maxCharacters),
|
||||
];
|
||||
}
|
||||
|
||||
export default function LocationMapObject({
|
||||
object,
|
||||
index,
|
||||
filters,
|
||||
zoneItems,
|
||||
assignedZoneItemCount,
|
||||
isSelected,
|
||||
canEditObjects,
|
||||
canSelect,
|
||||
zoom,
|
||||
mapSize,
|
||||
onStartDrag,
|
||||
onObjectClick,
|
||||
onObjectKeyDown,
|
||||
}) {
|
||||
if (object.visible === false || !filters.showZones) return null;
|
||||
|
||||
const key = getObjectKey(object);
|
||||
const displayLabel = getMapObjectDisplayLabel(object, index + 1);
|
||||
const count = zoneItems.length;
|
||||
const hasHiddenZoneItems = assignedZoneItemCount > count;
|
||||
const countLabel = hasHiddenZoneItems
|
||||
? `${count} shown`
|
||||
: `${count} item${count === 1 ? "" : "s"}`;
|
||||
const accessibleLabel = canEditObjects
|
||||
? object.locked
|
||||
? `Map area ${displayLabel}, locked`
|
||||
: `Map area ${displayLabel}`
|
||||
: `Map area ${displayLabel}, ${countLabel}`;
|
||||
const labelLines = fittedLabelLines(object, "Area");
|
||||
const countY = filters.showLabels && labelLines.length > 1 ? object.y + 76 : object.y + 52;
|
||||
const resizeHandleSize = Math.min(96, Math.max(44, Math.ceil(48 / zoom)));
|
||||
const resizeHandleInset = 6;
|
||||
const lockBadgeSize = Math.min(52, Math.max(30, Math.ceil(34 / zoom)));
|
||||
const lockBadgeInset = 8;
|
||||
const resizeHandleX = Math.min(
|
||||
Math.max(0, mapSize.width - resizeHandleSize),
|
||||
Math.max(
|
||||
object.x + resizeHandleInset,
|
||||
object.x + object.width - resizeHandleSize - resizeHandleInset
|
||||
)
|
||||
);
|
||||
const resizeHandleY = Math.min(
|
||||
Math.max(0, mapSize.height - resizeHandleSize),
|
||||
Math.max(
|
||||
object.y + resizeHandleInset,
|
||||
object.y + object.height - resizeHandleSize - resizeHandleInset
|
||||
)
|
||||
);
|
||||
const lockBadgeX = Math.min(
|
||||
Math.max(0, mapSize.width - lockBadgeSize),
|
||||
Math.max(
|
||||
object.x + lockBadgeInset,
|
||||
object.x + object.width - lockBadgeSize - lockBadgeInset
|
||||
)
|
||||
);
|
||||
const lockBadgeY = Math.min(
|
||||
Math.max(0, mapSize.height - lockBadgeSize),
|
||||
object.y + lockBadgeInset
|
||||
);
|
||||
const pinDots = filters.showPins
|
||||
? zoneItems.slice(0, PIN_PREVIEW_LIMIT).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
|
||||
data-object-key={key}
|
||||
className={[
|
||||
"location-map-object",
|
||||
isSelected ? "is-selected" : "",
|
||||
canEditObjects ? "is-editable" : "",
|
||||
object.locked ? "is-locked" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
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={accessibleLabel}
|
||||
aria-pressed={isSelected}
|
||||
tabIndex={canSelect ? 0 : -1}
|
||||
onPointerDown={(event) => onStartDrag(event, object, "move")}
|
||||
onClick={(event) => onObjectClick(event, object)}
|
||||
onKeyDown={(event) => onObjectKeyDown(event, object)}
|
||||
/>
|
||||
{filters.showLabels ? (
|
||||
<text x={object.x + 16} y={object.y + 28} className="location-map-label">
|
||||
{labelLines.map((line, lineIndex) => (
|
||||
<tspan key={`${line}-${lineIndex}`} x={object.x + 16} dy={lineIndex === 0 ? 0 : 24}>
|
||||
{line}
|
||||
</tspan>
|
||||
))}
|
||||
</text>
|
||||
) : null}
|
||||
{filters.showCounts ? (
|
||||
<text x={object.x + 16} y={countY} className="location-map-count">
|
||||
{countLabel}
|
||||
</text>
|
||||
) : null}
|
||||
{pinDots}
|
||||
{canEditObjects && isSelected && object.locked ? (
|
||||
<g
|
||||
className="location-map-lock-badge"
|
||||
transform={`translate(${lockBadgeX} ${lockBadgeY})`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect width={lockBadgeSize} height={lockBadgeSize} rx="10" />
|
||||
<path
|
||||
d={[
|
||||
`M ${lockBadgeSize * 0.32} ${lockBadgeSize * 0.48}`,
|
||||
`V ${lockBadgeSize * 0.4}`,
|
||||
`C ${lockBadgeSize * 0.32} ${lockBadgeSize * 0.24}, ${lockBadgeSize * 0.68} ${lockBadgeSize * 0.24}, ${lockBadgeSize * 0.68} ${lockBadgeSize * 0.4}`,
|
||||
`V ${lockBadgeSize * 0.48}`,
|
||||
].join(" ")}
|
||||
/>
|
||||
<rect
|
||||
className="location-map-lock-body"
|
||||
x={lockBadgeSize * 0.26}
|
||||
y={lockBadgeSize * 0.46}
|
||||
width={lockBadgeSize * 0.48}
|
||||
height={lockBadgeSize * 0.34}
|
||||
rx={lockBadgeSize * 0.08}
|
||||
/>
|
||||
</g>
|
||||
) : null}
|
||||
{canEditObjects && isSelected && !object.locked ? (
|
||||
<rect
|
||||
className="location-map-resize-handle"
|
||||
x={resizeHandleX}
|
||||
y={resizeHandleY}
|
||||
width={resizeHandleSize}
|
||||
height={resizeHandleSize}
|
||||
rx="8"
|
||||
onPointerDown={(event) => onStartDrag(event, object, "resize")}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
) : null}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
63
frontend/src/components/maps/LocationMapSetupPanel.jsx
Normal file
63
frontend/src/components/maps/LocationMapSetupPanel.jsx
Normal file
@ -0,0 +1,63 @@
|
||||
export default function LocationMapSetupPanel({
|
||||
hasAnyMap,
|
||||
canManage,
|
||||
zoneCount = 0,
|
||||
saving,
|
||||
savingAction,
|
||||
onContinue,
|
||||
onPreview,
|
||||
onPublish,
|
||||
onCreateFromZones,
|
||||
onCreateBlank,
|
||||
}) {
|
||||
const hasZones = zoneCount > 0;
|
||||
const createFromZonesLabel = `Create From ${zoneCount} Zone${zoneCount === 1 ? "" : "s"}`;
|
||||
const createBlankLabel = savingAction === "create-blank" ? "Creating..." : "Create Blank Map";
|
||||
const createZonesButtonLabel = savingAction === "create-zones" ? "Creating..." : createFromZonesLabel;
|
||||
const publishLabel = savingAction === "publish" ? "Publishing..." : "Publish";
|
||||
const heading = hasAnyMap ? "Draft Map" : canManage ? "No Map" : "No Published Map";
|
||||
|
||||
return (
|
||||
<section className="location-map-setup">
|
||||
<h2>{heading}</h2>
|
||||
{canManage ? (
|
||||
<div className="location-map-setup-rows" role="group" aria-label="Map setup details">
|
||||
<div className="location-map-setup-row">
|
||||
<span>Zones</span>
|
||||
<strong>{zoneCount}</strong>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{hasAnyMap && canManage ? (
|
||||
<div className="location-map-setup-actions">
|
||||
<button type="button" className="btn-primary" onClick={onContinue} disabled={saving}>
|
||||
Continue Editing
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" onClick={onPreview} disabled={saving}>
|
||||
View Draft
|
||||
</button>
|
||||
<button type="button" className="btn-primary" onClick={onPublish} disabled={saving || !canManage}>
|
||||
{publishLabel}
|
||||
</button>
|
||||
</div>
|
||||
) : !hasAnyMap && canManage ? (
|
||||
<div className="location-map-setup-actions">
|
||||
{!hasZones ? (
|
||||
<button type="button" className="btn-primary" onClick={onCreateBlank} disabled={saving}>
|
||||
{createBlankLabel}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className="btn-primary" onClick={onCreateFromZones} disabled={saving}>
|
||||
{createZonesButtonLabel}
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" onClick={onCreateBlank} disabled={saving}>
|
||||
{createBlankLabel}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
38
frontend/src/components/maps/LocationMapStateViews.jsx
Normal file
38
frontend/src/components/maps/LocationMapStateViews.jsx
Normal file
@ -0,0 +1,38 @@
|
||||
import LocationMapTopbar from "./LocationMapTopbar";
|
||||
|
||||
export function LocationMapMessageState({ message = "", location, status = "Loading", onBack }) {
|
||||
const showTopbar = Boolean(location && onBack);
|
||||
|
||||
return (
|
||||
<div className="location-map-page">
|
||||
{showTopbar ? (
|
||||
<LocationMapTopbar location={location} status={status} onBack={onBack} />
|
||||
) : null}
|
||||
<main className="location-map-workspace location-map-message-workspace">
|
||||
<section className="location-map-setup location-map-message-card" aria-live="polite">
|
||||
<h2>{status}</h2>
|
||||
{message ? <p className="location-map-state-message">{message}</p> : null}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LocationMapLoadErrorState({ location, loadError, onBack, onRetry }) {
|
||||
return (
|
||||
<div className="location-map-page">
|
||||
<LocationMapTopbar location={location} status="Load Error" onBack={onBack} />
|
||||
<main className="location-map-workspace">
|
||||
<section className="location-map-setup location-map-load-error" role="alert" aria-live="polite">
|
||||
<h2>Map Unavailable</h2>
|
||||
<p className="location-map-state-message">{loadError}</p>
|
||||
<div className="location-map-setup-actions">
|
||||
<button type="button" className="btn-primary" onClick={onRetry}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
frontend/src/components/maps/LocationMapToolbar.jsx
Normal file
161
frontend/src/components/maps/LocationMapToolbar.jsx
Normal file
@ -0,0 +1,161 @@
|
||||
import { MAX_MAP_ZOOM, MIN_MAP_ZOOM, clampMapZoom } from "../../lib/locationMapUtils";
|
||||
|
||||
function ToolbarIcon({ name }) {
|
||||
if (name === "undo") {
|
||||
return (
|
||||
<svg className="location-map-toolbar-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M9 7H4v5" />
|
||||
<path d="M5 11a7 7 0 1 0 2-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === "redo") {
|
||||
return (
|
||||
<svg className="location-map-toolbar-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M15 7h5v5" />
|
||||
<path d="M19 11a7 7 0 1 1-2-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === "zoom-in") {
|
||||
return (
|
||||
<svg className="location-map-toolbar-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M11 5v12" />
|
||||
<path d="M5 11h12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === "fit") {
|
||||
return (
|
||||
<svg className="location-map-toolbar-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8 4H4v4" />
|
||||
<path d="M16 4h4v4" />
|
||||
<path d="M20 16v4h-4" />
|
||||
<path d="M8 20H4v-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg className="location-map-toolbar-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M5 11h12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LocationMapToolbar({
|
||||
mode,
|
||||
hasAnyMap,
|
||||
canManage,
|
||||
saving,
|
||||
history = [],
|
||||
future = [],
|
||||
zoom,
|
||||
setZoom,
|
||||
onFit,
|
||||
onView,
|
||||
onEdit,
|
||||
onUndo,
|
||||
onRedo,
|
||||
}) {
|
||||
const isAtMinZoom = zoom <= MIN_MAP_ZOOM;
|
||||
const isAtMaxZoom = zoom >= MAX_MAP_ZOOM;
|
||||
const canUseHistoryControls = mode === "edit" && canManage && hasAnyMap;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`location-map-toolbar ${canUseHistoryControls ? "has-history-slot" : ""}`}
|
||||
aria-label="Map controls"
|
||||
>
|
||||
<div className="location-map-mode-group">
|
||||
<div className="location-map-mode-buttons" aria-label="Map mode">
|
||||
<button
|
||||
type="button"
|
||||
className={mode === "view" ? "active" : ""}
|
||||
disabled={saving || !hasAnyMap}
|
||||
onClick={onView}
|
||||
aria-pressed={mode === "view"}
|
||||
>
|
||||
View
|
||||
</button>
|
||||
{canManage ? (
|
||||
<button
|
||||
type="button"
|
||||
className={mode === "edit" ? "active" : ""}
|
||||
disabled={saving || !hasAnyMap}
|
||||
onClick={onEdit}
|
||||
aria-pressed={mode === "edit"}
|
||||
aria-label="Edit Draft"
|
||||
>
|
||||
<span className="location-map-label-full">Edit Draft</span>
|
||||
<span className="location-map-label-short">Edit</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{canUseHistoryControls ? (
|
||||
<div
|
||||
className="location-map-history-buttons"
|
||||
aria-label="Edit history"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="location-map-icon-button"
|
||||
onClick={onUndo}
|
||||
disabled={!canUseHistoryControls || saving || history.length === 0}
|
||||
aria-label="Undo"
|
||||
title="Undo"
|
||||
>
|
||||
<ToolbarIcon name="undo" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="location-map-icon-button"
|
||||
onClick={onRedo}
|
||||
disabled={!canUseHistoryControls || saving || future.length === 0}
|
||||
aria-label="Redo"
|
||||
title="Redo"
|
||||
>
|
||||
<ToolbarIcon name="redo" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="location-map-zoom-controls" aria-label="Zoom controls">
|
||||
<button
|
||||
type="button"
|
||||
className="location-map-icon-button"
|
||||
onClick={() => setZoom((value) => Number(clampMapZoom(value - 0.15).toFixed(2)))}
|
||||
disabled={!hasAnyMap || isAtMinZoom}
|
||||
aria-label="Zoom out"
|
||||
title="Zoom out"
|
||||
>
|
||||
<ToolbarIcon name="zoom-out" />
|
||||
</button>
|
||||
<span>{Math.round(zoom * 100)}%</span>
|
||||
<button
|
||||
type="button"
|
||||
className="location-map-icon-button"
|
||||
onClick={() => setZoom((value) => Number(clampMapZoom(value + 0.15).toFixed(2)))}
|
||||
disabled={!hasAnyMap || isAtMaxZoom}
|
||||
aria-label="Zoom in"
|
||||
title="Zoom in"
|
||||
>
|
||||
<ToolbarIcon name="zoom-in" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="location-map-icon-button"
|
||||
onClick={onFit}
|
||||
disabled={!hasAnyMap}
|
||||
aria-label="Fit"
|
||||
title="Fit"
|
||||
>
|
||||
<ToolbarIcon name="fit" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
29
frontend/src/components/maps/LocationMapTopbar.jsx
Normal file
29
frontend/src/components/maps/LocationMapTopbar.jsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { getLocationName, getStoreName } from "../../lib/locationMapUtils";
|
||||
|
||||
export default function LocationMapTopbar({ location, status, onBack }) {
|
||||
const statusClass = status.toLowerCase().replace(/\s+/g, "-");
|
||||
|
||||
return (
|
||||
<header className="location-map-topbar">
|
||||
<button
|
||||
type="button"
|
||||
className="location-map-back"
|
||||
onClick={onBack}
|
||||
aria-label="Back to grocery list"
|
||||
>
|
||||
<span aria-hidden="true">←</span>
|
||||
</button>
|
||||
<div className="location-map-title">
|
||||
<h1>{getStoreName(location)}</h1>
|
||||
<span>{getLocationName(location)}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`location-map-status location-map-status-${statusClass}`}
|
||||
role="status"
|
||||
aria-label={`Map status: ${status}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,15 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import "../../styles/ConfirmBuyModal.css";
|
||||
|
||||
function ChevronIcon({ direction }) {
|
||||
const path = direction === "previous" ? "M15 18l-6-6 6-6" : "M9 18l6-6-6-6";
|
||||
return (
|
||||
<svg className="confirm-buy-nav-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d={path} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConfirmBuyModal({
|
||||
item,
|
||||
onConfirm,
|
||||
@ -11,6 +20,7 @@ export default function ConfirmBuyModal({
|
||||
const [quantity, setQuantity] = useState(item.quantity);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const maxQuantity = item.quantity;
|
||||
const titleId = "confirm-buy-modal-title";
|
||||
|
||||
useEffect(() => {
|
||||
setQuantity(item.quantity);
|
||||
@ -21,17 +31,17 @@ export default function ConfirmBuyModal({
|
||||
const hasPrev = currentIndex > 0;
|
||||
const hasNext = currentIndex < allItems.length - 1;
|
||||
|
||||
const handleIncrement = () => {
|
||||
if (!isSubmitting && quantity < maxQuantity) {
|
||||
setQuantity((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
const handleIncrement = useCallback(() => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
const handleDecrement = () => {
|
||||
if (!isSubmitting && quantity > 1) {
|
||||
setQuantity((prev) => prev - 1);
|
||||
}
|
||||
};
|
||||
setQuantity((prev) => Math.min(maxQuantity, prev + 1));
|
||||
}, [isSubmitting, maxQuantity]);
|
||||
|
||||
const handleDecrement = useCallback(() => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
setQuantity((prev) => Math.max(1, prev - 1));
|
||||
}, [isSubmitting]);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (isSubmitting) return;
|
||||
@ -44,21 +54,58 @@ export default function ConfirmBuyModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
const handlePrev = useCallback(() => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
if (hasPrev && onNavigate) {
|
||||
onNavigate(allItems[currentIndex - 1]);
|
||||
}
|
||||
};
|
||||
}, [allItems, currentIndex, hasPrev, isSubmitting, onNavigate]);
|
||||
|
||||
const handleNext = () => {
|
||||
const handleNext = useCallback(() => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
if (hasNext && onNavigate) {
|
||||
onNavigate(allItems[currentIndex + 1]);
|
||||
}
|
||||
};
|
||||
}, [allItems, currentIndex, hasNext, isSubmitting, onNavigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event) => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
if (event.key === "Escape") {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
handlePrev();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
handleNext();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
handleIncrement();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
handleDecrement();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleDecrement, handleIncrement, handleNext, handlePrev, isSubmitting, onCancel]);
|
||||
|
||||
const imageUrl = item.item_image && item.image_mime_type
|
||||
? `data:${item.image_mime_type};base64,${item.item_image}`
|
||||
@ -73,46 +120,62 @@ export default function ConfirmBuyModal({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="confirm-buy-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<div
|
||||
className="confirm-buy-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="confirm-buy-header">
|
||||
{item.zone && <div className="confirm-buy-zone">{item.zone}</div>}
|
||||
<h2 className="confirm-buy-item-name">{item.item_name}</h2>
|
||||
<h2 id={titleId} className="confirm-buy-item-name">{item.item_name}</h2>
|
||||
</div>
|
||||
|
||||
<div className="confirm-buy-image-section">
|
||||
<button
|
||||
type="button"
|
||||
className="confirm-buy-nav-btn confirm-buy-nav-prev"
|
||||
onClick={handlePrev}
|
||||
style={{ visibility: hasPrev ? "visible" : "hidden" }}
|
||||
disabled={!hasPrev || isSubmitting}
|
||||
aria-label="Previous item"
|
||||
title="Previous item"
|
||||
>
|
||||
{"<"}
|
||||
<ChevronIcon direction="previous" />
|
||||
</button>
|
||||
|
||||
<div className="confirm-buy-image-container">
|
||||
{imageUrl ? (
|
||||
<img src={imageUrl} alt={item.item_name} className="confirm-buy-image" />
|
||||
) : (
|
||||
<div className="confirm-buy-image-placeholder">[ ]</div>
|
||||
<div className="confirm-buy-image-placeholder" role="img" aria-label="No item photo">
|
||||
<span aria-hidden="true">{"\uD83D\uDCE6"}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="confirm-buy-nav-btn confirm-buy-nav-next"
|
||||
onClick={handleNext}
|
||||
style={{ visibility: hasNext ? "visible" : "hidden" }}
|
||||
disabled={!hasNext || isSubmitting}
|
||||
aria-label="Next item"
|
||||
title="Next item"
|
||||
>
|
||||
{">"}
|
||||
<ChevronIcon direction="next" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="confirm-buy-quantity-section">
|
||||
<div className="confirm-buy-counter">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDecrement}
|
||||
className="confirm-buy-counter-btn"
|
||||
disabled={quantity <= 1 || isSubmitting}
|
||||
aria-label="Decrease quantity"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
@ -121,11 +184,14 @@ export default function ConfirmBuyModal({
|
||||
value={quantity}
|
||||
readOnly
|
||||
className="confirm-buy-counter-display"
|
||||
aria-label="Quantity to mark bought"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleIncrement}
|
||||
className="confirm-buy-counter-btn"
|
||||
disabled={quantity >= maxQuantity || isSubmitting}
|
||||
aria-label="Increase quantity"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
@ -133,10 +199,10 @@ export default function ConfirmBuyModal({
|
||||
</div>
|
||||
|
||||
<div className="confirm-buy-actions">
|
||||
<button onClick={onCancel} className="confirm-buy-cancel" disabled={isSubmitting}>
|
||||
<button type="button" onClick={onCancel} className="confirm-buy-cancel" disabled={isSubmitting}>
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleConfirm} className="confirm-buy-confirm" disabled={isSubmitting}>
|
||||
<button type="button" onClick={handleConfirm} className="confirm-buy-confirm" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Mark as Bought"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useContext, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { StoreContext } from '../../context/StoreContext';
|
||||
import '../../styles/components/StoreTabs.css';
|
||||
|
||||
@ -55,6 +55,7 @@ function buildStoreOptions(stores) {
|
||||
export default function StoreTabs() {
|
||||
const { stores, activeStore, setActiveStore, loading } = useContext(StoreContext);
|
||||
const navigate = useNavigate();
|
||||
const routeLocation = useLocation();
|
||||
const [activePicker, setActivePicker] = useState(null);
|
||||
const storeOptions = useMemo(() => buildStoreOptions(stores || []), [stores]);
|
||||
const activeStoreKey = getStoreKey(activeStore);
|
||||
@ -98,8 +99,12 @@ 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`, {
|
||||
state: {
|
||||
returnTo: `${routeLocation.pathname}${routeLocation.search}${routeLocation.hash}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@ -1 +1,41 @@
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:5000";
|
||||
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||
|
||||
function trimTrailingSlash(value: string) {
|
||||
return value.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function getDefaultApiBaseUrl() {
|
||||
if (typeof window === "undefined") {
|
||||
return "http://localhost:5000";
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "https:" : "http:";
|
||||
return `${protocol}//${window.location.hostname}:5000`;
|
||||
}
|
||||
|
||||
function shouldUseBrowserHostForApi(url: URL) {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return LOOPBACK_HOSTS.has(url.hostname) && !LOOPBACK_HOSTS.has(window.location.hostname);
|
||||
}
|
||||
|
||||
function resolveApiBaseUrl() {
|
||||
const configuredApiUrl = import.meta.env.VITE_API_URL?.trim();
|
||||
if (!configuredApiUrl) {
|
||||
return getDefaultApiBaseUrl();
|
||||
}
|
||||
|
||||
try {
|
||||
const apiUrl = new URL(configuredApiUrl);
|
||||
if (shouldUseBrowserHostForApi(apiUrl)) {
|
||||
apiUrl.hostname = window.location.hostname;
|
||||
}
|
||||
return trimTrailingSlash(apiUrl.toString());
|
||||
} catch {
|
||||
return trimTrailingSlash(configuredApiUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export const API_BASE_URL = resolveApiBaseUrl();
|
||||
|
||||
@ -84,7 +84,6 @@ export const StoreProvider = ({ children }) => {
|
||||
useEffect(() => {
|
||||
if (!activeHousehold || stores.length === 0) return;
|
||||
|
||||
console.log('[StoreContext] Setting active store from:', stores);
|
||||
const householdStoreStorageKey = `activeHouseholdStoreId_${activeHousehold.id}`;
|
||||
const legacyLocationStorageKey = `activeStoreId_${activeHousehold.id}`;
|
||||
const savedHouseholdStoreId = localStorage.getItem(householdStoreStorageKey);
|
||||
@ -94,7 +93,6 @@ export const StoreProvider = ({ children }) => {
|
||||
chooseLocationByStoredIds(stores, activeHousehold.id, savedHouseholdStoreId) ||
|
||||
chooseStoreByHouseholdStoreId(stores, savedHouseholdStoreId);
|
||||
if (store) {
|
||||
console.log('[StoreContext] Found saved household store:', store);
|
||||
setActiveStoreState(store);
|
||||
return;
|
||||
}
|
||||
@ -105,7 +103,6 @@ export const StoreProvider = ({ children }) => {
|
||||
const savedLocation = stores.find(s => String(s.id) === String(savedLocationId));
|
||||
const store = savedLocation || chooseStoreByHouseholdStoreId(stores, getHouseholdStoreKey(savedLocation));
|
||||
if (store) {
|
||||
console.log('[StoreContext] Migrated saved location to store:', store);
|
||||
const householdStoreId = getHouseholdStoreKey(store);
|
||||
setActiveStoreState(store);
|
||||
localStorage.setItem(householdStoreStorageKey, householdStoreId);
|
||||
@ -122,7 +119,6 @@ export const StoreProvider = ({ children }) => {
|
||||
stores,
|
||||
getHouseholdStoreKey(stores.find(s => s.is_default) || stores[0])
|
||||
);
|
||||
console.log('[StoreContext] Using store:', defaultStore);
|
||||
setActiveStoreState(defaultStore);
|
||||
const householdStoreId = getHouseholdStoreKey(defaultStore);
|
||||
localStorage.setItem(householdStoreStorageKey, householdStoreId);
|
||||
@ -146,9 +142,7 @@ export const StoreProvider = ({ children }) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
console.log('[StoreContext] Loading stores for household:', activeHousehold.id);
|
||||
const response = await getHouseholdStores(activeHousehold.id);
|
||||
console.log('[StoreContext] Loaded stores:', response.data);
|
||||
setStores(response.data);
|
||||
} catch (err) {
|
||||
console.error('[StoreContext] Failed to load stores:', err);
|
||||
|
||||
145
frontend/src/hooks/useLocationMapBuying.js
Normal file
145
frontend/src/hooks/useLocationMapBuying.js
Normal file
@ -0,0 +1,145 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getItemByName, markBought } from "../api/list";
|
||||
import getApiErrorMessage from "../lib/getApiErrorMessage";
|
||||
|
||||
const EMPTY_MAP_ITEMS = [];
|
||||
|
||||
function getNextMapBuyItem(items, currentIndex, excludedItemId) {
|
||||
const remainingItems = items.filter((item) => item.id !== excludedItemId);
|
||||
if (!remainingItems.length) return null;
|
||||
return remainingItems[Math.min(Math.max(currentIndex, 0), remainingItems.length - 1)];
|
||||
}
|
||||
|
||||
function getLocallyReducedMapItem(item, quantity) {
|
||||
const currentQuantity = Number(item.quantity || 1);
|
||||
const boughtQuantity = Math.max(1, Number(quantity || 1));
|
||||
return {
|
||||
...item,
|
||||
quantity: Math.max(1, currentQuantity - boughtQuantity),
|
||||
};
|
||||
}
|
||||
|
||||
function getLocallyBoughtMapItem(item) {
|
||||
return {
|
||||
...item,
|
||||
bought: true,
|
||||
};
|
||||
}
|
||||
|
||||
export default function useLocationMapBuying({
|
||||
activeHouseholdId,
|
||||
locationId,
|
||||
selectedObject,
|
||||
selectedZoneItems = EMPTY_MAP_ITEMS,
|
||||
setMapState,
|
||||
toast,
|
||||
visibleUnmappedItems = EMPTY_MAP_ITEMS,
|
||||
}) {
|
||||
const [buyModalItem, setBuyModalItem] = useState(null);
|
||||
const mapBuyScopeRef = useRef({ activeHouseholdId: null, locationId: null });
|
||||
const buyModalItems = useMemo(
|
||||
() => (selectedObject ? selectedZoneItems : visibleUnmappedItems).filter((item) => !item.bought),
|
||||
[selectedObject, selectedZoneItems, visibleUnmappedItems]
|
||||
);
|
||||
|
||||
const updateMapItems = useCallback((updater) => {
|
||||
setMapState((currentState) => {
|
||||
if (!currentState) return currentState;
|
||||
const currentItems = currentState.items || EMPTY_MAP_ITEMS;
|
||||
return { ...currentState, items: updater(currentItems) };
|
||||
});
|
||||
}, [setMapState]);
|
||||
|
||||
useEffect(() => {
|
||||
mapBuyScopeRef.current = {
|
||||
activeHouseholdId,
|
||||
locationId,
|
||||
};
|
||||
}, [activeHouseholdId, locationId]);
|
||||
|
||||
useEffect(() => {
|
||||
setBuyModalItem(null);
|
||||
}, [activeHouseholdId, locationId]);
|
||||
|
||||
const isCurrentMapBuyScope = useCallback((scopedHouseholdId, scopedLocationId) => (
|
||||
String(mapBuyScopeRef.current.activeHouseholdId || "") === String(scopedHouseholdId || "") &&
|
||||
String(mapBuyScopeRef.current.locationId || "") === String(scopedLocationId || "")
|
||||
), []);
|
||||
|
||||
const handleMapBuyCancel = useCallback(() => {
|
||||
setBuyModalItem(null);
|
||||
}, []);
|
||||
|
||||
const handleMapBuyNavigate = useCallback((item) => {
|
||||
setBuyModalItem(item);
|
||||
}, []);
|
||||
|
||||
const handleMapBuyConfirm = useCallback(async (quantity) => {
|
||||
if (!activeHouseholdId || !locationId || !buyModalItem) {
|
||||
setBuyModalItem(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const scopedHouseholdId = activeHouseholdId;
|
||||
const scopedLocationId = locationId;
|
||||
const item = buyModalItems.find((candidate) => candidate.id === buyModalItem.id) || buyModalItem;
|
||||
|
||||
try {
|
||||
const currentIndex = buyModalItems.findIndex((candidate) => candidate.id === item.id);
|
||||
const resolvedIndex = currentIndex >= 0 ? currentIndex : 0;
|
||||
|
||||
await markBought(scopedHouseholdId, scopedLocationId, item.item_name, quantity, true);
|
||||
if (!isCurrentMapBuyScope(scopedHouseholdId, scopedLocationId)) return;
|
||||
|
||||
let nextBuyModalItems = buyModalItems;
|
||||
if (quantity >= item.quantity) {
|
||||
nextBuyModalItems = buyModalItems.filter((candidate) => candidate.id !== item.id);
|
||||
updateMapItems((currentItems) =>
|
||||
currentItems.map((candidate) => (
|
||||
candidate.id === item.id ? getLocallyBoughtMapItem(candidate) : candidate
|
||||
))
|
||||
);
|
||||
} else {
|
||||
let updatedItem;
|
||||
try {
|
||||
const response = await getItemByName(scopedHouseholdId, scopedLocationId, item.item_name);
|
||||
if (!isCurrentMapBuyScope(scopedHouseholdId, scopedLocationId)) return;
|
||||
updatedItem = response.data || getLocallyReducedMapItem(item, quantity);
|
||||
} catch {
|
||||
if (!isCurrentMapBuyScope(scopedHouseholdId, scopedLocationId)) return;
|
||||
updatedItem = getLocallyReducedMapItem(item, quantity);
|
||||
toast.info("Updated item locally", "Latest quantity will sync when the map reloads.");
|
||||
}
|
||||
nextBuyModalItems = buyModalItems.map((candidate) => (candidate.id === item.id ? updatedItem : candidate));
|
||||
|
||||
updateMapItems((currentItems) =>
|
||||
currentItems.map((candidate) => (candidate.id === item.id ? updatedItem : candidate))
|
||||
);
|
||||
}
|
||||
setBuyModalItem(getNextMapBuyItem(nextBuyModalItems, resolvedIndex, item.id));
|
||||
|
||||
toast.success("Marked item bought", `Marked item ${item.item_name} as bought`);
|
||||
} catch (error) {
|
||||
if (!isCurrentMapBuyScope(scopedHouseholdId, scopedLocationId)) return;
|
||||
const message = getApiErrorMessage(error, "Failed to mark item as bought");
|
||||
toast.error("Mark item bought failed", `Mark item bought failed: ${message}`);
|
||||
}
|
||||
}, [
|
||||
activeHouseholdId,
|
||||
buyModalItem,
|
||||
buyModalItems,
|
||||
isCurrentMapBuyScope,
|
||||
locationId,
|
||||
toast,
|
||||
updateMapItems,
|
||||
]);
|
||||
|
||||
return {
|
||||
buyModalItem,
|
||||
buyModalItems,
|
||||
handleMapBuyCancel,
|
||||
handleMapBuyConfirm,
|
||||
handleMapBuyNavigate,
|
||||
setBuyModalItem,
|
||||
};
|
||||
}
|
||||
83
frontend/src/hooks/useLocationMapDerivedState.js
Normal file
83
frontend/src/hooks/useLocationMapDerivedState.js
Normal file
@ -0,0 +1,83 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
DEFAULT_MAP_SIZE,
|
||||
getObjectKey,
|
||||
itemMatchesMapFilters,
|
||||
itemsForZone,
|
||||
mapForMode,
|
||||
unmappedItems,
|
||||
} from "../lib/locationMapUtils";
|
||||
|
||||
const EMPTY_MAP_ITEMS = [];
|
||||
|
||||
export default function useLocationMapDerivedState({
|
||||
filters,
|
||||
mapDraft,
|
||||
mapState,
|
||||
mode,
|
||||
objects,
|
||||
previewDraft,
|
||||
selectedObjectKey,
|
||||
username,
|
||||
}) {
|
||||
const activeMap = useMemo(
|
||||
() => mapForMode(mapState, mode, previewDraft),
|
||||
[mapState, mode, previewDraft]
|
||||
);
|
||||
const mapItems = mapState?.items || EMPTY_MAP_ITEMS;
|
||||
const selectedObject = useMemo(
|
||||
() => objects.find((object) => getObjectKey(object) === selectedObjectKey) || null,
|
||||
[objects, selectedObjectKey]
|
||||
);
|
||||
const selectedZoneItems = useMemo(
|
||||
() => selectedObject?.zone_id
|
||||
? itemsForZone(mapItems, selectedObject.zone_id, filters, username)
|
||||
: EMPTY_MAP_ITEMS,
|
||||
[filters, mapItems, selectedObject?.zone_id, username]
|
||||
);
|
||||
const visibleUnmappedItems = useMemo(
|
||||
() => unmappedItems(mapItems, filters, username),
|
||||
[filters, mapItems, username]
|
||||
);
|
||||
const { visibleAssignedItemCount, unmappedItemCount } = useMemo(() => {
|
||||
let nextVisibleAssignedItemCount = 0;
|
||||
let nextUnmappedItemCount = 0;
|
||||
|
||||
mapItems.forEach((item) => {
|
||||
if (!item.zone_id) {
|
||||
nextUnmappedItemCount += 1;
|
||||
return;
|
||||
}
|
||||
if (itemMatchesMapFilters(item, filters, username)) {
|
||||
nextVisibleAssignedItemCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
visibleAssignedItemCount: nextVisibleAssignedItemCount,
|
||||
unmappedItemCount: nextUnmappedItemCount,
|
||||
};
|
||||
}, [filters, mapItems, username]);
|
||||
const hiddenAreaCount = useMemo(
|
||||
() => objects.filter((object) => object.visible === false).length,
|
||||
[objects]
|
||||
);
|
||||
const mapSize = useMemo(
|
||||
() => ({
|
||||
width: mapDraft.width || activeMap?.width || DEFAULT_MAP_SIZE.width,
|
||||
height: mapDraft.height || activeMap?.height || DEFAULT_MAP_SIZE.height,
|
||||
}),
|
||||
[activeMap?.height, activeMap?.width, mapDraft.height, mapDraft.width]
|
||||
);
|
||||
|
||||
return {
|
||||
hiddenAreaCount,
|
||||
mapItems,
|
||||
mapSize,
|
||||
selectedObject,
|
||||
selectedZoneItems,
|
||||
unmappedItemCount,
|
||||
visibleAssignedItemCount,
|
||||
visibleUnmappedItems,
|
||||
};
|
||||
}
|
||||
144
frontend/src/hooks/useLocationMapDraftActions.js
Normal file
144
frontend/src/hooks/useLocationMapDraftActions.js
Normal file
@ -0,0 +1,144 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
createBlankLocationMap,
|
||||
createLocationMapFromZones,
|
||||
publishLocationMapDraft,
|
||||
saveLocationMapDraft,
|
||||
} from "../api/locationMaps";
|
||||
import getApiErrorMessage from "../lib/getApiErrorMessage";
|
||||
import { getObjectKey, prepareObjectsForSave } from "../lib/locationMapUtils";
|
||||
|
||||
export default function useLocationMapDraftActions({
|
||||
activeHouseholdId,
|
||||
beginSaving,
|
||||
endSaving,
|
||||
hasUnsavedChanges,
|
||||
locationId,
|
||||
mapDraft,
|
||||
objects,
|
||||
selectedObjectKey,
|
||||
setMapState,
|
||||
setMode,
|
||||
setPreviewDraft,
|
||||
syncMapDraftFromState,
|
||||
toast,
|
||||
}) {
|
||||
const latestScopeRef = useRef({ activeHouseholdId, locationId });
|
||||
const selectedObjectIndex = selectedObjectKey
|
||||
? objects.findIndex((object) => getObjectKey(object) === selectedObjectKey)
|
||||
: -1;
|
||||
|
||||
useEffect(() => {
|
||||
latestScopeRef.current = { activeHouseholdId, locationId };
|
||||
}, [activeHouseholdId, locationId]);
|
||||
|
||||
const isCurrentScope = useCallback(() => (
|
||||
String(latestScopeRef.current.activeHouseholdId ?? "") === String(activeHouseholdId ?? "") &&
|
||||
String(latestScopeRef.current.locationId ?? "") === String(locationId ?? "")
|
||||
), [activeHouseholdId, locationId]);
|
||||
|
||||
const applyDraftResponse = useCallback((response, nextMode = "edit", nextPreviewDraft = true, options = {}) => {
|
||||
setMapState(response.data);
|
||||
setMode(nextMode);
|
||||
setPreviewDraft(nextPreviewDraft);
|
||||
syncMapDraftFromState(response.data, nextMode, nextPreviewDraft, options);
|
||||
}, [setMapState, setMode, setPreviewDraft, syncMapDraftFromState]);
|
||||
|
||||
const handleCreateBlank = useCallback(async () => {
|
||||
if (!activeHouseholdId || !locationId) return;
|
||||
beginSaving("create-blank");
|
||||
try {
|
||||
const response = await createBlankLocationMap(activeHouseholdId, locationId, mapDraft);
|
||||
if (!isCurrentScope()) return;
|
||||
applyDraftResponse(response);
|
||||
toast.success("Created map", "Blank draft map created");
|
||||
} catch (error) {
|
||||
if (!isCurrentScope()) return;
|
||||
toast.error("Create map failed", getApiErrorMessage(error, "Failed to create map"));
|
||||
} finally {
|
||||
if (isCurrentScope()) endSaving();
|
||||
}
|
||||
}, [
|
||||
activeHouseholdId, applyDraftResponse, beginSaving, endSaving, isCurrentScope,
|
||||
locationId, mapDraft, toast,
|
||||
]);
|
||||
|
||||
const handleCreateFromZones = useCallback(async () => {
|
||||
if (!activeHouseholdId || !locationId) return;
|
||||
beginSaving("create-zones");
|
||||
try {
|
||||
const response = await createLocationMapFromZones(activeHouseholdId, locationId, mapDraft);
|
||||
if (!isCurrentScope()) return;
|
||||
applyDraftResponse(response);
|
||||
toast.success("Created starter map", "Zones were added as editable rectangles");
|
||||
} catch (error) {
|
||||
if (!isCurrentScope()) return;
|
||||
toast.error("Create map failed", getApiErrorMessage(error, "Failed to create map from zones"));
|
||||
} finally {
|
||||
if (isCurrentScope()) endSaving();
|
||||
}
|
||||
}, [
|
||||
activeHouseholdId, applyDraftResponse, beginSaving, endSaving, isCurrentScope,
|
||||
locationId, mapDraft, toast,
|
||||
]);
|
||||
|
||||
const handleSaveDraft = useCallback(async () => {
|
||||
if (!activeHouseholdId || !locationId) return;
|
||||
beginSaving("save");
|
||||
try {
|
||||
const response = await saveLocationMapDraft(activeHouseholdId, locationId, {
|
||||
map: mapDraft,
|
||||
objects: prepareObjectsForSave(objects),
|
||||
});
|
||||
if (!isCurrentScope()) return;
|
||||
applyDraftResponse(response, "edit", true, { preserveSelectedIndex: selectedObjectIndex });
|
||||
toast.success("Saved draft", "Map draft saved");
|
||||
} catch (error) {
|
||||
if (!isCurrentScope()) return;
|
||||
toast.error("Save draft failed", getApiErrorMessage(error, "Failed to save map draft"));
|
||||
} finally {
|
||||
if (isCurrentScope()) endSaving();
|
||||
}
|
||||
}, [
|
||||
activeHouseholdId, applyDraftResponse, beginSaving, endSaving, isCurrentScope, locationId,
|
||||
mapDraft, objects, selectedObjectIndex, toast,
|
||||
]);
|
||||
|
||||
const handlePublish = useCallback(async () => {
|
||||
if (!activeHouseholdId || !locationId) return;
|
||||
beginSaving("publish");
|
||||
let savedPendingDraft = false;
|
||||
try {
|
||||
if (hasUnsavedChanges) {
|
||||
const saveResponse = await saveLocationMapDraft(activeHouseholdId, locationId, {
|
||||
map: mapDraft,
|
||||
objects: prepareObjectsForSave(objects),
|
||||
});
|
||||
if (!isCurrentScope()) return;
|
||||
savedPendingDraft = true;
|
||||
applyDraftResponse(saveResponse, "edit", true, { preserveSelectedIndex: selectedObjectIndex });
|
||||
}
|
||||
|
||||
const response = await publishLocationMapDraft(activeHouseholdId, locationId);
|
||||
if (!isCurrentScope()) return;
|
||||
applyDraftResponse(response, "view", false, { preserveSelectedIndex: selectedObjectIndex });
|
||||
toast.success(
|
||||
"Published map",
|
||||
hasUnsavedChanges
|
||||
? "Latest edits were saved and published"
|
||||
: "Map is now visible to household members"
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isCurrentScope()) return;
|
||||
const message = getApiErrorMessage(error, "Failed to publish map");
|
||||
toast.error("Publish failed", savedPendingDraft ? `${message}. Draft changes were saved.` : message);
|
||||
} finally {
|
||||
if (isCurrentScope()) endSaving();
|
||||
}
|
||||
}, [
|
||||
activeHouseholdId, applyDraftResponse, beginSaving, endSaving, hasUnsavedChanges, isCurrentScope,
|
||||
locationId, mapDraft, objects, selectedObjectIndex, toast,
|
||||
]);
|
||||
|
||||
return { handleCreateBlank, handleCreateFromZones, handlePublish, handleSaveDraft };
|
||||
}
|
||||
134
frontend/src/hooks/useLocationMapDraftState.js
Normal file
134
frontend/src/hooks/useLocationMapDraftState.js
Normal file
@ -0,0 +1,134 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getLocationMap } from "../api/locationMaps";
|
||||
import getApiErrorMessage from "../lib/getApiErrorMessage";
|
||||
import { getDefaultMapDraft, getMapDraftSnapshot } from "../lib/locationMapDraftStateUtils";
|
||||
import { DEFAULT_MAP_FILTERS } from "../lib/locationMapUtils";
|
||||
|
||||
export default function useLocationMapDraftState({
|
||||
activeHousehold,
|
||||
hasLoaded,
|
||||
householdLoading,
|
||||
locationId,
|
||||
toast,
|
||||
}) {
|
||||
const [mapState, setMapState] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingAction, setSavingAction] = useState(null);
|
||||
const [mode, setMode] = useState("setup");
|
||||
const [previewDraft, setPreviewDraft] = useState(false);
|
||||
const [mapDraft, setMapDraft] = useState(getDefaultMapDraft);
|
||||
const [objects, setObjects] = useState([]);
|
||||
const [selectedObjectKey, setSelectedObjectKey] = useState(null);
|
||||
const [filters, setFilters] = useState(DEFAULT_MAP_FILTERS);
|
||||
const [layersOpen, setLayersOpen] = useState(false);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [zoom, setZoom] = useState(0.75);
|
||||
const [history, setHistory] = useState([]);
|
||||
const [future, setFuture] = useState([]);
|
||||
const [dragState, setDragState] = useState(null);
|
||||
const toastRef = useRef(toast);
|
||||
const loadRequestRef = useRef(0);
|
||||
|
||||
const resetDraftState = useCallback(() => {
|
||||
setObjects([]);
|
||||
setSelectedObjectKey(null);
|
||||
setHistory([]);
|
||||
setFuture([]);
|
||||
setHasUnsavedChanges(false);
|
||||
setMapDraft(getDefaultMapDraft());
|
||||
}, []);
|
||||
|
||||
const syncMapDraftFromState = useCallback((nextState, nextMode, nextPreviewDraft, options = {}) => {
|
||||
const snapshot = getMapDraftSnapshot(nextState, nextMode, nextPreviewDraft, options);
|
||||
if (!snapshot) return;
|
||||
|
||||
setMapDraft(snapshot.mapDraft);
|
||||
setObjects(snapshot.objects);
|
||||
setSelectedObjectKey(snapshot.selectedObjectKey);
|
||||
setHistory([]);
|
||||
setFuture([]);
|
||||
setHasUnsavedChanges(false);
|
||||
}, []);
|
||||
|
||||
const loadMap = useCallback(async () => {
|
||||
const requestId = loadRequestRef.current + 1;
|
||||
loadRequestRef.current = requestId;
|
||||
|
||||
if (!activeHousehold?.id || !locationId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
setSaving(false);
|
||||
setSavingAction(null);
|
||||
setFilters({ ...DEFAULT_MAP_FILTERS });
|
||||
setLayersOpen(false);
|
||||
try {
|
||||
const response = await getLocationMap(activeHousehold.id, locationId);
|
||||
if (loadRequestRef.current !== requestId) return;
|
||||
|
||||
const nextState = response.data;
|
||||
const nextCanManage = Boolean(
|
||||
nextState.can_manage ?? ["owner", "admin"].includes(activeHousehold?.role)
|
||||
);
|
||||
setMapState(nextState);
|
||||
|
||||
if (!nextState.draft_map && !nextState.published_map) {
|
||||
setMode("setup");
|
||||
setPreviewDraft(false);
|
||||
resetDraftState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextState.published_map) {
|
||||
setMode("view");
|
||||
setPreviewDraft(false);
|
||||
syncMapDraftFromState(nextState, "view", false);
|
||||
} else if (nextState.draft_map && nextCanManage) {
|
||||
setMode("setup");
|
||||
setPreviewDraft(true);
|
||||
syncMapDraftFromState(nextState, "edit", true);
|
||||
} else {
|
||||
setMode("setup");
|
||||
setPreviewDraft(false);
|
||||
resetDraftState();
|
||||
}
|
||||
} catch (error) {
|
||||
if (loadRequestRef.current !== requestId) return;
|
||||
|
||||
const message = getApiErrorMessage(error, "Failed to load map");
|
||||
toastRef.current.error("Load map failed", message);
|
||||
setMapState(null);
|
||||
resetDraftState();
|
||||
setLoadError(message);
|
||||
} finally {
|
||||
if (loadRequestRef.current === requestId) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [activeHousehold?.id, activeHousehold?.role, locationId, resetDraftState, syncMapDraftFromState]);
|
||||
|
||||
useEffect(() => { toastRef.current = toast; }, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!householdLoading && hasLoaded) {
|
||||
loadMap();
|
||||
}
|
||||
}, [hasLoaded, householdLoading, loadMap]);
|
||||
|
||||
const beginSaving = useCallback((action) => { setSavingAction(action); setSaving(true); }, []);
|
||||
|
||||
const endSaving = useCallback(() => { setSaving(false); setSavingAction(null); }, []);
|
||||
|
||||
return {
|
||||
beginSaving, dragState, endSaving, filters, future, hasUnsavedChanges, history, layersOpen,
|
||||
loadError, loadMap, loading, mapDraft, mapState, mode, objects, previewDraft, saving,
|
||||
savingAction, selectedObjectKey, setDragState, setFilters, setFuture,
|
||||
setHasUnsavedChanges, setHistory, setLayersOpen, setMapState, setMode, setObjects,
|
||||
setMapDraft, setPreviewDraft, setSelectedObjectKey, setZoom, syncMapDraftFromState, zoom,
|
||||
};
|
||||
}
|
||||
190
frontend/src/hooks/useLocationMapObjectActions.js
Normal file
190
frontend/src/hooks/useLocationMapObjectActions.js
Normal file
@ -0,0 +1,190 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
clampObjectToMap,
|
||||
createClientObject,
|
||||
getMapObjectDisplayLabel,
|
||||
getObjectKey,
|
||||
objectZoneId,
|
||||
} from "../lib/locationMapUtils";
|
||||
|
||||
const EMPTY_ZONES = [];
|
||||
const MAP_EDGE_GROW_PADDING = 360;
|
||||
|
||||
function createHistorySnapshot(objects, selectedObjectKey) {
|
||||
return {
|
||||
objects: objects.map((object) => ({ ...object })),
|
||||
selectedObjectKey: selectedObjectKey ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export default function useLocationMapObjectActions({
|
||||
canManage,
|
||||
hiddenAreaCount,
|
||||
mapSize,
|
||||
mapState,
|
||||
objects,
|
||||
selectedObject,
|
||||
selectedObjectKey,
|
||||
setFuture,
|
||||
setHasUnsavedChanges,
|
||||
setHistory,
|
||||
setMapDraft,
|
||||
setObjects,
|
||||
setSelectedObjectKey,
|
||||
toast,
|
||||
}) {
|
||||
const [pendingDeleteObject, setPendingDeleteObject] = useState(null);
|
||||
const zones = mapState?.zones || EMPTY_ZONES;
|
||||
|
||||
const remember = useCallback((currentObjects = objects, currentSelectedObjectKey = selectedObjectKey) => {
|
||||
setHistory((previous) => [
|
||||
...previous.slice(-19),
|
||||
createHistorySnapshot(currentObjects, currentSelectedObjectKey),
|
||||
]);
|
||||
setFuture([]);
|
||||
}, [objects, selectedObjectKey, setFuture, setHistory]);
|
||||
|
||||
const updateObjects = useCallback((updater, bounds = mapSize) => {
|
||||
setHasUnsavedChanges(true);
|
||||
setObjects((currentObjects) =>
|
||||
updater(currentObjects).map((object) => clampObjectToMap(object, bounds))
|
||||
);
|
||||
}, [mapSize, setHasUnsavedChanges, setObjects]);
|
||||
|
||||
const expandMapToFitObject = useCallback((object) => {
|
||||
const shiftX = object.x < 0 ? Math.ceil(Math.abs(object.x) + MAP_EDGE_GROW_PADDING) : 0;
|
||||
const shiftY = object.y < 0 ? Math.ceil(Math.abs(object.y) + MAP_EDGE_GROW_PADDING) : 0;
|
||||
const shiftedObject = {
|
||||
...object,
|
||||
x: Number(object.x || 0) + shiftX,
|
||||
y: Number(object.y || 0) + shiftY,
|
||||
};
|
||||
const nextWidth = Math.max(
|
||||
mapSize.width + shiftX,
|
||||
Math.ceil(shiftedObject.x + Number(object.width || 0) + MAP_EDGE_GROW_PADDING)
|
||||
);
|
||||
const nextHeight = Math.max(
|
||||
mapSize.height + shiftY,
|
||||
Math.ceil(shiftedObject.y + Number(object.height || 0) + MAP_EDGE_GROW_PADDING)
|
||||
);
|
||||
const nextMapSize = { width: nextWidth, height: nextHeight };
|
||||
|
||||
if (nextWidth > mapSize.width || nextHeight > mapSize.height || shiftX > 0 || shiftY > 0) {
|
||||
setMapDraft((currentDraft) => ({
|
||||
...currentDraft,
|
||||
width: Math.max(Number(currentDraft.width || 0), nextWidth),
|
||||
height: Math.max(Number(currentDraft.height || 0), nextHeight),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
|
||||
return { mapSize: nextMapSize, shiftX, shiftY };
|
||||
}, [mapSize.height, mapSize.width, setHasUnsavedChanges, setMapDraft]);
|
||||
|
||||
const handleAddObject = useCallback(() => {
|
||||
if (!canManage) return;
|
||||
remember();
|
||||
const usedZoneIds = new Set(objects.map((object) => objectZoneId(object)).filter(Boolean));
|
||||
const nextZone = zones.find((zone) => !usedZoneIds.has(String(zone.id))) || zones[0];
|
||||
const nextObject = clampObjectToMap(createClientObject(nextZone, objects.length), mapSize);
|
||||
setObjects((currentObjects) => [...currentObjects, nextObject]);
|
||||
setSelectedObjectKey(getObjectKey(nextObject));
|
||||
setHasUnsavedChanges(true);
|
||||
}, [canManage, mapSize, objects, remember, setHasUnsavedChanges, setObjects, setSelectedObjectKey, zones]);
|
||||
|
||||
const handleDuplicateObject = useCallback(() => {
|
||||
if (!selectedObject) return;
|
||||
remember();
|
||||
const displayLabel = getMapObjectDisplayLabel(selectedObject, "Area");
|
||||
const nextObject = {
|
||||
...selectedObject,
|
||||
id: undefined,
|
||||
client_id: `copy-${Date.now()}`,
|
||||
label: `${displayLabel} Copy`,
|
||||
x: selectedObject.x + 28,
|
||||
y: selectedObject.y + 28,
|
||||
sort_order: objects.length + 1,
|
||||
};
|
||||
setObjects((currentObjects) => [...currentObjects, clampObjectToMap(nextObject, mapSize)]);
|
||||
setSelectedObjectKey(getObjectKey(nextObject));
|
||||
setHasUnsavedChanges(true);
|
||||
}, [mapSize, objects.length, remember, selectedObject, setHasUnsavedChanges, setObjects, setSelectedObjectKey]);
|
||||
|
||||
const requestDeleteObject = useCallback(() => {
|
||||
if (selectedObject) setPendingDeleteObject(selectedObject);
|
||||
}, [selectedObject]);
|
||||
|
||||
const handleDeleteObject = useCallback(() => {
|
||||
if (!pendingDeleteObject) return;
|
||||
remember();
|
||||
setObjects((currentObjects) =>
|
||||
currentObjects.filter((object) => getObjectKey(object) !== getObjectKey(pendingDeleteObject))
|
||||
);
|
||||
setSelectedObjectKey(null);
|
||||
setHasUnsavedChanges(true);
|
||||
toast.success("Deleted map area", "Save the draft to keep this change.");
|
||||
setPendingDeleteObject(null);
|
||||
}, [pendingDeleteObject, remember, setHasUnsavedChanges, setObjects, setSelectedObjectKey, toast]);
|
||||
|
||||
const handleShowHiddenAreas = useCallback(() => {
|
||||
if (!canManage || hiddenAreaCount === 0) return;
|
||||
remember();
|
||||
updateObjects((currentObjects) =>
|
||||
currentObjects.map((object) => (
|
||||
object.visible === false ? { ...object, visible: true } : object
|
||||
))
|
||||
);
|
||||
toast.success("Showed hidden areas", "Save the draft to keep this change.");
|
||||
}, [canManage, hiddenAreaCount, remember, toast, updateObjects]);
|
||||
|
||||
const handleObjectField = useCallback((field, value) => {
|
||||
if (!selectedObject) return;
|
||||
if (selectedObject[field] === value) return;
|
||||
|
||||
remember();
|
||||
updateObjects((currentObjects) =>
|
||||
currentObjects.map((object) => (
|
||||
getObjectKey(object) === getObjectKey(selectedObject) ? { ...object, [field]: value } : object
|
||||
))
|
||||
);
|
||||
}, [remember, selectedObject, updateObjects]);
|
||||
|
||||
const handleZoneLinkChange = useCallback((zoneId) => {
|
||||
if (!selectedObject) return;
|
||||
const zone = zones.find((candidate) => String(candidate.id) === String(zoneId));
|
||||
const nextZoneId = zone?.id || null;
|
||||
const nextZoneName = zone?.name || null;
|
||||
const nextLabel = selectedObject.label ?? zone?.name ?? "";
|
||||
if (
|
||||
(selectedObject.zone_id || null) === nextZoneId &&
|
||||
(selectedObject.zone_name || null) === nextZoneName &&
|
||||
selectedObject.label === nextLabel
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
remember();
|
||||
updateObjects((currentObjects) =>
|
||||
currentObjects.map((object) => (
|
||||
getObjectKey(object) === selectedObjectKey
|
||||
? { ...object, zone_id: nextZoneId, zone_name: nextZoneName, label: object.label ?? zone?.name ?? "" }
|
||||
: object
|
||||
))
|
||||
);
|
||||
}, [remember, selectedObject, selectedObjectKey, updateObjects, zones]);
|
||||
|
||||
return {
|
||||
handleAddObject,
|
||||
handleDeleteObject,
|
||||
handleDuplicateObject,
|
||||
handleObjectField,
|
||||
handleShowHiddenAreas,
|
||||
handleZoneLinkChange,
|
||||
expandMapToFitObject,
|
||||
pendingDeleteObject,
|
||||
remember,
|
||||
requestDeleteObject,
|
||||
setPendingDeleteObject,
|
||||
updateObjects,
|
||||
};
|
||||
}
|
||||
220
frontend/src/hooks/useLocationMapViewControls.js
Normal file
220
frontend/src/hooks/useLocationMapViewControls.js
Normal file
@ -0,0 +1,220 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import useMobileMapAutoFit from "./useMobileMapAutoFit";
|
||||
import { getObjectKey } from "../lib/locationMapUtils";
|
||||
|
||||
function cloneMapObjects(objects = []) {
|
||||
return objects.map((object) => ({ ...object }));
|
||||
}
|
||||
|
||||
function getHistorySnapshot(snapshot, fallbackSelectedObjectKey = null) {
|
||||
if (Array.isArray(snapshot)) {
|
||||
return {
|
||||
objects: cloneMapObjects(snapshot),
|
||||
selectedObjectKey: fallbackSelectedObjectKey,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
objects: cloneMapObjects(snapshot?.objects || []),
|
||||
selectedObjectKey: snapshot?.selectedObjectKey ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function createHistorySnapshot(objects, selectedObjectKey) {
|
||||
return {
|
||||
objects: cloneMapObjects(objects),
|
||||
selectedObjectKey: selectedObjectKey ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function isTextEditingTarget(target) {
|
||||
return Boolean(target?.closest?.("input, textarea, select, [contenteditable]"));
|
||||
}
|
||||
|
||||
export default function useLocationMapViewControls({
|
||||
filters,
|
||||
future = [],
|
||||
hasUnsavedChanges,
|
||||
history = [],
|
||||
mapSize,
|
||||
mapState,
|
||||
mode,
|
||||
objects,
|
||||
previewDraft,
|
||||
saving,
|
||||
selectedObjectKey,
|
||||
setFuture,
|
||||
setHasUnsavedChanges,
|
||||
setHistory,
|
||||
setLayersOpen,
|
||||
setMode,
|
||||
setObjects,
|
||||
setPreviewDraft,
|
||||
setSelectedObjectKey,
|
||||
setZoom,
|
||||
svgRef,
|
||||
syncMapDraftFromState,
|
||||
zoom,
|
||||
}) {
|
||||
const shortcutControlsRef = useRef({
|
||||
handleRedo: () => {},
|
||||
handleUndo: () => {},
|
||||
mode: "view",
|
||||
saving: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedObjectKey) return;
|
||||
|
||||
const selectedMapObject = objects.find((object) => getObjectKey(object) === selectedObjectKey);
|
||||
if (!selectedMapObject || !filters.showZones || selectedMapObject.visible === false) {
|
||||
setSelectedObjectKey(null);
|
||||
}
|
||||
}, [filters.showZones, objects, selectedObjectKey, setSelectedObjectKey]);
|
||||
|
||||
const fitMapToCanvas = useMobileMapAutoFit({
|
||||
mapSize,
|
||||
mapState,
|
||||
mode,
|
||||
setZoom,
|
||||
svgRef,
|
||||
zoom,
|
||||
});
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
if (history.length === 0) return;
|
||||
|
||||
const priorSnapshot = getHistorySnapshot(history[history.length - 1], selectedObjectKey);
|
||||
setFuture([
|
||||
createHistorySnapshot(objects, selectedObjectKey),
|
||||
...future.slice(0, 19),
|
||||
]);
|
||||
setObjects(priorSnapshot.objects);
|
||||
setSelectedObjectKey(priorSnapshot.selectedObjectKey);
|
||||
setHasUnsavedChanges(history.length > 1);
|
||||
setHistory(history.slice(0, -1));
|
||||
}, [
|
||||
future, history, objects, selectedObjectKey, setFuture, setHasUnsavedChanges, setHistory,
|
||||
setObjects, setSelectedObjectKey,
|
||||
]);
|
||||
|
||||
const handleRedo = useCallback(() => {
|
||||
if (future.length === 0) return;
|
||||
|
||||
const nextSnapshot = getHistorySnapshot(future[0], selectedObjectKey);
|
||||
setHistory([
|
||||
...history.slice(-19),
|
||||
createHistorySnapshot(objects, selectedObjectKey),
|
||||
]);
|
||||
setObjects(nextSnapshot.objects);
|
||||
setSelectedObjectKey(nextSnapshot.selectedObjectKey);
|
||||
setHasUnsavedChanges(true);
|
||||
setFuture(future.slice(1));
|
||||
}, [
|
||||
future, history, objects, selectedObjectKey, setFuture, setHasUnsavedChanges, setHistory,
|
||||
setObjects, setSelectedObjectKey,
|
||||
]);
|
||||
|
||||
shortcutControlsRef.current = {
|
||||
handleRedo,
|
||||
handleUndo,
|
||||
mode,
|
||||
saving,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.altKey ||
|
||||
!(event.ctrlKey || event.metaKey) ||
|
||||
isTextEditingTarget(event.target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controls = shortcutControlsRef.current;
|
||||
if (controls.mode !== "edit" || controls.saving) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
const wantsUndo = key === "z" && !event.shiftKey;
|
||||
const wantsRedo = key === "y" || (key === "z" && event.shiftKey);
|
||||
|
||||
if (wantsUndo) {
|
||||
event.preventDefault();
|
||||
controls.handleUndo();
|
||||
return;
|
||||
}
|
||||
|
||||
if (wantsRedo) {
|
||||
event.preventDefault();
|
||||
controls.handleRedo();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, []);
|
||||
|
||||
const handlePreviewDraft = useCallback(() => {
|
||||
setMode("view");
|
||||
setLayersOpen(false);
|
||||
setPreviewDraft(true);
|
||||
setSelectedObjectKey(null);
|
||||
}, [setLayersOpen, setMode, setPreviewDraft, setSelectedObjectKey]);
|
||||
|
||||
const handleViewMode = useCallback(() => {
|
||||
setMode("view");
|
||||
setLayersOpen(false);
|
||||
if ((mode === "edit" || previewDraft) && (hasUnsavedChanges || mapState?.draft_map)) {
|
||||
setPreviewDraft(true);
|
||||
return;
|
||||
}
|
||||
setPreviewDraft(false);
|
||||
if (mapState) {
|
||||
syncMapDraftFromState(mapState, "view", false);
|
||||
}
|
||||
}, [
|
||||
hasUnsavedChanges,
|
||||
mapState,
|
||||
mode,
|
||||
previewDraft,
|
||||
setLayersOpen,
|
||||
setMode,
|
||||
setPreviewDraft,
|
||||
setSelectedObjectKey,
|
||||
syncMapDraftFromState,
|
||||
]);
|
||||
|
||||
const handleEditMode = useCallback(() => {
|
||||
setMode("edit");
|
||||
setLayersOpen(false);
|
||||
setPreviewDraft(true);
|
||||
if (!previewDraft && mapState && !hasUnsavedChanges) {
|
||||
syncMapDraftFromState(mapState, "edit", true);
|
||||
}
|
||||
}, [
|
||||
hasUnsavedChanges,
|
||||
mapState,
|
||||
previewDraft,
|
||||
setLayersOpen,
|
||||
setMode,
|
||||
setPreviewDraft,
|
||||
syncMapDraftFromState,
|
||||
]);
|
||||
|
||||
return {
|
||||
handleEditMode,
|
||||
handleFitMap: fitMapToCanvas,
|
||||
handlePreviewDraft,
|
||||
handleRedo,
|
||||
handleUndo,
|
||||
handleViewMode,
|
||||
};
|
||||
}
|
||||
111
frontend/src/hooks/useMobileMapAutoFit.js
Normal file
111
frontend/src/hooks/useMobileMapAutoFit.js
Normal file
@ -0,0 +1,111 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { DEFAULT_MAP_SIZE, clampMapZoom } from "../lib/locationMapUtils";
|
||||
|
||||
const COMPACT_MAP_LAYOUT_QUERY = "(max-width: 839px)";
|
||||
|
||||
function getAutoFitKey(mapState, mapSize) {
|
||||
return [
|
||||
mapState?.location?.id || "",
|
||||
mapState?.draft_map?.id || "",
|
||||
mapState?.published_map?.id || "",
|
||||
mapSize.width,
|
||||
mapSize.height,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function getFitZoom(mapSize, scrollElement) {
|
||||
const fallbackWidth = typeof window === "undefined" ? DEFAULT_MAP_SIZE.width : window.innerWidth;
|
||||
const fallbackHeight = typeof window === "undefined" ? DEFAULT_MAP_SIZE.height : window.innerHeight;
|
||||
const isCompactLayout = typeof window !== "undefined"
|
||||
&& window.matchMedia(COMPACT_MAP_LAYOUT_QUERY).matches;
|
||||
const minimumWidth = isCompactLayout ? 160 : 280;
|
||||
const minimumHeight = isCompactLayout ? 120 : 220;
|
||||
const availableWidth = Math.max(minimumWidth, (scrollElement?.clientWidth || fallbackWidth) - 24);
|
||||
const availableHeight = Math.max(minimumHeight, (scrollElement?.clientHeight || fallbackHeight) - 24);
|
||||
|
||||
return Number(clampMapZoom(
|
||||
Math.min(availableWidth / mapSize.width, availableHeight / mapSize.height)
|
||||
).toFixed(2));
|
||||
}
|
||||
|
||||
export default function useMobileMapAutoFit({
|
||||
mapSize,
|
||||
mapState,
|
||||
mode,
|
||||
setZoom,
|
||||
svgRef,
|
||||
zoom,
|
||||
}) {
|
||||
const lastAutoFitKeyRef = useRef(null);
|
||||
const lastFittedZoomRef = useRef(null);
|
||||
const latestZoomRef = useRef(zoom);
|
||||
const autoFitKey = useMemo(
|
||||
() => getAutoFitKey(mapState, mapSize),
|
||||
[mapSize, mapState]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
latestZoomRef.current = zoom;
|
||||
}, [zoom]);
|
||||
|
||||
const fitMapToCanvas = useCallback(() => {
|
||||
const scrollElement = svgRef.current?.closest(".location-map-scroll");
|
||||
const nextZoom = getFitZoom(mapSize, scrollElement);
|
||||
lastFittedZoomRef.current = nextZoom;
|
||||
setZoom(nextZoom);
|
||||
scrollElement?.scrollTo({ left: 0, top: 0 });
|
||||
}, [mapSize, setZoom, svgRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
if (!mapState || mode === "setup") return undefined;
|
||||
if (!window.matchMedia(COMPACT_MAP_LAYOUT_QUERY).matches) return undefined;
|
||||
if (lastAutoFitKeyRef.current === autoFitKey) return undefined;
|
||||
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
if (!svgRef.current) return;
|
||||
lastAutoFitKeyRef.current = autoFitKey;
|
||||
fitMapToCanvas();
|
||||
});
|
||||
|
||||
return () => window.cancelAnimationFrame(frameId);
|
||||
}, [autoFitKey, fitMapToCanvas, mapState, mode, svgRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
if (!mapState || mode === "setup") return undefined;
|
||||
|
||||
let frameId = null;
|
||||
const mediaQuery = window.matchMedia(COMPACT_MAP_LAYOUT_QUERY);
|
||||
const shouldPreserveManualZoom = () => {
|
||||
const lastFittedZoom = lastFittedZoomRef.current;
|
||||
return lastFittedZoom !== null && Math.abs(latestZoomRef.current - lastFittedZoom) > 0.01;
|
||||
};
|
||||
const scheduleFit = () => {
|
||||
if (!mediaQuery.matches || shouldPreserveManualZoom()) return;
|
||||
if (frameId !== null) {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
}
|
||||
frameId = window.requestAnimationFrame(() => {
|
||||
frameId = null;
|
||||
fitMapToCanvas();
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("resize", scheduleFit);
|
||||
window.addEventListener("orientationchange", scheduleFit);
|
||||
window.visualViewport?.addEventListener("resize", scheduleFit);
|
||||
scheduleFit();
|
||||
|
||||
return () => {
|
||||
if (frameId !== null) {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
}
|
||||
window.removeEventListener("resize", scheduleFit);
|
||||
window.removeEventListener("orientationchange", scheduleFit);
|
||||
window.visualViewport?.removeEventListener("resize", scheduleFit);
|
||||
};
|
||||
}, [fitMapToCanvas, mapState, mode]);
|
||||
|
||||
return fitMapToCanvas;
|
||||
}
|
||||
158
frontend/src/hooks/useUnsavedMapLeaveGuard.js
Normal file
158
frontend/src/hooks/useUnsavedMapLeaveGuard.js
Normal file
@ -0,0 +1,158 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useBeforeUnload } from "react-router-dom";
|
||||
|
||||
export default function useUnsavedMapLeaveGuard({
|
||||
shouldGuardLeave,
|
||||
navigate,
|
||||
performBackNavigation,
|
||||
}) {
|
||||
const [pendingLeaveTarget, setPendingLeaveTarget] = useState(null);
|
||||
const guardedClickTargetRef = useRef(null);
|
||||
const allowGuardedClickRef = useRef(false);
|
||||
const confirmedLeaveRef = useRef(false);
|
||||
const historyGuardActiveRef = useRef(false);
|
||||
const guardedUrlRef = useRef("");
|
||||
|
||||
useBeforeUnload(
|
||||
useCallback((event) => {
|
||||
if (!shouldGuardLeave) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
}, [shouldGuardLeave]),
|
||||
{ capture: true }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldGuardLeave) {
|
||||
if (historyGuardActiveRef.current && guardedUrlRef.current === window.location.href) {
|
||||
window.history.back();
|
||||
}
|
||||
setPendingLeaveTarget(null);
|
||||
guardedClickTargetRef.current = null;
|
||||
confirmedLeaveRef.current = false;
|
||||
historyGuardActiveRef.current = false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!historyGuardActiveRef.current) {
|
||||
guardedUrlRef.current = window.location.href;
|
||||
window.history.pushState(
|
||||
{ ...(window.history.state || {}), unsavedMapGuard: true },
|
||||
"",
|
||||
guardedUrlRef.current
|
||||
);
|
||||
historyGuardActiveRef.current = true;
|
||||
}
|
||||
|
||||
const handleDocumentClick = (event) => {
|
||||
if (allowGuardedClickRef.current) return;
|
||||
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.altKey ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.shiftKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
const button = target?.closest("button");
|
||||
const isSwitchingHousehold = Boolean(
|
||||
button?.classList.contains("household-option") &&
|
||||
!button.classList.contains("create-household-btn") &&
|
||||
!button.closest(".household-option-row.active")
|
||||
);
|
||||
|
||||
if (button?.classList.contains("user-dropdown-logout") || isSwitchingHousehold) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
guardedClickTargetRef.current = button;
|
||||
setPendingLeaveTarget({ type: "click" });
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = target?.closest("a[href]");
|
||||
if (!anchor || (anchor.target && anchor.target !== "_self") || anchor.hasAttribute("download")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextUrl = new URL(anchor.href, window.location.href);
|
||||
if (nextUrl.origin !== window.location.origin) return;
|
||||
|
||||
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
|
||||
if (nextPath === currentPath) return;
|
||||
|
||||
event.preventDefault();
|
||||
setPendingLeaveTarget({ type: "path", path: nextPath });
|
||||
};
|
||||
const handleBrowserHistoryNavigation = () => {
|
||||
if (confirmedLeaveRef.current) return;
|
||||
|
||||
window.history.pushState(
|
||||
{ ...(window.history.state || {}), unsavedMapGuard: true },
|
||||
"",
|
||||
guardedUrlRef.current || window.location.href
|
||||
);
|
||||
setPendingLeaveTarget({ type: "history" });
|
||||
};
|
||||
|
||||
document.addEventListener("click", handleDocumentClick, true);
|
||||
window.addEventListener("popstate", handleBrowserHistoryNavigation, true);
|
||||
return () => {
|
||||
document.removeEventListener("click", handleDocumentClick, true);
|
||||
window.removeEventListener("popstate", handleBrowserHistoryNavigation, true);
|
||||
};
|
||||
}, [shouldGuardLeave]);
|
||||
|
||||
const requestBackNavigation = useCallback(() => {
|
||||
if (shouldGuardLeave) {
|
||||
setPendingLeaveTarget({ type: "back" });
|
||||
return;
|
||||
}
|
||||
performBackNavigation();
|
||||
}, [performBackNavigation, shouldGuardLeave]);
|
||||
|
||||
const clearPendingLeave = useCallback(() => {
|
||||
guardedClickTargetRef.current = null;
|
||||
setPendingLeaveTarget(null);
|
||||
}, []);
|
||||
|
||||
const confirmPendingLeave = useCallback(() => {
|
||||
const target = pendingLeaveTarget;
|
||||
setPendingLeaveTarget(null);
|
||||
confirmedLeaveRef.current = true;
|
||||
if (target?.type === "history") {
|
||||
historyGuardActiveRef.current = false;
|
||||
window.history.go(-2);
|
||||
return;
|
||||
}
|
||||
if (target?.type === "click") {
|
||||
const guardedClickTarget = guardedClickTargetRef.current;
|
||||
guardedClickTargetRef.current = null;
|
||||
if (guardedClickTarget) {
|
||||
allowGuardedClickRef.current = true;
|
||||
guardedClickTarget.click();
|
||||
window.setTimeout(() => {
|
||||
allowGuardedClickRef.current = false;
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (target?.type === "path" && target.path) {
|
||||
navigate(target.path);
|
||||
return;
|
||||
}
|
||||
performBackNavigation();
|
||||
}, [navigate, pendingLeaveTarget, performBackNavigation]);
|
||||
|
||||
return {
|
||||
pendingLeaveTarget,
|
||||
requestBackNavigation,
|
||||
clearPendingLeave,
|
||||
confirmPendingLeave,
|
||||
};
|
||||
}
|
||||
39
frontend/src/lib/locationMapDraftStateUtils.js
Normal file
39
frontend/src/lib/locationMapDraftStateUtils.js
Normal file
@ -0,0 +1,39 @@
|
||||
import {
|
||||
DEFAULT_MAP_SIZE,
|
||||
getObjectKey,
|
||||
mapForMode,
|
||||
normalizeMapObject,
|
||||
objectsForMode,
|
||||
} from "./locationMapUtils";
|
||||
|
||||
export function getDefaultMapDraft() {
|
||||
return {
|
||||
name: "Store Map",
|
||||
width: DEFAULT_MAP_SIZE.width,
|
||||
height: DEFAULT_MAP_SIZE.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMapDraftSnapshot(nextState, nextMode, nextPreviewDraft, options = {}) {
|
||||
const nextMap = mapForMode(nextState, nextMode, nextPreviewDraft);
|
||||
const nextObjects = objectsForMode(nextState, nextMode, nextPreviewDraft);
|
||||
if (!nextMap) return null;
|
||||
|
||||
const normalizedObjects = nextObjects.map(normalizeMapObject);
|
||||
const preserveSelectedIndex = Number.isInteger(options.preserveSelectedIndex)
|
||||
? options.preserveSelectedIndex
|
||||
: -1;
|
||||
const preservedSelectedObject = preserveSelectedIndex >= 0
|
||||
? normalizedObjects[preserveSelectedIndex]
|
||||
: null;
|
||||
|
||||
return {
|
||||
mapDraft: {
|
||||
name: nextMap.name || "Store Map",
|
||||
width: nextMap.width || DEFAULT_MAP_SIZE.width,
|
||||
height: nextMap.height || DEFAULT_MAP_SIZE.height,
|
||||
},
|
||||
objects: normalizedObjects,
|
||||
selectedObjectKey: preservedSelectedObject ? getObjectKey(preservedSelectedObject) : null,
|
||||
};
|
||||
}
|
||||
202
frontend/src/lib/locationMapUtils.js
Normal file
202
frontend/src/lib/locationMapUtils.js
Normal file
@ -0,0 +1,202 @@
|
||||
const DEFAULT_LOCATION_NAME = "Default Location";
|
||||
|
||||
export const DEFAULT_MAP_SIZE = {
|
||||
width: 1000,
|
||||
height: 700,
|
||||
};
|
||||
|
||||
export const MIN_MAP_ZOOM = 0.3;
|
||||
export const MAX_MAP_ZOOM = 1.6;
|
||||
|
||||
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, options = {}) {
|
||||
if (options.hasUnsavedChanges) return "Unsaved Draft";
|
||||
if (!state?.draft_map && !state?.published_map) return "No Map";
|
||||
if (options.mode === "edit") return "Draft";
|
||||
if (options.mode === "view" && options.previewDraft && state?.draft_map) return "Viewing Draft";
|
||||
if (state?.published_map) return "Published";
|
||||
if (state?.draft_map) return "Draft";
|
||||
return "No Map";
|
||||
}
|
||||
|
||||
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) {
|
||||
const hasExplicitLabel = Object.prototype.hasOwnProperty.call(object, "label");
|
||||
|
||||
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: hasExplicitLabel
|
||||
? String(object.label ?? "").trim()
|
||||
: 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 getMapObjectDisplayLabel(object, fallback = "Map Area") {
|
||||
const label = String(object?.label ?? "").trim();
|
||||
const zoneName = String(object?.zone_name ?? "").trim();
|
||||
return label || zoneName || fallback;
|
||||
}
|
||||
|
||||
export function prepareObjectsForSave(objects) {
|
||||
return objects.map((object, index) => ({
|
||||
zone_id: object.zone_id || null,
|
||||
type: object.type || "zone",
|
||||
label: String(object.label ?? "").trim(),
|
||||
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 itemMatchesMapFilters(item, filters, username) {
|
||||
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 itemsForZone(items, zoneId, filters, username) {
|
||||
return items.filter((item) => (
|
||||
String(item.zone_id || "") === String(zoneId || "") &&
|
||||
itemMatchesMapFilters(item, filters, username)
|
||||
));
|
||||
}
|
||||
|
||||
export function unmappedItems(items, filters, username) {
|
||||
return items.filter((item) => !item.zone_id && itemMatchesMapFilters(item, filters, username));
|
||||
}
|
||||
|
||||
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 clampMapZoom(value) {
|
||||
return Math.max(MIN_MAP_ZOOM, Math.min(MAX_MAP_ZOOM, value));
|
||||
}
|
||||
|
||||
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)),
|
||||
};
|
||||
}
|
||||
483
frontend/src/pages/LocationMapManager.jsx
Normal file
483
frontend/src/pages/LocationMapManager.jsx
Normal file
@ -0,0 +1,483 @@
|
||||
import { useCallback, useContext, useEffect, useRef } from "react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import { HouseholdContext } from "../context/HouseholdContext";
|
||||
import { StoreContext } from "../context/StoreContext";
|
||||
import LocationMapBottomSheet from "../components/maps/LocationMapBottomSheet";
|
||||
import LocationMapCanvas from "../components/maps/LocationMapCanvas";
|
||||
import LocationMapDialogs from "../components/maps/LocationMapDialogs";
|
||||
import LocationMapSetupPanel from "../components/maps/LocationMapSetupPanel";
|
||||
import {
|
||||
LocationMapLoadErrorState,
|
||||
LocationMapMessageState,
|
||||
} from "../components/maps/LocationMapStateViews";
|
||||
import LocationMapToolbar from "../components/maps/LocationMapToolbar";
|
||||
import LocationMapTopbar from "../components/maps/LocationMapTopbar";
|
||||
import useActionToast from "../hooks/useActionToast";
|
||||
import useLocationMapDerivedState from "../hooks/useLocationMapDerivedState";
|
||||
import useLocationMapDraftActions from "../hooks/useLocationMapDraftActions";
|
||||
import useLocationMapDraftState from "../hooks/useLocationMapDraftState";
|
||||
import useLocationMapBuying from "../hooks/useLocationMapBuying";
|
||||
import useLocationMapObjectActions from "../hooks/useLocationMapObjectActions";
|
||||
import useLocationMapViewControls from "../hooks/useLocationMapViewControls";
|
||||
import useUnsavedMapLeaveGuard from "../hooks/useUnsavedMapLeaveGuard";
|
||||
import { getMapStatus } from "../lib/locationMapUtils";
|
||||
import "../styles/pages/LocationMapManager.css";
|
||||
|
||||
function isTextEditingTarget(target) {
|
||||
return Boolean(target?.closest?.("input, textarea, select, [contenteditable]"));
|
||||
}
|
||||
|
||||
export default function LocationMapManager() {
|
||||
const { storeId, locationId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const routeLocation = useLocation();
|
||||
const returnPath = routeLocation.state?.returnTo;
|
||||
const toast = useActionToast();
|
||||
const { username } = useContext(AuthContext);
|
||||
const { activeHousehold, loading: householdLoading, hasLoaded } = useContext(HouseholdContext);
|
||||
const { stores, activeStore, setActiveStore } = useContext(StoreContext);
|
||||
|
||||
const {
|
||||
beginSaving,
|
||||
dragState,
|
||||
endSaving,
|
||||
filters,
|
||||
future,
|
||||
hasUnsavedChanges,
|
||||
history,
|
||||
layersOpen,
|
||||
loadError,
|
||||
loadMap,
|
||||
loading,
|
||||
mapDraft,
|
||||
mapState,
|
||||
mode,
|
||||
objects,
|
||||
previewDraft,
|
||||
saving,
|
||||
savingAction,
|
||||
selectedObjectKey,
|
||||
setDragState,
|
||||
setFilters,
|
||||
setFuture,
|
||||
setHasUnsavedChanges,
|
||||
setHistory,
|
||||
setLayersOpen,
|
||||
setMapDraft,
|
||||
setMapState,
|
||||
setMode,
|
||||
setObjects,
|
||||
setPreviewDraft,
|
||||
setSelectedObjectKey,
|
||||
setZoom,
|
||||
syncMapDraftFromState,
|
||||
zoom,
|
||||
} = useLocationMapDraftState({
|
||||
activeHousehold,
|
||||
hasLoaded,
|
||||
householdLoading,
|
||||
locationId,
|
||||
toast,
|
||||
});
|
||||
const svgRef = useRef(null);
|
||||
|
||||
const location = mapState?.location || stores.find((store) => String(store.id) === String(locationId));
|
||||
const canManage = Boolean(mapState?.can_manage ?? ["owner", "admin"].includes(activeHousehold?.role));
|
||||
const {
|
||||
hiddenAreaCount,
|
||||
mapItems,
|
||||
mapSize,
|
||||
selectedObject,
|
||||
selectedZoneItems,
|
||||
unmappedItemCount,
|
||||
visibleAssignedItemCount,
|
||||
visibleUnmappedItems,
|
||||
} = useLocationMapDerivedState({
|
||||
filters,
|
||||
mapDraft,
|
||||
mapState,
|
||||
mode,
|
||||
objects,
|
||||
previewDraft,
|
||||
selectedObjectKey,
|
||||
username,
|
||||
});
|
||||
const {
|
||||
buyModalItem,
|
||||
buyModalItems,
|
||||
handleMapBuyCancel,
|
||||
handleMapBuyConfirm,
|
||||
handleMapBuyNavigate,
|
||||
setBuyModalItem,
|
||||
} = useLocationMapBuying({
|
||||
activeHouseholdId: activeHousehold?.id,
|
||||
locationId,
|
||||
selectedObject,
|
||||
selectedZoneItems,
|
||||
setMapState,
|
||||
toast,
|
||||
visibleUnmappedItems,
|
||||
});
|
||||
const shouldGuardLeave = canManage && hasUnsavedChanges;
|
||||
|
||||
useEffect(() => {
|
||||
const matchingLocation = stores.find((store) => String(store.id) === String(locationId));
|
||||
if (matchingLocation && String(activeStore?.id) !== String(matchingLocation.id)) {
|
||||
setActiveStore(matchingLocation);
|
||||
}
|
||||
}, [activeStore?.id, locationId, setActiveStore, stores]);
|
||||
|
||||
const {
|
||||
handleCreateBlank,
|
||||
handleCreateFromZones,
|
||||
handlePublish,
|
||||
handleSaveDraft,
|
||||
} = useLocationMapDraftActions({
|
||||
activeHouseholdId: activeHousehold?.id,
|
||||
beginSaving,
|
||||
endSaving,
|
||||
hasUnsavedChanges,
|
||||
locationId,
|
||||
mapDraft,
|
||||
objects,
|
||||
selectedObjectKey,
|
||||
setMapState,
|
||||
setMode,
|
||||
setPreviewDraft,
|
||||
syncMapDraftFromState,
|
||||
toast,
|
||||
});
|
||||
|
||||
const {
|
||||
handleAddObject,
|
||||
handleDeleteObject,
|
||||
handleDuplicateObject,
|
||||
handleObjectField,
|
||||
handleShowHiddenAreas,
|
||||
handleZoneLinkChange,
|
||||
expandMapToFitObject,
|
||||
pendingDeleteObject,
|
||||
remember,
|
||||
requestDeleteObject,
|
||||
setPendingDeleteObject,
|
||||
updateObjects,
|
||||
} = useLocationMapObjectActions({
|
||||
canManage,
|
||||
hiddenAreaCount,
|
||||
mapSize,
|
||||
mapState,
|
||||
objects,
|
||||
selectedObject,
|
||||
selectedObjectKey,
|
||||
setFuture,
|
||||
setHasUnsavedChanges,
|
||||
setHistory,
|
||||
setMapDraft,
|
||||
setObjects,
|
||||
setSelectedObjectKey,
|
||||
toast,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setPendingDeleteObject(null);
|
||||
}, [activeHousehold?.id, locationId, setPendingDeleteObject]);
|
||||
|
||||
const {
|
||||
handleEditMode,
|
||||
handleFitMap,
|
||||
handlePreviewDraft,
|
||||
handleRedo,
|
||||
handleUndo,
|
||||
handleViewMode,
|
||||
} = useLocationMapViewControls({
|
||||
filters,
|
||||
future,
|
||||
hasUnsavedChanges,
|
||||
history,
|
||||
mapSize,
|
||||
mapState,
|
||||
mode,
|
||||
objects,
|
||||
previewDraft,
|
||||
saving,
|
||||
selectedObjectKey,
|
||||
setFuture,
|
||||
setHasUnsavedChanges,
|
||||
setHistory,
|
||||
setLayersOpen,
|
||||
setMode,
|
||||
setObjects,
|
||||
setPreviewDraft,
|
||||
setSelectedObjectKey,
|
||||
setZoom,
|
||||
svgRef,
|
||||
syncMapDraftFromState,
|
||||
zoom,
|
||||
});
|
||||
|
||||
const performBackNavigation = useCallback(() => {
|
||||
if (typeof returnPath === "string" && returnPath.startsWith("/")) {
|
||||
navigate(returnPath, { replace: true });
|
||||
return;
|
||||
}
|
||||
navigate("/");
|
||||
}, [navigate, returnPath]);
|
||||
|
||||
const {
|
||||
pendingLeaveTarget,
|
||||
requestBackNavigation,
|
||||
clearPendingLeave,
|
||||
confirmPendingLeave,
|
||||
} = useUnsavedMapLeaveGuard({
|
||||
shouldGuardLeave,
|
||||
navigate,
|
||||
performBackNavigation,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const hasOpenModal = Boolean(buyModalItem || pendingDeleteObject || pendingLeaveTarget);
|
||||
if (!selectedObjectKey || layersOpen || hasOpenModal || typeof window === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.key !== "Escape" ||
|
||||
isTextEditingTarget(event.target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedObjectKey(null);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [buyModalItem, layersOpen, pendingDeleteObject, pendingLeaveTarget, selectedObjectKey, setSelectedObjectKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const hasOpenModal = Boolean(buyModalItem || pendingDeleteObject || pendingLeaveTarget);
|
||||
if (mode !== "edit" || !canManage || hasOpenModal || typeof window === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
const wantsSave =
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
!event.altKey &&
|
||||
event.key.toLowerCase() === "s";
|
||||
|
||||
if (!wantsSave) return;
|
||||
event.preventDefault();
|
||||
if (saving || !hasUnsavedChanges) return;
|
||||
handleSaveDraft();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, [
|
||||
buyModalItem,
|
||||
canManage,
|
||||
handleSaveDraft,
|
||||
hasUnsavedChanges,
|
||||
mode,
|
||||
pendingDeleteObject,
|
||||
pendingLeaveTarget,
|
||||
saving,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const hasOpenModal = Boolean(buyModalItem || pendingDeleteObject || pendingLeaveTarget);
|
||||
if (
|
||||
!selectedObjectKey ||
|
||||
mode !== "edit" ||
|
||||
!canManage ||
|
||||
saving ||
|
||||
layersOpen ||
|
||||
hasOpenModal ||
|
||||
typeof window === "undefined"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
!["Backspace", "Delete"].includes(event.key) ||
|
||||
isTextEditingTarget(event.target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
requestDeleteObject();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, [
|
||||
buyModalItem,
|
||||
canManage,
|
||||
layersOpen,
|
||||
mode,
|
||||
pendingDeleteObject,
|
||||
pendingLeaveTarget,
|
||||
requestDeleteObject,
|
||||
saving,
|
||||
selectedObjectKey,
|
||||
]);
|
||||
|
||||
if (!hasLoaded || householdLoading || loading) {
|
||||
return (
|
||||
<LocationMapMessageState
|
||||
message="Loading map..."
|
||||
location={location}
|
||||
status="Loading"
|
||||
onBack={requestBackNavigation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeHousehold) {
|
||||
return (
|
||||
<LocationMapMessageState
|
||||
status="Select Household"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<LocationMapLoadErrorState
|
||||
location={location}
|
||||
loadError={loadError}
|
||||
onBack={requestBackNavigation}
|
||||
onRetry={loadMap}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const hasAnyMap = Boolean(mapState?.draft_map || mapState?.published_map);
|
||||
const hasVisibleOrManageableMap = Boolean(mapState?.published_map || (mapState?.draft_map && canManage));
|
||||
const status = getMapStatus(mapState, { mode, previewDraft, hasUnsavedChanges });
|
||||
|
||||
return (
|
||||
<div className="location-map-page">
|
||||
<LocationMapTopbar location={location} status={status} onBack={requestBackNavigation} />
|
||||
|
||||
<main className="location-map-workspace">
|
||||
{hasAnyMap && mode !== "setup" ? (
|
||||
<LocationMapToolbar
|
||||
mode={mode}
|
||||
hasAnyMap={hasAnyMap}
|
||||
canManage={canManage}
|
||||
saving={saving}
|
||||
history={history}
|
||||
future={future}
|
||||
zoom={zoom}
|
||||
setZoom={setZoom}
|
||||
onFit={handleFitMap}
|
||||
onView={handleViewMode}
|
||||
onEdit={handleEditMode}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!hasAnyMap || (mode === "setup" && mapState?.draft_map && !mapState?.published_map) ? (
|
||||
<LocationMapSetupPanel
|
||||
hasAnyMap={hasVisibleOrManageableMap}
|
||||
canManage={canManage}
|
||||
zoneCount={mapState?.zones?.length || 0}
|
||||
saving={saving}
|
||||
savingAction={savingAction}
|
||||
onContinue={() => {
|
||||
setMode("edit");
|
||||
setPreviewDraft(true);
|
||||
}}
|
||||
onPreview={handlePreviewDraft}
|
||||
onPublish={handlePublish}
|
||||
onCreateFromZones={handleCreateFromZones}
|
||||
onCreateBlank={handleCreateBlank}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<LocationMapCanvas
|
||||
mode={mode}
|
||||
objects={objects}
|
||||
filters={filters}
|
||||
mapSize={mapSize}
|
||||
zoom={zoom}
|
||||
selectedObjectKey={selectedObjectKey}
|
||||
mapItems={mapItems}
|
||||
username={username}
|
||||
svgRef={svgRef}
|
||||
dragState={dragState}
|
||||
editingLocked={saving}
|
||||
hiddenAreaCount={hiddenAreaCount}
|
||||
onAddObject={handleAddObject}
|
||||
setDragState={setDragState}
|
||||
setSelectedObjectKey={setSelectedObjectKey}
|
||||
remember={remember}
|
||||
updateObjects={updateObjects}
|
||||
expandMapToFitObject={expandMapToFitObject}
|
||||
onShowZones={() =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
showZones: true,
|
||||
}))
|
||||
}
|
||||
onShowHiddenAreas={handleShowHiddenAreas}
|
||||
/>
|
||||
<LocationMapBottomSheet
|
||||
mode={mode}
|
||||
canManage={canManage}
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
layersOpen={layersOpen}
|
||||
setLayersOpen={setLayersOpen}
|
||||
selectedObject={selectedObject}
|
||||
mapObjects={objects}
|
||||
selectedZoneItems={selectedZoneItems}
|
||||
visibleAssignedItemCount={visibleAssignedItemCount}
|
||||
visibleUnmappedItems={visibleUnmappedItems}
|
||||
unmappedItemCount={unmappedItemCount}
|
||||
hiddenAreaCount={hiddenAreaCount}
|
||||
saving={saving}
|
||||
savingAction={savingAction}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
mapState={mapState}
|
||||
onAddObject={handleAddObject}
|
||||
onSaveDraft={handleSaveDraft}
|
||||
onPublish={handlePublish}
|
||||
onObjectField={handleObjectField}
|
||||
onZoneLinkChange={handleZoneLinkChange}
|
||||
onDuplicateObject={handleDuplicateObject}
|
||||
onDeleteObject={requestDeleteObject}
|
||||
onShowHiddenAreas={handleShowHiddenAreas}
|
||||
onClearSelection={() => setSelectedObjectKey(null)}
|
||||
onSelectZoneItem={setBuyModalItem}
|
||||
onSelectUnmappedItem={setBuyModalItem}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<LocationMapDialogs
|
||||
buyModalItem={buyModalItem}
|
||||
buyModalItems={buyModalItems}
|
||||
onBuyNavigate={handleMapBuyNavigate}
|
||||
onBuyCancel={handleMapBuyCancel}
|
||||
onBuyConfirm={handleMapBuyConfirm}
|
||||
pendingDeleteObject={pendingDeleteObject}
|
||||
onDeleteClose={() => setPendingDeleteObject(null)}
|
||||
onDeleteConfirm={handleDeleteObject}
|
||||
pendingLeaveTarget={pendingLeaveTarget}
|
||||
onLeaveClose={clearPendingLeave}
|
||||
onLeaveConfirm={confirmPendingLeave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -52,13 +52,12 @@
|
||||
}
|
||||
|
||||
.confirm-buy-nav-btn {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: var(--border-width-medium) solid var(--color-primary);
|
||||
border-radius: var(--border-radius-full);
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-primary);
|
||||
font-size: 1.8em;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base);
|
||||
@ -70,6 +69,18 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.confirm-buy-nav-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: block;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2.4;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.confirm-buy-nav-btn:hover:not(:disabled) {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
@ -100,8 +111,21 @@
|
||||
}
|
||||
|
||||
.confirm-buy-image-placeholder {
|
||||
font-size: 4em;
|
||||
width: min(58%, 150px);
|
||||
aspect-ratio: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: var(--border-width-medium) solid var(--color-border-light);
|
||||
border-radius: var(--border-radius-lg);
|
||||
background: var(--color-gray-100);
|
||||
color: var(--color-border-medium);
|
||||
font-size: 4.75em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.confirm-buy-image-placeholder span {
|
||||
transform: translateY(0.02em);
|
||||
}
|
||||
|
||||
.confirm-buy-quantity-section {
|
||||
@ -260,9 +284,8 @@
|
||||
}
|
||||
|
||||
.confirm-buy-nav-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 1.6em;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.confirm-buy-counter-btn {
|
||||
@ -299,8 +322,7 @@
|
||||
}
|
||||
|
||||
.confirm-buy-nav-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 1.4em;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
1271
frontend/src/styles/pages/LocationMapManager.css
Normal file
1271
frontend/src/styles/pages/LocationMapManager.css
Normal file
File diff suppressed because it is too large
Load Diff
@ -64,7 +64,19 @@ async function setupBuyModalRoutes(
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/10/list/recent", async (route) => {
|
||||
await page.route("**/households/1/locations/10/zones", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
zones: [
|
||||
{ id: 501, name: "Produce", sort_order: 10 },
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/locations/10/list/recent", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@ -72,7 +84,7 @@ async function setupBuyModalRoutes(
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/10/list/suggestions**", async (route) => {
|
||||
await page.route("**/households/1/locations/10/list/suggestions**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@ -80,7 +92,7 @@ async function setupBuyModalRoutes(
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/10/list/classification**", async (route) => {
|
||||
await page.route("**/households/1/locations/10/list/classification**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@ -88,7 +100,7 @@ async function setupBuyModalRoutes(
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/10/list/item**", async (route) => {
|
||||
await page.route("**/households/1/locations/10/list/item**", async (route) => {
|
||||
const request = route.request();
|
||||
|
||||
if (request.method() === "PATCH") {
|
||||
@ -156,7 +168,7 @@ async function setupBuyModalRoutes(
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/10/list", async (route) => {
|
||||
await page.route("**/households/1/locations/10/list", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@ -183,12 +195,11 @@ test("buying an item advances to the next one in the current sort order", async
|
||||
]);
|
||||
|
||||
await page.goto("/");
|
||||
await page.locator(".glist-sort").selectOption("qty-high");
|
||||
|
||||
await openBuyModal(page, "bread");
|
||||
await page.getByRole("button", { name: "Mark as Bought" }).click();
|
||||
|
||||
await expect(page.locator(".confirm-buy-item-name")).toHaveText("apples");
|
||||
await expect(page.locator(".confirm-buy-item-name")).toHaveText("milk");
|
||||
});
|
||||
|
||||
test("buying the last item in the current order wraps to the first remaining item", async ({ page }) => {
|
||||
@ -201,7 +212,6 @@ test("buying the last item in the current order wraps to the first remaining ite
|
||||
]);
|
||||
|
||||
await page.goto("/");
|
||||
await page.locator(".glist-sort").selectOption("az");
|
||||
|
||||
await openBuyModal(page, "milk");
|
||||
await page.getByRole("button", { name: "Mark as Bought" }).click();
|
||||
@ -219,7 +229,6 @@ test("partial buy keeps the item on the list and advances past it", async ({ pag
|
||||
]);
|
||||
|
||||
await page.goto("/");
|
||||
await page.locator(".glist-sort").selectOption("qty-low");
|
||||
|
||||
await openBuyModal(page, "bravo");
|
||||
await page.locator(".confirm-buy-counter-btn").nth(0).click();
|
||||
|
||||
@ -16,8 +16,12 @@ type HouseholdSeed = {
|
||||
|
||||
type StoreSeed = {
|
||||
id?: number;
|
||||
household_store_id?: number;
|
||||
household_id?: number;
|
||||
name?: string;
|
||||
location?: string;
|
||||
location_name?: string;
|
||||
display_name?: string;
|
||||
is_default?: boolean;
|
||||
};
|
||||
|
||||
@ -57,9 +61,21 @@ export async function mockHouseholdAndStoreShell(
|
||||
invite_code: "ABCD1234",
|
||||
...options.household,
|
||||
};
|
||||
const stores = options.stores || [
|
||||
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
|
||||
];
|
||||
const stores = (options.stores || [
|
||||
{ id: 10, household_store_id: 100, name: "Costco", location_name: "Warehouse", is_default: true },
|
||||
]).map((store, index) => {
|
||||
const locationName = store.location_name || store.location || "Default Location";
|
||||
return {
|
||||
household_id: household.id,
|
||||
household_store_id: store.household_store_id ?? store.id ?? index + 1,
|
||||
id: store.id ?? index + 10,
|
||||
name: store.name || "Costco",
|
||||
location_name: locationName,
|
||||
display_name: store.display_name || `${store.name || "Costco"} - ${locationName}`,
|
||||
is_default: store.is_default ?? index === 0,
|
||||
...store,
|
||||
};
|
||||
});
|
||||
|
||||
await page.route("**/households", async (route) => {
|
||||
await route.fulfill({
|
||||
@ -76,6 +92,14 @@ export async function mockHouseholdAndStoreShell(
|
||||
body: JSON.stringify(stores),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/households/${household.id}/stores`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(stores),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function confirmSlide(page: Page) {
|
||||
|
||||
5410
frontend/tests/location-map-manager.spec.ts
Normal file
5410
frontend/tests/location-map-manager.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -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,
|
||||
@ -75,6 +95,13 @@ async function mockGroceryShell(page: Page, stores: Array<Record<string, unknown
|
||||
|
||||
test("grocery store selector shows one selected store and picks from a modal", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 473, height: 1000 });
|
||||
const storeContextMessages: string[] = [];
|
||||
page.on("console", (message) => {
|
||||
const text = message.text();
|
||||
if (text.includes("[StoreContext]")) {
|
||||
storeContextMessages.push(text);
|
||||
}
|
||||
});
|
||||
await mockGroceryShell(page, [
|
||||
{
|
||||
id: 10,
|
||||
@ -161,7 +188,17 @@ 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$/);
|
||||
await expect(page.getByRole("heading", { name: "Costco" })).toBeVisible();
|
||||
await expect(page.getByText("Fontana")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "No Map" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Back" }).click();
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await expect(storeSelector).toContainText("Costco");
|
||||
await expect(locationSelector).toContainText("Fontana");
|
||||
await expect(page.getByRole("button", { name: "Manage Map" })).toBeVisible();
|
||||
expect(storeContextMessages).toEqual([]);
|
||||
});
|
||||
|
||||
test("single grocery store selector click does not open picker modal", async ({ page }) => {
|
||||
|
||||
@ -2,9 +2,12 @@ import react from '@vitejs/plugin-react';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
|
||||
const env = loadEnv('', process.cwd());
|
||||
const allowedHosts = env.VITE_ALLOWED_HOSTS
|
||||
const configuredAllowedHosts = env.VITE_ALLOWED_HOSTS
|
||||
? env.VITE_ALLOWED_HOSTS.split(',').map((host) => host.trim())
|
||||
: [];
|
||||
const allowedHosts = Array.from(
|
||||
new Set(['localhost', '127.0.0.1', '192.168.7.45', ...configuredAllowedHosts].filter(Boolean))
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
|
||||
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