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`
|
- `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:
|
- 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`
|
- `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`.
|
- 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.
|
- Do not print or commit real `.env` values while checking or updating local Docker env.
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,6 @@ DB_PORT=5432
|
|||||||
DB_NAME=
|
DB_NAME=
|
||||||
PORT=5000
|
PORT=5000
|
||||||
JWT_SECRET=change-me
|
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_COOKIE_NAME=sid
|
||||||
SESSION_TTL_DAYS=30
|
SESSION_TTL_DAYS=30
|
||||||
|
|||||||
@ -4,6 +4,7 @@ const path = require("path");
|
|||||||
const User = require("./models/user.model");
|
const User = require("./models/user.model");
|
||||||
const requestIdMiddleware = require("./middleware/request-id");
|
const requestIdMiddleware = require("./middleware/request-id");
|
||||||
const { sendError } = require("./utils/http");
|
const { sendError } = require("./utils/http");
|
||||||
|
const { isAllowedCorsOrigin, parseAllowedOrigins } = require("./utils/cors-origins");
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(requestIdMiddleware);
|
app.use(requestIdMiddleware);
|
||||||
@ -14,17 +15,11 @@ if (process.env.NODE_ENV !== "production") {
|
|||||||
app.use("/test", express.static(path.join(__dirname, "public")));
|
app.use("/test", express.static(path.join(__dirname, "public")));
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowedOrigins = (process.env.ALLOWED_ORIGINS || "")
|
const allowedOrigins = parseAllowedOrigins(process.env.ALLOWED_ORIGINS || "");
|
||||||
.split(",")
|
|
||||||
.map((origin) => origin.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin: function (origin, callback) {
|
origin: function (origin, callback) {
|
||||||
if (!origin) return callback(null, true);
|
if (isAllowedCorsOrigin(origin, allowedOrigins)) 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);
|
|
||||||
console.error(`CORS blocked origin: ${origin}`);
|
console.error(`CORS blocked origin: ${origin}`);
|
||||||
callback(new Error(`CORS blocked: ${origin}. Add this origin to ALLOWED_ORIGINS environment variable.`));
|
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 controller = require("../controllers/households.controller");
|
||||||
const listsController = require("../controllers/lists.controller.v2");
|
const listsController = require("../controllers/lists.controller.v2");
|
||||||
const availableItemsController = require("../controllers/available-items.controller");
|
const availableItemsController = require("../controllers/available-items.controller");
|
||||||
|
const locationMapsController = require("../controllers/location-maps.controller");
|
||||||
const storesController = require("../controllers/stores.controller");
|
const storesController = require("../controllers/stores.controller");
|
||||||
const auth = require("../middleware/auth");
|
const auth = require("../middleware/auth");
|
||||||
const {
|
const {
|
||||||
@ -134,6 +135,46 @@ router.delete(
|
|||||||
storesController.deleteZone
|
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(
|
router.get(
|
||||||
"/:householdId/locations/:locationId/available-items",
|
"/:householdId/locations/:locationId/available-items",
|
||||||
auth,
|
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(),
|
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", () => ({
|
jest.mock("../controllers/stores.controller", () => ({
|
||||||
addLocationToStore: jest.fn((req, res) => res.status(201).json({ message: "location" })),
|
addLocationToStore: jest.fn((req, res) => res.status(201).json({ message: "location" })),
|
||||||
createHouseholdStore: jest.fn((req, res) => res.status(201).json({ message: "store" })),
|
createHouseholdStore: jest.fn((req, res) => res.status(201).json({ message: "store" })),
|
||||||
@ -89,6 +97,7 @@ const express = require("express");
|
|||||||
const request = require("supertest");
|
const request = require("supertest");
|
||||||
const router = require("../routes/households.routes");
|
const router = require("../routes/households.routes");
|
||||||
const householdsController = require("../controllers/households.controller");
|
const householdsController = require("../controllers/households.controller");
|
||||||
|
const locationMapsController = require("../controllers/location-maps.controller");
|
||||||
const storesController = require("../controllers/stores.controller");
|
const storesController = require("../controllers/stores.controller");
|
||||||
|
|
||||||
describe("store location routes", () => {
|
describe("store location routes", () => {
|
||||||
@ -161,4 +170,51 @@ describe("store location routes", () => {
|
|||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(storesController.updateZone).toHaveBeenCalled();
|
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
|
# Leave blank to infer the API host from the browser URL, e.g. http://192.168.7.45:5000.
|
||||||
VITE_ALLOWED_HOSTS=localhost,127.0.0.1
|
VITE_API_URL=
|
||||||
|
VITE_ALLOWED_HOSTS=localhost,127.0.0.1,192.168.7.45
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { StoreProvider } from "./context/StoreContext.jsx";
|
|||||||
|
|
||||||
import AdminPanel from "./pages/AdminPanel.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 Login from "./pages/Login.jsx";
|
||||||
import Manage from "./pages/Manage.jsx";
|
import Manage from "./pages/Manage.jsx";
|
||||||
import Register from "./pages/Register.jsx";
|
import Register from "./pages/Register.jsx";
|
||||||
@ -48,6 +49,7 @@ function App() {
|
|||||||
<Route path="/" element={<GroceryList />} />
|
<Route path="/" element={<GroceryList />} />
|
||||||
<Route path="/manage" element={<Manage />} />
|
<Route path="/manage" element={<Manage />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route path="/stores/:storeId/locations/:locationId/map" element={<LocationMapManager />} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/admin"
|
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";
|
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({
|
export default function ConfirmBuyModal({
|
||||||
item,
|
item,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
@ -11,6 +20,7 @@ export default function ConfirmBuyModal({
|
|||||||
const [quantity, setQuantity] = useState(item.quantity);
|
const [quantity, setQuantity] = useState(item.quantity);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const maxQuantity = item.quantity;
|
const maxQuantity = item.quantity;
|
||||||
|
const titleId = "confirm-buy-modal-title";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setQuantity(item.quantity);
|
setQuantity(item.quantity);
|
||||||
@ -21,17 +31,17 @@ export default function ConfirmBuyModal({
|
|||||||
const hasPrev = currentIndex > 0;
|
const hasPrev = currentIndex > 0;
|
||||||
const hasNext = currentIndex < allItems.length - 1;
|
const hasNext = currentIndex < allItems.length - 1;
|
||||||
|
|
||||||
const handleIncrement = () => {
|
const handleIncrement = useCallback(() => {
|
||||||
if (!isSubmitting && quantity < maxQuantity) {
|
if (isSubmitting) return;
|
||||||
setQuantity((prev) => prev + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDecrement = () => {
|
setQuantity((prev) => Math.min(maxQuantity, prev + 1));
|
||||||
if (!isSubmitting && quantity > 1) {
|
}, [isSubmitting, maxQuantity]);
|
||||||
setQuantity((prev) => prev - 1);
|
|
||||||
}
|
const handleDecrement = useCallback(() => {
|
||||||
};
|
if (isSubmitting) return;
|
||||||
|
|
||||||
|
setQuantity((prev) => Math.max(1, prev - 1));
|
||||||
|
}, [isSubmitting]);
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
if (isSubmitting) return;
|
if (isSubmitting) return;
|
||||||
@ -44,21 +54,58 @@ export default function ConfirmBuyModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrev = () => {
|
const handlePrev = useCallback(() => {
|
||||||
if (isSubmitting) return;
|
if (isSubmitting) return;
|
||||||
|
|
||||||
if (hasPrev && onNavigate) {
|
if (hasPrev && onNavigate) {
|
||||||
onNavigate(allItems[currentIndex - 1]);
|
onNavigate(allItems[currentIndex - 1]);
|
||||||
}
|
}
|
||||||
};
|
}, [allItems, currentIndex, hasPrev, isSubmitting, onNavigate]);
|
||||||
|
|
||||||
const handleNext = () => {
|
const handleNext = useCallback(() => {
|
||||||
if (isSubmitting) return;
|
if (isSubmitting) return;
|
||||||
|
|
||||||
if (hasNext && onNavigate) {
|
if (hasNext && onNavigate) {
|
||||||
onNavigate(allItems[currentIndex + 1]);
|
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
|
const imageUrl = item.item_image && item.image_mime_type
|
||||||
? `data:${item.image_mime_type};base64,${item.item_image}`
|
? `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">
|
<div className="confirm-buy-header">
|
||||||
{item.zone && <div className="confirm-buy-zone">{item.zone}</div>}
|
{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>
|
||||||
|
|
||||||
<div className="confirm-buy-image-section">
|
<div className="confirm-buy-image-section">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="confirm-buy-nav-btn confirm-buy-nav-prev"
|
className="confirm-buy-nav-btn confirm-buy-nav-prev"
|
||||||
onClick={handlePrev}
|
onClick={handlePrev}
|
||||||
style={{ visibility: hasPrev ? "visible" : "hidden" }}
|
style={{ visibility: hasPrev ? "visible" : "hidden" }}
|
||||||
disabled={!hasPrev || isSubmitting}
|
disabled={!hasPrev || isSubmitting}
|
||||||
|
aria-label="Previous item"
|
||||||
|
title="Previous item"
|
||||||
>
|
>
|
||||||
{"<"}
|
<ChevronIcon direction="previous" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="confirm-buy-image-container">
|
<div className="confirm-buy-image-container">
|
||||||
{imageUrl ? (
|
{imageUrl ? (
|
||||||
<img src={imageUrl} alt={item.item_name} className="confirm-buy-image" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="confirm-buy-nav-btn confirm-buy-nav-next"
|
className="confirm-buy-nav-btn confirm-buy-nav-next"
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
style={{ visibility: hasNext ? "visible" : "hidden" }}
|
style={{ visibility: hasNext ? "visible" : "hidden" }}
|
||||||
disabled={!hasNext || isSubmitting}
|
disabled={!hasNext || isSubmitting}
|
||||||
|
aria-label="Next item"
|
||||||
|
title="Next item"
|
||||||
>
|
>
|
||||||
{">"}
|
<ChevronIcon direction="next" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="confirm-buy-quantity-section">
|
<div className="confirm-buy-quantity-section">
|
||||||
<div className="confirm-buy-counter">
|
<div className="confirm-buy-counter">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleDecrement}
|
onClick={handleDecrement}
|
||||||
className="confirm-buy-counter-btn"
|
className="confirm-buy-counter-btn"
|
||||||
disabled={quantity <= 1 || isSubmitting}
|
disabled={quantity <= 1 || isSubmitting}
|
||||||
|
aria-label="Decrease quantity"
|
||||||
>
|
>
|
||||||
-
|
-
|
||||||
</button>
|
</button>
|
||||||
@ -121,11 +184,14 @@ export default function ConfirmBuyModal({
|
|||||||
value={quantity}
|
value={quantity}
|
||||||
readOnly
|
readOnly
|
||||||
className="confirm-buy-counter-display"
|
className="confirm-buy-counter-display"
|
||||||
|
aria-label="Quantity to mark bought"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleIncrement}
|
onClick={handleIncrement}
|
||||||
className="confirm-buy-counter-btn"
|
className="confirm-buy-counter-btn"
|
||||||
disabled={quantity >= maxQuantity || isSubmitting}
|
disabled={quantity >= maxQuantity || isSubmitting}
|
||||||
|
aria-label="Increase quantity"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
@ -133,10 +199,10 @@ export default function ConfirmBuyModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="confirm-buy-actions">
|
<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
|
Cancel
|
||||||
</button>
|
</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"}
|
{isSubmitting ? "Saving..." : "Mark as Bought"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useContext, useMemo, useState } from 'react';
|
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 { StoreContext } from '../../context/StoreContext';
|
||||||
import '../../styles/components/StoreTabs.css';
|
import '../../styles/components/StoreTabs.css';
|
||||||
|
|
||||||
@ -55,6 +55,7 @@ function buildStoreOptions(stores) {
|
|||||||
export default function StoreTabs() {
|
export default function StoreTabs() {
|
||||||
const { stores, activeStore, setActiveStore, loading } = useContext(StoreContext);
|
const { stores, activeStore, setActiveStore, loading } = useContext(StoreContext);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const routeLocation = useLocation();
|
||||||
const [activePicker, setActivePicker] = useState(null);
|
const [activePicker, setActivePicker] = useState(null);
|
||||||
const storeOptions = useMemo(() => buildStoreOptions(stores || []), [stores]);
|
const storeOptions = useMemo(() => buildStoreOptions(stores || []), [stores]);
|
||||||
const activeStoreKey = getStoreKey(activeStore);
|
const activeStoreKey = getStoreKey(activeStore);
|
||||||
@ -98,8 +99,12 @@ export default function StoreTabs() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleManageMap = () => {
|
const handleManageMap = () => {
|
||||||
const locationQuery = selectedLocation?.id ? `&locationId=${selectedLocation.id}` : "";
|
if (!selectedLocation?.id || !selectedStoreOption?.key) return;
|
||||||
navigate(`/manage?tab=stores${locationQuery}`);
|
navigate(`/stores/${selectedStoreOption.key}/locations/${selectedLocation.id}/map`, {
|
||||||
|
state: {
|
||||||
|
returnTo: `${routeLocation.pathname}${routeLocation.search}${routeLocation.hash}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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(() => {
|
useEffect(() => {
|
||||||
if (!activeHousehold || stores.length === 0) return;
|
if (!activeHousehold || stores.length === 0) return;
|
||||||
|
|
||||||
console.log('[StoreContext] Setting active store from:', stores);
|
|
||||||
const householdStoreStorageKey = `activeHouseholdStoreId_${activeHousehold.id}`;
|
const householdStoreStorageKey = `activeHouseholdStoreId_${activeHousehold.id}`;
|
||||||
const legacyLocationStorageKey = `activeStoreId_${activeHousehold.id}`;
|
const legacyLocationStorageKey = `activeStoreId_${activeHousehold.id}`;
|
||||||
const savedHouseholdStoreId = localStorage.getItem(householdStoreStorageKey);
|
const savedHouseholdStoreId = localStorage.getItem(householdStoreStorageKey);
|
||||||
@ -94,7 +93,6 @@ export const StoreProvider = ({ children }) => {
|
|||||||
chooseLocationByStoredIds(stores, activeHousehold.id, savedHouseholdStoreId) ||
|
chooseLocationByStoredIds(stores, activeHousehold.id, savedHouseholdStoreId) ||
|
||||||
chooseStoreByHouseholdStoreId(stores, savedHouseholdStoreId);
|
chooseStoreByHouseholdStoreId(stores, savedHouseholdStoreId);
|
||||||
if (store) {
|
if (store) {
|
||||||
console.log('[StoreContext] Found saved household store:', store);
|
|
||||||
setActiveStoreState(store);
|
setActiveStoreState(store);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -105,7 +103,6 @@ export const StoreProvider = ({ children }) => {
|
|||||||
const savedLocation = stores.find(s => String(s.id) === String(savedLocationId));
|
const savedLocation = stores.find(s => String(s.id) === String(savedLocationId));
|
||||||
const store = savedLocation || chooseStoreByHouseholdStoreId(stores, getHouseholdStoreKey(savedLocation));
|
const store = savedLocation || chooseStoreByHouseholdStoreId(stores, getHouseholdStoreKey(savedLocation));
|
||||||
if (store) {
|
if (store) {
|
||||||
console.log('[StoreContext] Migrated saved location to store:', store);
|
|
||||||
const householdStoreId = getHouseholdStoreKey(store);
|
const householdStoreId = getHouseholdStoreKey(store);
|
||||||
setActiveStoreState(store);
|
setActiveStoreState(store);
|
||||||
localStorage.setItem(householdStoreStorageKey, householdStoreId);
|
localStorage.setItem(householdStoreStorageKey, householdStoreId);
|
||||||
@ -122,7 +119,6 @@ export const StoreProvider = ({ children }) => {
|
|||||||
stores,
|
stores,
|
||||||
getHouseholdStoreKey(stores.find(s => s.is_default) || stores[0])
|
getHouseholdStoreKey(stores.find(s => s.is_default) || stores[0])
|
||||||
);
|
);
|
||||||
console.log('[StoreContext] Using store:', defaultStore);
|
|
||||||
setActiveStoreState(defaultStore);
|
setActiveStoreState(defaultStore);
|
||||||
const householdStoreId = getHouseholdStoreKey(defaultStore);
|
const householdStoreId = getHouseholdStoreKey(defaultStore);
|
||||||
localStorage.setItem(householdStoreStorageKey, householdStoreId);
|
localStorage.setItem(householdStoreStorageKey, householdStoreId);
|
||||||
@ -146,9 +142,7 @@ export const StoreProvider = ({ children }) => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
console.log('[StoreContext] Loading stores for household:', activeHousehold.id);
|
|
||||||
const response = await getHouseholdStores(activeHousehold.id);
|
const response = await getHouseholdStores(activeHousehold.id);
|
||||||
console.log('[StoreContext] Loaded stores:', response.data);
|
|
||||||
setStores(response.data);
|
setStores(response.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[StoreContext] Failed to load stores:', 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 {
|
.confirm-buy-nav-btn {
|
||||||
width: 35px;
|
width: 40px;
|
||||||
height: 35px;
|
height: 40px;
|
||||||
border: var(--border-width-medium) solid var(--color-primary);
|
border: var(--border-width-medium) solid var(--color-primary);
|
||||||
border-radius: var(--border-radius-full);
|
border-radius: var(--border-radius-full);
|
||||||
background: var(--color-bg-surface);
|
background: var(--color-bg-surface);
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
font-size: 1.8em;
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition-base);
|
transition: var(--transition-base);
|
||||||
@ -70,6 +69,18 @@
|
|||||||
flex-shrink: 0;
|
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) {
|
.confirm-buy-nav-btn:hover:not(:disabled) {
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: var(--color-text-inverse);
|
color: var(--color-text-inverse);
|
||||||
@ -100,8 +111,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-image-placeholder {
|
.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);
|
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 {
|
.confirm-buy-quantity-section {
|
||||||
@ -260,9 +284,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-nav-btn {
|
.confirm-buy-nav-btn {
|
||||||
width: 30px;
|
width: 40px;
|
||||||
height: 30px;
|
height: 40px;
|
||||||
font-size: 1.6em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-counter-btn {
|
.confirm-buy-counter-btn {
|
||||||
@ -299,8 +322,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-nav-btn {
|
.confirm-buy-nav-btn {
|
||||||
width: 28px;
|
width: 40px;
|
||||||
height: 28px;
|
height: 40px;
|
||||||
font-size: 1.4em;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
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({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
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({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
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();
|
const request = route.request();
|
||||||
|
|
||||||
if (request.method() === "PATCH") {
|
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({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
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.goto("/");
|
||||||
await page.locator(".glist-sort").selectOption("qty-high");
|
|
||||||
|
|
||||||
await openBuyModal(page, "bread");
|
await openBuyModal(page, "bread");
|
||||||
await page.getByRole("button", { name: "Mark as Bought" }).click();
|
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 }) => {
|
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.goto("/");
|
||||||
await page.locator(".glist-sort").selectOption("az");
|
|
||||||
|
|
||||||
await openBuyModal(page, "milk");
|
await openBuyModal(page, "milk");
|
||||||
await page.getByRole("button", { name: "Mark as Bought" }).click();
|
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.goto("/");
|
||||||
await page.locator(".glist-sort").selectOption("qty-low");
|
|
||||||
|
|
||||||
await openBuyModal(page, "bravo");
|
await openBuyModal(page, "bravo");
|
||||||
await page.locator(".confirm-buy-counter-btn").nth(0).click();
|
await page.locator(".confirm-buy-counter-btn").nth(0).click();
|
||||||
|
|||||||
@ -16,8 +16,12 @@ type HouseholdSeed = {
|
|||||||
|
|
||||||
type StoreSeed = {
|
type StoreSeed = {
|
||||||
id?: number;
|
id?: number;
|
||||||
|
household_store_id?: number;
|
||||||
|
household_id?: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
|
location_name?: string;
|
||||||
|
display_name?: string;
|
||||||
is_default?: boolean;
|
is_default?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -57,9 +61,21 @@ export async function mockHouseholdAndStoreShell(
|
|||||||
invite_code: "ABCD1234",
|
invite_code: "ABCD1234",
|
||||||
...options.household,
|
...options.household,
|
||||||
};
|
};
|
||||||
const stores = options.stores || [
|
const stores = (options.stores || [
|
||||||
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
|
{ 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 page.route("**/households", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
@ -76,6 +92,14 @@ export async function mockHouseholdAndStoreShell(
|
|||||||
body: JSON.stringify(stores),
|
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) {
|
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 page.route("**/households/1/locations/*/list/recent", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
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 }) => {
|
test("grocery store selector shows one selected store and picks from a modal", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 473, height: 1000 });
|
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, [
|
await mockGroceryShell(page, [
|
||||||
{
|
{
|
||||||
id: 10,
|
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 expect.poll(() => page.evaluate(() => localStorage.getItem("activeStoreLocationId_1_100"))).toBe("13");
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Manage Map" }).click();
|
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 }) => {
|
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';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
|
|
||||||
const env = loadEnv('', process.cwd());
|
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())
|
? 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({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
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