Compare commits
234 Commits
main
...
feature/lo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
815cd1b37f | ||
|
|
0580914516 | ||
|
|
c9fbbafe58 | ||
|
|
5ba538065f | ||
|
|
1c50c67410 | ||
|
|
dedfc30998 | ||
|
|
5ad3c46836 | ||
|
|
1c081038dd | ||
|
|
37614ea499 | ||
|
|
25b1efc214 | ||
|
|
fc883e9f62 | ||
|
|
d3194959fc | ||
|
|
662fdbb6c5 | ||
|
|
11bace3c1b | ||
|
|
ff68413e62 | ||
|
|
407d8a6943 | ||
|
|
82eab81149 | ||
|
|
35ce919cd2 | ||
|
|
32a4724858 | ||
|
|
57ed1dc70f | ||
|
|
7251cec05e | ||
|
|
567d4b6088 | ||
|
|
853a30093f | ||
|
|
b09edae159 | ||
|
|
14c01d6152 | ||
|
|
984b55a4da | ||
|
|
4a866763ac | ||
|
|
ed40554ef3 | ||
|
|
dcafea4a95 | ||
|
|
2fb7bc6d45 | ||
|
|
66d217dfc0 | ||
|
|
4290b5bb50 | ||
|
|
d116129c62 | ||
|
|
a482828244 | ||
|
|
8ba41b06c6 | ||
|
|
932033682d | ||
|
|
ac028bc446 | ||
|
|
03a4817c5e | ||
|
|
870253eee5 | ||
|
|
90404b778a | ||
|
|
771436b4ca | ||
|
|
8ad160196c | ||
|
|
95afcf3978 | ||
|
|
1ab359d343 | ||
|
|
67ae2763b2 | ||
|
|
691d27fe1b | ||
|
|
635347bea2 | ||
|
|
4c753579dd | ||
|
|
05c9577c71 | ||
|
|
40776821a6 | ||
|
|
2af22f6bb2 | ||
|
|
1020d11382 | ||
|
|
a7a4a8714a | ||
|
|
4e276797c6 | ||
|
|
e5927a4811 | ||
|
|
0c8f18e1e5 | ||
|
|
15324c25f2 | ||
|
|
3bd55846a4 | ||
|
|
a45f02b727 | ||
|
|
5262e89d11 | ||
|
|
7af995f561 | ||
|
|
76f3b8b086 | ||
|
|
a2db8cd655 | ||
|
|
5f1eb30080 | ||
|
|
11979af190 | ||
|
|
85061b93d1 | ||
|
|
f25307cb47 | ||
|
|
f397f19e48 | ||
|
|
26b05d9729 | ||
|
|
0778c1a71c | ||
|
|
bf60bd6a3f | ||
|
|
faf96ef929 | ||
|
|
155ab77541 | ||
|
|
1a01c788f6 | ||
|
|
b96cb65d6f | ||
|
|
913d458fd6 | ||
|
|
9f4420b23c | ||
|
|
0d05bf761e | ||
|
|
8f06f3e072 | ||
|
|
cfa6e70c13 | ||
|
|
9772d7e043 | ||
|
|
27a50d599c | ||
|
|
a2837facaa | ||
|
|
5e5b6f9cbd | ||
|
|
10d442698b | ||
|
|
a862eba0fa | ||
|
|
64b8071578 | ||
|
|
37a7b79cd8 | ||
|
|
8510c1180a | ||
|
|
e0417783e9 | ||
|
|
8614c0d984 | ||
|
|
48aab43220 | ||
|
|
1874851b37 | ||
|
|
d5b38b6711 | ||
|
|
abda001208 | ||
|
|
bddae7d489 | ||
|
|
628a4f48c2 | ||
|
|
4c42808396 | ||
|
|
1658b7f979 | ||
|
|
a92c0a69c2 | ||
|
|
35b514911b | ||
|
|
ada2193085 | ||
|
|
1b7aaa1989 | ||
|
|
6a5d90df09 | ||
|
|
95ac122266 | ||
|
|
aff9b2cac9 | ||
|
|
604025588e | ||
|
|
bc7b8e41f8 | ||
|
|
95b14b447f | ||
|
|
03814b8b4c | ||
|
|
3f6eade576 | ||
|
|
d5cfd22014 | ||
|
|
ea05691a65 | ||
|
|
19d345ac9a | ||
|
|
505d9ce0f2 | ||
|
|
11ac4c9eb2 | ||
|
|
214df9faec | ||
|
|
69488bb1ba | ||
|
|
f86709f45e | ||
|
|
a0feb0e0c9 | ||
|
|
90878a6d7f | ||
|
|
f9aa38e57a | ||
|
|
97a502362d | ||
|
|
be15ac9fd4 | ||
|
|
0fec9dcc3e | ||
|
|
d78dca7b1a | ||
|
|
5324ee893a | ||
|
|
34c818b035 | ||
|
|
44cc376e1b | ||
|
|
567087ab53 | ||
|
|
1f52d43ca4 | ||
|
|
b0cfaeba60 | ||
|
|
c945a71f45 | ||
|
|
9390bfdf53 | ||
|
|
2a3e028281 | ||
|
|
e5ccc26a89 | ||
|
|
8808dad38e | ||
|
|
7f73a3aed3 | ||
|
|
4fd3807ebf | ||
|
|
af08bf5c94 | ||
|
|
bf34cc341a | ||
|
|
59b82650a6 | ||
|
|
80f7831647 | ||
|
|
e3998acc12 | ||
|
|
6c9323cdd8 | ||
|
|
24f2283caf | ||
|
|
493b0b3d34 | ||
|
|
164a95e058 | ||
|
|
2b534eed6a | ||
|
|
b941d5ffed | ||
|
|
76dcea237e | ||
|
|
e2128a4ad7 | ||
|
|
ba48ece884 | ||
|
|
784d0f4d48 | ||
|
|
0a16ad3e7e | ||
|
|
214e2626fe | ||
|
|
0ab839ef7d | ||
|
|
daa283b160 | ||
|
|
5b8b7ee2b8 | ||
|
|
1bcf4ea6d1 | ||
|
|
306812d692 | ||
|
|
415a7223cd | ||
|
|
daef7a86de | ||
|
|
ff78149336 | ||
|
|
46293cd3c2 | ||
|
|
0701a62ff1 | ||
|
|
c8a29d20a8 | ||
|
|
e09a95e172 | ||
|
|
aaf0876b63 | ||
|
|
381a21015c | ||
|
|
bff830dd4b | ||
|
|
f8b3a684b8 | ||
|
|
fd22cc1fe8 | ||
|
|
c236efdd72 | ||
|
|
24a6405a1a | ||
|
|
3ac80c9a5b | ||
|
|
a8f747984d | ||
|
|
757a5fb5b7 | ||
|
|
bc6cfb97a1 | ||
|
|
eef8accf45 | ||
|
|
eb89c1f6fc | ||
|
|
18b11edba9 | ||
|
|
ff6468bd71 | ||
|
|
c0ed1c0472 | ||
|
|
1eaddd8a07 | ||
|
|
04b538011e | ||
|
|
138c3e3aa8 | ||
|
|
607f74e679 | ||
|
|
0361420813 | ||
|
|
ad74262604 | ||
|
|
82e948ee66 | ||
|
|
d370e23529 | ||
|
|
023aef10ec | ||
|
|
c87aa8f474 | ||
|
|
96f1cf4625 | ||
|
|
80f09f324d | ||
|
|
5b964eef1f | ||
|
|
7bb76a8eb4 | ||
|
|
6cddc1f95a | ||
|
|
66e5c41563 | ||
|
|
9d538f97bf | ||
|
|
2a7fa1871f | ||
|
|
6f57b485d8 | ||
|
|
b739be29ba | ||
|
|
c100174ea8 | ||
|
|
78893545b2 | ||
|
|
23df814fb3 | ||
|
|
b4abcf3380 | ||
| 5fd9d68fe3 | |||
|
|
5e2b2e6e5a | ||
| d94a169417 | |||
|
|
fb35b2b695 | ||
| bafbc0fdac | |||
|
|
346fb917b7 | ||
| 27a6a50744 | |||
|
|
f80ea472ce | ||
| 8387e22b4f | |||
|
|
4b98740a7b | ||
| 737aa6d66e | |||
|
|
0ef35c3f92 | ||
| 6891fc2235 | |||
|
|
a432db93bc | ||
| 99d42f6dd0 | |||
|
|
31eade066f | ||
| ce04ac6951 | |||
|
|
3816f255a0 | ||
| fe9f1eeacc | |||
|
|
7e46e25366 | ||
| f968d304cc | |||
|
|
6252c0538f | ||
| eef2c15e8c | |||
|
|
6731ba3d09 | ||
| cb38b051b3 | |||
|
|
ec7b403546 |
@ -24,7 +24,7 @@ If anything conflicts, follow **this** doc.
|
||||
- `docker compose -f docker-compose.dev.yml up -d --build backend`
|
||||
- After backend env/CORS changes, recreate the backend service so `backend/.env` is reloaded:
|
||||
- `docker compose -f docker-compose.dev.yml up -d --force-recreate --no-deps backend`
|
||||
- For the Docker frontend on port `3010`, `ALLOWED_ORIGINS` must include the exact browser origin, for example `http://localhost:3010` and `http://127.0.0.1:3010`.
|
||||
- For the Docker frontend on port `3010`, `ALLOWED_ORIGINS` must include exact loopback browser origins such as `http://localhost:3010` and `http://127.0.0.1:3010`; private LAN origins such as `http://192.168.7.45:3010` are allowed by the backend CORS helper for local device testing.
|
||||
- Verify the restarted API with `GET http://127.0.0.1:5000/` and `GET http://127.0.0.1:5000/config`.
|
||||
- Do not print or commit real `.env` values while checking or updating local Docker env.
|
||||
|
||||
@ -162,6 +162,8 @@ For `app/api/**/[param]/route.ts`:
|
||||
- Touch: long-press affordance for item-level actions when no visible button.
|
||||
- Mouse: hover affordance on interactive rows/cards.
|
||||
- Tap targets remain >= 40px on mobile.
|
||||
- Avoid redundant nearby labels. If a tab, section title, count chip, row label, or control state already communicates the meaning, do not repeat it with an eyebrow label, explanatory zero-state sentence, or duplicate card label.
|
||||
- Prefer compact label/value rows for dense settings controls instead of stacked labels with large vertical gaps.
|
||||
- Modal overlays must close on outside click/tap.
|
||||
- For every frontend action that manipulates database state, show a toast/bubble notification with basic outcome details (action + target + success/failure).
|
||||
- Frontend destructive actions should use the shared `ConfirmSlideModal` pattern instead of browser `confirm()` unless there is a documented exception.
|
||||
|
||||
@ -6,6 +6,6 @@ DB_PORT=5432
|
||||
DB_NAME=
|
||||
PORT=5000
|
||||
JWT_SECRET=change-me
|
||||
ALLOWED_ORIGINS=http://localhost:3000
|
||||
ALLOWED_ORIGINS=http://localhost:3010,http://127.0.0.1:3010,http://192.168.7.45:3010
|
||||
SESSION_COOKIE_NAME=sid
|
||||
SESSION_TTL_DAYS=30
|
||||
|
||||
@ -4,6 +4,7 @@ const path = require("path");
|
||||
const User = require("./models/user.model");
|
||||
const requestIdMiddleware = require("./middleware/request-id");
|
||||
const { sendError } = require("./utils/http");
|
||||
const { isAllowedCorsOrigin, parseAllowedOrigins } = require("./utils/cors-origins");
|
||||
|
||||
const app = express();
|
||||
app.use(requestIdMiddleware);
|
||||
@ -14,17 +15,11 @@ if (process.env.NODE_ENV !== "production") {
|
||||
app.use("/test", express.static(path.join(__dirname, "public")));
|
||||
}
|
||||
|
||||
const allowedOrigins = (process.env.ALLOWED_ORIGINS || "")
|
||||
.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
const allowedOrigins = parseAllowedOrigins(process.env.ALLOWED_ORIGINS || "");
|
||||
app.use(
|
||||
cors({
|
||||
origin: function (origin, callback) {
|
||||
if (!origin) return callback(null, true);
|
||||
if (allowedOrigins.includes(origin)) return callback(null, true);
|
||||
if (/^http:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
|
||||
if (/^https:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
|
||||
if (isAllowedCorsOrigin(origin, allowedOrigins)) return callback(null, true);
|
||||
console.error(`CORS blocked origin: ${origin}`);
|
||||
callback(new Error(`CORS blocked: ${origin}. Add this origin to ALLOWED_ORIGINS environment variable.`));
|
||||
},
|
||||
|
||||
131
backend/controllers/location-maps.controller.js
Normal file
131
backend/controllers/location-maps.controller.js
Normal file
@ -0,0 +1,131 @@
|
||||
const locationMapModel = require("../models/location-map.model");
|
||||
const { sendError } = require("../utils/http");
|
||||
const { logError } = require("../utils/logger");
|
||||
|
||||
function parsePositiveInteger(value) {
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function canManageMaps(req) {
|
||||
return ["owner", "admin"].includes(req.household?.role);
|
||||
}
|
||||
|
||||
function getScope(req) {
|
||||
return {
|
||||
householdId: parsePositiveInteger(req.params.householdId),
|
||||
locationId: parsePositiveInteger(req.params.locationId),
|
||||
};
|
||||
}
|
||||
|
||||
function validateScope(res, scope) {
|
||||
if (!scope.householdId || !scope.locationId) {
|
||||
sendError(res, 400, "Household ID and location ID must be positive integers");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function sendMapState(res, state, req) {
|
||||
if (!state) {
|
||||
return sendError(res, 404, "Store location not found");
|
||||
}
|
||||
|
||||
return res.json({
|
||||
...state,
|
||||
can_manage: canManageMaps(req),
|
||||
});
|
||||
}
|
||||
|
||||
exports.getLocationMap = async (req, res) => {
|
||||
const scope = getScope(req);
|
||||
if (!validateScope(res, scope)) return;
|
||||
|
||||
try {
|
||||
const state = await locationMapModel.getMapState(
|
||||
scope.householdId,
|
||||
scope.locationId,
|
||||
canManageMaps(req)
|
||||
);
|
||||
sendMapState(res, state, req);
|
||||
} catch (error) {
|
||||
logError(req, "locationMaps.getLocationMap", error);
|
||||
sendError(res, 500, "Failed to load location map");
|
||||
}
|
||||
};
|
||||
|
||||
exports.createBlankMap = async (req, res) => {
|
||||
const scope = getScope(req);
|
||||
if (!validateScope(res, scope)) return;
|
||||
|
||||
try {
|
||||
const state = await locationMapModel.saveBlankDraft(
|
||||
scope.householdId,
|
||||
scope.locationId,
|
||||
req.user.id,
|
||||
req.body?.map || req.body || {}
|
||||
);
|
||||
res.status(201);
|
||||
sendMapState(res, state, req);
|
||||
} catch (error) {
|
||||
logError(req, "locationMaps.createBlankMap", error);
|
||||
sendError(res, 500, "Failed to create blank map");
|
||||
}
|
||||
};
|
||||
|
||||
exports.createMapFromZones = async (req, res) => {
|
||||
const scope = getScope(req);
|
||||
if (!validateScope(res, scope)) return;
|
||||
|
||||
try {
|
||||
const state = await locationMapModel.createDraftFromZones(
|
||||
scope.householdId,
|
||||
scope.locationId,
|
||||
req.user.id,
|
||||
req.body?.map || req.body || {}
|
||||
);
|
||||
res.status(201);
|
||||
sendMapState(res, state, req);
|
||||
} catch (error) {
|
||||
logError(req, "locationMaps.createMapFromZones", error);
|
||||
sendError(res, 500, "Failed to create map from zones");
|
||||
}
|
||||
};
|
||||
|
||||
exports.saveDraft = async (req, res) => {
|
||||
const scope = getScope(req);
|
||||
if (!validateScope(res, scope)) return;
|
||||
|
||||
try {
|
||||
const state = await locationMapModel.saveDraft(
|
||||
scope.householdId,
|
||||
scope.locationId,
|
||||
req.user.id,
|
||||
req.body || {}
|
||||
);
|
||||
sendMapState(res, state, req);
|
||||
} catch (error) {
|
||||
logError(req, "locationMaps.saveDraft", error);
|
||||
sendError(res, 500, "Failed to save map draft");
|
||||
}
|
||||
};
|
||||
|
||||
exports.publishDraft = async (req, res) => {
|
||||
const scope = getScope(req);
|
||||
if (!validateScope(res, scope)) return;
|
||||
|
||||
try {
|
||||
const state = await locationMapModel.publishDraft(
|
||||
scope.householdId,
|
||||
scope.locationId,
|
||||
req.user.id
|
||||
);
|
||||
if (!state) {
|
||||
return sendError(res, 404, "No draft map to publish");
|
||||
}
|
||||
sendMapState(res, state, req);
|
||||
} catch (error) {
|
||||
logError(req, "locationMaps.publishDraft", error);
|
||||
sendError(res, 500, "Failed to publish map");
|
||||
}
|
||||
};
|
||||
536
backend/models/location-map.model.js
Normal file
536
backend/models/location-map.model.js
Normal file
@ -0,0 +1,536 @@
|
||||
const pool = require("../db/pool");
|
||||
const storeModel = require("./store.model");
|
||||
const {
|
||||
buildStarterObjectsFromZones,
|
||||
getStarterMapSize,
|
||||
} = require("../services/location-map-layout.service");
|
||||
|
||||
const DEFAULT_MAP_WIDTH = 1000;
|
||||
const DEFAULT_MAP_HEIGHT = 700;
|
||||
const DEFAULT_MAP_NAME = "Store Map";
|
||||
const MAP_OBJECT_TYPES = new Set([
|
||||
"zone",
|
||||
"aisle",
|
||||
"entrance",
|
||||
"checkout",
|
||||
"shelf",
|
||||
"wall",
|
||||
"department",
|
||||
"anchor",
|
||||
"other",
|
||||
]);
|
||||
|
||||
function toNumber(value, fallback = 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function toPositiveNumber(value, fallback) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function toPositiveInteger(value, fallback) {
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function normalizeMapRow(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
...row,
|
||||
width: toPositiveInteger(row.width, DEFAULT_MAP_WIDTH),
|
||||
height: toPositiveInteger(row.height, DEFAULT_MAP_HEIGHT),
|
||||
version: toPositiveInteger(row.version, 1),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeObjectRow(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
...row,
|
||||
x: toNumber(row.x),
|
||||
y: toNumber(row.y),
|
||||
width: toPositiveNumber(row.width, 160),
|
||||
height: toPositiveNumber(row.height, 100),
|
||||
rotation: toNumber(row.rotation),
|
||||
locked: Boolean(row.locked),
|
||||
visible: row.visible !== false,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeMapPayload(payload = {}) {
|
||||
return {
|
||||
name:
|
||||
typeof payload.name === "string" && payload.name.trim()
|
||||
? payload.name.trim().slice(0, 120)
|
||||
: DEFAULT_MAP_NAME,
|
||||
width: toPositiveInteger(payload.width, DEFAULT_MAP_WIDTH),
|
||||
height: toPositiveInteger(payload.height, DEFAULT_MAP_HEIGHT),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeObjectPayload(object = {}, index = 0) {
|
||||
const type = MAP_OBJECT_TYPES.has(object.type) ? object.type : "zone";
|
||||
const rawZoneId = object.zone_id ?? object.zoneId;
|
||||
const zoneId = rawZoneId === null || rawZoneId === "" || rawZoneId === undefined
|
||||
? null
|
||||
: toPositiveInteger(rawZoneId, null);
|
||||
|
||||
return {
|
||||
zone_id: zoneId,
|
||||
type,
|
||||
label:
|
||||
typeof object.label === "string" && object.label.trim()
|
||||
? object.label.trim().slice(0, 160)
|
||||
: "",
|
||||
x: toNumber(object.x),
|
||||
y: toNumber(object.y),
|
||||
width: toPositiveNumber(object.width, 160),
|
||||
height: toPositiveNumber(object.height, 100),
|
||||
rotation: toNumber(object.rotation),
|
||||
locked: Boolean(object.locked),
|
||||
visible: object.visible !== false,
|
||||
sort_order: toPositiveInteger(object.sort_order ?? object.sortOrder, index + 1),
|
||||
metadata_json:
|
||||
object.metadata_json && typeof object.metadata_json === "object" && !Array.isArray(object.metadata_json)
|
||||
? object.metadata_json
|
||||
: {},
|
||||
};
|
||||
}
|
||||
|
||||
async function getMaps(db, householdId, locationId, includeDraft) {
|
||||
const statuses = includeDraft ? ["draft", "published"] : ["published"];
|
||||
const result = await db.query(
|
||||
`SELECT
|
||||
id,
|
||||
household_id,
|
||||
store_location_id,
|
||||
name,
|
||||
width,
|
||||
height,
|
||||
status,
|
||||
version,
|
||||
created_by,
|
||||
updated_by,
|
||||
published_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM location_maps
|
||||
WHERE household_id = $1
|
||||
AND store_location_id = $2
|
||||
AND status = ANY($3::text[])
|
||||
ORDER BY
|
||||
CASE status WHEN 'draft' THEN 0 WHEN 'published' THEN 1 ELSE 2 END,
|
||||
updated_at DESC`,
|
||||
[householdId, locationId, statuses]
|
||||
);
|
||||
|
||||
return result.rows.map(normalizeMapRow);
|
||||
}
|
||||
|
||||
async function getObjects(db, mapId) {
|
||||
if (!mapId) return [];
|
||||
const result = await db.query(
|
||||
`SELECT
|
||||
lmo.id,
|
||||
lmo.location_map_id,
|
||||
lmo.zone_id,
|
||||
lmo.type,
|
||||
lmo.label,
|
||||
lmo.x,
|
||||
lmo.y,
|
||||
lmo.width,
|
||||
lmo.height,
|
||||
lmo.rotation,
|
||||
lmo.locked,
|
||||
lmo.visible,
|
||||
lmo.sort_order,
|
||||
lmo.metadata_json,
|
||||
slz.name AS zone_name,
|
||||
lmo.created_at,
|
||||
lmo.updated_at
|
||||
FROM location_map_objects lmo
|
||||
LEFT JOIN store_location_zones slz ON slz.id = lmo.zone_id
|
||||
WHERE lmo.location_map_id = $1
|
||||
ORDER BY lmo.sort_order ASC, lmo.id ASC`,
|
||||
[mapId]
|
||||
);
|
||||
|
||||
return result.rows.map(normalizeObjectRow);
|
||||
}
|
||||
|
||||
async function getZoneSummaries(db, householdId, locationId) {
|
||||
const result = await db.query(
|
||||
`WITH map_items AS (
|
||||
SELECT
|
||||
hl.id,
|
||||
resolved_zone.id AS zone_id
|
||||
FROM household_lists hl
|
||||
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
|
||||
LEFT JOIN household_item_classifications hic
|
||||
ON hic.household_id = hl.household_id
|
||||
AND hic.store_location_id = hl.store_location_id
|
||||
AND hic.household_store_item_id = hl.household_store_item_id
|
||||
LEFT JOIN store_location_zones resolved_zone
|
||||
ON resolved_zone.household_id = hl.household_id
|
||||
AND resolved_zone.store_location_id = hl.store_location_id
|
||||
AND resolved_zone.is_active = TRUE
|
||||
AND (
|
||||
resolved_zone.id = hic.zone_id
|
||||
OR (
|
||||
hic.zone_id IS NULL
|
||||
AND hic.zone IS NOT NULL
|
||||
AND resolved_zone.normalized_name = LOWER(BTRIM(hic.zone))
|
||||
)
|
||||
)
|
||||
WHERE hl.household_id = $1
|
||||
AND hl.store_location_id = $2
|
||||
AND hl.bought = FALSE
|
||||
),
|
||||
item_counts AS (
|
||||
SELECT zone_id, COUNT(*)::int AS item_count
|
||||
FROM map_items
|
||||
WHERE zone_id IS NOT NULL
|
||||
GROUP BY zone_id
|
||||
)
|
||||
SELECT
|
||||
slz.id,
|
||||
slz.name,
|
||||
slz.sort_order,
|
||||
slz.color,
|
||||
slz.map_metadata,
|
||||
slz.is_active,
|
||||
COALESCE(item_counts.item_count, 0)::int AS item_count
|
||||
FROM store_location_zones slz
|
||||
LEFT JOIN item_counts ON item_counts.zone_id = slz.id
|
||||
WHERE slz.household_id = $1
|
||||
AND slz.store_location_id = $2
|
||||
AND slz.is_active = TRUE
|
||||
ORDER BY slz.sort_order ASC, slz.name ASC`,
|
||||
[householdId, locationId]
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async function getMapItems(db, householdId, locationId) {
|
||||
const result = await db.query(
|
||||
`SELECT
|
||||
hl.id,
|
||||
hl.household_store_item_id AS item_id,
|
||||
hsi.name AS item_name,
|
||||
hl.quantity,
|
||||
hl.bought,
|
||||
resolved_zone.id AS zone_id,
|
||||
COALESCE(resolved_zone.name, hic.zone) AS zone,
|
||||
(
|
||||
SELECT ARRAY_AGG(active_added_by_users.user_label ORDER BY active_added_by_users.last_added_on DESC, active_added_by_users.user_label)
|
||||
FROM (
|
||||
SELECT
|
||||
COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label,
|
||||
MAX(active_history.added_on) AS last_added_on
|
||||
FROM (
|
||||
SELECT
|
||||
hlh.*,
|
||||
COALESCE(
|
||||
SUM(hlh.quantity) OVER (
|
||||
PARTITION BY hlh.household_list_id
|
||||
ORDER BY hlh.added_on DESC, hlh.id DESC
|
||||
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
|
||||
),
|
||||
0
|
||||
) AS newer_quantity
|
||||
FROM household_list_history hlh
|
||||
WHERE hlh.household_list_id = hl.id
|
||||
) active_history
|
||||
JOIN users u ON active_history.added_by = u.id
|
||||
WHERE active_history.newer_quantity < GREATEST(hl.quantity, 0)
|
||||
GROUP BY user_label
|
||||
) active_added_by_users
|
||||
) AS added_by_users
|
||||
FROM household_lists hl
|
||||
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
|
||||
LEFT JOIN household_item_classifications hic
|
||||
ON hic.household_id = hl.household_id
|
||||
AND hic.store_location_id = hl.store_location_id
|
||||
AND hic.household_store_item_id = hl.household_store_item_id
|
||||
LEFT JOIN store_location_zones resolved_zone
|
||||
ON resolved_zone.household_id = hl.household_id
|
||||
AND resolved_zone.store_location_id = hl.store_location_id
|
||||
AND resolved_zone.is_active = TRUE
|
||||
AND (
|
||||
resolved_zone.id = hic.zone_id
|
||||
OR (
|
||||
hic.zone_id IS NULL
|
||||
AND hic.zone IS NOT NULL
|
||||
AND resolved_zone.normalized_name = LOWER(BTRIM(hic.zone))
|
||||
)
|
||||
)
|
||||
WHERE hl.household_id = $1
|
||||
AND hl.store_location_id = $2
|
||||
AND (
|
||||
hl.bought = FALSE
|
||||
OR hl.modified_on >= NOW() - INTERVAL '24 hours'
|
||||
)
|
||||
ORDER BY resolved_zone.sort_order ASC NULLS LAST, hsi.name ASC`,
|
||||
[householdId, locationId]
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
...row,
|
||||
added_by_users: Array.isArray(row.added_by_users) ? row.added_by_users : [],
|
||||
}));
|
||||
}
|
||||
|
||||
async function getActiveZoneIds(db, householdId, locationId) {
|
||||
const result = await db.query(
|
||||
`SELECT id
|
||||
FROM store_location_zones
|
||||
WHERE household_id = $1
|
||||
AND store_location_id = $2
|
||||
AND is_active = TRUE`,
|
||||
[householdId, locationId]
|
||||
);
|
||||
|
||||
return new Set(result.rows.map((row) => Number(row.id)));
|
||||
}
|
||||
|
||||
async function insertObjects(db, mapId, objects, validZoneIds = null) {
|
||||
for (let index = 0; index < objects.length; index += 1) {
|
||||
const object = sanitizeObjectPayload(objects[index], index);
|
||||
const scopedZoneId =
|
||||
object.zone_id && validZoneIds && !validZoneIds.has(Number(object.zone_id))
|
||||
? null
|
||||
: object.zone_id;
|
||||
await db.query(
|
||||
`INSERT INTO location_map_objects
|
||||
(
|
||||
location_map_id,
|
||||
zone_id,
|
||||
type,
|
||||
label,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
rotation,
|
||||
locked,
|
||||
visible,
|
||||
sort_order,
|
||||
metadata_json
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)`,
|
||||
[
|
||||
mapId,
|
||||
scopedZoneId,
|
||||
object.type,
|
||||
object.label,
|
||||
object.x,
|
||||
object.y,
|
||||
object.width,
|
||||
object.height,
|
||||
object.rotation,
|
||||
object.locked,
|
||||
object.visible,
|
||||
object.sort_order,
|
||||
JSON.stringify(object.metadata_json),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertDraftMap(db, householdId, locationId, userId, mapPayload) {
|
||||
const map = sanitizeMapPayload(mapPayload);
|
||||
const result = await db.query(
|
||||
`INSERT INTO location_maps
|
||||
(household_id, store_location_id, name, width, height, status, version, created_by, updated_by, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, 'draft', 1, $6, $6, NOW())
|
||||
ON CONFLICT (store_location_id) WHERE status = 'draft'
|
||||
DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
width = EXCLUDED.width,
|
||||
height = EXCLUDED.height,
|
||||
updated_by = EXCLUDED.updated_by,
|
||||
updated_at = NOW()
|
||||
RETURNING
|
||||
id,
|
||||
household_id,
|
||||
store_location_id,
|
||||
name,
|
||||
width,
|
||||
height,
|
||||
status,
|
||||
version,
|
||||
created_by,
|
||||
updated_by,
|
||||
published_at,
|
||||
created_at,
|
||||
updated_at`,
|
||||
[householdId, locationId, map.name, map.width, map.height, userId]
|
||||
);
|
||||
|
||||
return normalizeMapRow(result.rows[0]);
|
||||
}
|
||||
|
||||
exports.getMapState = async (householdId, locationId, includeDraft = false) => {
|
||||
const location = await storeModel.getLocationById(householdId, locationId);
|
||||
if (!location) return null;
|
||||
|
||||
const maps = await getMaps(pool, householdId, locationId, includeDraft);
|
||||
const draftMap = includeDraft ? maps.find((map) => map.status === "draft") || null : null;
|
||||
const publishedMap = maps.find((map) => map.status === "published") || null;
|
||||
const activeMap = draftMap || publishedMap;
|
||||
const [draftObjects, publishedObjects, zones, items] = await Promise.all([
|
||||
getObjects(pool, draftMap?.id),
|
||||
getObjects(pool, publishedMap?.id),
|
||||
getZoneSummaries(pool, householdId, locationId),
|
||||
getMapItems(pool, householdId, locationId),
|
||||
]);
|
||||
const objects = draftMap ? draftObjects : publishedObjects;
|
||||
|
||||
return {
|
||||
location,
|
||||
map: activeMap || null,
|
||||
draft_map: draftMap,
|
||||
published_map: publishedMap,
|
||||
objects,
|
||||
draft_objects: draftObjects,
|
||||
published_objects: publishedObjects,
|
||||
zones,
|
||||
items,
|
||||
unmapped_count: items.filter((item) => !item.zone_id).length,
|
||||
};
|
||||
};
|
||||
|
||||
exports.saveBlankDraft = async (householdId, locationId, userId, mapPayload = {}) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const map = await upsertDraftMap(client, householdId, locationId, userId, mapPayload);
|
||||
await client.query("DELETE FROM location_map_objects WHERE location_map_id = $1", [map.id]);
|
||||
await client.query("COMMIT");
|
||||
return exports.getMapState(householdId, locationId, true);
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
exports.createDraftFromZones = async (householdId, locationId, userId, mapPayload = {}) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const zones = await getZoneSummaries(client, householdId, locationId);
|
||||
const map = await upsertDraftMap(client, householdId, locationId, userId, mapPayload);
|
||||
await client.query("DELETE FROM location_map_objects WHERE location_map_id = $1", [map.id]);
|
||||
|
||||
const starterSize = getStarterMapSize(zones, map);
|
||||
if (starterSize.width !== map.width || starterSize.height !== map.height) {
|
||||
await client.query(
|
||||
`UPDATE location_maps
|
||||
SET width = $1,
|
||||
height = $2,
|
||||
updated_at = NOW(),
|
||||
updated_by = $3
|
||||
WHERE id = $4`,
|
||||
[starterSize.width, starterSize.height, userId, map.id]
|
||||
);
|
||||
map.width = starterSize.width;
|
||||
map.height = starterSize.height;
|
||||
}
|
||||
|
||||
await insertObjects(
|
||||
client,
|
||||
map.id,
|
||||
buildStarterObjectsFromZones(zones, map.width, map.height)
|
||||
);
|
||||
await client.query("COMMIT");
|
||||
return exports.getMapState(householdId, locationId, true);
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
exports.saveDraft = async (householdId, locationId, userId, payload = {}) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const map = await upsertDraftMap(client, householdId, locationId, userId, payload.map || {});
|
||||
const validZoneIds = await getActiveZoneIds(client, householdId, locationId);
|
||||
await client.query("DELETE FROM location_map_objects WHERE location_map_id = $1", [map.id]);
|
||||
await insertObjects(client, map.id, Array.isArray(payload.objects) ? payload.objects : [], validZoneIds);
|
||||
await client.query("COMMIT");
|
||||
return exports.getMapState(householdId, locationId, true);
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
exports.publishDraft = async (householdId, locationId, userId) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const draftResult = await client.query(
|
||||
`SELECT id
|
||||
FROM location_maps
|
||||
WHERE household_id = $1
|
||||
AND store_location_id = $2
|
||||
AND status = 'draft'
|
||||
FOR UPDATE`,
|
||||
[householdId, locationId]
|
||||
);
|
||||
|
||||
if (draftResult.rowCount === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return null;
|
||||
}
|
||||
|
||||
const versionResult = await client.query(
|
||||
`SELECT COALESCE(MAX(version), 0)::int AS version
|
||||
FROM location_maps
|
||||
WHERE household_id = $1
|
||||
AND store_location_id = $2`,
|
||||
[householdId, locationId]
|
||||
);
|
||||
const nextVersion = (versionResult.rows[0]?.version || 0) + 1;
|
||||
|
||||
await client.query(
|
||||
`UPDATE location_maps
|
||||
SET status = 'archived', updated_by = $3, updated_at = NOW()
|
||||
WHERE household_id = $1
|
||||
AND store_location_id = $2
|
||||
AND status = 'published'`,
|
||||
[householdId, locationId, userId]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`UPDATE location_maps
|
||||
SET status = 'published',
|
||||
version = $3,
|
||||
published_at = NOW(),
|
||||
updated_by = $4,
|
||||
updated_at = NOW()
|
||||
WHERE household_id = $1
|
||||
AND store_location_id = $2
|
||||
AND id = $5`,
|
||||
[householdId, locationId, nextVersion, userId, draftResult.rows[0].id]
|
||||
);
|
||||
|
||||
await client.query("COMMIT");
|
||||
return exports.getMapState(householdId, locationId, true);
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
@ -134,6 +134,8 @@ exports.getHouseholdStores = async (householdId) => {
|
||||
sl.address,
|
||||
sl.is_default,
|
||||
sl.map_data,
|
||||
COALESCE(zone_counts.zone_count, 0)::int AS zone_count,
|
||||
COALESCE(item_counts.item_count, 0)::int AS item_count,
|
||||
sl.created_at,
|
||||
sl.updated_at,
|
||||
CASE
|
||||
@ -142,6 +144,19 @@ exports.getHouseholdStores = async (householdId) => {
|
||||
END AS display_name
|
||||
FROM store_locations sl
|
||||
JOIN household_custom_stores hcs ON hcs.id = sl.household_store_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*)::int AS zone_count
|
||||
FROM store_location_zones slz
|
||||
WHERE slz.household_id = sl.household_id
|
||||
AND slz.store_location_id = sl.id
|
||||
AND slz.is_active = TRUE
|
||||
) zone_counts ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*)::int AS item_count
|
||||
FROM household_store_items hsi
|
||||
WHERE hsi.household_id = sl.household_id
|
||||
AND hsi.store_location_id = sl.id
|
||||
) item_counts ON TRUE
|
||||
WHERE sl.household_id = $1
|
||||
ORDER BY sl.is_default DESC, hcs.name ASC, sl.name ASC`,
|
||||
[householdId, DEFAULT_LOCATION_NAME]
|
||||
|
||||
@ -3,6 +3,7 @@ const router = express.Router();
|
||||
const controller = require("../controllers/households.controller");
|
||||
const listsController = require("../controllers/lists.controller.v2");
|
||||
const availableItemsController = require("../controllers/available-items.controller");
|
||||
const locationMapsController = require("../controllers/location-maps.controller");
|
||||
const storesController = require("../controllers/stores.controller");
|
||||
const auth = require("../middleware/auth");
|
||||
const {
|
||||
@ -134,6 +135,46 @@ router.delete(
|
||||
storesController.deleteZone
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/:householdId/locations/:locationId/map",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
locationMapsController.getLocationMap
|
||||
);
|
||||
router.post(
|
||||
"/:householdId/locations/:locationId/map/blank",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
requireHouseholdAdmin,
|
||||
locationMapsController.createBlankMap
|
||||
);
|
||||
router.post(
|
||||
"/:householdId/locations/:locationId/map/from-zones",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
requireHouseholdAdmin,
|
||||
locationMapsController.createMapFromZones
|
||||
);
|
||||
router.put(
|
||||
"/:householdId/locations/:locationId/map/draft",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
requireHouseholdAdmin,
|
||||
locationMapsController.saveDraft
|
||||
);
|
||||
router.post(
|
||||
"/:householdId/locations/:locationId/map/publish",
|
||||
auth,
|
||||
householdAccess,
|
||||
locationAccess,
|
||||
requireHouseholdAdmin,
|
||||
locationMapsController.publishDraft
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/:householdId/locations/:locationId/available-items",
|
||||
auth,
|
||||
|
||||
71
backend/services/location-map-layout.service.js
Normal file
71
backend/services/location-map-layout.service.js
Normal file
@ -0,0 +1,71 @@
|
||||
const DEFAULT_MAP_WIDTH = 1000;
|
||||
const DEFAULT_MAP_HEIGHT = 700;
|
||||
const STARTER_GAP = 28;
|
||||
const STARTER_MIN_OBJECT_WIDTH = 120;
|
||||
const STARTER_MIN_OBJECT_HEIGHT = 80;
|
||||
const STARTER_TARGET_ROW_HEIGHT = 210;
|
||||
|
||||
function toPositiveInteger(value, fallback) {
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function roundToTwo(value) {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function getStarterGrid(zones) {
|
||||
const zoneCount = Array.isArray(zones) ? zones.length : 0;
|
||||
const columnCount = zoneCount <= 4 ? 2 : 3;
|
||||
const rowCount = Math.ceil(Math.max(zoneCount, 1) / columnCount);
|
||||
return { columnCount, rowCount };
|
||||
}
|
||||
|
||||
function getStarterMapSize(zones, mapSize = {}) {
|
||||
const { columnCount, rowCount } = getStarterGrid(zones);
|
||||
const currentWidth = toPositiveInteger(mapSize.width, DEFAULT_MAP_WIDTH);
|
||||
const currentHeight = toPositiveInteger(mapSize.height, DEFAULT_MAP_HEIGHT);
|
||||
const requiredWidth =
|
||||
columnCount * STARTER_MIN_OBJECT_WIDTH + STARTER_GAP * (columnCount + 1);
|
||||
const minimumGridHeight =
|
||||
rowCount * STARTER_MIN_OBJECT_HEIGHT + STARTER_GAP * (rowCount + 1);
|
||||
const preferredGridHeight = rowCount * STARTER_TARGET_ROW_HEIGHT;
|
||||
|
||||
return {
|
||||
width: Math.max(currentWidth, requiredWidth),
|
||||
height: Math.max(currentHeight, minimumGridHeight, preferredGridHeight),
|
||||
};
|
||||
}
|
||||
|
||||
function buildStarterObjectsFromZones(zones, width, height) {
|
||||
if (!Array.isArray(zones) || !zones.length) return [];
|
||||
|
||||
const mapSize = getStarterMapSize(zones, { width, height });
|
||||
const { columnCount, rowCount } = getStarterGrid(zones);
|
||||
const cellWidth = (mapSize.width - STARTER_GAP * (columnCount + 1)) / columnCount;
|
||||
const cellHeight = (mapSize.height - STARTER_GAP * (rowCount + 1)) / rowCount;
|
||||
|
||||
return zones.map((zone, index) => {
|
||||
const column = index % columnCount;
|
||||
const row = Math.floor(index / columnCount);
|
||||
return {
|
||||
zone_id: zone.id,
|
||||
type: "zone",
|
||||
label: zone.name,
|
||||
x: roundToTwo(STARTER_GAP + column * (cellWidth + STARTER_GAP)),
|
||||
y: roundToTwo(STARTER_GAP + row * (cellHeight + STARTER_GAP)),
|
||||
width: Math.max(STARTER_MIN_OBJECT_WIDTH, roundToTwo(cellWidth)),
|
||||
height: Math.max(STARTER_MIN_OBJECT_HEIGHT, roundToTwo(cellHeight)),
|
||||
rotation: 0,
|
||||
locked: false,
|
||||
visible: true,
|
||||
sort_order: index + 1,
|
||||
metadata_json: { starter: true },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildStarterObjectsFromZones,
|
||||
getStarterMapSize,
|
||||
};
|
||||
37
backend/tests/cors-origins.test.js
Normal file
37
backend/tests/cors-origins.test.js
Normal file
@ -0,0 +1,37 @@
|
||||
const {
|
||||
isAllowedCorsOrigin,
|
||||
isPrivateLanHostname,
|
||||
parseAllowedOrigins,
|
||||
} = require("../utils/cors-origins");
|
||||
|
||||
describe("CORS origin helpers", () => {
|
||||
test("parses configured allowed origins", () => {
|
||||
expect(parseAllowedOrigins(" http://localhost:3010, http://127.0.0.1:3010 ,,")).toEqual([
|
||||
"http://localhost:3010",
|
||||
"http://127.0.0.1:3010",
|
||||
]);
|
||||
});
|
||||
|
||||
test("allows exact configured origins", () => {
|
||||
const allowedOrigins = ["http://localhost:3010"];
|
||||
|
||||
expect(isAllowedCorsOrigin("http://localhost:3010", allowedOrigins)).toBe(true);
|
||||
});
|
||||
|
||||
test("allows browser access from the local private network", () => {
|
||||
expect(isAllowedCorsOrigin("http://192.168.7.45:3010", [])).toBe(true);
|
||||
});
|
||||
|
||||
test("recognizes RFC1918 private IPv4 hostnames", () => {
|
||||
expect(isPrivateLanHostname("10.1.2.3")).toBe(true);
|
||||
expect(isPrivateLanHostname("172.16.0.1")).toBe(true);
|
||||
expect(isPrivateLanHostname("172.31.255.254")).toBe(true);
|
||||
expect(isPrivateLanHostname("192.168.7.45")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects public and lookalike hostnames", () => {
|
||||
expect(isAllowedCorsOrigin("http://192.168.7.45.example.test:3010", [])).toBe(false);
|
||||
expect(isAllowedCorsOrigin("http://203.0.113.10:3010", [])).toBe(false);
|
||||
expect(isAllowedCorsOrigin("ftp://192.168.7.45:3010", [])).toBe(false);
|
||||
});
|
||||
});
|
||||
78
backend/tests/location-map-layout.service.test.js
Normal file
78
backend/tests/location-map-layout.service.test.js
Normal file
@ -0,0 +1,78 @@
|
||||
const {
|
||||
buildStarterObjectsFromZones,
|
||||
getStarterMapSize,
|
||||
} = require("../services/location-map-layout.service");
|
||||
|
||||
function makeZones(count) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: index + 1,
|
||||
name: `Zone ${index + 1}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function overlaps(first, second) {
|
||||
return !(
|
||||
first.x + first.width <= second.x ||
|
||||
second.x + second.width <= first.x ||
|
||||
first.y + first.height <= second.y ||
|
||||
second.y + second.height <= first.y
|
||||
);
|
||||
}
|
||||
|
||||
describe("location map starter layout", () => {
|
||||
test("creates bounded starter rectangles for many zones", () => {
|
||||
const zones = makeZones(13);
|
||||
const mapSize = getStarterMapSize(zones, { width: 1000, height: 700 });
|
||||
const objects = buildStarterObjectsFromZones(zones, mapSize.width, mapSize.height);
|
||||
|
||||
expect(mapSize).toEqual({ width: 1000, height: 1050 });
|
||||
expect(objects).toHaveLength(13);
|
||||
|
||||
objects.forEach((object, index) => {
|
||||
expect(object).toEqual(
|
||||
expect.objectContaining({
|
||||
zone_id: zones[index].id,
|
||||
type: "zone",
|
||||
label: zones[index].name,
|
||||
locked: false,
|
||||
visible: true,
|
||||
sort_order: index + 1,
|
||||
metadata_json: { starter: true },
|
||||
})
|
||||
);
|
||||
expect(object.x).toBeGreaterThanOrEqual(0);
|
||||
expect(object.y).toBeGreaterThanOrEqual(0);
|
||||
expect(object.width).toBeGreaterThanOrEqual(120);
|
||||
expect(object.height).toBeGreaterThanOrEqual(80);
|
||||
expect(object.x + object.width).toBeLessThanOrEqual(mapSize.width);
|
||||
expect(object.y + object.height).toBeLessThanOrEqual(mapSize.height);
|
||||
});
|
||||
|
||||
for (let firstIndex = 0; firstIndex < objects.length; firstIndex += 1) {
|
||||
for (let secondIndex = firstIndex + 1; secondIndex < objects.length; secondIndex += 1) {
|
||||
expect(overlaps(objects[firstIndex], objects[secondIndex])).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("expands cramped map dimensions before placing starter rectangles", () => {
|
||||
const zones = makeZones(5);
|
||||
const mapSize = getStarterMapSize(zones, { width: 300, height: 180 });
|
||||
const objects = buildStarterObjectsFromZones(zones, 300, 180);
|
||||
|
||||
expect(mapSize).toEqual({ width: 472, height: 420 });
|
||||
expect(objects).toHaveLength(5);
|
||||
objects.forEach((object) => {
|
||||
expect(object.x + object.width).toBeLessThanOrEqual(mapSize.width);
|
||||
expect(object.y + object.height).toBeLessThanOrEqual(mapSize.height);
|
||||
});
|
||||
});
|
||||
|
||||
test("returns no objects for locations with no zones", () => {
|
||||
expect(buildStarterObjectsFromZones([], 1000, 700)).toEqual([]);
|
||||
expect(getStarterMapSize([], { width: 1000, height: 700 })).toEqual({
|
||||
width: 1000,
|
||||
height: 700,
|
||||
});
|
||||
});
|
||||
});
|
||||
235
backend/tests/location-map.model.test.js
Normal file
235
backend/tests/location-map.model.test.js
Normal file
@ -0,0 +1,235 @@
|
||||
jest.mock("../db/pool", () => ({
|
||||
query: jest.fn(),
|
||||
connect: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../models/store.model", () => ({
|
||||
getLocationById: jest.fn(),
|
||||
}));
|
||||
|
||||
const pool = require("../db/pool");
|
||||
const storeModel = require("../models/store.model");
|
||||
const LocationMap = require("../models/location-map.model");
|
||||
|
||||
function createClient() {
|
||||
return {
|
||||
query: jest.fn(),
|
||||
release: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("location-map.model", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function mockMapStateQueries() {
|
||||
const location = {
|
||||
id: 10,
|
||||
household_id: 1,
|
||||
name: "Costco",
|
||||
location_name: "Eastvale",
|
||||
};
|
||||
const draftMap = {
|
||||
id: 900,
|
||||
household_id: 1,
|
||||
store_location_id: 10,
|
||||
name: "Draft Map",
|
||||
width: 1000,
|
||||
height: 700,
|
||||
status: "draft",
|
||||
version: 3,
|
||||
};
|
||||
const publishedMap = {
|
||||
id: 901,
|
||||
household_id: 1,
|
||||
store_location_id: 10,
|
||||
name: "Published Map",
|
||||
width: 1000,
|
||||
height: 700,
|
||||
status: "published",
|
||||
version: 2,
|
||||
};
|
||||
const draftObject = {
|
||||
id: 1100,
|
||||
location_map_id: 900,
|
||||
zone_id: 501,
|
||||
type: "zone",
|
||||
label: "Draft Bakery",
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 200,
|
||||
height: 120,
|
||||
rotation: 0,
|
||||
locked: false,
|
||||
visible: true,
|
||||
sort_order: 1,
|
||||
};
|
||||
const publishedObject = {
|
||||
...draftObject,
|
||||
id: 1200,
|
||||
location_map_id: 901,
|
||||
label: "Published Bakery",
|
||||
};
|
||||
const zones = [{ id: 501, name: "Bakery", sort_order: 10, item_count: 1 }];
|
||||
const items = [
|
||||
{
|
||||
id: 3001,
|
||||
item_id: 2001,
|
||||
item_name: "sourdough",
|
||||
quantity: 1,
|
||||
bought: false,
|
||||
zone_id: 501,
|
||||
zone: "Bakery",
|
||||
added_by_users: ["Owner"],
|
||||
},
|
||||
{
|
||||
id: 3002,
|
||||
item_id: 2002,
|
||||
item_name: "bagels",
|
||||
quantity: 1,
|
||||
bought: true,
|
||||
zone_id: 501,
|
||||
zone: "Bakery",
|
||||
added_by_users: ["Owner"],
|
||||
},
|
||||
];
|
||||
|
||||
storeModel.getLocationById.mockResolvedValue(location);
|
||||
pool.query.mockImplementation(async (sql, params = []) => {
|
||||
const query = String(sql);
|
||||
if (query.includes("FROM location_maps") && query.includes("status = ANY")) {
|
||||
const statuses = params[2] || [];
|
||||
return {
|
||||
rows: [draftMap, publishedMap].filter((map) => statuses.includes(map.status)),
|
||||
};
|
||||
}
|
||||
if (query.includes("FROM location_map_objects")) {
|
||||
const mapId = params[0];
|
||||
return {
|
||||
rows: mapId === 900 ? [draftObject] : mapId === 901 ? [publishedObject] : [],
|
||||
};
|
||||
}
|
||||
if (query.includes("FROM store_location_zones")) {
|
||||
return { rows: zones };
|
||||
}
|
||||
if (query.includes("FROM household_lists hl")) {
|
||||
return { rows: items };
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
});
|
||||
|
||||
return { draftMap, publishedMap, draftObject, publishedObject, location, zones, items };
|
||||
}
|
||||
|
||||
test("hides draft map state from non-managers", async () => {
|
||||
const { publishedMap, publishedObject, location, zones, items } = mockMapStateQueries();
|
||||
|
||||
const state = await LocationMap.getMapState(1, 10, false);
|
||||
|
||||
expect(storeModel.getLocationById).toHaveBeenCalledWith(1, 10);
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("FROM location_maps"),
|
||||
[1, 10, ["published"]]
|
||||
);
|
||||
expect(state).toEqual(expect.objectContaining({
|
||||
location,
|
||||
map: expect.objectContaining({ id: publishedMap.id, status: "published" }),
|
||||
draft_map: null,
|
||||
published_map: expect.objectContaining({ id: publishedMap.id, status: "published" }),
|
||||
objects: [expect.objectContaining({ id: publishedObject.id, label: "Published Bakery" })],
|
||||
draft_objects: [],
|
||||
published_objects: [expect.objectContaining({ id: publishedObject.id })],
|
||||
zones,
|
||||
items,
|
||||
unmapped_count: 0,
|
||||
}));
|
||||
});
|
||||
|
||||
test("includes draft and published map state for managers", async () => {
|
||||
const { draftMap, publishedMap, draftObject, publishedObject } = mockMapStateQueries();
|
||||
|
||||
const state = await LocationMap.getMapState(1, 10, true);
|
||||
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("FROM location_maps"),
|
||||
[1, 10, ["draft", "published"]]
|
||||
);
|
||||
expect(state.map).toEqual(expect.objectContaining({ id: draftMap.id, status: "draft" }));
|
||||
expect(state.draft_map).toEqual(expect.objectContaining({ id: draftMap.id, status: "draft" }));
|
||||
expect(state.published_map).toEqual(expect.objectContaining({ id: publishedMap.id, status: "published" }));
|
||||
expect(state.objects).toEqual([expect.objectContaining({ id: draftObject.id, label: "Draft Bakery" })]);
|
||||
expect(state.draft_objects).toEqual([expect.objectContaining({ id: draftObject.id })]);
|
||||
expect(state.published_objects).toEqual([expect.objectContaining({ id: publishedObject.id })]);
|
||||
});
|
||||
|
||||
test("loads active and recent bought rows for map item layers", async () => {
|
||||
const { items } = mockMapStateQueries();
|
||||
|
||||
const state = await LocationMap.getMapState(1, 10, true);
|
||||
|
||||
expect(state.items).toEqual(items);
|
||||
expect(state.items.some((item) => item.bought)).toBe(true);
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("OR hl.modified_on >= NOW() - INTERVAL '24 hours'"),
|
||||
[1, 10]
|
||||
);
|
||||
});
|
||||
|
||||
test("unlinks saved map objects from zones outside the current location", async () => {
|
||||
const client = createClient();
|
||||
pool.connect.mockResolvedValue(client);
|
||||
const getMapStateSpy = jest.spyOn(LocationMap, "getMapState").mockResolvedValue({
|
||||
draft_map: { id: 900 },
|
||||
draft_objects: [],
|
||||
});
|
||||
|
||||
client.query.mockImplementation(async (sql) => {
|
||||
if (sql === "BEGIN" || sql === "COMMIT") {
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
if (String(sql).includes("INSERT INTO location_maps")) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
id: 900,
|
||||
household_id: 1,
|
||||
store_location_id: 10,
|
||||
name: "Store Map",
|
||||
width: 1000,
|
||||
height: 700,
|
||||
status: "draft",
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (String(sql).includes("FROM store_location_zones")) {
|
||||
return { rows: [{ id: 501 }] };
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
});
|
||||
|
||||
await LocationMap.saveDraft(1, 10, 42, {
|
||||
objects: [
|
||||
{ zone_id: 501, label: "Valid zone" },
|
||||
{ zone_id: 999, label: "Foreign zone" },
|
||||
],
|
||||
});
|
||||
|
||||
const insertCalls = client.query.mock.calls.filter(([sql]) =>
|
||||
String(sql).includes("INSERT INTO location_map_objects")
|
||||
);
|
||||
|
||||
expect(insertCalls).toHaveLength(2);
|
||||
expect(insertCalls[0][1][1]).toBe(501);
|
||||
expect(insertCalls[1][1][1]).toBeNull();
|
||||
expect(client.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("FROM store_location_zones"),
|
||||
[1, 10]
|
||||
);
|
||||
expect(getMapStateSpy).toHaveBeenCalledWith(1, 10, true);
|
||||
expect(client.query).toHaveBeenCalledWith("COMMIT");
|
||||
expect(client.release).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -70,6 +70,14 @@ jest.mock("../controllers/available-items.controller", () => ({
|
||||
updateAvailableItem: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../controllers/location-maps.controller", () => ({
|
||||
createBlankMap: jest.fn((req, res) => res.status(201).json({ message: "blank map" })),
|
||||
createMapFromZones: jest.fn((req, res) => res.status(201).json({ message: "zone map" })),
|
||||
getLocationMap: jest.fn((req, res) => res.json({ map: null, objects: [] })),
|
||||
publishDraft: jest.fn((req, res) => res.json({ message: "published map" })),
|
||||
saveDraft: jest.fn((req, res) => res.json({ message: "saved draft" })),
|
||||
}));
|
||||
|
||||
jest.mock("../controllers/stores.controller", () => ({
|
||||
addLocationToStore: jest.fn((req, res) => res.status(201).json({ message: "location" })),
|
||||
createHouseholdStore: jest.fn((req, res) => res.status(201).json({ message: "store" })),
|
||||
@ -89,6 +97,7 @@ const express = require("express");
|
||||
const request = require("supertest");
|
||||
const router = require("../routes/households.routes");
|
||||
const householdsController = require("../controllers/households.controller");
|
||||
const locationMapsController = require("../controllers/location-maps.controller");
|
||||
const storesController = require("../controllers/stores.controller");
|
||||
|
||||
describe("store location routes", () => {
|
||||
@ -161,4 +170,51 @@ describe("store location routes", () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(storesController.updateZone).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("members can view a location map", async () => {
|
||||
const response = await request(app)
|
||||
.get("/households/1/locations/2/map")
|
||||
.set("x-household-role", "member");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(locationMapsController.getLocationMap).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("members cannot create or publish location maps", async () => {
|
||||
const createResponse = await request(app)
|
||||
.post("/households/1/locations/2/map/from-zones")
|
||||
.set("x-household-role", "member")
|
||||
.send({});
|
||||
const publishResponse = await request(app)
|
||||
.post("/households/1/locations/2/map/publish")
|
||||
.set("x-household-role", "member")
|
||||
.send({});
|
||||
|
||||
expect(createResponse.status).toBe(403);
|
||||
expect(publishResponse.status).toBe(403);
|
||||
expect(locationMapsController.createMapFromZones).not.toHaveBeenCalled();
|
||||
expect(locationMapsController.publishDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("admins can create, save, and publish location maps", async () => {
|
||||
const blankResponse = await request(app)
|
||||
.post("/households/1/locations/2/map/blank")
|
||||
.set("x-household-role", "admin")
|
||||
.send({ map: { name: "Costco Map" } });
|
||||
const saveResponse = await request(app)
|
||||
.put("/households/1/locations/2/map/draft")
|
||||
.set("x-household-role", "admin")
|
||||
.send({ objects: [] });
|
||||
const publishResponse = await request(app)
|
||||
.post("/households/1/locations/2/map/publish")
|
||||
.set("x-household-role", "admin")
|
||||
.send({});
|
||||
|
||||
expect(blankResponse.status).toBe(201);
|
||||
expect(saveResponse.status).toBe(200);
|
||||
expect(publishResponse.status).toBe(200);
|
||||
expect(locationMapsController.createBlankMap).toHaveBeenCalled();
|
||||
expect(locationMapsController.saveDraft).toHaveBeenCalled();
|
||||
expect(locationMapsController.publishDraft).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
48
backend/tests/store.model.test.js
Normal file
48
backend/tests/store.model.test.js
Normal file
@ -0,0 +1,48 @@
|
||||
jest.mock("../db/pool", () => ({
|
||||
query: jest.fn(),
|
||||
}));
|
||||
|
||||
const pool = require("../db/pool");
|
||||
const Stores = require("../models/store.model");
|
||||
|
||||
describe("store.model", () => {
|
||||
beforeEach(() => {
|
||||
pool.query.mockReset();
|
||||
});
|
||||
|
||||
test("lists household stores with location manager counts", async () => {
|
||||
pool.query.mockResolvedValueOnce({
|
||||
rows: [
|
||||
{
|
||||
location_id: 10,
|
||||
id: 10,
|
||||
household_id: 1,
|
||||
household_store_id: 100,
|
||||
name: "Costco",
|
||||
location_name: "Default Location",
|
||||
is_default: true,
|
||||
zone_count: 2,
|
||||
item_count: 5,
|
||||
display_name: "Costco",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await Stores.getHouseholdStores(1);
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 10,
|
||||
display_name: "Costco",
|
||||
zone_count: 2,
|
||||
item_count: 5,
|
||||
}),
|
||||
]);
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("AS zone_count"),
|
||||
[1, "Default Location"]
|
||||
);
|
||||
expect(pool.query.mock.calls[0][0]).toContain("FROM store_location_zones slz");
|
||||
expect(pool.query.mock.calls[0][0]).toContain("FROM household_store_items hsi");
|
||||
});
|
||||
});
|
||||
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,
|
||||
};
|
||||
@ -24,6 +24,7 @@ This directory contains practical project documentation. Root-level rules still
|
||||
## Guides
|
||||
- `guides/api-documentation.md`: REST API reference. Verify against current code before changing APIs.
|
||||
- `guides/frontend-readme.md`: frontend development notes.
|
||||
- `guides/management-modal-patterns.md`: reusable modal patterns for managing scoped item/list records.
|
||||
- `guides/MOBILE_RESPONSIVE_AUDIT.md`: mobile design and audit checklist.
|
||||
- `guides/setup-checklist.md`: older setup checklist; prefer `DEVELOPMENT.md` for current commands.
|
||||
|
||||
|
||||
47
docs/guides/management-modal-patterns.md
Normal file
47
docs/guides/management-modal-patterns.md
Normal file
@ -0,0 +1,47 @@
|
||||
# Management Modal Patterns
|
||||
|
||||
Use this guide for modals that manage scoped lists of app-owned records, such as store items, store zones, and store locations.
|
||||
|
||||
Current adopters:
|
||||
- Store item catalog management.
|
||||
- Store location management.
|
||||
- Store location zone management.
|
||||
|
||||
## Purpose
|
||||
- Management modals should keep users in the current workflow while they inspect, edit, add, or remove records for a scoped parent.
|
||||
- The parent scope must be obvious in the title, for example `Costco Items`.
|
||||
- Modals should avoid repeating table labels inside every row. Use row layout, grouping, and the edit surface for detail.
|
||||
|
||||
## Structure
|
||||
- Header: title, one short description when the scope is not obvious, and a close button.
|
||||
- Primary toolbar: search input plus the primary create action inline.
|
||||
- Bulk toolbar: destructive or multi-select actions above the list, separate from search/create controls.
|
||||
- List: compact rows with the record's primary identity and any essential visual affordance.
|
||||
- Editor: clicking or tapping a row opens the edit/settings modal for that record.
|
||||
- Confirmation: destructive actions must use `ConfirmSlideModal`, not browser dialogs.
|
||||
|
||||
## Row Behavior
|
||||
- Normal mode: the entire row opens settings for that record.
|
||||
- Delete mode: the row toggles selected/unselected state and does not open settings.
|
||||
- Selection state must be visible on the row and must not rely only on color.
|
||||
- Avoid per-row action buttons when the same action applies to every row.
|
||||
|
||||
## Bulk Delete Pattern
|
||||
- Show a `Delete Items` button above the list for users with delete permission.
|
||||
- Clicking `Delete Items` enters delete mode, clears any previous selection, and changes the button to `Confirm Delete (#)`.
|
||||
- Show a `Cancel` button while delete mode is active.
|
||||
- Disable confirm while zero items are selected.
|
||||
- Clicking confirm opens `ConfirmSlideModal`; only the slide confirmation performs the mutation.
|
||||
- On success, exit delete mode, clear selection, refresh the list, and show a toast.
|
||||
- On failure, keep the modal open and show a toast with the API error summary.
|
||||
|
||||
## Permission Rules
|
||||
- Keep authorization server-side. Client visibility only improves UX.
|
||||
- Members can open item settings when the API allows them to manage item details.
|
||||
- Delete controls should be shown only to owners/admins when deletion is admin-scoped.
|
||||
|
||||
## Accessibility
|
||||
- Modal containers should use dialog semantics when practical.
|
||||
- Rows that perform actions should be keyboard reachable.
|
||||
- Delete-mode rows should expose selected state with `aria-pressed` or an equivalent state.
|
||||
- Buttons must have stable labels that describe the action in the current mode.
|
||||
@ -1,2 +1,3 @@
|
||||
VITE_API_URL=http://localhost:5000
|
||||
VITE_ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
# Leave blank to infer the API host from the browser URL, e.g. http://192.168.7.45:5000.
|
||||
VITE_API_URL=
|
||||
VITE_ALLOWED_HOSTS=localhost,127.0.0.1,192.168.7.45
|
||||
|
||||
@ -9,7 +9,8 @@ import { SettingsProvider } from "./context/SettingsContext.jsx";
|
||||
import { StoreProvider } from "./context/StoreContext.jsx";
|
||||
|
||||
import AdminPanel from "./pages/AdminPanel.jsx";
|
||||
import GroceryList from "./pages/GroceryList.jsx";
|
||||
import GroceryList from "./pages/GroceryList.jsx";
|
||||
import LocationMapManager from "./pages/LocationMapManager.jsx";
|
||||
import Login from "./pages/Login.jsx";
|
||||
import Manage from "./pages/Manage.jsx";
|
||||
import Register from "./pages/Register.jsx";
|
||||
@ -48,6 +49,7 @@ function App() {
|
||||
<Route path="/" element={<GroceryList />} />
|
||||
<Route path="/manage" element={<Manage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/stores/:storeId/locations/:locationId/map" element={<LocationMapManager />} />
|
||||
|
||||
<Route
|
||||
path="/admin"
|
||||
|
||||
16
frontend/src/api/locationMaps.js
Normal file
16
frontend/src/api/locationMaps.js
Normal file
@ -0,0 +1,16 @@
|
||||
import api from "./axios";
|
||||
|
||||
export const getLocationMap = (householdId, locationId) =>
|
||||
api.get(`/households/${householdId}/locations/${locationId}/map`);
|
||||
|
||||
export const createBlankLocationMap = (householdId, locationId, map = {}) =>
|
||||
api.post(`/households/${householdId}/locations/${locationId}/map/blank`, { map });
|
||||
|
||||
export const createLocationMapFromZones = (householdId, locationId, map = {}) =>
|
||||
api.post(`/households/${householdId}/locations/${locationId}/map/from-zones`, { map });
|
||||
|
||||
export const saveLocationMapDraft = (householdId, locationId, payload) =>
|
||||
api.put(`/households/${householdId}/locations/${locationId}/map/draft`, payload);
|
||||
|
||||
export const publishLocationMapDraft = (householdId, locationId) =>
|
||||
api.post(`/households/${householdId}/locations/${locationId}/map/publish`);
|
||||
@ -71,6 +71,7 @@ export default function ManageHousehold() {
|
||||
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
|
||||
const [pendingRoleChange, setPendingRoleChange] = useState(null);
|
||||
const [pendingMemberRemoval, setPendingMemberRemoval] = useState(null);
|
||||
const [selectedMember, setSelectedMember] = useState(null);
|
||||
|
||||
const isManager = ["owner", "admin"].includes(activeHousehold?.role);
|
||||
const isOwner = activeHousehold?.role === "owner";
|
||||
@ -85,6 +86,19 @@ export default function ManageHousehold() {
|
||||
}
|
||||
}, [activeHousehold?.id, isManager]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedMember) return undefined;
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === "Escape") {
|
||||
setSelectedMember(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectedMember]);
|
||||
|
||||
const loadMembers = async () => {
|
||||
if (!activeHousehold?.id) return;
|
||||
setLoading(true);
|
||||
@ -360,13 +374,34 @@ export default function ManageHousehold() {
|
||||
|
||||
const managerCount = members.filter((member) => ["owner", "admin"].includes(member.role)).length;
|
||||
const memberCount = members.filter((member) => member.role === "member").length;
|
||||
const selectedRoleMeta = selectedMember
|
||||
? ROLE_METADATA[selectedMember.role] || { icon: "👤", label: selectedMember.role }
|
||||
: null;
|
||||
const selectedMemberIsSelf = selectedMember?.id === parseInt(userId, 10);
|
||||
const canManageSelectedMember =
|
||||
Boolean(selectedMember) &&
|
||||
isManager &&
|
||||
!selectedMemberIsSelf &&
|
||||
selectedMember.role !== "owner";
|
||||
const selectedMemberNextRole = selectedMember?.role === "admin" ? "member" : "admin";
|
||||
|
||||
const openMemberRoleChange = (nextRole) => {
|
||||
if (!selectedMember) return;
|
||||
handleUpdateRole(selectedMember.id, nextRole, selectedMember.username);
|
||||
setSelectedMember(null);
|
||||
};
|
||||
|
||||
const openMemberRemoval = () => {
|
||||
if (!selectedMember) return;
|
||||
handleRemoveMember(selectedMember.id, selectedMember.username);
|
||||
setSelectedMember(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="manage-household">
|
||||
<section key="household-name" className="manage-section">
|
||||
<div className="manage-section-header">
|
||||
<div>
|
||||
<p className="manage-section-eyebrow">Household</p>
|
||||
<h2>Identity</h2>
|
||||
</div>
|
||||
</div>
|
||||
@ -411,7 +446,6 @@ export default function ManageHousehold() {
|
||||
<section key="join-and-invites" className="manage-section">
|
||||
<div className="manage-section-header">
|
||||
<div>
|
||||
<p className="manage-section-eyebrow">Entry Rules</p>
|
||||
<h2>Invite Links</h2>
|
||||
</div>
|
||||
</div>
|
||||
@ -435,9 +469,7 @@ export default function ManageHousehold() {
|
||||
|
||||
{inviteLoading ? (
|
||||
<p>Loading invite settings...</p>
|
||||
) : pendingRequests.length === 0 ? (
|
||||
<p className="section-description">No pending join requests right now.</p>
|
||||
) : (
|
||||
) : pendingRequests.length > 0 ? (
|
||||
<div className="pending-requests-list">
|
||||
{pendingRequests.map((request) => {
|
||||
const requesterLabel = getRequesterLabel(request);
|
||||
@ -473,7 +505,7 @@ export default function ManageHousehold() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<div className="invite-controls">
|
||||
<label>
|
||||
@ -547,7 +579,6 @@ export default function ManageHousehold() {
|
||||
<section key="members" className="manage-section">
|
||||
<div className="manage-section-header">
|
||||
<div>
|
||||
<p className="manage-section-eyebrow">People</p>
|
||||
<h2>Members ({members.length})</h2>
|
||||
</div>
|
||||
</div>
|
||||
@ -560,7 +591,13 @@ export default function ManageHousehold() {
|
||||
const isSelf = member.id === parseInt(userId, 10);
|
||||
|
||||
return (
|
||||
<div key={member.id} className="member-card">
|
||||
<button
|
||||
key={member.id}
|
||||
type="button"
|
||||
className="member-card member-card-button"
|
||||
onClick={() => setSelectedMember(member)}
|
||||
aria-label={`Open member actions for ${member.username}`}
|
||||
>
|
||||
<div className="member-main">
|
||||
<div className="member-info">
|
||||
<span className={`member-role member-role-${member.role}`}>
|
||||
@ -570,46 +607,76 @@ export default function ManageHousehold() {
|
||||
{isSelf && <span className="member-self-pill">You</span>}
|
||||
</div>
|
||||
</div>
|
||||
{isManager && !isSelf && member.role !== "owner" && (
|
||||
<div className="member-actions">
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={() => handleUpdateRole(member.id, "owner", member.username)}
|
||||
className="btn-primary btn-small member-owner-action"
|
||||
>
|
||||
Make Owner
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleUpdateRole(
|
||||
member.id,
|
||||
member.role === "admin" ? "member" : "admin",
|
||||
member.username
|
||||
)}
|
||||
className="btn-secondary btn-small member-role-action"
|
||||
>
|
||||
{member.role === "admin" ? "Make Member" : "Make Admin"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveMember(member.id, member.username)}
|
||||
className="btn-danger btn-small"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{selectedMember && (
|
||||
<div className="member-actions-modal-overlay" onClick={() => setSelectedMember(null)}>
|
||||
<div
|
||||
className="member-actions-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="member-actions-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="member-actions-modal-header">
|
||||
<div className="member-actions-modal-copy">
|
||||
<h3 id="member-actions-title">{selectedMember.username}</h3>
|
||||
<span className={`member-role member-role-${selectedMember.role}`}>
|
||||
{selectedRoleMeta.icon} {selectedRoleMeta.label}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="member-actions-modal-close"
|
||||
onClick={() => setSelectedMember(null)}
|
||||
aria-label="Close member actions"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{canManageSelectedMember ? (
|
||||
<div className="member-actions-modal-actions">
|
||||
{isOwner && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openMemberRoleChange("owner")}
|
||||
className="btn-primary member-owner-action"
|
||||
>
|
||||
Make Owner
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openMemberRoleChange(selectedMemberNextRole)}
|
||||
className="btn-secondary member-role-action"
|
||||
>
|
||||
{selectedMember.role === "admin" ? "Make Member" : "Make Admin"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openMemberRemoval}
|
||||
className="btn-danger"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="member-actions-modal-empty">No actions available for this member.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isManager || isMemberOnly) && (
|
||||
<section key="danger-zone" className="manage-section danger-zone">
|
||||
<div className="manage-section-header">
|
||||
<div>
|
||||
<p className="manage-section-eyebrow">Final Actions</p>
|
||||
<h2>Danger Zone</h2>
|
||||
</div>
|
||||
{isMemberOnly ? (
|
||||
|
||||
@ -1,21 +1,14 @@
|
||||
import { useContext, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
addLocationToStore,
|
||||
createHouseholdStore,
|
||||
createLocationZone,
|
||||
deleteLocationZone,
|
||||
getLocationZones,
|
||||
removeLocation,
|
||||
setDefaultLocation,
|
||||
updateLocationZone,
|
||||
} from "../../api/stores";
|
||||
import StoreAvailableItemsManager from "./StoreAvailableItemsManager";
|
||||
import { useContext, useMemo, useState } from "react";
|
||||
import { createHouseholdStore } from "../../api/stores";
|
||||
import { HouseholdContext } from "../../context/HouseholdContext";
|
||||
import { StoreContext } from "../../context/StoreContext";
|
||||
import useActionToast from "../../hooks/useActionToast";
|
||||
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
||||
import "../../styles/components/manage/ManageStores.css";
|
||||
import StoreAvailableItemsManager from "./StoreAvailableItemsManager";
|
||||
import StoreLocationManager from "./StoreLocationManager";
|
||||
import StoreZoneManager from "./StoreZoneManager";
|
||||
import "../../styles/components/manage/StoreAvailableItemsManager.css";
|
||||
import "../../styles/components/manage/ManageStores.css";
|
||||
|
||||
function groupLocationsByStore(locations) {
|
||||
const grouped = new Map();
|
||||
@ -36,163 +29,8 @@ function groupLocationsByStore(locations) {
|
||||
return Array.from(grouped.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function ZoneManager({ householdId, location, canManage, refreshActiveZones }) {
|
||||
const toast = useActionToast();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [zones, setZones] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [newZoneName, setNewZoneName] = useState("");
|
||||
|
||||
const loadZones = async () => {
|
||||
if (!householdId || !location?.id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getLocationZones(householdId, location.id);
|
||||
setZones(response.data?.zones || []);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to load zones");
|
||||
toast.error("Load zones failed", `Load zones failed: ${message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadZones();
|
||||
}
|
||||
}, [isOpen, householdId, location?.id]);
|
||||
|
||||
const handleCreateZone = async () => {
|
||||
const name = newZoneName.trim();
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
const nextSortOrder =
|
||||
zones.length > 0 ? Math.max(...zones.map((zone) => zone.sort_order || 0)) + 10 : 10;
|
||||
await createLocationZone(householdId, location.id, {
|
||||
name,
|
||||
sort_order: nextSortOrder,
|
||||
});
|
||||
setNewZoneName("");
|
||||
await loadZones();
|
||||
await refreshActiveZones();
|
||||
toast.success("Added zone", `Added zone ${name}`);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to add zone");
|
||||
toast.error("Add zone failed", `Add zone failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveZone = async (zone, direction) => {
|
||||
const currentIndex = zones.findIndex((candidate) => candidate.id === zone.id);
|
||||
const swapIndex = currentIndex + direction;
|
||||
if (currentIndex < 0 || swapIndex < 0 || swapIndex >= zones.length) return;
|
||||
|
||||
const other = zones[swapIndex];
|
||||
try {
|
||||
await Promise.all([
|
||||
updateLocationZone(householdId, location.id, zone.id, {
|
||||
sort_order: other.sort_order,
|
||||
}),
|
||||
updateLocationZone(householdId, location.id, other.id, {
|
||||
sort_order: zone.sort_order,
|
||||
}),
|
||||
]);
|
||||
await loadZones();
|
||||
await refreshActiveZones();
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to reorder zones");
|
||||
toast.error("Reorder zones failed", `Reorder zones failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteZone = async (zone) => {
|
||||
if (!confirm(`Remove zone "${zone.name}" from ${location.display_name || location.name}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteLocationZone(householdId, location.id, zone.id);
|
||||
await loadZones();
|
||||
await refreshActiveZones();
|
||||
toast.success("Removed zone", `Removed zone ${zone.name}`);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to remove zone");
|
||||
toast.error("Remove zone failed", `Remove zone failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="store-zones-panel">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary btn-small"
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
>
|
||||
{isOpen ? "Hide Zones" : "Manage Zones"}
|
||||
</button>
|
||||
|
||||
{isOpen ? (
|
||||
<div className="store-zones-content">
|
||||
{canManage ? (
|
||||
<div className="store-zone-create-row">
|
||||
<input
|
||||
value={newZoneName}
|
||||
onChange={(event) => setNewZoneName(event.target.value)}
|
||||
placeholder="New zone name"
|
||||
/>
|
||||
<button type="button" className="btn-primary btn-small" onClick={handleCreateZone}>
|
||||
Add Zone
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="empty-message">Loading zones...</p>
|
||||
) : zones.length === 0 ? (
|
||||
<p className="empty-message">No zones for this location.</p>
|
||||
) : (
|
||||
<div className="store-zone-list">
|
||||
{zones.map((zone, index) => (
|
||||
<div key={zone.id} className="store-zone-row">
|
||||
<span className="store-zone-order">{index + 1}</span>
|
||||
<span className="store-zone-name">{zone.name}</span>
|
||||
{canManage ? (
|
||||
<div className="store-zone-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary btn-small"
|
||||
disabled={index === 0}
|
||||
onClick={() => handleMoveZone(zone, -1)}
|
||||
>
|
||||
Up
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary btn-small"
|
||||
disabled={index === zones.length - 1}
|
||||
onClick={() => handleMoveZone(zone, 1)}
|
||||
>
|
||||
Down
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger btn-small"
|
||||
onClick={() => handleDeleteZone(zone)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
function locationLabel(location) {
|
||||
return location.display_name || location.name;
|
||||
}
|
||||
|
||||
export default function ManageStores() {
|
||||
@ -204,12 +42,7 @@ export default function ManageStores() {
|
||||
refreshZones,
|
||||
} = useContext(StoreContext);
|
||||
const toast = useActionToast();
|
||||
const [createForm, setCreateForm] = useState({
|
||||
name: "",
|
||||
location_name: "",
|
||||
address: "",
|
||||
});
|
||||
const [locationDrafts, setLocationDrafts] = useState({});
|
||||
const [newStoreName, setNewStoreName] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const isAdmin = ["owner", "admin"].includes(activeHousehold?.role);
|
||||
@ -225,18 +58,17 @@ export default function ManageStores() {
|
||||
|
||||
const handleCreateStore = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!createForm.name.trim()) return;
|
||||
const storeName = newStoreName.trim();
|
||||
if (!storeName) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await createHouseholdStore(activeHousehold.id, {
|
||||
name: createForm.name.trim(),
|
||||
location_name: createForm.location_name.trim() || "Default Location",
|
||||
address: createForm.address.trim() || null,
|
||||
name: storeName,
|
||||
});
|
||||
setCreateForm({ name: "", location_name: "", address: "" });
|
||||
setNewStoreName("");
|
||||
await refreshAfterStoreChange();
|
||||
toast.success("Created store", `Created store ${createForm.name.trim()}`);
|
||||
toast.success("Created store", `Created store ${storeName}`);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to create store");
|
||||
toast.error("Create store failed", `Create store failed: ${message}`);
|
||||
@ -245,60 +77,29 @@ export default function ManageStores() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddLocation = async (householdStoreId, storeName) => {
|
||||
const draft = locationDrafts[householdStoreId] || {};
|
||||
const name = String(draft.name || "").trim();
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
await addLocationToStore(activeHousehold.id, householdStoreId, {
|
||||
name,
|
||||
address: String(draft.address || "").trim() || null,
|
||||
});
|
||||
setLocationDrafts((current) => ({
|
||||
...current,
|
||||
[householdStoreId]: { name: "", address: "" },
|
||||
}));
|
||||
await refreshAfterStoreChange();
|
||||
toast.success("Added location", `Added ${name} to ${storeName}`);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to add location");
|
||||
toast.error("Add location failed", `Add location failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = async (location) => {
|
||||
try {
|
||||
await setDefaultLocation(activeHousehold.id, location.id);
|
||||
await refreshAfterStoreChange();
|
||||
toast.success("Updated default location", `Default location set to ${location.display_name || location.name}`);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to set default location");
|
||||
toast.error("Set default failed", `Set default failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveLocation = async (location) => {
|
||||
const label = location.display_name || location.name;
|
||||
if (!confirm(`Remove ${label} from this household?`)) return;
|
||||
|
||||
try {
|
||||
await removeLocation(activeHousehold.id, location.id);
|
||||
await refreshAfterStoreChange();
|
||||
toast.success("Removed location", `Removed ${label}`);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to remove location");
|
||||
toast.error("Remove location failed", `Remove location failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="manage-stores">
|
||||
<section className="manage-section">
|
||||
<h2>Store Locations ({householdStores.length})</h2>
|
||||
<div className="manage-stores-topline">
|
||||
<h2>Store Locations ({householdStores.length})</h2>
|
||||
{isAdmin ? (
|
||||
<form className="add-store-inline" onSubmit={handleCreateStore}>
|
||||
<input
|
||||
value={newStoreName}
|
||||
onChange={(event) => setNewStoreName(event.target.value)}
|
||||
placeholder="Store name"
|
||||
aria-label="Store name"
|
||||
required
|
||||
/>
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? "Adding..." : "Add"}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="manage-stores-help">
|
||||
Stores and locations are private to this household. Each location has its own zones,
|
||||
item defaults, and shopping order.
|
||||
Stores are private to this household. Locations define map-specific zones, item
|
||||
placement, and shopping order.
|
||||
</p>
|
||||
{householdStores.length === 0 ? (
|
||||
<p className="empty-message">No store locations added yet.</p>
|
||||
@ -306,133 +107,64 @@ export default function ManageStores() {
|
||||
<div className="stores-list">
|
||||
{groupedStores.map((storeGroup) => (
|
||||
<div key={storeGroup.household_store_id} className="store-card">
|
||||
<div className="store-info">
|
||||
<h3>{storeGroup.name}</h3>
|
||||
<div className="store-card-header">
|
||||
<div className="store-info">
|
||||
<h3>{storeGroup.name}</h3>
|
||||
</div>
|
||||
|
||||
<StoreLocationManager
|
||||
householdId={activeHousehold.id}
|
||||
storeGroup={storeGroup}
|
||||
allLocationCount={householdStores.length}
|
||||
canManage={isAdmin}
|
||||
refreshAfterStoreChange={refreshAfterStoreChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="store-location-list">
|
||||
{storeGroup.locations.map((location) => (
|
||||
<div key={location.id} className="store-location-row">
|
||||
<div className="store-info">
|
||||
<strong>{location.display_name || location.name}</strong>
|
||||
{location.address ? (
|
||||
<p className="store-location">{location.address}</p>
|
||||
) : null}
|
||||
{location.is_default ? (
|
||||
<p className="store-location">Default shopping location</p>
|
||||
) : null}
|
||||
{storeGroup.locations.map((location) => {
|
||||
const label = locationLabel(location);
|
||||
const showLocationName =
|
||||
storeGroup.locations.length > 1 || label !== storeGroup.name;
|
||||
|
||||
return (
|
||||
<div key={location.id} className="store-location-row">
|
||||
<div className="store-info store-location-copy">
|
||||
{showLocationName ? <strong>{label}</strong> : null}
|
||||
{location.address ? (
|
||||
<p className="store-location">{location.address}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="store-location-controls">
|
||||
<StoreZoneManager
|
||||
householdId={activeHousehold.id}
|
||||
location={location}
|
||||
canManage={isAdmin}
|
||||
refreshActiveZones={refreshZones}
|
||||
refreshStoreCounts={refreshStores}
|
||||
zoneCount={Number(location.zone_count ?? location.zoneCount ?? 0)}
|
||||
/>
|
||||
|
||||
<StoreAvailableItemsManager
|
||||
householdId={activeHousehold.id}
|
||||
store={location}
|
||||
isAdmin={isAdmin}
|
||||
refreshStoreCounts={refreshStores}
|
||||
itemCount={Number(location.item_count ?? location.itemCount ?? 0)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="store-actions">
|
||||
{isAdmin && !location.is_default ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSetDefault(location)}
|
||||
className="btn-secondary btn-small"
|
||||
>
|
||||
Set Default
|
||||
</button>
|
||||
) : null}
|
||||
{isAdmin ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveLocation(location)}
|
||||
className="btn-danger btn-small"
|
||||
disabled={householdStores.length === 1}
|
||||
title={householdStores.length === 1 ? "Cannot remove last location" : ""}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ZoneManager
|
||||
householdId={activeHousehold.id}
|
||||
location={location}
|
||||
canManage={isAdmin}
|
||||
refreshActiveZones={refreshZones}
|
||||
/>
|
||||
|
||||
<StoreAvailableItemsManager
|
||||
householdId={activeHousehold.id}
|
||||
store={location}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isAdmin ? (
|
||||
<div className="add-location-panel">
|
||||
<input
|
||||
value={locationDrafts[storeGroup.household_store_id]?.name || ""}
|
||||
onChange={(event) =>
|
||||
setLocationDrafts((current) => ({
|
||||
...current,
|
||||
[storeGroup.household_store_id]: {
|
||||
...(current[storeGroup.household_store_id] || {}),
|
||||
name: event.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
placeholder="Location name"
|
||||
/>
|
||||
<input
|
||||
value={locationDrafts[storeGroup.household_store_id]?.address || ""}
|
||||
onChange={(event) =>
|
||||
setLocationDrafts((current) => ({
|
||||
...current,
|
||||
[storeGroup.household_store_id]: {
|
||||
...(current[storeGroup.household_store_id] || {}),
|
||||
address: event.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
placeholder="Address or notes"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary btn-small"
|
||||
onClick={() => handleAddLocation(storeGroup.household_store_id, storeGroup.name)}
|
||||
>
|
||||
Add Location
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{isAdmin ? (
|
||||
<section className="manage-section">
|
||||
<h2>Add Store</h2>
|
||||
<form className="add-store-panel" onSubmit={handleCreateStore}>
|
||||
<input
|
||||
value={createForm.name}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, name: event.target.value }))}
|
||||
placeholder="Store name, e.g. Costco"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
value={createForm.location_name}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, location_name: event.target.value }))
|
||||
}
|
||||
placeholder="Location name, e.g. Fontana"
|
||||
/>
|
||||
<input
|
||||
value={createForm.address}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, address: event.target.value }))}
|
||||
placeholder="Address or notes"
|
||||
/>
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? "Adding..." : "+ Add Store"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
) : activeStore ? (
|
||||
{!isAdmin && activeStore ? (
|
||||
<p className="manage-stores-note">
|
||||
Household members can manage item defaults. Only owners and admins can manage stores,
|
||||
locations, zones, and item deletion.
|
||||
|
||||
@ -20,10 +20,17 @@ function itemImageSource(item) {
|
||||
return `data:${mimeType};base64,${item.item_image}`;
|
||||
}
|
||||
|
||||
export default function StoreAvailableItemsManager({ householdId, store, isAdmin }) {
|
||||
export default function StoreAvailableItemsManager({
|
||||
householdId,
|
||||
store,
|
||||
isAdmin,
|
||||
refreshStoreCounts,
|
||||
itemCount = 0,
|
||||
}) {
|
||||
const toast = useActionToast();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [items, setItems] = useState([]);
|
||||
const [displayItemCount, setDisplayItemCount] = useState(itemCount);
|
||||
const [zones, setZones] = useState([]);
|
||||
const [catalogReady, setCatalogReady] = useState(true);
|
||||
const [catalogMessage, setCatalogMessage] = useState("");
|
||||
@ -31,7 +38,12 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editorItem, setEditorItem] = useState(null);
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [pendingDeleteItem, setPendingDeleteItem] = useState(null);
|
||||
const [deleteMode, setDeleteMode] = useState(false);
|
||||
const [selectedDeleteIds, setSelectedDeleteIds] = useState(() => new Set());
|
||||
const [pendingDeleteItems, setPendingDeleteItems] = useState([]);
|
||||
|
||||
const selectedDeleteItems = items.filter((item) => selectedDeleteIds.has(item.item_id));
|
||||
const selectedDeleteCount = selectedDeleteItems.length;
|
||||
|
||||
const loadItems = useCallback(async (search = query) => {
|
||||
if (!householdId || !store?.id) {
|
||||
@ -80,9 +92,15 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
loadZones();
|
||||
}, [isOpen, query, loadItems, loadZones]);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayItemCount(itemCount);
|
||||
}, [itemCount]);
|
||||
|
||||
const closeManager = () => {
|
||||
setIsOpen(false);
|
||||
setPendingDeleteItem(null);
|
||||
setDeleteMode(false);
|
||||
setSelectedDeleteIds(new Set());
|
||||
setPendingDeleteItems([]);
|
||||
};
|
||||
|
||||
const handleUpdate = async (payload) => {
|
||||
@ -108,6 +126,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
setShowEditor(false);
|
||||
setEditorItem(null);
|
||||
await loadItems(query);
|
||||
await refreshStoreCounts?.();
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to update store item");
|
||||
toast.error("Update store item failed", `Update store item failed: ${message}`);
|
||||
@ -115,19 +134,65 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
}
|
||||
};
|
||||
|
||||
const openEditor = (item) => {
|
||||
setEditorItem(item);
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
const toggleDeleteSelection = (itemId) => {
|
||||
setSelectedDeleteIds((currentIds) => {
|
||||
const nextIds = new Set(currentIds);
|
||||
|
||||
if (nextIds.has(itemId)) {
|
||||
nextIds.delete(itemId);
|
||||
} else {
|
||||
nextIds.add(itemId);
|
||||
}
|
||||
|
||||
return nextIds;
|
||||
});
|
||||
};
|
||||
|
||||
const startDeleteMode = () => {
|
||||
setDeleteMode(true);
|
||||
setSelectedDeleteIds(new Set());
|
||||
};
|
||||
|
||||
const cancelDeleteMode = () => {
|
||||
setDeleteMode(false);
|
||||
setSelectedDeleteIds(new Set());
|
||||
};
|
||||
|
||||
const confirmSelectedDelete = () => {
|
||||
if (selectedDeleteCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingDeleteItems(selectedDeleteItems);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!pendingDeleteItem) {
|
||||
if (pendingDeleteItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAvailableItem(householdId, store.id, pendingDeleteItem.item_id);
|
||||
toast.success("Deleted store item", `Deleted ${pendingDeleteItem.item_name} from ${store.display_name || store.name}`);
|
||||
setPendingDeleteItem(null);
|
||||
await Promise.all(
|
||||
pendingDeleteItems.map((item) => deleteAvailableItem(householdId, store.id, item.item_id))
|
||||
);
|
||||
const count = pendingDeleteItems.length;
|
||||
toast.success(
|
||||
count === 1 ? "Deleted store item" : "Deleted store items",
|
||||
`Deleted ${count} ${count === 1 ? "item" : "items"} from ${store.display_name || store.name}`
|
||||
);
|
||||
setPendingDeleteItems([]);
|
||||
setDeleteMode(false);
|
||||
setSelectedDeleteIds(new Set());
|
||||
await loadItems(query);
|
||||
await refreshStoreCounts?.();
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to delete store item");
|
||||
toast.error("Delete store item failed", `Delete store item failed: ${message}`);
|
||||
toast.error("Delete store items failed", `Delete store items failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@ -138,7 +203,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
className="btn-secondary btn-small store-available-items-trigger"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
Manage Items
|
||||
Manage Items ({displayItemCount})
|
||||
</button>
|
||||
|
||||
{isOpen ? (
|
||||
@ -186,6 +251,28 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isAdmin && catalogReady && items.length > 0 ? (
|
||||
<div className="store-items-bulk-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger btn-small store-items-delete-toggle"
|
||||
disabled={deleteMode ? selectedDeleteCount === 0 : loading}
|
||||
onClick={deleteMode ? confirmSelectedDelete : startDeleteMode}
|
||||
>
|
||||
{deleteMode ? `Confirm Delete (${selectedDeleteCount})` : "Delete Items"}
|
||||
</button>
|
||||
{deleteMode ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary btn-small store-items-delete-cancel"
|
||||
onClick={cancelDeleteMode}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="store-items-modal-body">
|
||||
{!catalogReady ? (
|
||||
<p className="empty-message">Run the latest database migrations to enable store item management.</p>
|
||||
@ -195,26 +282,40 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
<p className="empty-message">No household items found for this store yet.</p>
|
||||
) : (
|
||||
<div className="store-items-table">
|
||||
<div className="store-items-table-head" aria-hidden="true">
|
||||
<span>Item</span>
|
||||
<span>Store Defaults</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
<div className="store-items-table-body">
|
||||
{items.map((item) => {
|
||||
const imageSrc = itemImageSource(item);
|
||||
const details = [item.item_type, item.item_group, item.zone].filter(Boolean);
|
||||
const isSelectedForDelete = selectedDeleteIds.has(item.item_id);
|
||||
|
||||
return (
|
||||
<div key={item.item_id} className="store-items-table-row">
|
||||
<button
|
||||
key={item.item_id}
|
||||
type="button"
|
||||
className={`store-items-table-row store-items-table-row-button ${deleteMode ? "is-delete-selectable" : ""} ${isSelectedForDelete ? "is-selected" : ""}`}
|
||||
aria-label={
|
||||
deleteMode
|
||||
? `${isSelectedForDelete ? "Deselect" : "Select"} ${item.item_name} for deletion`
|
||||
: `Edit settings for ${item.item_name}`
|
||||
}
|
||||
aria-pressed={deleteMode ? isSelectedForDelete : undefined}
|
||||
onClick={() => {
|
||||
if (deleteMode) {
|
||||
toggleDeleteSelection(item.item_id);
|
||||
} else {
|
||||
openEditor(item);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="store-items-table-cell store-items-table-item">
|
||||
<span className="store-items-mobile-label">Item</span>
|
||||
<div className="store-available-items-summary">
|
||||
{imageSrc ? (
|
||||
<img src={imageSrc} alt="" className="store-available-items-thumb" />
|
||||
) : (
|
||||
<span className="store-available-items-thumb store-available-items-thumb-placeholder">
|
||||
{item.item_name?.slice(0, 1).toUpperCase() || "?"}
|
||||
<span
|
||||
className="store-available-items-thumb store-available-items-thumb-placeholder"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{"\uD83D\uDCE6"}
|
||||
</span>
|
||||
)}
|
||||
<div className="store-available-items-copy">
|
||||
@ -223,38 +324,12 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="store-items-table-cell">
|
||||
<span className="store-items-mobile-label">Store Defaults</span>
|
||||
<span className="store-items-defaults-text">
|
||||
{details.join(" | ") || "No store defaults set"}
|
||||
{deleteMode ? (
|
||||
<span className="store-items-delete-indicator" aria-hidden="true">
|
||||
{isSelectedForDelete ? "✓" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="store-items-table-cell store-items-table-actions">
|
||||
<span className="store-items-mobile-label">Actions</span>
|
||||
<div className="store-available-items-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary btn-small"
|
||||
onClick={() => {
|
||||
setEditorItem(item);
|
||||
setShowEditor(true);
|
||||
}}
|
||||
>
|
||||
Edit Settings
|
||||
</button>
|
||||
{isAdmin ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger btn-small"
|
||||
onClick={() => setPendingDeleteItem(item)}
|
||||
>
|
||||
Delete Item
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@ -277,15 +352,19 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
||||
/>
|
||||
|
||||
<ConfirmSlideModal
|
||||
isOpen={Boolean(pendingDeleteItem)}
|
||||
title={pendingDeleteItem ? `Delete ${pendingDeleteItem.item_name}?` : "Delete item?"}
|
||||
isOpen={pendingDeleteItems.length > 0}
|
||||
title={
|
||||
pendingDeleteItems.length === 1
|
||||
? `Delete ${pendingDeleteItems[0].item_name}?`
|
||||
: `Delete ${pendingDeleteItems.length} items?`
|
||||
}
|
||||
description={
|
||||
pendingDeleteItem
|
||||
? `Slide to confirm. This permanently deletes ${pendingDeleteItem.item_name} from ${store.display_name || store.name} for this household, including current list entries and history.`
|
||||
pendingDeleteItems.length > 0
|
||||
? `Slide to confirm. This permanently deletes ${pendingDeleteItems.length === 1 ? pendingDeleteItems[0].item_name : `${pendingDeleteItems.length} items`} from ${store.display_name || store.name} for this household, including current list entries and history.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Delete Item"
|
||||
onClose={() => setPendingDeleteItem(null)}
|
||||
confirmLabel={pendingDeleteItems.length === 1 ? "Delete Item" : "Delete Items"}
|
||||
onClose={() => setPendingDeleteItems([])}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
</>
|
||||
|
||||
381
frontend/src/components/manage/StoreLocationManager.jsx
Normal file
381
frontend/src/components/manage/StoreLocationManager.jsx
Normal file
@ -0,0 +1,381 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
addLocationToStore,
|
||||
removeLocation,
|
||||
setDefaultLocation,
|
||||
updateLocation,
|
||||
} from "../../api/stores";
|
||||
import useActionToast from "../../hooks/useActionToast";
|
||||
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
||||
import ConfirmSlideModal from "../modals/ConfirmSlideModal";
|
||||
|
||||
function locationLabel(location) {
|
||||
return location.display_name || location.name;
|
||||
}
|
||||
|
||||
function locationEditName(location) {
|
||||
return location.location_name || location.name || "";
|
||||
}
|
||||
|
||||
function LocationSettingsModal({
|
||||
location,
|
||||
draft,
|
||||
setDraft,
|
||||
canManage,
|
||||
onCancel,
|
||||
onSave,
|
||||
onSetDefault,
|
||||
}) {
|
||||
if (!location) return null;
|
||||
|
||||
return (
|
||||
<div className="store-items-modal-overlay" onClick={onCancel}>
|
||||
<div className="store-items-modal store-settings-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="store-items-modal-header">
|
||||
<div>
|
||||
<h3>{locationLabel(location)} Settings</h3>
|
||||
<p>Update this location name, notes, or default status.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="store-items-modal-close"
|
||||
onClick={onCancel}
|
||||
aria-label="Close location settings"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="store-settings-form">
|
||||
<label>
|
||||
<span>Location name</span>
|
||||
<input
|
||||
value={draft.name}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
|
||||
disabled={!canManage}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Address or notes</span>
|
||||
<input
|
||||
value={draft.address}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, address: event.target.value }))}
|
||||
disabled={!canManage}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{canManage ? (
|
||||
<div className="store-settings-actions">
|
||||
{!location.is_default ? (
|
||||
<button type="button" className="btn-secondary btn-small" onClick={onSetDefault}>
|
||||
Set Default
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="btn-primary btn-small" onClick={onSave}>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StoreLocationManager({
|
||||
householdId,
|
||||
storeGroup,
|
||||
allLocationCount,
|
||||
canManage,
|
||||
refreshAfterStoreChange,
|
||||
}) {
|
||||
const toast = useActionToast();
|
||||
const locationCount = storeGroup.locations.length;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [locationDraft, setLocationDraft] = useState({ name: "", address: "" });
|
||||
const [deleteMode, setDeleteMode] = useState(false);
|
||||
const [selectedDeleteIds, setSelectedDeleteIds] = useState(() => new Set());
|
||||
const [pendingDeleteLocations, setPendingDeleteLocations] = useState([]);
|
||||
const [editingLocation, setEditingLocation] = useState(null);
|
||||
const [editingLocationDraft, setEditingLocationDraft] = useState({ name: "", address: "" });
|
||||
|
||||
const selectedDeleteLocations = storeGroup.locations.filter((location) =>
|
||||
selectedDeleteIds.has(location.id)
|
||||
);
|
||||
const selectedDeleteCount = selectedDeleteLocations.length;
|
||||
const canConfirmDelete = selectedDeleteCount > 0 && allLocationCount - selectedDeleteCount >= 1;
|
||||
|
||||
const closeManager = () => {
|
||||
setIsOpen(false);
|
||||
setDeleteMode(false);
|
||||
setSelectedDeleteIds(new Set());
|
||||
setPendingDeleteLocations([]);
|
||||
setEditingLocation(null);
|
||||
};
|
||||
|
||||
const handleAddLocation = async () => {
|
||||
const name = locationDraft.name.trim();
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
await addLocationToStore(householdId, storeGroup.household_store_id, {
|
||||
name,
|
||||
address: locationDraft.address.trim() || null,
|
||||
});
|
||||
setLocationDraft({ name: "", address: "" });
|
||||
await refreshAfterStoreChange();
|
||||
toast.success("Added location", `Added ${name} to ${storeGroup.name}`);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to add location");
|
||||
toast.error("Add location failed", `Add location failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const openLocationSettings = (location) => {
|
||||
setEditingLocation(location);
|
||||
setEditingLocationDraft({
|
||||
name: locationEditName(location),
|
||||
address: location.address || "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveLocation = async () => {
|
||||
if (!editingLocation) return;
|
||||
|
||||
const name = editingLocationDraft.name.trim();
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
await updateLocation(householdId, editingLocation.id, {
|
||||
name,
|
||||
address: editingLocationDraft.address.trim() || null,
|
||||
});
|
||||
await refreshAfterStoreChange();
|
||||
setEditingLocation(null);
|
||||
toast.success("Updated location", `Updated ${name}`);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to update location");
|
||||
toast.error("Update location failed", `Update location failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = async () => {
|
||||
if (!editingLocation) return;
|
||||
|
||||
try {
|
||||
await setDefaultLocation(householdId, editingLocation.id);
|
||||
await refreshAfterStoreChange();
|
||||
setEditingLocation(null);
|
||||
toast.success("Updated default location", `Default location set to ${locationLabel(editingLocation)}`);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to set default location");
|
||||
toast.error("Set default failed", `Set default failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLocationSelection = (locationId) => {
|
||||
setSelectedDeleteIds((currentIds) => {
|
||||
const nextIds = new Set(currentIds);
|
||||
if (nextIds.has(locationId)) {
|
||||
nextIds.delete(locationId);
|
||||
} else {
|
||||
nextIds.add(locationId);
|
||||
}
|
||||
return nextIds;
|
||||
});
|
||||
};
|
||||
|
||||
const startDeleteMode = () => {
|
||||
setDeleteMode(true);
|
||||
setSelectedDeleteIds(new Set());
|
||||
};
|
||||
|
||||
const cancelDeleteMode = () => {
|
||||
setDeleteMode(false);
|
||||
setSelectedDeleteIds(new Set());
|
||||
};
|
||||
|
||||
const confirmSelectedDelete = () => {
|
||||
if (!canConfirmDelete) return;
|
||||
setPendingDeleteLocations(selectedDeleteLocations);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (pendingDeleteLocations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
pendingDeleteLocations.map((location) => removeLocation(householdId, location.id))
|
||||
);
|
||||
const count = pendingDeleteLocations.length;
|
||||
await refreshAfterStoreChange();
|
||||
setPendingDeleteLocations([]);
|
||||
setDeleteMode(false);
|
||||
setSelectedDeleteIds(new Set());
|
||||
toast.success(
|
||||
count === 1 ? "Removed location" : "Removed locations",
|
||||
`Removed ${count} ${count === 1 ? "location" : "locations"} from ${storeGroup.name}`
|
||||
);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to remove locations");
|
||||
toast.error("Remove locations failed", `Remove locations failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary btn-small store-location-manager-trigger"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
Manage Locations ({locationCount})
|
||||
</button>
|
||||
|
||||
{isOpen ? (
|
||||
<div className="store-items-modal-overlay" onClick={closeManager}>
|
||||
<div className="store-items-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="store-items-modal-header">
|
||||
<div>
|
||||
<h3>{storeGroup.name} Locations</h3>
|
||||
<p>Manage locations, defaults, and location notes for this store.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="store-items-modal-close"
|
||||
onClick={closeManager}
|
||||
aria-label="Close manage locations modal"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{canManage ? (
|
||||
<div className="store-items-modal-toolbar store-management-create-row store-location-create-row">
|
||||
<input
|
||||
value={locationDraft.name}
|
||||
onChange={(event) =>
|
||||
setLocationDraft((current) => ({ ...current, name: event.target.value }))
|
||||
}
|
||||
placeholder="Location name"
|
||||
/>
|
||||
<input
|
||||
value={locationDraft.address}
|
||||
onChange={(event) =>
|
||||
setLocationDraft((current) => ({ ...current, address: event.target.value }))
|
||||
}
|
||||
placeholder="Address or notes"
|
||||
/>
|
||||
<button type="button" className="btn-primary btn-small" onClick={handleAddLocation}>
|
||||
Add Location
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canManage && storeGroup.locations.length > 0 ? (
|
||||
<div className="store-items-bulk-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger btn-small store-items-delete-toggle"
|
||||
disabled={deleteMode ? !canConfirmDelete : allLocationCount <= 1}
|
||||
onClick={deleteMode ? confirmSelectedDelete : startDeleteMode}
|
||||
title={
|
||||
deleteMode && selectedDeleteCount > 0 && !canConfirmDelete
|
||||
? "At least one household location must remain"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{deleteMode ? `Confirm Delete (${selectedDeleteCount})` : "Delete Locations"}
|
||||
</button>
|
||||
{deleteMode ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary btn-small store-items-delete-cancel"
|
||||
onClick={cancelDeleteMode}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="store-items-modal-body">
|
||||
<div className="store-items-table">
|
||||
<div className="store-items-table-body">
|
||||
{storeGroup.locations.map((location) => {
|
||||
const isSelectedForDelete = selectedDeleteIds.has(location.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={location.id}
|
||||
type="button"
|
||||
className={`store-items-table-row store-items-table-row-button store-management-row ${deleteMode ? "is-delete-selectable" : ""} ${isSelectedForDelete ? "is-selected" : ""}`}
|
||||
aria-label={
|
||||
deleteMode
|
||||
? `${isSelectedForDelete ? "Deselect" : "Select"} ${locationLabel(location)} for deletion`
|
||||
: `Edit location ${locationLabel(location)}`
|
||||
}
|
||||
aria-pressed={deleteMode ? isSelectedForDelete : undefined}
|
||||
onClick={() => {
|
||||
if (deleteMode) {
|
||||
toggleLocationSelection(location.id);
|
||||
} else {
|
||||
openLocationSettings(location);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="store-management-name">
|
||||
{locationLabel(location)}
|
||||
{location.is_default ? (
|
||||
<span className="store-management-badge">Default</span>
|
||||
) : null}
|
||||
</span>
|
||||
{location.address ? (
|
||||
<span className="store-management-meta">{location.address}</span>
|
||||
) : null}
|
||||
{deleteMode ? (
|
||||
<span className="store-items-delete-indicator" aria-hidden="true">
|
||||
{isSelectedForDelete ? "\u2713" : ""}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<LocationSettingsModal
|
||||
location={editingLocation}
|
||||
draft={editingLocationDraft}
|
||||
setDraft={setEditingLocationDraft}
|
||||
canManage={canManage}
|
||||
onCancel={() => setEditingLocation(null)}
|
||||
onSave={handleSaveLocation}
|
||||
onSetDefault={handleSetDefault}
|
||||
/>
|
||||
|
||||
<ConfirmSlideModal
|
||||
isOpen={pendingDeleteLocations.length > 0}
|
||||
title={
|
||||
pendingDeleteLocations.length === 1
|
||||
? `Delete ${locationLabel(pendingDeleteLocations[0])}?`
|
||||
: `Delete ${pendingDeleteLocations.length} locations?`
|
||||
}
|
||||
description={
|
||||
pendingDeleteLocations.length > 0
|
||||
? `Slide to confirm. This removes ${pendingDeleteLocations.length === 1 ? locationLabel(pendingDeleteLocations[0]) : `${pendingDeleteLocations.length} locations`} from this household.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel={pendingDeleteLocations.length === 1 ? "Delete Location" : "Delete Locations"}
|
||||
onClose={() => setPendingDeleteLocations([])}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
506
frontend/src/components/manage/StoreZoneManager.jsx
Normal file
506
frontend/src/components/manage/StoreZoneManager.jsx
Normal file
@ -0,0 +1,506 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
createLocationZone,
|
||||
deleteLocationZone,
|
||||
getLocationZones,
|
||||
updateLocationZone,
|
||||
} from "../../api/stores";
|
||||
import useActionToast from "../../hooks/useActionToast";
|
||||
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
||||
import ConfirmSlideModal from "../modals/ConfirmSlideModal";
|
||||
|
||||
function locationLabel(location) {
|
||||
return location.display_name || location.name;
|
||||
}
|
||||
|
||||
function ZoneSettingsModal({
|
||||
zone,
|
||||
draft,
|
||||
setDraft,
|
||||
canManage,
|
||||
canMoveUp,
|
||||
canMoveDown,
|
||||
onCancel,
|
||||
onSave,
|
||||
onMove,
|
||||
}) {
|
||||
if (!zone) return null;
|
||||
|
||||
return (
|
||||
<div className="store-items-modal-overlay" onClick={onCancel}>
|
||||
<div className="store-items-modal store-settings-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="store-items-modal-header">
|
||||
<div>
|
||||
<h3>{zone.name} Settings</h3>
|
||||
<p>Update this zone name or adjust its shopping order.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="store-items-modal-close"
|
||||
onClick={onCancel}
|
||||
aria-label="Close zone settings"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="store-settings-form">
|
||||
<label>
|
||||
<span>Zone name</span>
|
||||
<input
|
||||
value={draft.name}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
|
||||
disabled={!canManage}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{canManage ? (
|
||||
<div className="store-settings-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary btn-small"
|
||||
disabled={!canMoveUp}
|
||||
onClick={() => onMove(-1)}
|
||||
>
|
||||
Move Up
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary btn-small"
|
||||
disabled={!canMoveDown}
|
||||
onClick={() => onMove(1)}
|
||||
>
|
||||
Move Down
|
||||
</button>
|
||||
<button type="button" className="btn-primary btn-small" onClick={onSave}>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StoreZoneManager({
|
||||
householdId,
|
||||
location,
|
||||
canManage,
|
||||
refreshActiveZones,
|
||||
refreshStoreCounts,
|
||||
zoneCount = 0,
|
||||
}) {
|
||||
const toast = useActionToast();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [zones, setZones] = useState([]);
|
||||
const [displayZoneCount, setDisplayZoneCount] = useState(zoneCount);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [newZoneName, setNewZoneName] = useState("");
|
||||
const [deleteMode, setDeleteMode] = useState(false);
|
||||
const [selectedDeleteIds, setSelectedDeleteIds] = useState(() => new Set());
|
||||
const [pendingDeleteZones, setPendingDeleteZones] = useState([]);
|
||||
const [editingZone, setEditingZone] = useState(null);
|
||||
const [editingZoneDraft, setEditingZoneDraft] = useState({ name: "" });
|
||||
const [draggedZoneId, setDraggedZoneId] = useState(null);
|
||||
const [dragOverZoneId, setDragOverZoneId] = useState(null);
|
||||
const [reordering, setReordering] = useState(false);
|
||||
const draggedZoneIdRef = useRef(null);
|
||||
const hasDraggedZoneRef = useRef(false);
|
||||
|
||||
const selectedDeleteZones = zones.filter((zone) => selectedDeleteIds.has(zone.id));
|
||||
const selectedDeleteCount = selectedDeleteZones.length;
|
||||
const canDragReorder = canManage && !deleteMode && !loading && !reordering;
|
||||
|
||||
const loadZones = async () => {
|
||||
if (!householdId || !location?.id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getLocationZones(householdId, location.id);
|
||||
const nextZones = response.data?.zones || [];
|
||||
setZones(nextZones);
|
||||
setDisplayZoneCount(nextZones.length);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to load zones");
|
||||
toast.error("Load zones failed", `Load zones failed: ${message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeManager = () => {
|
||||
setIsOpen(false);
|
||||
setDeleteMode(false);
|
||||
setSelectedDeleteIds(new Set());
|
||||
setPendingDeleteZones([]);
|
||||
setEditingZone(null);
|
||||
setDraggedZoneId(null);
|
||||
setDragOverZoneId(null);
|
||||
draggedZoneIdRef.current = null;
|
||||
hasDraggedZoneRef.current = false;
|
||||
};
|
||||
|
||||
const openZoneSettings = (zone) => {
|
||||
setEditingZone(zone);
|
||||
setEditingZoneDraft({ name: zone.name || "" });
|
||||
};
|
||||
|
||||
const toggleZoneSelection = (zoneId) => {
|
||||
setSelectedDeleteIds((currentIds) => {
|
||||
const nextIds = new Set(currentIds);
|
||||
if (nextIds.has(zoneId)) {
|
||||
nextIds.delete(zoneId);
|
||||
} else {
|
||||
nextIds.add(zoneId);
|
||||
}
|
||||
return nextIds;
|
||||
});
|
||||
};
|
||||
|
||||
const startDeleteMode = () => {
|
||||
setDeleteMode(true);
|
||||
setSelectedDeleteIds(new Set());
|
||||
};
|
||||
|
||||
const cancelDeleteMode = () => {
|
||||
setDeleteMode(false);
|
||||
setSelectedDeleteIds(new Set());
|
||||
};
|
||||
|
||||
const confirmSelectedDelete = () => {
|
||||
if (selectedDeleteCount === 0) return;
|
||||
setPendingDeleteZones(selectedDeleteZones);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadZones();
|
||||
}
|
||||
}, [isOpen, householdId, location?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayZoneCount(zoneCount);
|
||||
}, [zoneCount]);
|
||||
|
||||
const handleCreateZone = async () => {
|
||||
const name = newZoneName.trim();
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
const nextSortOrder =
|
||||
zones.length > 0 ? Math.max(...zones.map((zone) => zone.sort_order || 0)) + 10 : 10;
|
||||
await createLocationZone(householdId, location.id, {
|
||||
name,
|
||||
sort_order: nextSortOrder,
|
||||
});
|
||||
setNewZoneName("");
|
||||
await loadZones();
|
||||
await refreshActiveZones();
|
||||
await refreshStoreCounts?.();
|
||||
toast.success("Added zone", `Added zone ${name}`);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to add zone");
|
||||
toast.error("Add zone failed", `Add zone failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const persistZoneOrder = async (orderedZones) => {
|
||||
const zonesWithSortOrder = orderedZones.map((zone, index) => ({
|
||||
...zone,
|
||||
sort_order: (index + 1) * 10,
|
||||
}));
|
||||
const changedZones = zonesWithSortOrder.filter((zone) => {
|
||||
const currentZone = zones.find((candidate) => candidate.id === zone.id);
|
||||
return currentZone && currentZone.sort_order !== zone.sort_order;
|
||||
});
|
||||
|
||||
if (changedZones.length === 0) return true;
|
||||
|
||||
setReordering(true);
|
||||
setZones(zonesWithSortOrder);
|
||||
try {
|
||||
await Promise.all(
|
||||
changedZones.map((zone) =>
|
||||
updateLocationZone(householdId, location.id, zone.id, {
|
||||
sort_order: zone.sort_order,
|
||||
})
|
||||
)
|
||||
);
|
||||
await loadZones();
|
||||
await refreshActiveZones();
|
||||
toast.success("Reordered zones", "Updated shopping order");
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to reorder zones");
|
||||
toast.error("Reorder zones failed", `Reorder zones failed: ${message}`);
|
||||
await loadZones();
|
||||
return false;
|
||||
} finally {
|
||||
setReordering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const moveZone = async (fromIndex, toIndex) => {
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex >= zones.length || toIndex >= zones.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const orderedZones = [...zones];
|
||||
const [movedZone] = orderedZones.splice(fromIndex, 1);
|
||||
orderedZones.splice(toIndex, 0, movedZone);
|
||||
return persistZoneOrder(orderedZones);
|
||||
};
|
||||
|
||||
const handleMoveZone = async (zone, direction) => {
|
||||
const currentIndex = zones.findIndex((candidate) => candidate.id === zone.id);
|
||||
const moved = await moveZone(currentIndex, currentIndex + direction);
|
||||
if (moved) {
|
||||
setEditingZone(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoneDragStart = (event, zoneId) => {
|
||||
if (!canDragReorder) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
draggedZoneIdRef.current = zoneId;
|
||||
hasDraggedZoneRef.current = true;
|
||||
setDraggedZoneId(zoneId);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", String(zoneId));
|
||||
};
|
||||
|
||||
const clearZoneDrag = () => {
|
||||
draggedZoneIdRef.current = null;
|
||||
setDraggedZoneId(null);
|
||||
setDragOverZoneId(null);
|
||||
window.setTimeout(() => {
|
||||
hasDraggedZoneRef.current = false;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleZoneDrop = async (event, targetZoneId) => {
|
||||
event.preventDefault();
|
||||
const sourceZoneId = draggedZoneIdRef.current || event.dataTransfer.getData("text/plain");
|
||||
clearZoneDrag();
|
||||
|
||||
if (!sourceZoneId || String(sourceZoneId) === String(targetZoneId)) return;
|
||||
|
||||
const sourceIndex = zones.findIndex((zone) => String(zone.id) === String(sourceZoneId));
|
||||
const targetIndex = zones.findIndex((zone) => String(zone.id) === String(targetZoneId));
|
||||
await moveZone(sourceIndex, targetIndex);
|
||||
};
|
||||
|
||||
const handleSaveZone = async () => {
|
||||
if (!editingZone) return;
|
||||
|
||||
const name = editingZoneDraft.name.trim();
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
await updateLocationZone(householdId, location.id, editingZone.id, { name });
|
||||
await loadZones();
|
||||
await refreshActiveZones();
|
||||
setEditingZone(null);
|
||||
toast.success("Updated zone", `Updated zone ${name}`);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to update zone");
|
||||
toast.error("Update zone failed", `Update zone failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (pendingDeleteZones.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
pendingDeleteZones.map((zone) => deleteLocationZone(householdId, location.id, zone.id))
|
||||
);
|
||||
const count = pendingDeleteZones.length;
|
||||
await loadZones();
|
||||
await refreshActiveZones();
|
||||
await refreshStoreCounts?.();
|
||||
setPendingDeleteZones([]);
|
||||
setDeleteMode(false);
|
||||
setSelectedDeleteIds(new Set());
|
||||
toast.success(count === 1 ? "Removed zone" : "Removed zones", `Removed ${count} ${count === 1 ? "zone" : "zones"}`);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to remove zones");
|
||||
toast.error("Remove zones failed", `Remove zones failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const editingZoneIndex = editingZone
|
||||
? zones.findIndex((zone) => zone.id === editingZone.id)
|
||||
: -1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary btn-small store-zone-manager-trigger"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
Manage Zones ({displayZoneCount})
|
||||
</button>
|
||||
|
||||
{isOpen ? (
|
||||
<div className="store-items-modal-overlay" onClick={closeManager}>
|
||||
<div className="store-items-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="store-items-modal-header">
|
||||
<div>
|
||||
<h3>{locationLabel(location)} Zones</h3>
|
||||
<p>Manage shopping order zones for this location.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="store-items-modal-close"
|
||||
onClick={closeManager}
|
||||
aria-label="Close manage zones modal"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{canManage ? (
|
||||
<div className="store-items-modal-toolbar store-management-create-row">
|
||||
<input
|
||||
value={newZoneName}
|
||||
onChange={(event) => setNewZoneName(event.target.value)}
|
||||
placeholder="New zone name"
|
||||
/>
|
||||
<button type="button" className="btn-primary btn-small" onClick={handleCreateZone}>
|
||||
Add Zone
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canManage && zones.length > 0 ? (
|
||||
<div className="store-items-bulk-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger btn-small store-items-delete-toggle"
|
||||
disabled={deleteMode ? selectedDeleteCount === 0 : loading}
|
||||
onClick={deleteMode ? confirmSelectedDelete : startDeleteMode}
|
||||
>
|
||||
{deleteMode ? `Confirm Delete (${selectedDeleteCount})` : "Delete Zones"}
|
||||
</button>
|
||||
{deleteMode ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary btn-small store-items-delete-cancel"
|
||||
onClick={cancelDeleteMode}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="store-items-modal-body">
|
||||
{loading ? (
|
||||
<p className="empty-message">Loading zones...</p>
|
||||
) : zones.length === 0 ? (
|
||||
<p className="empty-message">No zones for this location.</p>
|
||||
) : (
|
||||
<div className="store-items-table">
|
||||
<div className="store-items-table-body">
|
||||
{zones.map((zone, index) => {
|
||||
const isSelectedForDelete = selectedDeleteIds.has(zone.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={zone.id}
|
||||
type="button"
|
||||
className={`store-items-table-row store-items-table-row-button store-management-row store-management-row-with-order ${canDragReorder ? "store-management-row-with-drag" : ""} ${draggedZoneId === zone.id ? "is-dragging" : ""} ${dragOverZoneId === zone.id ? "is-drag-target" : ""} ${deleteMode ? "is-delete-selectable" : ""} ${isSelectedForDelete ? "is-selected" : ""}`}
|
||||
aria-label={
|
||||
deleteMode
|
||||
? `${isSelectedForDelete ? "Deselect" : "Select"} ${zone.name} for deletion`
|
||||
: `Edit zone ${zone.name}`
|
||||
}
|
||||
aria-pressed={deleteMode ? isSelectedForDelete : undefined}
|
||||
onDragOver={(event) => {
|
||||
if (!canDragReorder || !draggedZoneIdRef.current) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
setDragOverZoneId(zone.id);
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||
setDragOverZoneId(null);
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => handleZoneDrop(event, zone.id)}
|
||||
onClick={() => {
|
||||
if (hasDraggedZoneRef.current) return;
|
||||
if (deleteMode) {
|
||||
toggleZoneSelection(zone.id);
|
||||
} else {
|
||||
openZoneSettings(zone);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{canDragReorder ? (
|
||||
<span
|
||||
className="store-zone-drag-handle"
|
||||
draggable
|
||||
aria-hidden="true"
|
||||
title="Drag to reorder"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDragStart={(event) => handleZoneDragStart(event, zone.id)}
|
||||
onDragEnd={clearZoneDrag}
|
||||
/>
|
||||
) : null}
|
||||
<span className="store-management-order">{index + 1}</span>
|
||||
<span className="store-management-name">{zone.name}</span>
|
||||
{deleteMode ? (
|
||||
<span className="store-items-delete-indicator" aria-hidden="true">
|
||||
{isSelectedForDelete ? "\u2713" : ""}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ZoneSettingsModal
|
||||
zone={editingZone}
|
||||
draft={editingZoneDraft}
|
||||
setDraft={setEditingZoneDraft}
|
||||
canManage={canManage}
|
||||
canMoveUp={editingZoneIndex > 0}
|
||||
canMoveDown={editingZoneIndex >= 0 && editingZoneIndex < zones.length - 1}
|
||||
onCancel={() => setEditingZone(null)}
|
||||
onSave={handleSaveZone}
|
||||
onMove={(direction) => handleMoveZone(editingZone, direction)}
|
||||
/>
|
||||
|
||||
<ConfirmSlideModal
|
||||
isOpen={pendingDeleteZones.length > 0}
|
||||
title={
|
||||
pendingDeleteZones.length === 1
|
||||
? `Delete ${pendingDeleteZones[0].name}?`
|
||||
: `Delete ${pendingDeleteZones.length} zones?`
|
||||
}
|
||||
description={
|
||||
pendingDeleteZones.length > 0
|
||||
? `Slide to confirm. This removes ${pendingDeleteZones.length === 1 ? pendingDeleteZones[0].name : `${pendingDeleteZones.length} zones`} from ${locationLabel(location)}.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel={pendingDeleteZones.length === 1 ? "Delete Zone" : "Delete Zones"}
|
||||
onClose={() => setPendingDeleteZones([])}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
323
frontend/src/components/maps/LocationMapBottomSheet.jsx
Normal file
323
frontend/src/components/maps/LocationMapBottomSheet.jsx
Normal file
@ -0,0 +1,323 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import {
|
||||
DEFAULT_MAP_FILTERS,
|
||||
getMapObjectDisplayLabel,
|
||||
objectZoneId,
|
||||
} from "../../lib/locationMapUtils";
|
||||
import {
|
||||
LocationMapLayerPanel,
|
||||
LocationMapOverview,
|
||||
MAP_DISPLAY_CONTROLS,
|
||||
SelectedMapObjectForm,
|
||||
SelectedZoneItemsPanel,
|
||||
UnmappedItemsPanel,
|
||||
} from "./LocationMapBottomSheetSections";
|
||||
|
||||
const EMPTY_ITEMS = [];
|
||||
|
||||
export default function LocationMapBottomSheet({
|
||||
mode,
|
||||
canManage,
|
||||
filters,
|
||||
setFilters,
|
||||
layersOpen,
|
||||
setLayersOpen,
|
||||
selectedObject,
|
||||
mapObjects = [],
|
||||
selectedZoneItems = EMPTY_ITEMS,
|
||||
visibleAssignedItemCount = 0,
|
||||
visibleUnmappedItems = EMPTY_ITEMS,
|
||||
unmappedItemCount = 0,
|
||||
hiddenAreaCount,
|
||||
saving,
|
||||
savingAction,
|
||||
hasUnsavedChanges,
|
||||
mapState,
|
||||
onAddObject,
|
||||
onSaveDraft,
|
||||
onPublish,
|
||||
onObjectField,
|
||||
onZoneLinkChange,
|
||||
onDuplicateObject,
|
||||
onDeleteObject,
|
||||
onShowHiddenAreas,
|
||||
onClearSelection,
|
||||
onSelectZoneItem,
|
||||
onSelectUnmappedItem,
|
||||
}) {
|
||||
const selectedTitle = getMapObjectDisplayLabel(selectedObject);
|
||||
const selectedZoneId = objectZoneId(selectedObject);
|
||||
const mapItems = mapState?.items || EMPTY_ITEMS;
|
||||
const { assignedItemCount, selectedAssignedItems } = useMemo(() => {
|
||||
const nextSelectedAssignedItems = [];
|
||||
let nextAssignedItemCount = 0;
|
||||
|
||||
mapItems.forEach((item) => {
|
||||
if (!item.zone_id) return;
|
||||
nextAssignedItemCount += 1;
|
||||
if (selectedZoneId && String(item.zone_id) === selectedZoneId) {
|
||||
nextSelectedAssignedItems.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
assignedItemCount: nextAssignedItemCount,
|
||||
selectedAssignedItems: nextSelectedAssignedItems,
|
||||
};
|
||||
}, [mapItems, selectedZoneId]);
|
||||
const hiddenSelectedItemCount = Math.max(0, selectedAssignedItems.length - selectedZoneItems.length);
|
||||
const hasHiddenSelectedItems = hiddenSelectedItemCount > 0;
|
||||
const selectedItemCountLabel = hasHiddenSelectedItems
|
||||
? `${selectedZoneItems.length} shown`
|
||||
: `${selectedZoneItems.length} item${selectedZoneItems.length === 1 ? "" : "s"}`;
|
||||
const sheetTitle = mode === "edit"
|
||||
? selectedObject
|
||||
? selectedTitle
|
||||
: "Edit Areas"
|
||||
: selectedObject
|
||||
? selectedTitle
|
||||
: "Map Details";
|
||||
const showHeaderItemCount = mode !== "edit" && Boolean(selectedObject);
|
||||
const showEditorControls = mode === "edit" && canManage;
|
||||
const showSelectedObjectForm = Boolean(mode === "edit" && selectedObject);
|
||||
const showUnmappedPanel = filters.showUnmapped && !selectedObject;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedObject) {
|
||||
setLayersOpen(false);
|
||||
}
|
||||
}, [selectedObject, setLayersOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!layersOpen || typeof window === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === "Escape") {
|
||||
setLayersOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [layersOpen, setLayersOpen]);
|
||||
|
||||
const changedLayerCount = useMemo(
|
||||
() => MAP_DISPLAY_CONTROLS.filter(([key]) => filters[key] !== DEFAULT_MAP_FILTERS[key]).length,
|
||||
[filters]
|
||||
);
|
||||
const totalAreaCount = mapObjects.length;
|
||||
const visibleAreaCount = filters.showZones
|
||||
? Math.max(0, totalAreaCount - hiddenAreaCount)
|
||||
: 0;
|
||||
const hasNoVisibleAreas =
|
||||
filters.showZones && totalAreaCount > 0 && visibleAreaCount === 0 && hiddenAreaCount > 0;
|
||||
const showMapOverview = mode !== "edit" && !selectedObject;
|
||||
const unlinkedAreaCount = useMemo(
|
||||
() => mapObjects.filter((object) => !objectZoneId(object)).length,
|
||||
[mapObjects]
|
||||
);
|
||||
const showDraftOverview = mode === "edit" && !selectedObject;
|
||||
const draftOverviewRows = [
|
||||
{ label: "Areas", value: totalAreaCount },
|
||||
...(hiddenAreaCount > 0 ? [{ label: "Hidden", value: hiddenAreaCount }] : []),
|
||||
...(unlinkedAreaCount > 0 ? [{ label: "Unlinked", value: unlinkedAreaCount }] : []),
|
||||
];
|
||||
const hiddenUnmappedItemCount = Math.max(0, unmappedItemCount - visibleUnmappedItems.length);
|
||||
const hasHiddenUnmappedItems =
|
||||
filters.showUnmapped && unmappedItemCount > 0 && visibleUnmappedItems.length === 0;
|
||||
const hiddenAreaLabel = `Show Hidden Areas (${hiddenAreaCount})`;
|
||||
const showHiddenAreaAction =
|
||||
showEditorControls && hiddenAreaCount > 0 && filters.showZones && !hasNoVisibleAreas;
|
||||
const saveDraftLabel = savingAction === "save" ? "Saving..." : "Save Draft";
|
||||
const publishLabel = savingAction === "publish" ? "Publishing..." : "Publish";
|
||||
const canPublishDraft = Boolean(mapState?.draft_map || hasUnsavedChanges);
|
||||
const showSaveDraftAction = Boolean(savingAction === "save" || hasUnsavedChanges);
|
||||
const showPublishAction = Boolean(savingAction === "publish" || canPublishDraft);
|
||||
const mappedItemSummary = assignedItemCount === visibleAssignedItemCount
|
||||
? String(assignedItemCount)
|
||||
: `${visibleAssignedItemCount} shown`;
|
||||
const unmappedItemSummary = !filters.showUnmapped && unmappedItemCount > 0
|
||||
? `${unmappedItemCount} hidden`
|
||||
: visibleUnmappedItems.length === unmappedItemCount
|
||||
? String(unmappedItemCount)
|
||||
: `${visibleUnmappedItems.length} shown`;
|
||||
const mapOverviewRows = [
|
||||
{ label: "Areas", value: filters.showZones ? `${visibleAreaCount} shown` : "Hidden" },
|
||||
...(assignedItemCount > 0 ? [{ label: "Mapped items", value: mappedItemSummary }] : []),
|
||||
...(unmappedItemCount > 0 ? [{ label: "Unmapped", value: unmappedItemSummary }] : []),
|
||||
];
|
||||
const resetLayerFilters = () => setFilters({ ...DEFAULT_MAP_FILTERS });
|
||||
const showSelectedZoneItems = () => {
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
showMyItems: true,
|
||||
showOtherItems: true,
|
||||
showCompleted: true,
|
||||
}));
|
||||
};
|
||||
const showUnmappedItems = () => {
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
showUnmapped: true,
|
||||
showMyItems: true,
|
||||
showOtherItems: true,
|
||||
showCompleted: true,
|
||||
}));
|
||||
};
|
||||
const primaryDraftActions = showEditorControls && (showSaveDraftAction || showPublishAction) ? (
|
||||
<div className="location-map-editor-actions location-map-primary-actions">
|
||||
{showSaveDraftAction ? (
|
||||
<button type="button" className="primary" onClick={onSaveDraft} disabled={saving || !hasUnsavedChanges}>
|
||||
{saveDraftLabel}
|
||||
</button>
|
||||
) : null}
|
||||
{showPublishAction ? (
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
onClick={onPublish}
|
||||
disabled={saving || !canPublishDraft}
|
||||
>
|
||||
{publishLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
const secondaryDraftActions = showEditorControls ? (
|
||||
<div className="location-map-editor-actions location-map-secondary-actions">
|
||||
<button type="button" onClick={onAddObject} disabled={saving}>Add Area</button>
|
||||
</div>
|
||||
) : null;
|
||||
const editorActions = showEditorControls ? (
|
||||
<div className="location-map-editor-action-stack" role="group" aria-label="Map draft actions">
|
||||
{primaryDraftActions}
|
||||
{secondaryDraftActions}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<aside className="location-map-bottom-sheet">
|
||||
<div className="location-map-bottom-sheet-handle" aria-hidden="true" />
|
||||
<div className="location-map-sheet-header">
|
||||
<div>
|
||||
<div className="location-map-sheet-heading">
|
||||
<strong>{sheetTitle}</strong>
|
||||
{showHeaderItemCount ? (
|
||||
<span className="location-map-sheet-count">{selectedItemCountLabel}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="location-map-layer-actions">
|
||||
{selectedObject ? (
|
||||
<button
|
||||
type="button"
|
||||
className="location-map-sheet-close"
|
||||
onClick={onClearSelection}
|
||||
aria-label="Close selected map area"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
) : null}
|
||||
{changedLayerCount ? (
|
||||
<button
|
||||
type="button"
|
||||
className="location-map-layer-reset"
|
||||
onClick={resetLayerFilters}
|
||||
aria-label="Reset layer filters"
|
||||
title="Reset layer filters"
|
||||
>
|
||||
<svg className="location-map-layer-reset-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 7v5h5" />
|
||||
<path d="M5.2 12a7 7 0 1 0 2.1-5" />
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`location-map-layer-toggle ${layersOpen ? "active" : ""}`}
|
||||
onClick={() => setLayersOpen((current) => !current)}
|
||||
aria-expanded={layersOpen}
|
||||
aria-label={changedLayerCount ? `Layers, ${changedLayerCount} changed` : "Layers"}
|
||||
>
|
||||
Layers
|
||||
{changedLayerCount ? (
|
||||
<span className="location-map-layer-count" aria-hidden="true">{changedLayerCount}</span>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{layersOpen ? (
|
||||
<LocationMapLayerPanel filters={filters} setFilters={setFilters} />
|
||||
) : null}
|
||||
|
||||
{showSelectedObjectForm ? (
|
||||
primaryDraftActions ? (
|
||||
<div
|
||||
className="location-map-editor-action-stack location-map-selected-primary-actions"
|
||||
role="group"
|
||||
aria-label="Map draft actions"
|
||||
>
|
||||
{primaryDraftActions}
|
||||
</div>
|
||||
) : null
|
||||
) : editorActions}
|
||||
|
||||
{showHiddenAreaAction ? (
|
||||
<div className="location-map-hidden-areas">
|
||||
<button type="button" onClick={onShowHiddenAreas} disabled={saving}>
|
||||
{hiddenAreaLabel}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showMapOverview ? (
|
||||
<LocationMapOverview
|
||||
ariaLabel="Map summary"
|
||||
rows={mapOverviewRows}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showDraftOverview ? (
|
||||
<LocationMapOverview
|
||||
ariaLabel="Draft map summary"
|
||||
rows={draftOverviewRows}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{mode === "edit" && selectedObject ? (
|
||||
<SelectedMapObjectForm
|
||||
selectedObject={selectedObject}
|
||||
zones={mapState?.zones || EMPTY_ITEMS}
|
||||
saving={saving}
|
||||
onObjectField={onObjectField}
|
||||
onZoneLinkChange={onZoneLinkChange}
|
||||
onDuplicateObject={onDuplicateObject}
|
||||
onDeleteObject={onDeleteObject}
|
||||
/>
|
||||
) : mode === "edit" ? (
|
||||
null
|
||||
) : selectedObject ? (
|
||||
<SelectedZoneItemsPanel
|
||||
selectedZoneItems={selectedZoneItems}
|
||||
hasHiddenSelectedItems={hasHiddenSelectedItems}
|
||||
hiddenSelectedItemCount={hiddenSelectedItemCount}
|
||||
onShowSelectedZoneItems={showSelectedZoneItems}
|
||||
onSelectZoneItem={onSelectZoneItem}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showUnmappedPanel ? (
|
||||
<UnmappedItemsPanel
|
||||
visibleUnmappedItems={visibleUnmappedItems}
|
||||
hiddenUnmappedItemCount={hiddenUnmappedItemCount}
|
||||
hasHiddenUnmappedItems={hasHiddenUnmappedItems}
|
||||
onShowUnmappedItems={showUnmappedItems}
|
||||
onSelectUnmappedItem={onSelectUnmappedItem}
|
||||
/>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
306
frontend/src/components/maps/LocationMapBottomSheetSections.jsx
Normal file
306
frontend/src/components/maps/LocationMapBottomSheetSections.jsx
Normal file
@ -0,0 +1,306 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getMapObjectDisplayLabel, objectZoneId } from "../../lib/locationMapUtils";
|
||||
|
||||
export const MAP_DISPLAY_CONTROLS = [
|
||||
["showZones", "Zones"],
|
||||
["showLabels", "Labels"],
|
||||
["showCounts", "Counts"],
|
||||
["showPins", "Pins"],
|
||||
["showMyItems", "Mine"],
|
||||
["showOtherItems", "Others"],
|
||||
["showCompleted", "Bought"],
|
||||
["showUnmapped", "Unmapped"],
|
||||
];
|
||||
|
||||
const MAP_ITEM_PREVIEW_LIMIT = 8;
|
||||
|
||||
function MapItemActionRow({ item, onSelectItem }) {
|
||||
const isBought = Boolean(item.bought);
|
||||
|
||||
return (
|
||||
<li className={`location-map-item-row ${isBought ? "is-bought" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="location-map-item-button"
|
||||
onClick={() => {
|
||||
if (!isBought) onSelectItem?.(item);
|
||||
}}
|
||||
disabled={isBought}
|
||||
aria-label={isBought ? `${item.item_name} bought` : `Mark ${item.item_name} bought`}
|
||||
>
|
||||
<span>{item.item_name}</span>
|
||||
<small>{isBought ? "Bought" : `x${item.quantity}`}</small>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function MapItemActionList({ items, onSelectItem, itemGroupLabel }) {
|
||||
const [showAllItems, setShowAllItems] = useState(false);
|
||||
const itemListKey = items.map((item) => item.id).join(":");
|
||||
|
||||
useEffect(() => {
|
||||
setShowAllItems(false);
|
||||
}, [itemListKey]);
|
||||
|
||||
const visibleItems = showAllItems ? items : items.slice(0, MAP_ITEM_PREVIEW_LIMIT);
|
||||
const overflowItemCount = items.length - visibleItems.length;
|
||||
const hasOverflow = items.length > MAP_ITEM_PREVIEW_LIMIT;
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{visibleItems.map((item) => (
|
||||
<MapItemActionRow key={item.id} item={item} onSelectItem={onSelectItem} />
|
||||
))}
|
||||
{hasOverflow ? (
|
||||
<li className="location-map-list-more">
|
||||
<button
|
||||
type="button"
|
||||
className="location-map-list-more-button"
|
||||
onClick={() => setShowAllItems((current) => !current)}
|
||||
aria-label={
|
||||
showAllItems
|
||||
? `Show fewer ${itemGroupLabel}`
|
||||
: `Show ${overflowItemCount} more ${itemGroupLabel}`
|
||||
}
|
||||
>
|
||||
{showAllItems ? "Show fewer" : `+${overflowItemCount} more`}
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function LocationMapLayerPanel({ filters, setFilters }) {
|
||||
return (
|
||||
<div className="location-map-display-panel">
|
||||
<div className="location-map-display-controls" role="group" aria-label="Layer filters">
|
||||
{MAP_DISPLAY_CONTROLS.map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={filters[key] ? "active" : ""}
|
||||
aria-pressed={filters[key]}
|
||||
onClick={() =>
|
||||
setFilters((current) => ({ ...current, [key]: !current[key] }))
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`location-map-layer-state ${filters[key] ? "is-on" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="location-map-layer-label">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LocationMapOverview({ ariaLabel, rows }) {
|
||||
return (
|
||||
<div className="location-map-overview" role="group" aria-label={ariaLabel}>
|
||||
{rows.map((row) => (
|
||||
<div className="location-map-overview-row" key={row.label}>
|
||||
<span>{row.label}</span>
|
||||
<strong>{row.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineStateAction({ label, value, actionLabel, actionText = "Show", onAction }) {
|
||||
return (
|
||||
<div className="location-map-inline-state">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
{onAction ? (
|
||||
<button type="button" onClick={onAction} aria-label={actionLabel}>
|
||||
{actionText}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectedMapObjectForm({
|
||||
selectedObject,
|
||||
zones = [],
|
||||
saving,
|
||||
onObjectField,
|
||||
onZoneLinkChange,
|
||||
onDuplicateObject,
|
||||
onDeleteObject,
|
||||
}) {
|
||||
const selectedObjectLabel = getMapObjectDisplayLabel(selectedObject);
|
||||
|
||||
return (
|
||||
<div className="location-map-object-form">
|
||||
<label className="location-map-field-row">
|
||||
<span>Label</span>
|
||||
<input
|
||||
value={selectedObject.label || ""}
|
||||
disabled={saving}
|
||||
onChange={(event) => onObjectField("label", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="location-map-field-row">
|
||||
<span>Zone</span>
|
||||
<select
|
||||
aria-label="Linked zone"
|
||||
value={objectZoneId(selectedObject)}
|
||||
disabled={saving}
|
||||
onChange={(event) => onZoneLinkChange(event.target.value)}
|
||||
>
|
||||
<option value="">No linked zone</option>
|
||||
{zones.map((zone) => (
|
||||
<option key={zone.id} value={zone.id}>{zone.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="location-map-object-flags">
|
||||
<button
|
||||
type="button"
|
||||
className={selectedObject.visible !== false ? "active" : ""}
|
||||
aria-pressed={selectedObject.visible !== false}
|
||||
disabled={saving}
|
||||
onClick={() => onObjectField("visible", selectedObject.visible === false)}
|
||||
>
|
||||
<span
|
||||
className={`location-map-object-flag-state ${selectedObject.visible !== false ? "is-on" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="location-map-object-flag-label">Visible</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={selectedObject.locked ? "active" : ""}
|
||||
aria-pressed={Boolean(selectedObject.locked)}
|
||||
disabled={saving}
|
||||
onClick={() => onObjectField("locked", !selectedObject.locked)}
|
||||
>
|
||||
<span
|
||||
className={`location-map-object-flag-state ${selectedObject.locked ? "is-on" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="location-map-object-flag-label">Locked</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="location-map-editor-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDuplicateObject}
|
||||
disabled={saving}
|
||||
aria-label={`Duplicate area ${selectedObjectLabel}`}
|
||||
>
|
||||
Duplicate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
onClick={onDeleteObject}
|
||||
disabled={saving}
|
||||
aria-label={`Delete area ${selectedObjectLabel}`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectedZoneItemsPanel({
|
||||
selectedZoneItems,
|
||||
hasHiddenSelectedItems,
|
||||
hiddenSelectedItemCount,
|
||||
onShowSelectedZoneItems,
|
||||
onSelectZoneItem,
|
||||
}) {
|
||||
if (selectedZoneItems.length === 0) {
|
||||
if (!hasHiddenSelectedItems) return null;
|
||||
|
||||
return (
|
||||
<div className="location-map-zone-items" role="group" aria-label="Zone items">
|
||||
<div className="location-map-zone-empty">
|
||||
<InlineStateAction
|
||||
label="Hidden"
|
||||
value={hiddenSelectedItemCount}
|
||||
actionLabel="Show Zone Items"
|
||||
onAction={onShowSelectedZoneItems}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="location-map-zone-items" role="group" aria-label="Zone items">
|
||||
<MapItemActionList
|
||||
items={selectedZoneItems}
|
||||
onSelectItem={onSelectZoneItem}
|
||||
itemGroupLabel="zone items"
|
||||
/>
|
||||
{hasHiddenSelectedItems ? (
|
||||
<div className="location-map-zone-hidden-note">
|
||||
<InlineStateAction
|
||||
label="Hidden"
|
||||
value={hiddenSelectedItemCount}
|
||||
actionLabel="Show All Zone Items"
|
||||
actionText="Show All"
|
||||
onAction={onShowSelectedZoneItems}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UnmappedItemsPanel({
|
||||
visibleUnmappedItems,
|
||||
hiddenUnmappedItemCount,
|
||||
hasHiddenUnmappedItems,
|
||||
onShowUnmappedItems,
|
||||
onSelectUnmappedItem,
|
||||
}) {
|
||||
if (visibleUnmappedItems.length === 0 && hasHiddenUnmappedItems) {
|
||||
return (
|
||||
<div className="location-map-unmapped-list" role="group" aria-label="Unmapped items">
|
||||
<div className="location-map-zone-empty">
|
||||
<InlineStateAction
|
||||
label="Hidden"
|
||||
value={hiddenUnmappedItemCount}
|
||||
actionLabel="Show Unmapped Items"
|
||||
onAction={onShowUnmappedItems}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (visibleUnmappedItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="location-map-unmapped-list" role="group" aria-label="Unmapped items">
|
||||
<MapItemActionList
|
||||
items={visibleUnmappedItems}
|
||||
onSelectItem={onSelectUnmappedItem}
|
||||
itemGroupLabel="unmapped items"
|
||||
/>
|
||||
{hiddenUnmappedItemCount > 0 ? (
|
||||
<div className="location-map-zone-hidden-note">
|
||||
<InlineStateAction
|
||||
label="Hidden"
|
||||
value={hiddenUnmappedItemCount}
|
||||
actionLabel="Show All Unmapped Items"
|
||||
actionText="Show All"
|
||||
onAction={onShowUnmappedItems}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
644
frontend/src/components/maps/LocationMapCanvas.jsx
Normal file
644
frontend/src/components/maps/LocationMapCanvas.jsx
Normal file
@ -0,0 +1,644 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
clampObjectToMap,
|
||||
clientPointToMap,
|
||||
getObjectKey,
|
||||
itemMatchesMapFilters,
|
||||
} from "../../lib/locationMapUtils";
|
||||
import LocationMapObject from "./LocationMapObject";
|
||||
|
||||
const DRAG_CHANGE_THRESHOLD = 2;
|
||||
const NUDGE_KEYS = new Set(["ArrowUp", "ArrowRight", "ArrowDown", "ArrowLeft"]);
|
||||
const SELECTED_OBJECT_SCROLL_PADDING = 24;
|
||||
const EDGE_PAN_ZONE = 56;
|
||||
const MAX_EDGE_PAN_STEP = 18;
|
||||
|
||||
function clampScrollPosition(value, maxValue) {
|
||||
return Math.min(Math.max(0, value), Math.max(0, maxValue));
|
||||
}
|
||||
|
||||
function getEdgePanStep(pointerPosition, start, end) {
|
||||
if (pointerPosition < start + EDGE_PAN_ZONE) {
|
||||
const intensity = (start + EDGE_PAN_ZONE - pointerPosition) / EDGE_PAN_ZONE;
|
||||
return -Math.ceil(MAX_EDGE_PAN_STEP * intensity);
|
||||
}
|
||||
|
||||
if (pointerPosition > end - EDGE_PAN_ZONE) {
|
||||
const intensity = (pointerPosition - (end - EDGE_PAN_ZONE)) / EDGE_PAN_ZONE;
|
||||
return Math.ceil(MAX_EDGE_PAN_STEP * intensity);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function panScrollElement(scrollElement, deltaX, deltaY) {
|
||||
if (!scrollElement || (!deltaX && !deltaY)) return;
|
||||
|
||||
scrollElement.scrollLeft = clampScrollPosition(
|
||||
scrollElement.scrollLeft + deltaX,
|
||||
scrollElement.scrollWidth - scrollElement.clientWidth
|
||||
);
|
||||
scrollElement.scrollTop = clampScrollPosition(
|
||||
scrollElement.scrollTop + deltaY,
|
||||
scrollElement.scrollHeight - scrollElement.clientHeight
|
||||
);
|
||||
}
|
||||
|
||||
function shiftMapObject(object, shiftX, shiftY) {
|
||||
if (!shiftX && !shiftY) return object;
|
||||
|
||||
return {
|
||||
...object,
|
||||
x: Number(object.x || 0) + shiftX,
|
||||
y: Number(object.y || 0) + shiftY,
|
||||
};
|
||||
}
|
||||
|
||||
function getSelectedObjectScrollKey(object, selectedObjectKey, zoom) {
|
||||
return [
|
||||
selectedObjectKey,
|
||||
zoom.toFixed(2),
|
||||
object.x,
|
||||
object.y,
|
||||
object.width,
|
||||
object.height,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function clampResizedObjectToMap(object, mapSize) {
|
||||
const x = Math.max(0, Math.min(object.x, mapSize.width - 40));
|
||||
const y = Math.max(0, Math.min(object.y, mapSize.height - 40));
|
||||
return {
|
||||
...object,
|
||||
x,
|
||||
y,
|
||||
width: Math.max(40, Math.min(object.width, mapSize.width - x)),
|
||||
height: Math.max(40, Math.min(object.height, mapSize.height - y)),
|
||||
};
|
||||
}
|
||||
|
||||
function getDraggedObjectCandidate(object, dragState, deltaX, deltaY) {
|
||||
if (dragState.type === "resize") {
|
||||
return {
|
||||
...object,
|
||||
width: Math.max(40, dragState.startObject.width + deltaX),
|
||||
height: Math.max(40, dragState.startObject.height + deltaY),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...object,
|
||||
x: dragState.startObject.x + deltaX,
|
||||
y: dragState.startObject.y + deltaY,
|
||||
};
|
||||
}
|
||||
|
||||
function hasObjectBoundsChanged(object, nextObject) {
|
||||
return (
|
||||
object.x !== nextObject.x ||
|
||||
object.y !== nextObject.y ||
|
||||
object.width !== nextObject.width ||
|
||||
object.height !== nextObject.height
|
||||
);
|
||||
}
|
||||
|
||||
export default function LocationMapCanvas({
|
||||
mode,
|
||||
objects,
|
||||
filters,
|
||||
mapSize,
|
||||
zoom,
|
||||
selectedObjectKey,
|
||||
mapItems,
|
||||
username,
|
||||
svgRef,
|
||||
dragState,
|
||||
editingLocked = false,
|
||||
hiddenAreaCount = 0,
|
||||
onAddObject,
|
||||
onShowZones,
|
||||
onShowHiddenAreas,
|
||||
setDragState,
|
||||
setSelectedObjectKey,
|
||||
remember,
|
||||
updateObjects,
|
||||
expandMapToFitObject,
|
||||
}) {
|
||||
const canEditObjects = !editingLocked && mode === "edit";
|
||||
const canDragPan = mode === "view" || mode === "edit";
|
||||
const hiddenByZonesLayer = objects.length > 0 && !filters.showZones;
|
||||
const visibleObjectCount = filters.showZones
|
||||
? objects.filter((object) => object.visible !== false).length
|
||||
: 0;
|
||||
const hasNoVisibleObjects = objects.length > 0 && filters.showZones && visibleObjectCount === 0;
|
||||
const canAddFirstArea =
|
||||
mode === "edit" && objects.length === 0 && typeof onAddObject === "function";
|
||||
const canRecoverHiddenAreas =
|
||||
mode === "edit" && hasNoVisibleObjects && hiddenAreaCount > 0 && typeof onShowHiddenAreas === "function";
|
||||
const hasEmptyCanvasAction = canAddFirstArea || hiddenByZonesLayer || canRecoverHiddenAreas;
|
||||
const emptyCanvasMessage = objects.length === 0
|
||||
? "No map areas yet"
|
||||
: hiddenByZonesLayer
|
||||
? "Zones hidden in Layers"
|
||||
: hasNoVisibleObjects
|
||||
? "No visible map areas"
|
||||
: "";
|
||||
const scrollRef = useRef(null);
|
||||
const panDragRef = useRef(null);
|
||||
const pendingMapShiftScrollRef = useRef({ x: 0, y: 0 });
|
||||
const scheduledMapShiftScrollRef = useRef(null);
|
||||
const objectDragHistoryCapturedRef = useRef(false);
|
||||
const suppressPanClickRef = useRef(false);
|
||||
const autoScrolledObjectViewRef = useRef(null);
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const orderedObjects = useMemo(() => {
|
||||
if (!selectedObjectKey) return objects;
|
||||
|
||||
const selectedObjects = [];
|
||||
const remainingObjects = [];
|
||||
objects.forEach((object) => {
|
||||
if (getObjectKey(object) === selectedObjectKey) {
|
||||
selectedObjects.push(object);
|
||||
return;
|
||||
}
|
||||
remainingObjects.push(object);
|
||||
});
|
||||
|
||||
return [...remainingObjects, ...selectedObjects];
|
||||
}, [objects, selectedObjectKey]);
|
||||
const zoneItemSummary = useMemo(() => {
|
||||
const assignedCounts = new Map();
|
||||
const visibleItemsByZone = new Map();
|
||||
|
||||
(mapItems || []).forEach((item) => {
|
||||
const zoneKey = String(item.zone_id || "");
|
||||
if (!zoneKey) return;
|
||||
|
||||
assignedCounts.set(zoneKey, (assignedCounts.get(zoneKey) || 0) + 1);
|
||||
if (!itemMatchesMapFilters(item, filters, username)) return;
|
||||
|
||||
const existingItems = visibleItemsByZone.get(zoneKey) || [];
|
||||
existingItems.push(item);
|
||||
visibleItemsByZone.set(zoneKey, existingItems);
|
||||
});
|
||||
|
||||
return { assignedCounts, visibleItemsByZone };
|
||||
}, [filters, mapItems, username]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (scheduledMapShiftScrollRef.current && typeof window !== "undefined") {
|
||||
window.clearTimeout(scheduledMapShiftScrollRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const flushQueuedMapShiftScroll = () => {
|
||||
const queuedShift = pendingMapShiftScrollRef.current;
|
||||
if (!queuedShift.x && !queuedShift.y) return;
|
||||
|
||||
pendingMapShiftScrollRef.current = { x: 0, y: 0 };
|
||||
const scrollElement = scrollRef.current;
|
||||
panScrollElement(scrollElement, queuedShift.x * zoom, queuedShift.y * zoom);
|
||||
|
||||
if (!scrollElement || typeof window === "undefined") return;
|
||||
|
||||
const targetLeft = scrollElement.scrollLeft;
|
||||
const targetTop = scrollElement.scrollTop;
|
||||
const restoreScrollTarget = () => {
|
||||
const currentScrollElement = scrollRef.current;
|
||||
if (!currentScrollElement) return;
|
||||
|
||||
currentScrollElement.scrollLeft = Math.max(currentScrollElement.scrollLeft, targetLeft);
|
||||
currentScrollElement.scrollTop = Math.max(currentScrollElement.scrollTop, targetTop);
|
||||
};
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
restoreScrollTarget();
|
||||
window.setTimeout(restoreScrollTarget, 80);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const queueMapShiftScroll = (shiftX, shiftY) => {
|
||||
if (!shiftX && !shiftY) return;
|
||||
|
||||
pendingMapShiftScrollRef.current = {
|
||||
x: pendingMapShiftScrollRef.current.x + shiftX,
|
||||
y: pendingMapShiftScrollRef.current.y + shiftY,
|
||||
};
|
||||
|
||||
if (typeof window === "undefined" || scheduledMapShiftScrollRef.current) return;
|
||||
|
||||
scheduledMapShiftScrollRef.current = window.setTimeout(() => {
|
||||
scheduledMapShiftScrollRef.current = null;
|
||||
window.requestAnimationFrame(flushQueuedMapShiftScroll);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedObjectKey) {
|
||||
autoScrolledObjectViewRef.current = null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (mode !== "edit" || dragState) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const selectedObject = objects.find((object) => getObjectKey(object) === selectedObjectKey);
|
||||
if (!selectedObject || !scrollRef.current || typeof window === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const scrollKey = getSelectedObjectScrollKey(selectedObject, selectedObjectKey, zoom);
|
||||
if (autoScrolledObjectViewRef.current === scrollKey) {
|
||||
return undefined;
|
||||
}
|
||||
autoScrolledObjectViewRef.current = scrollKey;
|
||||
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
const scrollElement = scrollRef.current;
|
||||
if (!scrollElement) return;
|
||||
|
||||
const objectLeft = selectedObject.x * zoom - SELECTED_OBJECT_SCROLL_PADDING;
|
||||
const objectTop = selectedObject.y * zoom - SELECTED_OBJECT_SCROLL_PADDING;
|
||||
const objectRight = (selectedObject.x + selectedObject.width) * zoom + SELECTED_OBJECT_SCROLL_PADDING;
|
||||
const objectBottom = (selectedObject.y + selectedObject.height) * zoom + SELECTED_OBJECT_SCROLL_PADDING;
|
||||
let nextLeft = scrollElement.scrollLeft;
|
||||
let nextTop = scrollElement.scrollTop;
|
||||
|
||||
if (objectLeft < nextLeft) {
|
||||
nextLeft = objectLeft;
|
||||
} else if (objectRight > nextLeft + scrollElement.clientWidth) {
|
||||
nextLeft = objectRight - scrollElement.clientWidth;
|
||||
}
|
||||
|
||||
if (objectTop < nextTop) {
|
||||
nextTop = objectTop;
|
||||
} else if (objectBottom > nextTop + scrollElement.clientHeight) {
|
||||
nextTop = objectBottom - scrollElement.clientHeight;
|
||||
}
|
||||
|
||||
scrollElement.scrollTo({
|
||||
left: clampScrollPosition(nextLeft, scrollElement.scrollWidth - scrollElement.clientWidth),
|
||||
top: clampScrollPosition(nextTop, scrollElement.scrollHeight - scrollElement.clientHeight),
|
||||
});
|
||||
});
|
||||
|
||||
return () => window.cancelAnimationFrame(frameId);
|
||||
}, [dragState, mode, objects, selectedObjectKey, zoom]);
|
||||
|
||||
const startDrag = (event, object, type) => {
|
||||
if (!canEditObjects || object.locked || !svgRef.current) return;
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
objectDragHistoryCapturedRef.current = false;
|
||||
const point = clientPointToMap(event, svgRef.current, mapSize);
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
setSelectedObjectKey(getObjectKey(object));
|
||||
setDragState({
|
||||
type,
|
||||
key: getObjectKey(object),
|
||||
startPoint: point,
|
||||
startObject: { ...object },
|
||||
});
|
||||
};
|
||||
|
||||
const handlePointerMove = (event) => {
|
||||
if (!dragState || !svgRef.current) return;
|
||||
event.preventDefault();
|
||||
const scrollElement = scrollRef.current;
|
||||
if (scrollElement) {
|
||||
const scrollBounds = scrollElement.getBoundingClientRect();
|
||||
panScrollElement(
|
||||
scrollElement,
|
||||
getEdgePanStep(event.clientX, scrollBounds.left, scrollBounds.right),
|
||||
getEdgePanStep(event.clientY, scrollBounds.top, scrollBounds.bottom)
|
||||
);
|
||||
}
|
||||
|
||||
const point = clientPointToMap(event, svgRef.current, mapSize);
|
||||
const deltaX = point.x - dragState.startPoint.x;
|
||||
const deltaY = point.y - dragState.startPoint.y;
|
||||
const hasMeaningfulChange =
|
||||
Math.abs(deltaX) >= DRAG_CHANGE_THRESHOLD ||
|
||||
Math.abs(deltaY) >= DRAG_CHANGE_THRESHOLD;
|
||||
|
||||
if (!hasMeaningfulChange) return;
|
||||
const draggedObject = objects.find((object) => getObjectKey(object) === dragState.key);
|
||||
if (!draggedObject) return;
|
||||
const nextDraggedObjectCandidate = getDraggedObjectCandidate(draggedObject, dragState, deltaX, deltaY);
|
||||
const expansion = expandMapToFitObject?.(nextDraggedObjectCandidate) || {
|
||||
mapSize,
|
||||
shiftX: 0,
|
||||
shiftY: 0,
|
||||
};
|
||||
const nextMapSize = expansion.mapSize;
|
||||
const shiftedDraggedObjectCandidate = shiftMapObject(
|
||||
nextDraggedObjectCandidate,
|
||||
expansion.shiftX,
|
||||
expansion.shiftY
|
||||
);
|
||||
const nextDraggedObject = dragState.type === "resize"
|
||||
? clampResizedObjectToMap(shiftedDraggedObjectCandidate, nextMapSize)
|
||||
: clampObjectToMap(shiftedDraggedObjectCandidate, nextMapSize);
|
||||
if (!hasObjectBoundsChanged(draggedObject, nextDraggedObject)) return;
|
||||
|
||||
if (!objectDragHistoryCapturedRef.current) {
|
||||
remember(undefined, dragState.key);
|
||||
objectDragHistoryCapturedRef.current = true;
|
||||
}
|
||||
|
||||
if (expansion.shiftX || expansion.shiftY) {
|
||||
queueMapShiftScroll(expansion.shiftX, expansion.shiftY);
|
||||
setDragState((currentDragState) => {
|
||||
if (!currentDragState || currentDragState.key !== dragState.key) return currentDragState;
|
||||
|
||||
return {
|
||||
...currentDragState,
|
||||
startObject: shiftMapObject(currentDragState.startObject, expansion.shiftX, expansion.shiftY),
|
||||
startPoint: {
|
||||
x: currentDragState.startPoint.x + expansion.shiftX,
|
||||
y: currentDragState.startPoint.y + expansion.shiftY,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
updateObjects(
|
||||
(currentObjects) => currentObjects.map((object) => {
|
||||
if (getObjectKey(object) !== dragState.key) {
|
||||
return shiftMapObject(object, expansion.shiftX, expansion.shiftY);
|
||||
}
|
||||
const candidate = getDraggedObjectCandidate(object, dragState, deltaX, deltaY);
|
||||
const shiftedCandidate = shiftMapObject(candidate, expansion.shiftX, expansion.shiftY);
|
||||
return dragState.type === "resize"
|
||||
? clampResizedObjectToMap(shiftedCandidate, nextMapSize)
|
||||
: clampObjectToMap(shiftedCandidate, nextMapSize);
|
||||
}),
|
||||
nextMapSize
|
||||
);
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
objectDragHistoryCapturedRef.current = false;
|
||||
setDragState(null);
|
||||
};
|
||||
|
||||
const selectObject = (object) => {
|
||||
if (mode === "view" || canEditObjects) {
|
||||
setSelectedObjectKey(getObjectKey(object));
|
||||
}
|
||||
};
|
||||
|
||||
const handleObjectClick = (event, object) => {
|
||||
event.stopPropagation();
|
||||
if (suppressPanClickRef.current) {
|
||||
suppressPanClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
selectObject(object);
|
||||
};
|
||||
|
||||
const handleObjectKeyDown = (event, object) => {
|
||||
if (event.key === "Escape" && (mode === "view" || canEditObjects)) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
setSelectedObjectKey(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (["Enter", " ", "Spacebar"].includes(event.key)) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
selectObject(object);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canEditObjects || object.locked || !NUDGE_KEYS.has(event.key)) return;
|
||||
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
const nudgeAmount = event.shiftKey ? 25 : 10;
|
||||
const deltaX = event.key === "ArrowLeft" ? -nudgeAmount : event.key === "ArrowRight" ? nudgeAmount : 0;
|
||||
const deltaY = event.key === "ArrowUp" ? -nudgeAmount : event.key === "ArrowDown" ? nudgeAmount : 0;
|
||||
const nextObjectCandidate = {
|
||||
...object,
|
||||
x: object.x + deltaX,
|
||||
y: object.y + deltaY,
|
||||
};
|
||||
const expansion = expandMapToFitObject?.(nextObjectCandidate) || {
|
||||
mapSize,
|
||||
shiftX: 0,
|
||||
shiftY: 0,
|
||||
};
|
||||
const nextMapSize = expansion.mapSize;
|
||||
const nextObject = clampObjectToMap(
|
||||
shiftMapObject(nextObjectCandidate, expansion.shiftX, expansion.shiftY),
|
||||
nextMapSize
|
||||
);
|
||||
|
||||
setSelectedObjectKey(getObjectKey(object));
|
||||
if (!hasObjectBoundsChanged(object, nextObject)) return;
|
||||
|
||||
remember(undefined, getObjectKey(object));
|
||||
queueMapShiftScroll(expansion.shiftX, expansion.shiftY);
|
||||
updateObjects(
|
||||
(currentObjects) => currentObjects.map((candidate) =>
|
||||
getObjectKey(candidate) === getObjectKey(object)
|
||||
? nextObject
|
||||
: shiftMapObject(candidate, expansion.shiftX, expansion.shiftY)
|
||||
),
|
||||
nextMapSize
|
||||
);
|
||||
};
|
||||
|
||||
const handleShowZones = (event) => {
|
||||
event.stopPropagation();
|
||||
onShowZones?.();
|
||||
};
|
||||
|
||||
const handleAddObject = (event) => {
|
||||
event.stopPropagation();
|
||||
if (editingLocked) return;
|
||||
onAddObject?.();
|
||||
};
|
||||
|
||||
const handleShowHiddenAreas = (event) => {
|
||||
event.stopPropagation();
|
||||
if (editingLocked) return;
|
||||
onShowHiddenAreas?.();
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
if (suppressPanClickRef.current) {
|
||||
suppressPanClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!dragState) {
|
||||
setSelectedObjectKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const startPan = (event) => {
|
||||
if (!canDragPan || !scrollRef.current || event.button !== 0) return;
|
||||
const objectElement = event.target.closest?.(".location-map-object");
|
||||
if (canEditObjects && objectElement) return;
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
panDragRef.current = {
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
scrollLeft: scrollRef.current.scrollLeft,
|
||||
scrollTop: scrollRef.current.scrollTop,
|
||||
objectKey: objectElement?.getAttribute("data-object-key") || null,
|
||||
moved: false,
|
||||
};
|
||||
setIsPanning(true);
|
||||
};
|
||||
|
||||
const movePan = (event) => {
|
||||
if (!panDragRef.current || !scrollRef.current) return;
|
||||
const deltaX = event.clientX - panDragRef.current.startX;
|
||||
const deltaY = event.clientY - panDragRef.current.startY;
|
||||
if (Math.abs(deltaX) > 4 || Math.abs(deltaY) > 4) {
|
||||
panDragRef.current.moved = true;
|
||||
event.preventDefault();
|
||||
}
|
||||
scrollRef.current.scrollLeft = clampScrollPosition(
|
||||
panDragRef.current.scrollLeft - deltaX,
|
||||
scrollRef.current.scrollWidth - scrollRef.current.clientWidth
|
||||
);
|
||||
scrollRef.current.scrollTop = clampScrollPosition(
|
||||
panDragRef.current.scrollTop - deltaY,
|
||||
scrollRef.current.scrollHeight - scrollRef.current.clientHeight
|
||||
);
|
||||
};
|
||||
|
||||
const endPan = (event) => {
|
||||
if (!panDragRef.current) return;
|
||||
const wasMoved = panDragRef.current.moved;
|
||||
const objectKey = panDragRef.current.objectKey;
|
||||
if (!wasMoved && mode === "view" && objectKey) {
|
||||
setSelectedObjectKey(objectKey);
|
||||
suppressPanClickRef.current = true;
|
||||
} else {
|
||||
suppressPanClickRef.current = wasMoved;
|
||||
}
|
||||
panDragRef.current = null;
|
||||
setIsPanning(false);
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
};
|
||||
|
||||
const renderMapObject = (object, index) => {
|
||||
const key = getObjectKey(object);
|
||||
const zoneKey = String(object.zone_id || "");
|
||||
const zoneItems = zoneKey ? zoneItemSummary.visibleItemsByZone.get(zoneKey) || [] : [];
|
||||
const assignedZoneItemCount = zoneKey ? zoneItemSummary.assignedCounts.get(zoneKey) || 0 : 0;
|
||||
return (
|
||||
<LocationMapObject
|
||||
key={key}
|
||||
object={object}
|
||||
index={index}
|
||||
filters={filters}
|
||||
zoneItems={zoneItems}
|
||||
assignedZoneItemCount={assignedZoneItemCount}
|
||||
isSelected={key === selectedObjectKey}
|
||||
canEditObjects={canEditObjects}
|
||||
canSelect={mode === "view" || canEditObjects}
|
||||
zoom={zoom}
|
||||
mapSize={mapSize}
|
||||
onStartDrag={startDrag}
|
||||
onObjectClick={handleObjectClick}
|
||||
onObjectKeyDown={handleObjectKeyDown}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className={[
|
||||
"location-map-canvas-shell",
|
||||
mode === "edit" ? "is-edit-mode" : "is-view-mode",
|
||||
mode === "edit" ? "is-object-tool" : "is-pan-tool",
|
||||
isPanning ? "is-panning" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="location-map-scroll"
|
||||
onPointerDown={startPan}
|
||||
onPointerMove={movePan}
|
||||
onPointerUp={endPan}
|
||||
onPointerCancel={endPan}
|
||||
onPointerLeave={endPan}
|
||||
onClick={clearSelection}
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className={[
|
||||
"location-map-svg",
|
||||
canEditObjects ? "is-editable" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
style={{
|
||||
width: `${mapSize.width * zoom}px`,
|
||||
height: `${mapSize.height * zoom}px`,
|
||||
}}
|
||||
viewBox={`0 0 ${mapSize.width} ${mapSize.height}`}
|
||||
role="img"
|
||||
aria-label="Store location map"
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
onClick={clearSelection}
|
||||
>
|
||||
<defs>
|
||||
<pattern id="location-map-grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
<path d="M 40 0 L 0 0 0 40" fill="none" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect className="location-map-background" width={mapSize.width} height={mapSize.height} />
|
||||
{mode === "edit" ? (
|
||||
<rect className="location-map-grid" width={mapSize.width} height={mapSize.height} />
|
||||
) : null}
|
||||
{orderedObjects.map(renderMapObject)}
|
||||
</svg>
|
||||
{emptyCanvasMessage ? (
|
||||
<div
|
||||
className={[
|
||||
"location-map-empty-canvas",
|
||||
hasEmptyCanvasAction ? "has-action" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
>
|
||||
<span>{emptyCanvasMessage}</span>
|
||||
{canAddFirstArea ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={editingLocked}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={handleAddObject}
|
||||
>
|
||||
Add Area
|
||||
</button>
|
||||
) : hiddenByZonesLayer ? (
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={handleShowZones}
|
||||
>
|
||||
Show Zones
|
||||
</button>
|
||||
) : canRecoverHiddenAreas ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={editingLocked}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={handleShowHiddenAreas}
|
||||
>
|
||||
Show Hidden
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/maps/LocationMapDialogs.jsx
Normal file
46
frontend/src/components/maps/LocationMapDialogs.jsx
Normal file
@ -0,0 +1,46 @@
|
||||
import ConfirmBuyModal from "../modals/ConfirmBuyModal";
|
||||
import ConfirmSlideModal from "../modals/ConfirmSlideModal";
|
||||
import { getMapObjectDisplayLabel } from "../../lib/locationMapUtils";
|
||||
|
||||
export default function LocationMapDialogs({
|
||||
buyModalItem,
|
||||
buyModalItems,
|
||||
onBuyCancel,
|
||||
onBuyConfirm,
|
||||
onBuyNavigate,
|
||||
pendingDeleteObject,
|
||||
onDeleteClose,
|
||||
onDeleteConfirm,
|
||||
pendingLeaveTarget,
|
||||
onLeaveClose,
|
||||
onLeaveConfirm,
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{buyModalItem ? (
|
||||
<ConfirmBuyModal
|
||||
item={buyModalItem}
|
||||
allItems={buyModalItems}
|
||||
onNavigate={onBuyNavigate}
|
||||
onCancel={onBuyCancel}
|
||||
onConfirm={onBuyConfirm}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<ConfirmSlideModal
|
||||
isOpen={Boolean(pendingDeleteObject)}
|
||||
title={`Delete ${getMapObjectDisplayLabel(pendingDeleteObject, "this map area")}?`}
|
||||
confirmLabel="Delete Area"
|
||||
onClose={onDeleteClose}
|
||||
onConfirm={onDeleteConfirm}
|
||||
/>
|
||||
<ConfirmSlideModal
|
||||
isOpen={Boolean(pendingLeaveTarget)}
|
||||
title="Leave with unsaved changes?"
|
||||
confirmLabel="Leave Map"
|
||||
onClose={onLeaveClose}
|
||||
onConfirm={onLeaveConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
193
frontend/src/components/maps/LocationMapObject.jsx
Normal file
193
frontend/src/components/maps/LocationMapObject.jsx
Normal file
@ -0,0 +1,193 @@
|
||||
import {
|
||||
getMapObjectDisplayLabel,
|
||||
getObjectKey,
|
||||
} from "../../lib/locationMapUtils";
|
||||
|
||||
const PIN_PREVIEW_LIMIT = 12;
|
||||
|
||||
function truncateLabel(value, maxCharacters) {
|
||||
if (value.length <= maxCharacters) return value;
|
||||
return `${value.slice(0, Math.max(5, maxCharacters - 3)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function fittedLabelLines(object, fallback) {
|
||||
const rawLabel = getMapObjectDisplayLabel(object, fallback);
|
||||
const maxCharacters = Math.max(8, Math.floor((Number(object.width) - 28) / 13));
|
||||
const canUseTwoLines = Number(object.height) >= 92 && rawLabel.length > maxCharacters;
|
||||
if (!canUseTwoLines) return [truncateLabel(rawLabel, maxCharacters)];
|
||||
|
||||
const words = rawLabel.split(/\s+/);
|
||||
let firstLine = "";
|
||||
let splitIndex = 0;
|
||||
for (let index = 0; index < words.length; index += 1) {
|
||||
const candidate = firstLine ? `${firstLine} ${words[index]}` : words[index];
|
||||
if (candidate.length > maxCharacters && firstLine) break;
|
||||
firstLine = candidate;
|
||||
splitIndex = index + 1;
|
||||
if (candidate.length >= maxCharacters) break;
|
||||
}
|
||||
|
||||
const secondLine = words.slice(splitIndex).join(" ");
|
||||
if (!secondLine) return [truncateLabel(firstLine, maxCharacters)];
|
||||
return [
|
||||
truncateLabel(firstLine, maxCharacters),
|
||||
truncateLabel(secondLine, maxCharacters),
|
||||
];
|
||||
}
|
||||
|
||||
export default function LocationMapObject({
|
||||
object,
|
||||
index,
|
||||
filters,
|
||||
zoneItems,
|
||||
assignedZoneItemCount,
|
||||
isSelected,
|
||||
canEditObjects,
|
||||
canSelect,
|
||||
zoom,
|
||||
mapSize,
|
||||
onStartDrag,
|
||||
onObjectClick,
|
||||
onObjectKeyDown,
|
||||
}) {
|
||||
if (object.visible === false || !filters.showZones) return null;
|
||||
|
||||
const key = getObjectKey(object);
|
||||
const displayLabel = getMapObjectDisplayLabel(object, index + 1);
|
||||
const count = zoneItems.length;
|
||||
const hasHiddenZoneItems = assignedZoneItemCount > count;
|
||||
const countLabel = hasHiddenZoneItems
|
||||
? `${count} shown`
|
||||
: `${count} item${count === 1 ? "" : "s"}`;
|
||||
const accessibleLabel = canEditObjects
|
||||
? object.locked
|
||||
? `Map area ${displayLabel}, locked`
|
||||
: `Map area ${displayLabel}`
|
||||
: `Map area ${displayLabel}, ${countLabel}`;
|
||||
const labelLines = fittedLabelLines(object, "Area");
|
||||
const countY = filters.showLabels && labelLines.length > 1 ? object.y + 76 : object.y + 52;
|
||||
const resizeHandleSize = Math.min(96, Math.max(44, Math.ceil(48 / zoom)));
|
||||
const resizeHandleInset = 6;
|
||||
const lockBadgeSize = Math.min(52, Math.max(30, Math.ceil(34 / zoom)));
|
||||
const lockBadgeInset = 8;
|
||||
const resizeHandleX = Math.min(
|
||||
Math.max(0, mapSize.width - resizeHandleSize),
|
||||
Math.max(
|
||||
object.x + resizeHandleInset,
|
||||
object.x + object.width - resizeHandleSize - resizeHandleInset
|
||||
)
|
||||
);
|
||||
const resizeHandleY = Math.min(
|
||||
Math.max(0, mapSize.height - resizeHandleSize),
|
||||
Math.max(
|
||||
object.y + resizeHandleInset,
|
||||
object.y + object.height - resizeHandleSize - resizeHandleInset
|
||||
)
|
||||
);
|
||||
const lockBadgeX = Math.min(
|
||||
Math.max(0, mapSize.width - lockBadgeSize),
|
||||
Math.max(
|
||||
object.x + lockBadgeInset,
|
||||
object.x + object.width - lockBadgeSize - lockBadgeInset
|
||||
)
|
||||
);
|
||||
const lockBadgeY = Math.min(
|
||||
Math.max(0, mapSize.height - lockBadgeSize),
|
||||
object.y + lockBadgeInset
|
||||
);
|
||||
const pinDots = filters.showPins
|
||||
? zoneItems.slice(0, PIN_PREVIEW_LIMIT).map((item, itemIndex) => {
|
||||
const column = itemIndex % 4;
|
||||
const row = Math.floor(itemIndex / 4);
|
||||
return (
|
||||
<circle
|
||||
key={item.id}
|
||||
className="location-map-pin"
|
||||
cx={object.x + 24 + column * 18}
|
||||
cy={object.y + 30 + row * 18}
|
||||
r="5"
|
||||
/>
|
||||
);
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<g
|
||||
data-object-key={key}
|
||||
className={[
|
||||
"location-map-object",
|
||||
isSelected ? "is-selected" : "",
|
||||
canEditObjects ? "is-editable" : "",
|
||||
object.locked ? "is-locked" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
transform={`rotate(${object.rotation || 0} ${object.x + object.width / 2} ${object.y + object.height / 2})`}
|
||||
>
|
||||
<rect
|
||||
x={object.x}
|
||||
y={object.y}
|
||||
width={object.width}
|
||||
height={object.height}
|
||||
rx="12"
|
||||
role="button"
|
||||
aria-label={accessibleLabel}
|
||||
aria-pressed={isSelected}
|
||||
tabIndex={canSelect ? 0 : -1}
|
||||
onPointerDown={(event) => onStartDrag(event, object, "move")}
|
||||
onClick={(event) => onObjectClick(event, object)}
|
||||
onKeyDown={(event) => onObjectKeyDown(event, object)}
|
||||
/>
|
||||
{filters.showLabels ? (
|
||||
<text x={object.x + 16} y={object.y + 28} className="location-map-label">
|
||||
{labelLines.map((line, lineIndex) => (
|
||||
<tspan key={`${line}-${lineIndex}`} x={object.x + 16} dy={lineIndex === 0 ? 0 : 24}>
|
||||
{line}
|
||||
</tspan>
|
||||
))}
|
||||
</text>
|
||||
) : null}
|
||||
{filters.showCounts ? (
|
||||
<text x={object.x + 16} y={countY} className="location-map-count">
|
||||
{countLabel}
|
||||
</text>
|
||||
) : null}
|
||||
{pinDots}
|
||||
{canEditObjects && isSelected && object.locked ? (
|
||||
<g
|
||||
className="location-map-lock-badge"
|
||||
transform={`translate(${lockBadgeX} ${lockBadgeY})`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect width={lockBadgeSize} height={lockBadgeSize} rx="10" />
|
||||
<path
|
||||
d={[
|
||||
`M ${lockBadgeSize * 0.32} ${lockBadgeSize * 0.48}`,
|
||||
`V ${lockBadgeSize * 0.4}`,
|
||||
`C ${lockBadgeSize * 0.32} ${lockBadgeSize * 0.24}, ${lockBadgeSize * 0.68} ${lockBadgeSize * 0.24}, ${lockBadgeSize * 0.68} ${lockBadgeSize * 0.4}`,
|
||||
`V ${lockBadgeSize * 0.48}`,
|
||||
].join(" ")}
|
||||
/>
|
||||
<rect
|
||||
className="location-map-lock-body"
|
||||
x={lockBadgeSize * 0.26}
|
||||
y={lockBadgeSize * 0.46}
|
||||
width={lockBadgeSize * 0.48}
|
||||
height={lockBadgeSize * 0.34}
|
||||
rx={lockBadgeSize * 0.08}
|
||||
/>
|
||||
</g>
|
||||
) : null}
|
||||
{canEditObjects && isSelected && !object.locked ? (
|
||||
<rect
|
||||
className="location-map-resize-handle"
|
||||
x={resizeHandleX}
|
||||
y={resizeHandleY}
|
||||
width={resizeHandleSize}
|
||||
height={resizeHandleSize}
|
||||
rx="8"
|
||||
onPointerDown={(event) => onStartDrag(event, object, "resize")}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
) : null}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
63
frontend/src/components/maps/LocationMapSetupPanel.jsx
Normal file
63
frontend/src/components/maps/LocationMapSetupPanel.jsx
Normal file
@ -0,0 +1,63 @@
|
||||
export default function LocationMapSetupPanel({
|
||||
hasAnyMap,
|
||||
canManage,
|
||||
zoneCount = 0,
|
||||
saving,
|
||||
savingAction,
|
||||
onContinue,
|
||||
onPreview,
|
||||
onPublish,
|
||||
onCreateFromZones,
|
||||
onCreateBlank,
|
||||
}) {
|
||||
const hasZones = zoneCount > 0;
|
||||
const createFromZonesLabel = `Create From ${zoneCount} Zone${zoneCount === 1 ? "" : "s"}`;
|
||||
const createBlankLabel = savingAction === "create-blank" ? "Creating..." : "Create Blank Map";
|
||||
const createZonesButtonLabel = savingAction === "create-zones" ? "Creating..." : createFromZonesLabel;
|
||||
const publishLabel = savingAction === "publish" ? "Publishing..." : "Publish";
|
||||
const heading = hasAnyMap ? "Draft Map" : canManage ? "No Map" : "No Published Map";
|
||||
|
||||
return (
|
||||
<section className="location-map-setup">
|
||||
<h2>{heading}</h2>
|
||||
{canManage ? (
|
||||
<div className="location-map-setup-rows" role="group" aria-label="Map setup details">
|
||||
<div className="location-map-setup-row">
|
||||
<span>Zones</span>
|
||||
<strong>{zoneCount}</strong>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{hasAnyMap && canManage ? (
|
||||
<div className="location-map-setup-actions">
|
||||
<button type="button" className="btn-primary" onClick={onContinue} disabled={saving}>
|
||||
Continue Editing
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" onClick={onPreview} disabled={saving}>
|
||||
View Draft
|
||||
</button>
|
||||
<button type="button" className="btn-primary" onClick={onPublish} disabled={saving || !canManage}>
|
||||
{publishLabel}
|
||||
</button>
|
||||
</div>
|
||||
) : !hasAnyMap && canManage ? (
|
||||
<div className="location-map-setup-actions">
|
||||
{!hasZones ? (
|
||||
<button type="button" className="btn-primary" onClick={onCreateBlank} disabled={saving}>
|
||||
{createBlankLabel}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className="btn-primary" onClick={onCreateFromZones} disabled={saving}>
|
||||
{createZonesButtonLabel}
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" onClick={onCreateBlank} disabled={saving}>
|
||||
{createBlankLabel}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
38
frontend/src/components/maps/LocationMapStateViews.jsx
Normal file
38
frontend/src/components/maps/LocationMapStateViews.jsx
Normal file
@ -0,0 +1,38 @@
|
||||
import LocationMapTopbar from "./LocationMapTopbar";
|
||||
|
||||
export function LocationMapMessageState({ message = "", location, status = "Loading", onBack }) {
|
||||
const showTopbar = Boolean(location && onBack);
|
||||
|
||||
return (
|
||||
<div className="location-map-page">
|
||||
{showTopbar ? (
|
||||
<LocationMapTopbar location={location} status={status} onBack={onBack} />
|
||||
) : null}
|
||||
<main className="location-map-workspace location-map-message-workspace">
|
||||
<section className="location-map-setup location-map-message-card" aria-live="polite">
|
||||
<h2>{status}</h2>
|
||||
{message ? <p className="location-map-state-message">{message}</p> : null}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LocationMapLoadErrorState({ location, loadError, onBack, onRetry }) {
|
||||
return (
|
||||
<div className="location-map-page">
|
||||
<LocationMapTopbar location={location} status="Load Error" onBack={onBack} />
|
||||
<main className="location-map-workspace">
|
||||
<section className="location-map-setup location-map-load-error" role="alert" aria-live="polite">
|
||||
<h2>Map Unavailable</h2>
|
||||
<p className="location-map-state-message">{loadError}</p>
|
||||
<div className="location-map-setup-actions">
|
||||
<button type="button" className="btn-primary" onClick={onRetry}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
frontend/src/components/maps/LocationMapToolbar.jsx
Normal file
161
frontend/src/components/maps/LocationMapToolbar.jsx
Normal file
@ -0,0 +1,161 @@
|
||||
import { MAX_MAP_ZOOM, MIN_MAP_ZOOM, clampMapZoom } from "../../lib/locationMapUtils";
|
||||
|
||||
function ToolbarIcon({ name }) {
|
||||
if (name === "undo") {
|
||||
return (
|
||||
<svg className="location-map-toolbar-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M9 7H4v5" />
|
||||
<path d="M5 11a7 7 0 1 0 2-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === "redo") {
|
||||
return (
|
||||
<svg className="location-map-toolbar-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M15 7h5v5" />
|
||||
<path d="M19 11a7 7 0 1 1-2-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === "zoom-in") {
|
||||
return (
|
||||
<svg className="location-map-toolbar-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M11 5v12" />
|
||||
<path d="M5 11h12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === "fit") {
|
||||
return (
|
||||
<svg className="location-map-toolbar-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8 4H4v4" />
|
||||
<path d="M16 4h4v4" />
|
||||
<path d="M20 16v4h-4" />
|
||||
<path d="M8 20H4v-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg className="location-map-toolbar-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M5 11h12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LocationMapToolbar({
|
||||
mode,
|
||||
hasAnyMap,
|
||||
canManage,
|
||||
saving,
|
||||
history = [],
|
||||
future = [],
|
||||
zoom,
|
||||
setZoom,
|
||||
onFit,
|
||||
onView,
|
||||
onEdit,
|
||||
onUndo,
|
||||
onRedo,
|
||||
}) {
|
||||
const isAtMinZoom = zoom <= MIN_MAP_ZOOM;
|
||||
const isAtMaxZoom = zoom >= MAX_MAP_ZOOM;
|
||||
const canUseHistoryControls = mode === "edit" && canManage && hasAnyMap;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`location-map-toolbar ${canUseHistoryControls ? "has-history-slot" : ""}`}
|
||||
aria-label="Map controls"
|
||||
>
|
||||
<div className="location-map-mode-group">
|
||||
<div className="location-map-mode-buttons" aria-label="Map mode">
|
||||
<button
|
||||
type="button"
|
||||
className={mode === "view" ? "active" : ""}
|
||||
disabled={saving || !hasAnyMap}
|
||||
onClick={onView}
|
||||
aria-pressed={mode === "view"}
|
||||
>
|
||||
View
|
||||
</button>
|
||||
{canManage ? (
|
||||
<button
|
||||
type="button"
|
||||
className={mode === "edit" ? "active" : ""}
|
||||
disabled={saving || !hasAnyMap}
|
||||
onClick={onEdit}
|
||||
aria-pressed={mode === "edit"}
|
||||
aria-label="Edit Draft"
|
||||
>
|
||||
<span className="location-map-label-full">Edit Draft</span>
|
||||
<span className="location-map-label-short">Edit</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{canUseHistoryControls ? (
|
||||
<div
|
||||
className="location-map-history-buttons"
|
||||
aria-label="Edit history"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="location-map-icon-button"
|
||||
onClick={onUndo}
|
||||
disabled={!canUseHistoryControls || saving || history.length === 0}
|
||||
aria-label="Undo"
|
||||
title="Undo"
|
||||
>
|
||||
<ToolbarIcon name="undo" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="location-map-icon-button"
|
||||
onClick={onRedo}
|
||||
disabled={!canUseHistoryControls || saving || future.length === 0}
|
||||
aria-label="Redo"
|
||||
title="Redo"
|
||||
>
|
||||
<ToolbarIcon name="redo" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="location-map-zoom-controls" aria-label="Zoom controls">
|
||||
<button
|
||||
type="button"
|
||||
className="location-map-icon-button"
|
||||
onClick={() => setZoom((value) => Number(clampMapZoom(value - 0.15).toFixed(2)))}
|
||||
disabled={!hasAnyMap || isAtMinZoom}
|
||||
aria-label="Zoom out"
|
||||
title="Zoom out"
|
||||
>
|
||||
<ToolbarIcon name="zoom-out" />
|
||||
</button>
|
||||
<span>{Math.round(zoom * 100)}%</span>
|
||||
<button
|
||||
type="button"
|
||||
className="location-map-icon-button"
|
||||
onClick={() => setZoom((value) => Number(clampMapZoom(value + 0.15).toFixed(2)))}
|
||||
disabled={!hasAnyMap || isAtMaxZoom}
|
||||
aria-label="Zoom in"
|
||||
title="Zoom in"
|
||||
>
|
||||
<ToolbarIcon name="zoom-in" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="location-map-icon-button"
|
||||
onClick={onFit}
|
||||
disabled={!hasAnyMap}
|
||||
aria-label="Fit"
|
||||
title="Fit"
|
||||
>
|
||||
<ToolbarIcon name="fit" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
29
frontend/src/components/maps/LocationMapTopbar.jsx
Normal file
29
frontend/src/components/maps/LocationMapTopbar.jsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { getLocationName, getStoreName } from "../../lib/locationMapUtils";
|
||||
|
||||
export default function LocationMapTopbar({ location, status, onBack }) {
|
||||
const statusClass = status.toLowerCase().replace(/\s+/g, "-");
|
||||
|
||||
return (
|
||||
<header className="location-map-topbar">
|
||||
<button
|
||||
type="button"
|
||||
className="location-map-back"
|
||||
onClick={onBack}
|
||||
aria-label="Back to grocery list"
|
||||
>
|
||||
<span aria-hidden="true">←</span>
|
||||
</button>
|
||||
<div className="location-map-title">
|
||||
<h1>{getStoreName(location)}</h1>
|
||||
<span>{getLocationName(location)}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`location-map-status location-map-status-${statusClass}`}
|
||||
role="status"
|
||||
aria-label={`Map status: ${status}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,15 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import "../../styles/ConfirmBuyModal.css";
|
||||
|
||||
function ChevronIcon({ direction }) {
|
||||
const path = direction === "previous" ? "M15 18l-6-6 6-6" : "M9 18l6-6-6-6";
|
||||
return (
|
||||
<svg className="confirm-buy-nav-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d={path} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConfirmBuyModal({
|
||||
item,
|
||||
onConfirm,
|
||||
@ -11,6 +20,7 @@ export default function ConfirmBuyModal({
|
||||
const [quantity, setQuantity] = useState(item.quantity);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const maxQuantity = item.quantity;
|
||||
const titleId = "confirm-buy-modal-title";
|
||||
|
||||
useEffect(() => {
|
||||
setQuantity(item.quantity);
|
||||
@ -21,17 +31,17 @@ export default function ConfirmBuyModal({
|
||||
const hasPrev = currentIndex > 0;
|
||||
const hasNext = currentIndex < allItems.length - 1;
|
||||
|
||||
const handleIncrement = () => {
|
||||
if (!isSubmitting && quantity < maxQuantity) {
|
||||
setQuantity((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
const handleIncrement = useCallback(() => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
const handleDecrement = () => {
|
||||
if (!isSubmitting && quantity > 1) {
|
||||
setQuantity((prev) => prev - 1);
|
||||
}
|
||||
};
|
||||
setQuantity((prev) => Math.min(maxQuantity, prev + 1));
|
||||
}, [isSubmitting, maxQuantity]);
|
||||
|
||||
const handleDecrement = useCallback(() => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
setQuantity((prev) => Math.max(1, prev - 1));
|
||||
}, [isSubmitting]);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (isSubmitting) return;
|
||||
@ -44,21 +54,58 @@ export default function ConfirmBuyModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
const handlePrev = useCallback(() => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
if (hasPrev && onNavigate) {
|
||||
onNavigate(allItems[currentIndex - 1]);
|
||||
}
|
||||
};
|
||||
}, [allItems, currentIndex, hasPrev, isSubmitting, onNavigate]);
|
||||
|
||||
const handleNext = () => {
|
||||
const handleNext = useCallback(() => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
if (hasNext && onNavigate) {
|
||||
onNavigate(allItems[currentIndex + 1]);
|
||||
}
|
||||
};
|
||||
}, [allItems, currentIndex, hasNext, isSubmitting, onNavigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event) => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
if (event.key === "Escape") {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
handlePrev();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
handleNext();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
handleIncrement();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
handleDecrement();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleDecrement, handleIncrement, handleNext, handlePrev, isSubmitting, onCancel]);
|
||||
|
||||
const imageUrl = item.item_image && item.image_mime_type
|
||||
? `data:${item.image_mime_type};base64,${item.item_image}`
|
||||
@ -73,46 +120,62 @@ export default function ConfirmBuyModal({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="confirm-buy-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<div
|
||||
className="confirm-buy-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="confirm-buy-header">
|
||||
{item.zone && <div className="confirm-buy-zone">{item.zone}</div>}
|
||||
<h2 className="confirm-buy-item-name">{item.item_name}</h2>
|
||||
<h2 id={titleId} className="confirm-buy-item-name">{item.item_name}</h2>
|
||||
</div>
|
||||
|
||||
<div className="confirm-buy-image-section">
|
||||
<button
|
||||
type="button"
|
||||
className="confirm-buy-nav-btn confirm-buy-nav-prev"
|
||||
onClick={handlePrev}
|
||||
style={{ visibility: hasPrev ? "visible" : "hidden" }}
|
||||
disabled={!hasPrev || isSubmitting}
|
||||
aria-label="Previous item"
|
||||
title="Previous item"
|
||||
>
|
||||
{"<"}
|
||||
<ChevronIcon direction="previous" />
|
||||
</button>
|
||||
|
||||
<div className="confirm-buy-image-container">
|
||||
{imageUrl ? (
|
||||
<img src={imageUrl} alt={item.item_name} className="confirm-buy-image" />
|
||||
) : (
|
||||
<div className="confirm-buy-image-placeholder">[ ]</div>
|
||||
<div className="confirm-buy-image-placeholder" role="img" aria-label="No item photo">
|
||||
<span aria-hidden="true">{"\uD83D\uDCE6"}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="confirm-buy-nav-btn confirm-buy-nav-next"
|
||||
onClick={handleNext}
|
||||
style={{ visibility: hasNext ? "visible" : "hidden" }}
|
||||
disabled={!hasNext || isSubmitting}
|
||||
aria-label="Next item"
|
||||
title="Next item"
|
||||
>
|
||||
{">"}
|
||||
<ChevronIcon direction="next" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="confirm-buy-quantity-section">
|
||||
<div className="confirm-buy-counter">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDecrement}
|
||||
className="confirm-buy-counter-btn"
|
||||
disabled={quantity <= 1 || isSubmitting}
|
||||
aria-label="Decrease quantity"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
@ -121,11 +184,14 @@ export default function ConfirmBuyModal({
|
||||
value={quantity}
|
||||
readOnly
|
||||
className="confirm-buy-counter-display"
|
||||
aria-label="Quantity to mark bought"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleIncrement}
|
||||
className="confirm-buy-counter-btn"
|
||||
disabled={quantity >= maxQuantity || isSubmitting}
|
||||
aria-label="Increase quantity"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
@ -133,10 +199,10 @@ export default function ConfirmBuyModal({
|
||||
</div>
|
||||
|
||||
<div className="confirm-buy-actions">
|
||||
<button onClick={onCancel} className="confirm-buy-cancel" disabled={isSubmitting}>
|
||||
<button type="button" onClick={onCancel} className="confirm-buy-cancel" disabled={isSubmitting}>
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleConfirm} className="confirm-buy-confirm" disabled={isSubmitting}>
|
||||
<button type="button" onClick={handleConfirm} className="confirm-buy-confirm" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Mark as Bought"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -1,11 +1,74 @@
|
||||
import { useContext } from 'react';
|
||||
import { useContext, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { StoreContext } from '../../context/StoreContext';
|
||||
import '../../styles/components/StoreTabs.css';
|
||||
|
||||
const DEFAULT_LOCATION_NAME = "Default Location";
|
||||
|
||||
function getStoreKey(store) {
|
||||
return String(store?.household_store_id ?? store?.store_id ?? store?.id ?? "");
|
||||
}
|
||||
|
||||
function getStoreName(store) {
|
||||
return store?.name || store?.display_name || "Store";
|
||||
}
|
||||
|
||||
function getLocationName(store) {
|
||||
if (!store) return "Location";
|
||||
const locationName = store.location_name || "";
|
||||
if (!locationName || locationName === DEFAULT_LOCATION_NAME) {
|
||||
return "Default";
|
||||
}
|
||||
return locationName;
|
||||
}
|
||||
|
||||
function chooseStoreLocation(locations) {
|
||||
if (!locations || locations.length === 0) return null;
|
||||
return (
|
||||
locations.find((store) => store.location_name === "Default Location" || store.display_name === store.name) ||
|
||||
locations.find((store) => store.is_default) ||
|
||||
locations[0]
|
||||
);
|
||||
}
|
||||
|
||||
function buildStoreOptions(stores) {
|
||||
const grouped = new Map();
|
||||
|
||||
for (const store of stores) {
|
||||
const key = getStoreKey(store);
|
||||
if (!grouped.has(key)) {
|
||||
grouped.set(key, {
|
||||
key,
|
||||
name: getStoreName(store),
|
||||
locations: [],
|
||||
});
|
||||
}
|
||||
grouped.get(key).locations.push(store);
|
||||
}
|
||||
|
||||
return Array.from(grouped.values()).map((group) => ({
|
||||
...group,
|
||||
store: chooseStoreLocation(group.locations),
|
||||
}));
|
||||
}
|
||||
|
||||
export default function StoreTabs() {
|
||||
const { stores, activeStore, setActiveStore, loading } = useContext(StoreContext);
|
||||
const navigate = useNavigate();
|
||||
const routeLocation = useLocation();
|
||||
const [activePicker, setActivePicker] = useState(null);
|
||||
const storeOptions = useMemo(() => buildStoreOptions(stores || []), [stores]);
|
||||
const activeStoreKey = getStoreKey(activeStore);
|
||||
const selectedStoreOption =
|
||||
storeOptions.find((option) => option.key === activeStoreKey) || storeOptions[0];
|
||||
const selectedLocations = selectedStoreOption?.locations || [];
|
||||
const selectedLocation =
|
||||
selectedLocations.find((location) => String(location.id) === String(activeStore?.id)) ||
|
||||
selectedStoreOption?.store;
|
||||
const canPickStore = storeOptions.length > 1 && !loading;
|
||||
const canPickLocation = selectedLocations.length > 1 && !loading;
|
||||
|
||||
if (!stores || stores.length === 0) {
|
||||
if (!stores || stores.length === 0 || storeOptions.length === 0) {
|
||||
return (
|
||||
<div className="store-tabs">
|
||||
<div className="store-tabs-empty">
|
||||
@ -15,20 +78,125 @@ export default function StoreTabs() {
|
||||
);
|
||||
}
|
||||
|
||||
const handleStoreTriggerClick = () => {
|
||||
if (!canPickStore) return;
|
||||
setActivePicker("store");
|
||||
};
|
||||
|
||||
const handleLocationTriggerClick = () => {
|
||||
if (!canPickLocation) return;
|
||||
setActivePicker("location");
|
||||
};
|
||||
|
||||
const handleStoreSelect = (store) => {
|
||||
setActiveStore(store);
|
||||
setActivePicker(null);
|
||||
};
|
||||
|
||||
const handleLocationSelect = (location) => {
|
||||
setActiveStore(location);
|
||||
setActivePicker(null);
|
||||
};
|
||||
|
||||
const handleManageMap = () => {
|
||||
if (!selectedLocation?.id || !selectedStoreOption?.key) return;
|
||||
navigate(`/stores/${selectedStoreOption.key}/locations/${selectedLocation.id}/map`, {
|
||||
state: {
|
||||
returnTo: `${routeLocation.pathname}${routeLocation.search}${routeLocation.hash}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="store-tabs">
|
||||
<div className="store-tabs-container">
|
||||
{stores.map(store => (
|
||||
<button
|
||||
key={store.id}
|
||||
className={`store-tab ${store.id === activeStore?.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveStore(store)}
|
||||
disabled={loading}
|
||||
>
|
||||
<span className="store-name">{store.display_name || store.name}</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={`store-selector-trigger ${canPickStore ? "is-interactive" : ""}`}
|
||||
onClick={handleStoreTriggerClick}
|
||||
disabled={loading}
|
||||
aria-haspopup={canPickStore ? "dialog" : undefined}
|
||||
aria-expanded={canPickStore ? activePicker === "store" : undefined}
|
||||
aria-label={`Store: ${selectedStoreOption.name}`}
|
||||
>
|
||||
<span className="store-name">{selectedStoreOption.name}</span>
|
||||
{canPickStore ? <span className="store-selector-caret" aria-hidden="true" /> : null}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`store-selector-trigger location-selector-trigger ${canPickLocation ? "is-interactive" : ""}`}
|
||||
onClick={handleLocationTriggerClick}
|
||||
disabled={loading}
|
||||
aria-haspopup={canPickLocation ? "dialog" : undefined}
|
||||
aria-expanded={canPickLocation ? activePicker === "location" : undefined}
|
||||
aria-label={`Location: ${getLocationName(selectedLocation)}`}
|
||||
>
|
||||
<span className="store-name">{getLocationName(selectedLocation)}</span>
|
||||
{canPickLocation ? <span className="store-selector-caret" aria-hidden="true" /> : null}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="store-map-button"
|
||||
onClick={handleManageMap}
|
||||
disabled={loading || !selectedLocation}
|
||||
>
|
||||
Manage Map
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activePicker === "store" ? (
|
||||
<div className="store-selector-modal-overlay" onClick={() => setActivePicker(null)}>
|
||||
<div
|
||||
className="store-selector-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Select store"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h2>Stores</h2>
|
||||
<div className="store-selector-list">
|
||||
{storeOptions.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
className={`store-selector-option ${option.key === selectedStoreOption.key ? "active" : ""}`}
|
||||
onClick={() => handleStoreSelect(option.store)}
|
||||
>
|
||||
{option.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activePicker === "location" ? (
|
||||
<div className="store-selector-modal-overlay" onClick={() => setActivePicker(null)}>
|
||||
<div
|
||||
className="store-selector-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Select location"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h2>Locations</h2>
|
||||
<div className="store-selector-list">
|
||||
{selectedLocations.map((location) => (
|
||||
<button
|
||||
key={location.id}
|
||||
type="button"
|
||||
className={`store-selector-option ${String(location.id) === String(selectedLocation?.id) ? "active" : ""}`}
|
||||
onClick={() => handleLocationSelect(location)}
|
||||
>
|
||||
{getLocationName(location)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -4,6 +4,48 @@ import { getHouseholdStores, getLocationZones } from '../api/stores';
|
||||
import { AuthContext } from './AuthContext';
|
||||
import { HouseholdContext } from './HouseholdContext';
|
||||
|
||||
const DEFAULT_LOCATION_NAME = "Default Location";
|
||||
|
||||
function getHouseholdStoreKey(store) {
|
||||
return String(store?.household_store_id ?? store?.store_id ?? store?.id ?? "");
|
||||
}
|
||||
|
||||
function isDefaultLocationName(store) {
|
||||
return store?.location_name === DEFAULT_LOCATION_NAME || store?.display_name === store?.name;
|
||||
}
|
||||
|
||||
function chooseStoreLocation(locations) {
|
||||
if (!locations || locations.length === 0) return null;
|
||||
return (
|
||||
locations.find(isDefaultLocationName) ||
|
||||
locations.find((store) => store.is_default) ||
|
||||
locations[0]
|
||||
);
|
||||
}
|
||||
|
||||
function chooseStoreByHouseholdStoreId(stores, householdStoreId) {
|
||||
const matchingLocations = stores.filter(
|
||||
(store) => getHouseholdStoreKey(store) === String(householdStoreId)
|
||||
);
|
||||
return chooseStoreLocation(matchingLocations);
|
||||
}
|
||||
|
||||
function getLocationStorageKey(householdId, householdStoreId) {
|
||||
return `activeStoreLocationId_${householdId}_${householdStoreId}`;
|
||||
}
|
||||
|
||||
function chooseLocationByStoredIds(stores, householdId, householdStoreId) {
|
||||
const storageKey = getLocationStorageKey(householdId, householdStoreId);
|
||||
const savedLocationId = localStorage.getItem(storageKey);
|
||||
if (!savedLocationId) return null;
|
||||
|
||||
return stores.find(
|
||||
(store) =>
|
||||
String(store.id) === String(savedLocationId) &&
|
||||
getHouseholdStoreKey(store) === String(householdStoreId)
|
||||
);
|
||||
}
|
||||
|
||||
export const StoreContext = createContext({
|
||||
stores: [],
|
||||
activeStore: null,
|
||||
@ -42,24 +84,48 @@ export const StoreProvider = ({ children }) => {
|
||||
useEffect(() => {
|
||||
if (!activeHousehold || stores.length === 0) return;
|
||||
|
||||
console.log('[StoreContext] Setting active store from:', stores);
|
||||
const storageKey = `activeStoreId_${activeHousehold.id}`;
|
||||
const savedStoreId = localStorage.getItem(storageKey);
|
||||
const householdStoreStorageKey = `activeHouseholdStoreId_${activeHousehold.id}`;
|
||||
const legacyLocationStorageKey = `activeStoreId_${activeHousehold.id}`;
|
||||
const savedHouseholdStoreId = localStorage.getItem(householdStoreStorageKey);
|
||||
|
||||
if (savedStoreId) {
|
||||
const store = stores.find(s => String(s.id) === String(savedStoreId));
|
||||
if (savedHouseholdStoreId) {
|
||||
const store =
|
||||
chooseLocationByStoredIds(stores, activeHousehold.id, savedHouseholdStoreId) ||
|
||||
chooseStoreByHouseholdStoreId(stores, savedHouseholdStoreId);
|
||||
if (store) {
|
||||
console.log('[StoreContext] Found saved store:', store);
|
||||
setActiveStoreState(store);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No saved store or not found, use default or first one
|
||||
const defaultStore = stores.find(s => s.is_default) || stores[0];
|
||||
console.log('[StoreContext] Using store:', defaultStore);
|
||||
const savedLocationId = localStorage.getItem(legacyLocationStorageKey);
|
||||
if (savedLocationId) {
|
||||
const savedLocation = stores.find(s => String(s.id) === String(savedLocationId));
|
||||
const store = savedLocation || chooseStoreByHouseholdStoreId(stores, getHouseholdStoreKey(savedLocation));
|
||||
if (store) {
|
||||
const householdStoreId = getHouseholdStoreKey(store);
|
||||
setActiveStoreState(store);
|
||||
localStorage.setItem(householdStoreStorageKey, householdStoreId);
|
||||
localStorage.setItem(
|
||||
getLocationStorageKey(activeHousehold.id, householdStoreId),
|
||||
String(store.id)
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No saved store or not found, use the default store's representative location.
|
||||
const defaultStore = chooseStoreByHouseholdStoreId(
|
||||
stores,
|
||||
getHouseholdStoreKey(stores.find(s => s.is_default) || stores[0])
|
||||
);
|
||||
setActiveStoreState(defaultStore);
|
||||
localStorage.setItem(storageKey, defaultStore.id);
|
||||
const householdStoreId = getHouseholdStoreKey(defaultStore);
|
||||
localStorage.setItem(householdStoreStorageKey, householdStoreId);
|
||||
localStorage.setItem(
|
||||
getLocationStorageKey(activeHousehold.id, householdStoreId),
|
||||
String(defaultStore.id)
|
||||
);
|
||||
}, [stores, activeHousehold]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -76,9 +142,7 @@ export const StoreProvider = ({ children }) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
console.log('[StoreContext] Loading stores for household:', activeHousehold.id);
|
||||
const response = await getHouseholdStores(activeHousehold.id);
|
||||
console.log('[StoreContext] Loaded stores:', response.data);
|
||||
setStores(response.data);
|
||||
} catch (err) {
|
||||
console.error('[StoreContext] Failed to load stores:', err);
|
||||
@ -99,8 +163,13 @@ export const StoreProvider = ({ children }) => {
|
||||
const setActiveStore = (store) => {
|
||||
setActiveStoreState(store);
|
||||
if (store && activeHousehold) {
|
||||
const storageKey = `activeStoreId_${activeHousehold.id}`;
|
||||
localStorage.setItem(storageKey, String(store.id));
|
||||
const storageKey = `activeHouseholdStoreId_${activeHousehold.id}`;
|
||||
const householdStoreId = getHouseholdStoreKey(store);
|
||||
localStorage.setItem(storageKey, householdStoreId);
|
||||
localStorage.setItem(
|
||||
getLocationStorageKey(activeHousehold.id, householdStoreId),
|
||||
String(store.id)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
145
frontend/src/hooks/useLocationMapBuying.js
Normal file
145
frontend/src/hooks/useLocationMapBuying.js
Normal file
@ -0,0 +1,145 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getItemByName, markBought } from "../api/list";
|
||||
import getApiErrorMessage from "../lib/getApiErrorMessage";
|
||||
|
||||
const EMPTY_MAP_ITEMS = [];
|
||||
|
||||
function getNextMapBuyItem(items, currentIndex, excludedItemId) {
|
||||
const remainingItems = items.filter((item) => item.id !== excludedItemId);
|
||||
if (!remainingItems.length) return null;
|
||||
return remainingItems[Math.min(Math.max(currentIndex, 0), remainingItems.length - 1)];
|
||||
}
|
||||
|
||||
function getLocallyReducedMapItem(item, quantity) {
|
||||
const currentQuantity = Number(item.quantity || 1);
|
||||
const boughtQuantity = Math.max(1, Number(quantity || 1));
|
||||
return {
|
||||
...item,
|
||||
quantity: Math.max(1, currentQuantity - boughtQuantity),
|
||||
};
|
||||
}
|
||||
|
||||
function getLocallyBoughtMapItem(item) {
|
||||
return {
|
||||
...item,
|
||||
bought: true,
|
||||
};
|
||||
}
|
||||
|
||||
export default function useLocationMapBuying({
|
||||
activeHouseholdId,
|
||||
locationId,
|
||||
selectedObject,
|
||||
selectedZoneItems = EMPTY_MAP_ITEMS,
|
||||
setMapState,
|
||||
toast,
|
||||
visibleUnmappedItems = EMPTY_MAP_ITEMS,
|
||||
}) {
|
||||
const [buyModalItem, setBuyModalItem] = useState(null);
|
||||
const mapBuyScopeRef = useRef({ activeHouseholdId: null, locationId: null });
|
||||
const buyModalItems = useMemo(
|
||||
() => (selectedObject ? selectedZoneItems : visibleUnmappedItems).filter((item) => !item.bought),
|
||||
[selectedObject, selectedZoneItems, visibleUnmappedItems]
|
||||
);
|
||||
|
||||
const updateMapItems = useCallback((updater) => {
|
||||
setMapState((currentState) => {
|
||||
if (!currentState) return currentState;
|
||||
const currentItems = currentState.items || EMPTY_MAP_ITEMS;
|
||||
return { ...currentState, items: updater(currentItems) };
|
||||
});
|
||||
}, [setMapState]);
|
||||
|
||||
useEffect(() => {
|
||||
mapBuyScopeRef.current = {
|
||||
activeHouseholdId,
|
||||
locationId,
|
||||
};
|
||||
}, [activeHouseholdId, locationId]);
|
||||
|
||||
useEffect(() => {
|
||||
setBuyModalItem(null);
|
||||
}, [activeHouseholdId, locationId]);
|
||||
|
||||
const isCurrentMapBuyScope = useCallback((scopedHouseholdId, scopedLocationId) => (
|
||||
String(mapBuyScopeRef.current.activeHouseholdId || "") === String(scopedHouseholdId || "") &&
|
||||
String(mapBuyScopeRef.current.locationId || "") === String(scopedLocationId || "")
|
||||
), []);
|
||||
|
||||
const handleMapBuyCancel = useCallback(() => {
|
||||
setBuyModalItem(null);
|
||||
}, []);
|
||||
|
||||
const handleMapBuyNavigate = useCallback((item) => {
|
||||
setBuyModalItem(item);
|
||||
}, []);
|
||||
|
||||
const handleMapBuyConfirm = useCallback(async (quantity) => {
|
||||
if (!activeHouseholdId || !locationId || !buyModalItem) {
|
||||
setBuyModalItem(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const scopedHouseholdId = activeHouseholdId;
|
||||
const scopedLocationId = locationId;
|
||||
const item = buyModalItems.find((candidate) => candidate.id === buyModalItem.id) || buyModalItem;
|
||||
|
||||
try {
|
||||
const currentIndex = buyModalItems.findIndex((candidate) => candidate.id === item.id);
|
||||
const resolvedIndex = currentIndex >= 0 ? currentIndex : 0;
|
||||
|
||||
await markBought(scopedHouseholdId, scopedLocationId, item.item_name, quantity, true);
|
||||
if (!isCurrentMapBuyScope(scopedHouseholdId, scopedLocationId)) return;
|
||||
|
||||
let nextBuyModalItems = buyModalItems;
|
||||
if (quantity >= item.quantity) {
|
||||
nextBuyModalItems = buyModalItems.filter((candidate) => candidate.id !== item.id);
|
||||
updateMapItems((currentItems) =>
|
||||
currentItems.map((candidate) => (
|
||||
candidate.id === item.id ? getLocallyBoughtMapItem(candidate) : candidate
|
||||
))
|
||||
);
|
||||
} else {
|
||||
let updatedItem;
|
||||
try {
|
||||
const response = await getItemByName(scopedHouseholdId, scopedLocationId, item.item_name);
|
||||
if (!isCurrentMapBuyScope(scopedHouseholdId, scopedLocationId)) return;
|
||||
updatedItem = response.data || getLocallyReducedMapItem(item, quantity);
|
||||
} catch {
|
||||
if (!isCurrentMapBuyScope(scopedHouseholdId, scopedLocationId)) return;
|
||||
updatedItem = getLocallyReducedMapItem(item, quantity);
|
||||
toast.info("Updated item locally", "Latest quantity will sync when the map reloads.");
|
||||
}
|
||||
nextBuyModalItems = buyModalItems.map((candidate) => (candidate.id === item.id ? updatedItem : candidate));
|
||||
|
||||
updateMapItems((currentItems) =>
|
||||
currentItems.map((candidate) => (candidate.id === item.id ? updatedItem : candidate))
|
||||
);
|
||||
}
|
||||
setBuyModalItem(getNextMapBuyItem(nextBuyModalItems, resolvedIndex, item.id));
|
||||
|
||||
toast.success("Marked item bought", `Marked item ${item.item_name} as bought`);
|
||||
} catch (error) {
|
||||
if (!isCurrentMapBuyScope(scopedHouseholdId, scopedLocationId)) return;
|
||||
const message = getApiErrorMessage(error, "Failed to mark item as bought");
|
||||
toast.error("Mark item bought failed", `Mark item bought failed: ${message}`);
|
||||
}
|
||||
}, [
|
||||
activeHouseholdId,
|
||||
buyModalItem,
|
||||
buyModalItems,
|
||||
isCurrentMapBuyScope,
|
||||
locationId,
|
||||
toast,
|
||||
updateMapItems,
|
||||
]);
|
||||
|
||||
return {
|
||||
buyModalItem,
|
||||
buyModalItems,
|
||||
handleMapBuyCancel,
|
||||
handleMapBuyConfirm,
|
||||
handleMapBuyNavigate,
|
||||
setBuyModalItem,
|
||||
};
|
||||
}
|
||||
83
frontend/src/hooks/useLocationMapDerivedState.js
Normal file
83
frontend/src/hooks/useLocationMapDerivedState.js
Normal file
@ -0,0 +1,83 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
DEFAULT_MAP_SIZE,
|
||||
getObjectKey,
|
||||
itemMatchesMapFilters,
|
||||
itemsForZone,
|
||||
mapForMode,
|
||||
unmappedItems,
|
||||
} from "../lib/locationMapUtils";
|
||||
|
||||
const EMPTY_MAP_ITEMS = [];
|
||||
|
||||
export default function useLocationMapDerivedState({
|
||||
filters,
|
||||
mapDraft,
|
||||
mapState,
|
||||
mode,
|
||||
objects,
|
||||
previewDraft,
|
||||
selectedObjectKey,
|
||||
username,
|
||||
}) {
|
||||
const activeMap = useMemo(
|
||||
() => mapForMode(mapState, mode, previewDraft),
|
||||
[mapState, mode, previewDraft]
|
||||
);
|
||||
const mapItems = mapState?.items || EMPTY_MAP_ITEMS;
|
||||
const selectedObject = useMemo(
|
||||
() => objects.find((object) => getObjectKey(object) === selectedObjectKey) || null,
|
||||
[objects, selectedObjectKey]
|
||||
);
|
||||
const selectedZoneItems = useMemo(
|
||||
() => selectedObject?.zone_id
|
||||
? itemsForZone(mapItems, selectedObject.zone_id, filters, username)
|
||||
: EMPTY_MAP_ITEMS,
|
||||
[filters, mapItems, selectedObject?.zone_id, username]
|
||||
);
|
||||
const visibleUnmappedItems = useMemo(
|
||||
() => unmappedItems(mapItems, filters, username),
|
||||
[filters, mapItems, username]
|
||||
);
|
||||
const { visibleAssignedItemCount, unmappedItemCount } = useMemo(() => {
|
||||
let nextVisibleAssignedItemCount = 0;
|
||||
let nextUnmappedItemCount = 0;
|
||||
|
||||
mapItems.forEach((item) => {
|
||||
if (!item.zone_id) {
|
||||
nextUnmappedItemCount += 1;
|
||||
return;
|
||||
}
|
||||
if (itemMatchesMapFilters(item, filters, username)) {
|
||||
nextVisibleAssignedItemCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
visibleAssignedItemCount: nextVisibleAssignedItemCount,
|
||||
unmappedItemCount: nextUnmappedItemCount,
|
||||
};
|
||||
}, [filters, mapItems, username]);
|
||||
const hiddenAreaCount = useMemo(
|
||||
() => objects.filter((object) => object.visible === false).length,
|
||||
[objects]
|
||||
);
|
||||
const mapSize = useMemo(
|
||||
() => ({
|
||||
width: mapDraft.width || activeMap?.width || DEFAULT_MAP_SIZE.width,
|
||||
height: mapDraft.height || activeMap?.height || DEFAULT_MAP_SIZE.height,
|
||||
}),
|
||||
[activeMap?.height, activeMap?.width, mapDraft.height, mapDraft.width]
|
||||
);
|
||||
|
||||
return {
|
||||
hiddenAreaCount,
|
||||
mapItems,
|
||||
mapSize,
|
||||
selectedObject,
|
||||
selectedZoneItems,
|
||||
unmappedItemCount,
|
||||
visibleAssignedItemCount,
|
||||
visibleUnmappedItems,
|
||||
};
|
||||
}
|
||||
144
frontend/src/hooks/useLocationMapDraftActions.js
Normal file
144
frontend/src/hooks/useLocationMapDraftActions.js
Normal file
@ -0,0 +1,144 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
createBlankLocationMap,
|
||||
createLocationMapFromZones,
|
||||
publishLocationMapDraft,
|
||||
saveLocationMapDraft,
|
||||
} from "../api/locationMaps";
|
||||
import getApiErrorMessage from "../lib/getApiErrorMessage";
|
||||
import { getObjectKey, prepareObjectsForSave } from "../lib/locationMapUtils";
|
||||
|
||||
export default function useLocationMapDraftActions({
|
||||
activeHouseholdId,
|
||||
beginSaving,
|
||||
endSaving,
|
||||
hasUnsavedChanges,
|
||||
locationId,
|
||||
mapDraft,
|
||||
objects,
|
||||
selectedObjectKey,
|
||||
setMapState,
|
||||
setMode,
|
||||
setPreviewDraft,
|
||||
syncMapDraftFromState,
|
||||
toast,
|
||||
}) {
|
||||
const latestScopeRef = useRef({ activeHouseholdId, locationId });
|
||||
const selectedObjectIndex = selectedObjectKey
|
||||
? objects.findIndex((object) => getObjectKey(object) === selectedObjectKey)
|
||||
: -1;
|
||||
|
||||
useEffect(() => {
|
||||
latestScopeRef.current = { activeHouseholdId, locationId };
|
||||
}, [activeHouseholdId, locationId]);
|
||||
|
||||
const isCurrentScope = useCallback(() => (
|
||||
String(latestScopeRef.current.activeHouseholdId ?? "") === String(activeHouseholdId ?? "") &&
|
||||
String(latestScopeRef.current.locationId ?? "") === String(locationId ?? "")
|
||||
), [activeHouseholdId, locationId]);
|
||||
|
||||
const applyDraftResponse = useCallback((response, nextMode = "edit", nextPreviewDraft = true, options = {}) => {
|
||||
setMapState(response.data);
|
||||
setMode(nextMode);
|
||||
setPreviewDraft(nextPreviewDraft);
|
||||
syncMapDraftFromState(response.data, nextMode, nextPreviewDraft, options);
|
||||
}, [setMapState, setMode, setPreviewDraft, syncMapDraftFromState]);
|
||||
|
||||
const handleCreateBlank = useCallback(async () => {
|
||||
if (!activeHouseholdId || !locationId) return;
|
||||
beginSaving("create-blank");
|
||||
try {
|
||||
const response = await createBlankLocationMap(activeHouseholdId, locationId, mapDraft);
|
||||
if (!isCurrentScope()) return;
|
||||
applyDraftResponse(response);
|
||||
toast.success("Created map", "Blank draft map created");
|
||||
} catch (error) {
|
||||
if (!isCurrentScope()) return;
|
||||
toast.error("Create map failed", getApiErrorMessage(error, "Failed to create map"));
|
||||
} finally {
|
||||
if (isCurrentScope()) endSaving();
|
||||
}
|
||||
}, [
|
||||
activeHouseholdId, applyDraftResponse, beginSaving, endSaving, isCurrentScope,
|
||||
locationId, mapDraft, toast,
|
||||
]);
|
||||
|
||||
const handleCreateFromZones = useCallback(async () => {
|
||||
if (!activeHouseholdId || !locationId) return;
|
||||
beginSaving("create-zones");
|
||||
try {
|
||||
const response = await createLocationMapFromZones(activeHouseholdId, locationId, mapDraft);
|
||||
if (!isCurrentScope()) return;
|
||||
applyDraftResponse(response);
|
||||
toast.success("Created starter map", "Zones were added as editable rectangles");
|
||||
} catch (error) {
|
||||
if (!isCurrentScope()) return;
|
||||
toast.error("Create map failed", getApiErrorMessage(error, "Failed to create map from zones"));
|
||||
} finally {
|
||||
if (isCurrentScope()) endSaving();
|
||||
}
|
||||
}, [
|
||||
activeHouseholdId, applyDraftResponse, beginSaving, endSaving, isCurrentScope,
|
||||
locationId, mapDraft, toast,
|
||||
]);
|
||||
|
||||
const handleSaveDraft = useCallback(async () => {
|
||||
if (!activeHouseholdId || !locationId) return;
|
||||
beginSaving("save");
|
||||
try {
|
||||
const response = await saveLocationMapDraft(activeHouseholdId, locationId, {
|
||||
map: mapDraft,
|
||||
objects: prepareObjectsForSave(objects),
|
||||
});
|
||||
if (!isCurrentScope()) return;
|
||||
applyDraftResponse(response, "edit", true, { preserveSelectedIndex: selectedObjectIndex });
|
||||
toast.success("Saved draft", "Map draft saved");
|
||||
} catch (error) {
|
||||
if (!isCurrentScope()) return;
|
||||
toast.error("Save draft failed", getApiErrorMessage(error, "Failed to save map draft"));
|
||||
} finally {
|
||||
if (isCurrentScope()) endSaving();
|
||||
}
|
||||
}, [
|
||||
activeHouseholdId, applyDraftResponse, beginSaving, endSaving, isCurrentScope, locationId,
|
||||
mapDraft, objects, selectedObjectIndex, toast,
|
||||
]);
|
||||
|
||||
const handlePublish = useCallback(async () => {
|
||||
if (!activeHouseholdId || !locationId) return;
|
||||
beginSaving("publish");
|
||||
let savedPendingDraft = false;
|
||||
try {
|
||||
if (hasUnsavedChanges) {
|
||||
const saveResponse = await saveLocationMapDraft(activeHouseholdId, locationId, {
|
||||
map: mapDraft,
|
||||
objects: prepareObjectsForSave(objects),
|
||||
});
|
||||
if (!isCurrentScope()) return;
|
||||
savedPendingDraft = true;
|
||||
applyDraftResponse(saveResponse, "edit", true, { preserveSelectedIndex: selectedObjectIndex });
|
||||
}
|
||||
|
||||
const response = await publishLocationMapDraft(activeHouseholdId, locationId);
|
||||
if (!isCurrentScope()) return;
|
||||
applyDraftResponse(response, "view", false, { preserveSelectedIndex: selectedObjectIndex });
|
||||
toast.success(
|
||||
"Published map",
|
||||
hasUnsavedChanges
|
||||
? "Latest edits were saved and published"
|
||||
: "Map is now visible to household members"
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isCurrentScope()) return;
|
||||
const message = getApiErrorMessage(error, "Failed to publish map");
|
||||
toast.error("Publish failed", savedPendingDraft ? `${message}. Draft changes were saved.` : message);
|
||||
} finally {
|
||||
if (isCurrentScope()) endSaving();
|
||||
}
|
||||
}, [
|
||||
activeHouseholdId, applyDraftResponse, beginSaving, endSaving, hasUnsavedChanges, isCurrentScope,
|
||||
locationId, mapDraft, objects, selectedObjectIndex, toast,
|
||||
]);
|
||||
|
||||
return { handleCreateBlank, handleCreateFromZones, handlePublish, handleSaveDraft };
|
||||
}
|
||||
134
frontend/src/hooks/useLocationMapDraftState.js
Normal file
134
frontend/src/hooks/useLocationMapDraftState.js
Normal file
@ -0,0 +1,134 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getLocationMap } from "../api/locationMaps";
|
||||
import getApiErrorMessage from "../lib/getApiErrorMessage";
|
||||
import { getDefaultMapDraft, getMapDraftSnapshot } from "../lib/locationMapDraftStateUtils";
|
||||
import { DEFAULT_MAP_FILTERS } from "../lib/locationMapUtils";
|
||||
|
||||
export default function useLocationMapDraftState({
|
||||
activeHousehold,
|
||||
hasLoaded,
|
||||
householdLoading,
|
||||
locationId,
|
||||
toast,
|
||||
}) {
|
||||
const [mapState, setMapState] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingAction, setSavingAction] = useState(null);
|
||||
const [mode, setMode] = useState("setup");
|
||||
const [previewDraft, setPreviewDraft] = useState(false);
|
||||
const [mapDraft, setMapDraft] = useState(getDefaultMapDraft);
|
||||
const [objects, setObjects] = useState([]);
|
||||
const [selectedObjectKey, setSelectedObjectKey] = useState(null);
|
||||
const [filters, setFilters] = useState(DEFAULT_MAP_FILTERS);
|
||||
const [layersOpen, setLayersOpen] = useState(false);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [zoom, setZoom] = useState(0.75);
|
||||
const [history, setHistory] = useState([]);
|
||||
const [future, setFuture] = useState([]);
|
||||
const [dragState, setDragState] = useState(null);
|
||||
const toastRef = useRef(toast);
|
||||
const loadRequestRef = useRef(0);
|
||||
|
||||
const resetDraftState = useCallback(() => {
|
||||
setObjects([]);
|
||||
setSelectedObjectKey(null);
|
||||
setHistory([]);
|
||||
setFuture([]);
|
||||
setHasUnsavedChanges(false);
|
||||
setMapDraft(getDefaultMapDraft());
|
||||
}, []);
|
||||
|
||||
const syncMapDraftFromState = useCallback((nextState, nextMode, nextPreviewDraft, options = {}) => {
|
||||
const snapshot = getMapDraftSnapshot(nextState, nextMode, nextPreviewDraft, options);
|
||||
if (!snapshot) return;
|
||||
|
||||
setMapDraft(snapshot.mapDraft);
|
||||
setObjects(snapshot.objects);
|
||||
setSelectedObjectKey(snapshot.selectedObjectKey);
|
||||
setHistory([]);
|
||||
setFuture([]);
|
||||
setHasUnsavedChanges(false);
|
||||
}, []);
|
||||
|
||||
const loadMap = useCallback(async () => {
|
||||
const requestId = loadRequestRef.current + 1;
|
||||
loadRequestRef.current = requestId;
|
||||
|
||||
if (!activeHousehold?.id || !locationId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
setSaving(false);
|
||||
setSavingAction(null);
|
||||
setFilters({ ...DEFAULT_MAP_FILTERS });
|
||||
setLayersOpen(false);
|
||||
try {
|
||||
const response = await getLocationMap(activeHousehold.id, locationId);
|
||||
if (loadRequestRef.current !== requestId) return;
|
||||
|
||||
const nextState = response.data;
|
||||
const nextCanManage = Boolean(
|
||||
nextState.can_manage ?? ["owner", "admin"].includes(activeHousehold?.role)
|
||||
);
|
||||
setMapState(nextState);
|
||||
|
||||
if (!nextState.draft_map && !nextState.published_map) {
|
||||
setMode("setup");
|
||||
setPreviewDraft(false);
|
||||
resetDraftState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextState.published_map) {
|
||||
setMode("view");
|
||||
setPreviewDraft(false);
|
||||
syncMapDraftFromState(nextState, "view", false);
|
||||
} else if (nextState.draft_map && nextCanManage) {
|
||||
setMode("setup");
|
||||
setPreviewDraft(true);
|
||||
syncMapDraftFromState(nextState, "edit", true);
|
||||
} else {
|
||||
setMode("setup");
|
||||
setPreviewDraft(false);
|
||||
resetDraftState();
|
||||
}
|
||||
} catch (error) {
|
||||
if (loadRequestRef.current !== requestId) return;
|
||||
|
||||
const message = getApiErrorMessage(error, "Failed to load map");
|
||||
toastRef.current.error("Load map failed", message);
|
||||
setMapState(null);
|
||||
resetDraftState();
|
||||
setLoadError(message);
|
||||
} finally {
|
||||
if (loadRequestRef.current === requestId) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [activeHousehold?.id, activeHousehold?.role, locationId, resetDraftState, syncMapDraftFromState]);
|
||||
|
||||
useEffect(() => { toastRef.current = toast; }, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!householdLoading && hasLoaded) {
|
||||
loadMap();
|
||||
}
|
||||
}, [hasLoaded, householdLoading, loadMap]);
|
||||
|
||||
const beginSaving = useCallback((action) => { setSavingAction(action); setSaving(true); }, []);
|
||||
|
||||
const endSaving = useCallback(() => { setSaving(false); setSavingAction(null); }, []);
|
||||
|
||||
return {
|
||||
beginSaving, dragState, endSaving, filters, future, hasUnsavedChanges, history, layersOpen,
|
||||
loadError, loadMap, loading, mapDraft, mapState, mode, objects, previewDraft, saving,
|
||||
savingAction, selectedObjectKey, setDragState, setFilters, setFuture,
|
||||
setHasUnsavedChanges, setHistory, setLayersOpen, setMapState, setMode, setObjects,
|
||||
setMapDraft, setPreviewDraft, setSelectedObjectKey, setZoom, syncMapDraftFromState, zoom,
|
||||
};
|
||||
}
|
||||
190
frontend/src/hooks/useLocationMapObjectActions.js
Normal file
190
frontend/src/hooks/useLocationMapObjectActions.js
Normal file
@ -0,0 +1,190 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
clampObjectToMap,
|
||||
createClientObject,
|
||||
getMapObjectDisplayLabel,
|
||||
getObjectKey,
|
||||
objectZoneId,
|
||||
} from "../lib/locationMapUtils";
|
||||
|
||||
const EMPTY_ZONES = [];
|
||||
const MAP_EDGE_GROW_PADDING = 360;
|
||||
|
||||
function createHistorySnapshot(objects, selectedObjectKey) {
|
||||
return {
|
||||
objects: objects.map((object) => ({ ...object })),
|
||||
selectedObjectKey: selectedObjectKey ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export default function useLocationMapObjectActions({
|
||||
canManage,
|
||||
hiddenAreaCount,
|
||||
mapSize,
|
||||
mapState,
|
||||
objects,
|
||||
selectedObject,
|
||||
selectedObjectKey,
|
||||
setFuture,
|
||||
setHasUnsavedChanges,
|
||||
setHistory,
|
||||
setMapDraft,
|
||||
setObjects,
|
||||
setSelectedObjectKey,
|
||||
toast,
|
||||
}) {
|
||||
const [pendingDeleteObject, setPendingDeleteObject] = useState(null);
|
||||
const zones = mapState?.zones || EMPTY_ZONES;
|
||||
|
||||
const remember = useCallback((currentObjects = objects, currentSelectedObjectKey = selectedObjectKey) => {
|
||||
setHistory((previous) => [
|
||||
...previous.slice(-19),
|
||||
createHistorySnapshot(currentObjects, currentSelectedObjectKey),
|
||||
]);
|
||||
setFuture([]);
|
||||
}, [objects, selectedObjectKey, setFuture, setHistory]);
|
||||
|
||||
const updateObjects = useCallback((updater, bounds = mapSize) => {
|
||||
setHasUnsavedChanges(true);
|
||||
setObjects((currentObjects) =>
|
||||
updater(currentObjects).map((object) => clampObjectToMap(object, bounds))
|
||||
);
|
||||
}, [mapSize, setHasUnsavedChanges, setObjects]);
|
||||
|
||||
const expandMapToFitObject = useCallback((object) => {
|
||||
const shiftX = object.x < 0 ? Math.ceil(Math.abs(object.x) + MAP_EDGE_GROW_PADDING) : 0;
|
||||
const shiftY = object.y < 0 ? Math.ceil(Math.abs(object.y) + MAP_EDGE_GROW_PADDING) : 0;
|
||||
const shiftedObject = {
|
||||
...object,
|
||||
x: Number(object.x || 0) + shiftX,
|
||||
y: Number(object.y || 0) + shiftY,
|
||||
};
|
||||
const nextWidth = Math.max(
|
||||
mapSize.width + shiftX,
|
||||
Math.ceil(shiftedObject.x + Number(object.width || 0) + MAP_EDGE_GROW_PADDING)
|
||||
);
|
||||
const nextHeight = Math.max(
|
||||
mapSize.height + shiftY,
|
||||
Math.ceil(shiftedObject.y + Number(object.height || 0) + MAP_EDGE_GROW_PADDING)
|
||||
);
|
||||
const nextMapSize = { width: nextWidth, height: nextHeight };
|
||||
|
||||
if (nextWidth > mapSize.width || nextHeight > mapSize.height || shiftX > 0 || shiftY > 0) {
|
||||
setMapDraft((currentDraft) => ({
|
||||
...currentDraft,
|
||||
width: Math.max(Number(currentDraft.width || 0), nextWidth),
|
||||
height: Math.max(Number(currentDraft.height || 0), nextHeight),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
|
||||
return { mapSize: nextMapSize, shiftX, shiftY };
|
||||
}, [mapSize.height, mapSize.width, setHasUnsavedChanges, setMapDraft]);
|
||||
|
||||
const handleAddObject = useCallback(() => {
|
||||
if (!canManage) return;
|
||||
remember();
|
||||
const usedZoneIds = new Set(objects.map((object) => objectZoneId(object)).filter(Boolean));
|
||||
const nextZone = zones.find((zone) => !usedZoneIds.has(String(zone.id))) || zones[0];
|
||||
const nextObject = clampObjectToMap(createClientObject(nextZone, objects.length), mapSize);
|
||||
setObjects((currentObjects) => [...currentObjects, nextObject]);
|
||||
setSelectedObjectKey(getObjectKey(nextObject));
|
||||
setHasUnsavedChanges(true);
|
||||
}, [canManage, mapSize, objects, remember, setHasUnsavedChanges, setObjects, setSelectedObjectKey, zones]);
|
||||
|
||||
const handleDuplicateObject = useCallback(() => {
|
||||
if (!selectedObject) return;
|
||||
remember();
|
||||
const displayLabel = getMapObjectDisplayLabel(selectedObject, "Area");
|
||||
const nextObject = {
|
||||
...selectedObject,
|
||||
id: undefined,
|
||||
client_id: `copy-${Date.now()}`,
|
||||
label: `${displayLabel} Copy`,
|
||||
x: selectedObject.x + 28,
|
||||
y: selectedObject.y + 28,
|
||||
sort_order: objects.length + 1,
|
||||
};
|
||||
setObjects((currentObjects) => [...currentObjects, clampObjectToMap(nextObject, mapSize)]);
|
||||
setSelectedObjectKey(getObjectKey(nextObject));
|
||||
setHasUnsavedChanges(true);
|
||||
}, [mapSize, objects.length, remember, selectedObject, setHasUnsavedChanges, setObjects, setSelectedObjectKey]);
|
||||
|
||||
const requestDeleteObject = useCallback(() => {
|
||||
if (selectedObject) setPendingDeleteObject(selectedObject);
|
||||
}, [selectedObject]);
|
||||
|
||||
const handleDeleteObject = useCallback(() => {
|
||||
if (!pendingDeleteObject) return;
|
||||
remember();
|
||||
setObjects((currentObjects) =>
|
||||
currentObjects.filter((object) => getObjectKey(object) !== getObjectKey(pendingDeleteObject))
|
||||
);
|
||||
setSelectedObjectKey(null);
|
||||
setHasUnsavedChanges(true);
|
||||
toast.success("Deleted map area", "Save the draft to keep this change.");
|
||||
setPendingDeleteObject(null);
|
||||
}, [pendingDeleteObject, remember, setHasUnsavedChanges, setObjects, setSelectedObjectKey, toast]);
|
||||
|
||||
const handleShowHiddenAreas = useCallback(() => {
|
||||
if (!canManage || hiddenAreaCount === 0) return;
|
||||
remember();
|
||||
updateObjects((currentObjects) =>
|
||||
currentObjects.map((object) => (
|
||||
object.visible === false ? { ...object, visible: true } : object
|
||||
))
|
||||
);
|
||||
toast.success("Showed hidden areas", "Save the draft to keep this change.");
|
||||
}, [canManage, hiddenAreaCount, remember, toast, updateObjects]);
|
||||
|
||||
const handleObjectField = useCallback((field, value) => {
|
||||
if (!selectedObject) return;
|
||||
if (selectedObject[field] === value) return;
|
||||
|
||||
remember();
|
||||
updateObjects((currentObjects) =>
|
||||
currentObjects.map((object) => (
|
||||
getObjectKey(object) === getObjectKey(selectedObject) ? { ...object, [field]: value } : object
|
||||
))
|
||||
);
|
||||
}, [remember, selectedObject, updateObjects]);
|
||||
|
||||
const handleZoneLinkChange = useCallback((zoneId) => {
|
||||
if (!selectedObject) return;
|
||||
const zone = zones.find((candidate) => String(candidate.id) === String(zoneId));
|
||||
const nextZoneId = zone?.id || null;
|
||||
const nextZoneName = zone?.name || null;
|
||||
const nextLabel = selectedObject.label ?? zone?.name ?? "";
|
||||
if (
|
||||
(selectedObject.zone_id || null) === nextZoneId &&
|
||||
(selectedObject.zone_name || null) === nextZoneName &&
|
||||
selectedObject.label === nextLabel
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
remember();
|
||||
updateObjects((currentObjects) =>
|
||||
currentObjects.map((object) => (
|
||||
getObjectKey(object) === selectedObjectKey
|
||||
? { ...object, zone_id: nextZoneId, zone_name: nextZoneName, label: object.label ?? zone?.name ?? "" }
|
||||
: object
|
||||
))
|
||||
);
|
||||
}, [remember, selectedObject, selectedObjectKey, updateObjects, zones]);
|
||||
|
||||
return {
|
||||
handleAddObject,
|
||||
handleDeleteObject,
|
||||
handleDuplicateObject,
|
||||
handleObjectField,
|
||||
handleShowHiddenAreas,
|
||||
handleZoneLinkChange,
|
||||
expandMapToFitObject,
|
||||
pendingDeleteObject,
|
||||
remember,
|
||||
requestDeleteObject,
|
||||
setPendingDeleteObject,
|
||||
updateObjects,
|
||||
};
|
||||
}
|
||||
220
frontend/src/hooks/useLocationMapViewControls.js
Normal file
220
frontend/src/hooks/useLocationMapViewControls.js
Normal file
@ -0,0 +1,220 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import useMobileMapAutoFit from "./useMobileMapAutoFit";
|
||||
import { getObjectKey } from "../lib/locationMapUtils";
|
||||
|
||||
function cloneMapObjects(objects = []) {
|
||||
return objects.map((object) => ({ ...object }));
|
||||
}
|
||||
|
||||
function getHistorySnapshot(snapshot, fallbackSelectedObjectKey = null) {
|
||||
if (Array.isArray(snapshot)) {
|
||||
return {
|
||||
objects: cloneMapObjects(snapshot),
|
||||
selectedObjectKey: fallbackSelectedObjectKey,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
objects: cloneMapObjects(snapshot?.objects || []),
|
||||
selectedObjectKey: snapshot?.selectedObjectKey ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function createHistorySnapshot(objects, selectedObjectKey) {
|
||||
return {
|
||||
objects: cloneMapObjects(objects),
|
||||
selectedObjectKey: selectedObjectKey ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function isTextEditingTarget(target) {
|
||||
return Boolean(target?.closest?.("input, textarea, select, [contenteditable]"));
|
||||
}
|
||||
|
||||
export default function useLocationMapViewControls({
|
||||
filters,
|
||||
future = [],
|
||||
hasUnsavedChanges,
|
||||
history = [],
|
||||
mapSize,
|
||||
mapState,
|
||||
mode,
|
||||
objects,
|
||||
previewDraft,
|
||||
saving,
|
||||
selectedObjectKey,
|
||||
setFuture,
|
||||
setHasUnsavedChanges,
|
||||
setHistory,
|
||||
setLayersOpen,
|
||||
setMode,
|
||||
setObjects,
|
||||
setPreviewDraft,
|
||||
setSelectedObjectKey,
|
||||
setZoom,
|
||||
svgRef,
|
||||
syncMapDraftFromState,
|
||||
zoom,
|
||||
}) {
|
||||
const shortcutControlsRef = useRef({
|
||||
handleRedo: () => {},
|
||||
handleUndo: () => {},
|
||||
mode: "view",
|
||||
saving: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedObjectKey) return;
|
||||
|
||||
const selectedMapObject = objects.find((object) => getObjectKey(object) === selectedObjectKey);
|
||||
if (!selectedMapObject || !filters.showZones || selectedMapObject.visible === false) {
|
||||
setSelectedObjectKey(null);
|
||||
}
|
||||
}, [filters.showZones, objects, selectedObjectKey, setSelectedObjectKey]);
|
||||
|
||||
const fitMapToCanvas = useMobileMapAutoFit({
|
||||
mapSize,
|
||||
mapState,
|
||||
mode,
|
||||
setZoom,
|
||||
svgRef,
|
||||
zoom,
|
||||
});
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
if (history.length === 0) return;
|
||||
|
||||
const priorSnapshot = getHistorySnapshot(history[history.length - 1], selectedObjectKey);
|
||||
setFuture([
|
||||
createHistorySnapshot(objects, selectedObjectKey),
|
||||
...future.slice(0, 19),
|
||||
]);
|
||||
setObjects(priorSnapshot.objects);
|
||||
setSelectedObjectKey(priorSnapshot.selectedObjectKey);
|
||||
setHasUnsavedChanges(history.length > 1);
|
||||
setHistory(history.slice(0, -1));
|
||||
}, [
|
||||
future, history, objects, selectedObjectKey, setFuture, setHasUnsavedChanges, setHistory,
|
||||
setObjects, setSelectedObjectKey,
|
||||
]);
|
||||
|
||||
const handleRedo = useCallback(() => {
|
||||
if (future.length === 0) return;
|
||||
|
||||
const nextSnapshot = getHistorySnapshot(future[0], selectedObjectKey);
|
||||
setHistory([
|
||||
...history.slice(-19),
|
||||
createHistorySnapshot(objects, selectedObjectKey),
|
||||
]);
|
||||
setObjects(nextSnapshot.objects);
|
||||
setSelectedObjectKey(nextSnapshot.selectedObjectKey);
|
||||
setHasUnsavedChanges(true);
|
||||
setFuture(future.slice(1));
|
||||
}, [
|
||||
future, history, objects, selectedObjectKey, setFuture, setHasUnsavedChanges, setHistory,
|
||||
setObjects, setSelectedObjectKey,
|
||||
]);
|
||||
|
||||
shortcutControlsRef.current = {
|
||||
handleRedo,
|
||||
handleUndo,
|
||||
mode,
|
||||
saving,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.altKey ||
|
||||
!(event.ctrlKey || event.metaKey) ||
|
||||
isTextEditingTarget(event.target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controls = shortcutControlsRef.current;
|
||||
if (controls.mode !== "edit" || controls.saving) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
const wantsUndo = key === "z" && !event.shiftKey;
|
||||
const wantsRedo = key === "y" || (key === "z" && event.shiftKey);
|
||||
|
||||
if (wantsUndo) {
|
||||
event.preventDefault();
|
||||
controls.handleUndo();
|
||||
return;
|
||||
}
|
||||
|
||||
if (wantsRedo) {
|
||||
event.preventDefault();
|
||||
controls.handleRedo();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, []);
|
||||
|
||||
const handlePreviewDraft = useCallback(() => {
|
||||
setMode("view");
|
||||
setLayersOpen(false);
|
||||
setPreviewDraft(true);
|
||||
setSelectedObjectKey(null);
|
||||
}, [setLayersOpen, setMode, setPreviewDraft, setSelectedObjectKey]);
|
||||
|
||||
const handleViewMode = useCallback(() => {
|
||||
setMode("view");
|
||||
setLayersOpen(false);
|
||||
if ((mode === "edit" || previewDraft) && (hasUnsavedChanges || mapState?.draft_map)) {
|
||||
setPreviewDraft(true);
|
||||
return;
|
||||
}
|
||||
setPreviewDraft(false);
|
||||
if (mapState) {
|
||||
syncMapDraftFromState(mapState, "view", false);
|
||||
}
|
||||
}, [
|
||||
hasUnsavedChanges,
|
||||
mapState,
|
||||
mode,
|
||||
previewDraft,
|
||||
setLayersOpen,
|
||||
setMode,
|
||||
setPreviewDraft,
|
||||
setSelectedObjectKey,
|
||||
syncMapDraftFromState,
|
||||
]);
|
||||
|
||||
const handleEditMode = useCallback(() => {
|
||||
setMode("edit");
|
||||
setLayersOpen(false);
|
||||
setPreviewDraft(true);
|
||||
if (!previewDraft && mapState && !hasUnsavedChanges) {
|
||||
syncMapDraftFromState(mapState, "edit", true);
|
||||
}
|
||||
}, [
|
||||
hasUnsavedChanges,
|
||||
mapState,
|
||||
previewDraft,
|
||||
setLayersOpen,
|
||||
setMode,
|
||||
setPreviewDraft,
|
||||
syncMapDraftFromState,
|
||||
]);
|
||||
|
||||
return {
|
||||
handleEditMode,
|
||||
handleFitMap: fitMapToCanvas,
|
||||
handlePreviewDraft,
|
||||
handleRedo,
|
||||
handleUndo,
|
||||
handleViewMode,
|
||||
};
|
||||
}
|
||||
111
frontend/src/hooks/useMobileMapAutoFit.js
Normal file
111
frontend/src/hooks/useMobileMapAutoFit.js
Normal file
@ -0,0 +1,111 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { DEFAULT_MAP_SIZE, clampMapZoom } from "../lib/locationMapUtils";
|
||||
|
||||
const COMPACT_MAP_LAYOUT_QUERY = "(max-width: 839px)";
|
||||
|
||||
function getAutoFitKey(mapState, mapSize) {
|
||||
return [
|
||||
mapState?.location?.id || "",
|
||||
mapState?.draft_map?.id || "",
|
||||
mapState?.published_map?.id || "",
|
||||
mapSize.width,
|
||||
mapSize.height,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function getFitZoom(mapSize, scrollElement) {
|
||||
const fallbackWidth = typeof window === "undefined" ? DEFAULT_MAP_SIZE.width : window.innerWidth;
|
||||
const fallbackHeight = typeof window === "undefined" ? DEFAULT_MAP_SIZE.height : window.innerHeight;
|
||||
const isCompactLayout = typeof window !== "undefined"
|
||||
&& window.matchMedia(COMPACT_MAP_LAYOUT_QUERY).matches;
|
||||
const minimumWidth = isCompactLayout ? 160 : 280;
|
||||
const minimumHeight = isCompactLayout ? 120 : 220;
|
||||
const availableWidth = Math.max(minimumWidth, (scrollElement?.clientWidth || fallbackWidth) - 24);
|
||||
const availableHeight = Math.max(minimumHeight, (scrollElement?.clientHeight || fallbackHeight) - 24);
|
||||
|
||||
return Number(clampMapZoom(
|
||||
Math.min(availableWidth / mapSize.width, availableHeight / mapSize.height)
|
||||
).toFixed(2));
|
||||
}
|
||||
|
||||
export default function useMobileMapAutoFit({
|
||||
mapSize,
|
||||
mapState,
|
||||
mode,
|
||||
setZoom,
|
||||
svgRef,
|
||||
zoom,
|
||||
}) {
|
||||
const lastAutoFitKeyRef = useRef(null);
|
||||
const lastFittedZoomRef = useRef(null);
|
||||
const latestZoomRef = useRef(zoom);
|
||||
const autoFitKey = useMemo(
|
||||
() => getAutoFitKey(mapState, mapSize),
|
||||
[mapSize, mapState]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
latestZoomRef.current = zoom;
|
||||
}, [zoom]);
|
||||
|
||||
const fitMapToCanvas = useCallback(() => {
|
||||
const scrollElement = svgRef.current?.closest(".location-map-scroll");
|
||||
const nextZoom = getFitZoom(mapSize, scrollElement);
|
||||
lastFittedZoomRef.current = nextZoom;
|
||||
setZoom(nextZoom);
|
||||
scrollElement?.scrollTo({ left: 0, top: 0 });
|
||||
}, [mapSize, setZoom, svgRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
if (!mapState || mode === "setup") return undefined;
|
||||
if (!window.matchMedia(COMPACT_MAP_LAYOUT_QUERY).matches) return undefined;
|
||||
if (lastAutoFitKeyRef.current === autoFitKey) return undefined;
|
||||
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
if (!svgRef.current) return;
|
||||
lastAutoFitKeyRef.current = autoFitKey;
|
||||
fitMapToCanvas();
|
||||
});
|
||||
|
||||
return () => window.cancelAnimationFrame(frameId);
|
||||
}, [autoFitKey, fitMapToCanvas, mapState, mode, svgRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
if (!mapState || mode === "setup") return undefined;
|
||||
|
||||
let frameId = null;
|
||||
const mediaQuery = window.matchMedia(COMPACT_MAP_LAYOUT_QUERY);
|
||||
const shouldPreserveManualZoom = () => {
|
||||
const lastFittedZoom = lastFittedZoomRef.current;
|
||||
return lastFittedZoom !== null && Math.abs(latestZoomRef.current - lastFittedZoom) > 0.01;
|
||||
};
|
||||
const scheduleFit = () => {
|
||||
if (!mediaQuery.matches || shouldPreserveManualZoom()) return;
|
||||
if (frameId !== null) {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
}
|
||||
frameId = window.requestAnimationFrame(() => {
|
||||
frameId = null;
|
||||
fitMapToCanvas();
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("resize", scheduleFit);
|
||||
window.addEventListener("orientationchange", scheduleFit);
|
||||
window.visualViewport?.addEventListener("resize", scheduleFit);
|
||||
scheduleFit();
|
||||
|
||||
return () => {
|
||||
if (frameId !== null) {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
}
|
||||
window.removeEventListener("resize", scheduleFit);
|
||||
window.removeEventListener("orientationchange", scheduleFit);
|
||||
window.visualViewport?.removeEventListener("resize", scheduleFit);
|
||||
};
|
||||
}, [fitMapToCanvas, mapState, mode]);
|
||||
|
||||
return fitMapToCanvas;
|
||||
}
|
||||
158
frontend/src/hooks/useUnsavedMapLeaveGuard.js
Normal file
158
frontend/src/hooks/useUnsavedMapLeaveGuard.js
Normal file
@ -0,0 +1,158 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useBeforeUnload } from "react-router-dom";
|
||||
|
||||
export default function useUnsavedMapLeaveGuard({
|
||||
shouldGuardLeave,
|
||||
navigate,
|
||||
performBackNavigation,
|
||||
}) {
|
||||
const [pendingLeaveTarget, setPendingLeaveTarget] = useState(null);
|
||||
const guardedClickTargetRef = useRef(null);
|
||||
const allowGuardedClickRef = useRef(false);
|
||||
const confirmedLeaveRef = useRef(false);
|
||||
const historyGuardActiveRef = useRef(false);
|
||||
const guardedUrlRef = useRef("");
|
||||
|
||||
useBeforeUnload(
|
||||
useCallback((event) => {
|
||||
if (!shouldGuardLeave) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
}, [shouldGuardLeave]),
|
||||
{ capture: true }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldGuardLeave) {
|
||||
if (historyGuardActiveRef.current && guardedUrlRef.current === window.location.href) {
|
||||
window.history.back();
|
||||
}
|
||||
setPendingLeaveTarget(null);
|
||||
guardedClickTargetRef.current = null;
|
||||
confirmedLeaveRef.current = false;
|
||||
historyGuardActiveRef.current = false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!historyGuardActiveRef.current) {
|
||||
guardedUrlRef.current = window.location.href;
|
||||
window.history.pushState(
|
||||
{ ...(window.history.state || {}), unsavedMapGuard: true },
|
||||
"",
|
||||
guardedUrlRef.current
|
||||
);
|
||||
historyGuardActiveRef.current = true;
|
||||
}
|
||||
|
||||
const handleDocumentClick = (event) => {
|
||||
if (allowGuardedClickRef.current) return;
|
||||
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.altKey ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.shiftKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
const button = target?.closest("button");
|
||||
const isSwitchingHousehold = Boolean(
|
||||
button?.classList.contains("household-option") &&
|
||||
!button.classList.contains("create-household-btn") &&
|
||||
!button.closest(".household-option-row.active")
|
||||
);
|
||||
|
||||
if (button?.classList.contains("user-dropdown-logout") || isSwitchingHousehold) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
guardedClickTargetRef.current = button;
|
||||
setPendingLeaveTarget({ type: "click" });
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = target?.closest("a[href]");
|
||||
if (!anchor || (anchor.target && anchor.target !== "_self") || anchor.hasAttribute("download")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextUrl = new URL(anchor.href, window.location.href);
|
||||
if (nextUrl.origin !== window.location.origin) return;
|
||||
|
||||
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
|
||||
if (nextPath === currentPath) return;
|
||||
|
||||
event.preventDefault();
|
||||
setPendingLeaveTarget({ type: "path", path: nextPath });
|
||||
};
|
||||
const handleBrowserHistoryNavigation = () => {
|
||||
if (confirmedLeaveRef.current) return;
|
||||
|
||||
window.history.pushState(
|
||||
{ ...(window.history.state || {}), unsavedMapGuard: true },
|
||||
"",
|
||||
guardedUrlRef.current || window.location.href
|
||||
);
|
||||
setPendingLeaveTarget({ type: "history" });
|
||||
};
|
||||
|
||||
document.addEventListener("click", handleDocumentClick, true);
|
||||
window.addEventListener("popstate", handleBrowserHistoryNavigation, true);
|
||||
return () => {
|
||||
document.removeEventListener("click", handleDocumentClick, true);
|
||||
window.removeEventListener("popstate", handleBrowserHistoryNavigation, true);
|
||||
};
|
||||
}, [shouldGuardLeave]);
|
||||
|
||||
const requestBackNavigation = useCallback(() => {
|
||||
if (shouldGuardLeave) {
|
||||
setPendingLeaveTarget({ type: "back" });
|
||||
return;
|
||||
}
|
||||
performBackNavigation();
|
||||
}, [performBackNavigation, shouldGuardLeave]);
|
||||
|
||||
const clearPendingLeave = useCallback(() => {
|
||||
guardedClickTargetRef.current = null;
|
||||
setPendingLeaveTarget(null);
|
||||
}, []);
|
||||
|
||||
const confirmPendingLeave = useCallback(() => {
|
||||
const target = pendingLeaveTarget;
|
||||
setPendingLeaveTarget(null);
|
||||
confirmedLeaveRef.current = true;
|
||||
if (target?.type === "history") {
|
||||
historyGuardActiveRef.current = false;
|
||||
window.history.go(-2);
|
||||
return;
|
||||
}
|
||||
if (target?.type === "click") {
|
||||
const guardedClickTarget = guardedClickTargetRef.current;
|
||||
guardedClickTargetRef.current = null;
|
||||
if (guardedClickTarget) {
|
||||
allowGuardedClickRef.current = true;
|
||||
guardedClickTarget.click();
|
||||
window.setTimeout(() => {
|
||||
allowGuardedClickRef.current = false;
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (target?.type === "path" && target.path) {
|
||||
navigate(target.path);
|
||||
return;
|
||||
}
|
||||
performBackNavigation();
|
||||
}, [navigate, pendingLeaveTarget, performBackNavigation]);
|
||||
|
||||
return {
|
||||
pendingLeaveTarget,
|
||||
requestBackNavigation,
|
||||
clearPendingLeave,
|
||||
confirmPendingLeave,
|
||||
};
|
||||
}
|
||||
39
frontend/src/lib/locationMapDraftStateUtils.js
Normal file
39
frontend/src/lib/locationMapDraftStateUtils.js
Normal file
@ -0,0 +1,39 @@
|
||||
import {
|
||||
DEFAULT_MAP_SIZE,
|
||||
getObjectKey,
|
||||
mapForMode,
|
||||
normalizeMapObject,
|
||||
objectsForMode,
|
||||
} from "./locationMapUtils";
|
||||
|
||||
export function getDefaultMapDraft() {
|
||||
return {
|
||||
name: "Store Map",
|
||||
width: DEFAULT_MAP_SIZE.width,
|
||||
height: DEFAULT_MAP_SIZE.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMapDraftSnapshot(nextState, nextMode, nextPreviewDraft, options = {}) {
|
||||
const nextMap = mapForMode(nextState, nextMode, nextPreviewDraft);
|
||||
const nextObjects = objectsForMode(nextState, nextMode, nextPreviewDraft);
|
||||
if (!nextMap) return null;
|
||||
|
||||
const normalizedObjects = nextObjects.map(normalizeMapObject);
|
||||
const preserveSelectedIndex = Number.isInteger(options.preserveSelectedIndex)
|
||||
? options.preserveSelectedIndex
|
||||
: -1;
|
||||
const preservedSelectedObject = preserveSelectedIndex >= 0
|
||||
? normalizedObjects[preserveSelectedIndex]
|
||||
: null;
|
||||
|
||||
return {
|
||||
mapDraft: {
|
||||
name: nextMap.name || "Store Map",
|
||||
width: nextMap.width || DEFAULT_MAP_SIZE.width,
|
||||
height: nextMap.height || DEFAULT_MAP_SIZE.height,
|
||||
},
|
||||
objects: normalizedObjects,
|
||||
selectedObjectKey: preservedSelectedObject ? getObjectKey(preservedSelectedObject) : null,
|
||||
};
|
||||
}
|
||||
202
frontend/src/lib/locationMapUtils.js
Normal file
202
frontend/src/lib/locationMapUtils.js
Normal file
@ -0,0 +1,202 @@
|
||||
const DEFAULT_LOCATION_NAME = "Default Location";
|
||||
|
||||
export const DEFAULT_MAP_SIZE = {
|
||||
width: 1000,
|
||||
height: 700,
|
||||
};
|
||||
|
||||
export const MIN_MAP_ZOOM = 0.3;
|
||||
export const MAX_MAP_ZOOM = 1.6;
|
||||
|
||||
export const DEFAULT_MAP_FILTERS = {
|
||||
showZones: true,
|
||||
showLabels: true,
|
||||
showCounts: true,
|
||||
showPins: false,
|
||||
showMyItems: true,
|
||||
showOtherItems: false,
|
||||
showCompleted: false,
|
||||
showUnmapped: false,
|
||||
};
|
||||
|
||||
export const MAP_OBJECT_TYPES = [
|
||||
"zone",
|
||||
"aisle",
|
||||
"entrance",
|
||||
"checkout",
|
||||
"shelf",
|
||||
"wall",
|
||||
"department",
|
||||
"anchor",
|
||||
"other",
|
||||
];
|
||||
|
||||
export function getStoreName(location) {
|
||||
return location?.name || "Store";
|
||||
}
|
||||
|
||||
export function getLocationName(location) {
|
||||
if (!location) return "Location";
|
||||
if (!location.location_name || location.location_name === DEFAULT_LOCATION_NAME) {
|
||||
return "Default";
|
||||
}
|
||||
return location.location_name;
|
||||
}
|
||||
|
||||
export function getMapStatus(state, options = {}) {
|
||||
if (options.hasUnsavedChanges) return "Unsaved Draft";
|
||||
if (!state?.draft_map && !state?.published_map) return "No Map";
|
||||
if (options.mode === "edit") return "Draft";
|
||||
if (options.mode === "view" && options.previewDraft && state?.draft_map) return "Viewing Draft";
|
||||
if (state?.published_map) return "Published";
|
||||
if (state?.draft_map) return "Draft";
|
||||
return "No Map";
|
||||
}
|
||||
|
||||
export function objectZoneId(object) {
|
||||
return object?.zone_id ? String(object.zone_id) : "";
|
||||
}
|
||||
|
||||
export function mapForMode(mapState, mode, previewDraft) {
|
||||
if (mode === "edit") {
|
||||
return mapState?.draft_map || mapState?.published_map || null;
|
||||
}
|
||||
|
||||
if (previewDraft && mapState?.draft_map) {
|
||||
return mapState.draft_map;
|
||||
}
|
||||
|
||||
return mapState?.published_map || mapState?.draft_map || null;
|
||||
}
|
||||
|
||||
export function objectsForMode(mapState, mode, previewDraft) {
|
||||
if (mode === "edit") {
|
||||
return mapState?.draft_objects?.length
|
||||
? mapState.draft_objects
|
||||
: mapState?.published_objects || [];
|
||||
}
|
||||
|
||||
if (previewDraft && mapState?.draft_map) {
|
||||
return mapState.draft_objects || [];
|
||||
}
|
||||
|
||||
return mapState?.published_objects?.length
|
||||
? mapState.published_objects
|
||||
: mapState?.draft_objects || [];
|
||||
}
|
||||
|
||||
export function createClientObject(zone, index = 0) {
|
||||
const offset = index * 24;
|
||||
return {
|
||||
client_id: `new-${Date.now()}-${index}`,
|
||||
zone_id: zone?.id || null,
|
||||
zone_name: zone?.name || null,
|
||||
type: "zone",
|
||||
label: zone?.name || "New Area",
|
||||
x: 80 + offset,
|
||||
y: 80 + offset,
|
||||
width: 220,
|
||||
height: 130,
|
||||
rotation: 0,
|
||||
locked: false,
|
||||
visible: true,
|
||||
sort_order: index + 1,
|
||||
metadata_json: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeMapObject(object, index = 0) {
|
||||
const hasExplicitLabel = Object.prototype.hasOwnProperty.call(object, "label");
|
||||
|
||||
return {
|
||||
...object,
|
||||
client_id: object.client_id || `object-${object.id || Date.now()}-${index}`,
|
||||
zone_id: object.zone_id ?? null,
|
||||
zone_name: object.zone_name || null,
|
||||
type: MAP_OBJECT_TYPES.includes(object.type) ? object.type : "zone",
|
||||
label: hasExplicitLabel
|
||||
? String(object.label ?? "").trim()
|
||||
: object.zone_name || "Map Area",
|
||||
x: Number(object.x) || 0,
|
||||
y: Number(object.y) || 0,
|
||||
width: Math.max(40, Number(object.width) || 160),
|
||||
height: Math.max(40, Number(object.height) || 100),
|
||||
rotation: Number(object.rotation) || 0,
|
||||
locked: Boolean(object.locked),
|
||||
visible: object.visible !== false,
|
||||
sort_order: Number(object.sort_order ?? object.sortOrder ?? index + 1),
|
||||
metadata_json: object.metadata_json || {},
|
||||
};
|
||||
}
|
||||
|
||||
export function getObjectKey(object) {
|
||||
return String(object.id || object.client_id);
|
||||
}
|
||||
|
||||
export function getMapObjectDisplayLabel(object, fallback = "Map Area") {
|
||||
const label = String(object?.label ?? "").trim();
|
||||
const zoneName = String(object?.zone_name ?? "").trim();
|
||||
return label || zoneName || fallback;
|
||||
}
|
||||
|
||||
export function prepareObjectsForSave(objects) {
|
||||
return objects.map((object, index) => ({
|
||||
zone_id: object.zone_id || null,
|
||||
type: object.type || "zone",
|
||||
label: String(object.label ?? "").trim(),
|
||||
x: Number(object.x) || 0,
|
||||
y: Number(object.y) || 0,
|
||||
width: Math.max(40, Number(object.width) || 160),
|
||||
height: Math.max(40, Number(object.height) || 100),
|
||||
rotation: Number(object.rotation) || 0,
|
||||
locked: Boolean(object.locked),
|
||||
visible: object.visible !== false,
|
||||
sort_order: index + 1,
|
||||
metadata_json: object.metadata_json || {},
|
||||
}));
|
||||
}
|
||||
|
||||
export function itemMatchesMapFilters(item, filters, username) {
|
||||
if (!filters.showCompleted && item.bought) return false;
|
||||
|
||||
const addedBy = Array.isArray(item.added_by_users) ? item.added_by_users : [];
|
||||
const hasOwnershipData = addedBy.length > 0 && username;
|
||||
const isMine = hasOwnershipData ? addedBy.includes(username) : true;
|
||||
if (isMine && !filters.showMyItems) return false;
|
||||
if (!isMine && !filters.showOtherItems) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function itemsForZone(items, zoneId, filters, username) {
|
||||
return items.filter((item) => (
|
||||
String(item.zone_id || "") === String(zoneId || "") &&
|
||||
itemMatchesMapFilters(item, filters, username)
|
||||
));
|
||||
}
|
||||
|
||||
export function unmappedItems(items, filters, username) {
|
||||
return items.filter((item) => !item.zone_id && itemMatchesMapFilters(item, filters, username));
|
||||
}
|
||||
|
||||
export function clientPointToMap(event, svgElement, mapSize) {
|
||||
const rect = svgElement.getBoundingClientRect();
|
||||
const x = ((event.clientX - rect.left) / rect.width) * mapSize.width;
|
||||
const y = ((event.clientY - rect.top) / rect.height) * mapSize.height;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
export function clampMapZoom(value) {
|
||||
return Math.max(MIN_MAP_ZOOM, Math.min(MAX_MAP_ZOOM, value));
|
||||
}
|
||||
|
||||
export function clampObjectToMap(object, mapSize) {
|
||||
const width = Math.max(40, Math.min(object.width, mapSize.width));
|
||||
const height = Math.max(40, Math.min(object.height, mapSize.height));
|
||||
return {
|
||||
...object,
|
||||
width,
|
||||
height,
|
||||
x: Math.max(0, Math.min(object.x, mapSize.width - width)),
|
||||
y: Math.max(0, Math.min(object.y, mapSize.height - height)),
|
||||
};
|
||||
}
|
||||
483
frontend/src/pages/LocationMapManager.jsx
Normal file
483
frontend/src/pages/LocationMapManager.jsx
Normal file
@ -0,0 +1,483 @@
|
||||
import { useCallback, useContext, useEffect, useRef } from "react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import { HouseholdContext } from "../context/HouseholdContext";
|
||||
import { StoreContext } from "../context/StoreContext";
|
||||
import LocationMapBottomSheet from "../components/maps/LocationMapBottomSheet";
|
||||
import LocationMapCanvas from "../components/maps/LocationMapCanvas";
|
||||
import LocationMapDialogs from "../components/maps/LocationMapDialogs";
|
||||
import LocationMapSetupPanel from "../components/maps/LocationMapSetupPanel";
|
||||
import {
|
||||
LocationMapLoadErrorState,
|
||||
LocationMapMessageState,
|
||||
} from "../components/maps/LocationMapStateViews";
|
||||
import LocationMapToolbar from "../components/maps/LocationMapToolbar";
|
||||
import LocationMapTopbar from "../components/maps/LocationMapTopbar";
|
||||
import useActionToast from "../hooks/useActionToast";
|
||||
import useLocationMapDerivedState from "../hooks/useLocationMapDerivedState";
|
||||
import useLocationMapDraftActions from "../hooks/useLocationMapDraftActions";
|
||||
import useLocationMapDraftState from "../hooks/useLocationMapDraftState";
|
||||
import useLocationMapBuying from "../hooks/useLocationMapBuying";
|
||||
import useLocationMapObjectActions from "../hooks/useLocationMapObjectActions";
|
||||
import useLocationMapViewControls from "../hooks/useLocationMapViewControls";
|
||||
import useUnsavedMapLeaveGuard from "../hooks/useUnsavedMapLeaveGuard";
|
||||
import { getMapStatus } from "../lib/locationMapUtils";
|
||||
import "../styles/pages/LocationMapManager.css";
|
||||
|
||||
function isTextEditingTarget(target) {
|
||||
return Boolean(target?.closest?.("input, textarea, select, [contenteditable]"));
|
||||
}
|
||||
|
||||
export default function LocationMapManager() {
|
||||
const { storeId, locationId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const routeLocation = useLocation();
|
||||
const returnPath = routeLocation.state?.returnTo;
|
||||
const toast = useActionToast();
|
||||
const { username } = useContext(AuthContext);
|
||||
const { activeHousehold, loading: householdLoading, hasLoaded } = useContext(HouseholdContext);
|
||||
const { stores, activeStore, setActiveStore } = useContext(StoreContext);
|
||||
|
||||
const {
|
||||
beginSaving,
|
||||
dragState,
|
||||
endSaving,
|
||||
filters,
|
||||
future,
|
||||
hasUnsavedChanges,
|
||||
history,
|
||||
layersOpen,
|
||||
loadError,
|
||||
loadMap,
|
||||
loading,
|
||||
mapDraft,
|
||||
mapState,
|
||||
mode,
|
||||
objects,
|
||||
previewDraft,
|
||||
saving,
|
||||
savingAction,
|
||||
selectedObjectKey,
|
||||
setDragState,
|
||||
setFilters,
|
||||
setFuture,
|
||||
setHasUnsavedChanges,
|
||||
setHistory,
|
||||
setLayersOpen,
|
||||
setMapDraft,
|
||||
setMapState,
|
||||
setMode,
|
||||
setObjects,
|
||||
setPreviewDraft,
|
||||
setSelectedObjectKey,
|
||||
setZoom,
|
||||
syncMapDraftFromState,
|
||||
zoom,
|
||||
} = useLocationMapDraftState({
|
||||
activeHousehold,
|
||||
hasLoaded,
|
||||
householdLoading,
|
||||
locationId,
|
||||
toast,
|
||||
});
|
||||
const svgRef = useRef(null);
|
||||
|
||||
const location = mapState?.location || stores.find((store) => String(store.id) === String(locationId));
|
||||
const canManage = Boolean(mapState?.can_manage ?? ["owner", "admin"].includes(activeHousehold?.role));
|
||||
const {
|
||||
hiddenAreaCount,
|
||||
mapItems,
|
||||
mapSize,
|
||||
selectedObject,
|
||||
selectedZoneItems,
|
||||
unmappedItemCount,
|
||||
visibleAssignedItemCount,
|
||||
visibleUnmappedItems,
|
||||
} = useLocationMapDerivedState({
|
||||
filters,
|
||||
mapDraft,
|
||||
mapState,
|
||||
mode,
|
||||
objects,
|
||||
previewDraft,
|
||||
selectedObjectKey,
|
||||
username,
|
||||
});
|
||||
const {
|
||||
buyModalItem,
|
||||
buyModalItems,
|
||||
handleMapBuyCancel,
|
||||
handleMapBuyConfirm,
|
||||
handleMapBuyNavigate,
|
||||
setBuyModalItem,
|
||||
} = useLocationMapBuying({
|
||||
activeHouseholdId: activeHousehold?.id,
|
||||
locationId,
|
||||
selectedObject,
|
||||
selectedZoneItems,
|
||||
setMapState,
|
||||
toast,
|
||||
visibleUnmappedItems,
|
||||
});
|
||||
const shouldGuardLeave = canManage && hasUnsavedChanges;
|
||||
|
||||
useEffect(() => {
|
||||
const matchingLocation = stores.find((store) => String(store.id) === String(locationId));
|
||||
if (matchingLocation && String(activeStore?.id) !== String(matchingLocation.id)) {
|
||||
setActiveStore(matchingLocation);
|
||||
}
|
||||
}, [activeStore?.id, locationId, setActiveStore, stores]);
|
||||
|
||||
const {
|
||||
handleCreateBlank,
|
||||
handleCreateFromZones,
|
||||
handlePublish,
|
||||
handleSaveDraft,
|
||||
} = useLocationMapDraftActions({
|
||||
activeHouseholdId: activeHousehold?.id,
|
||||
beginSaving,
|
||||
endSaving,
|
||||
hasUnsavedChanges,
|
||||
locationId,
|
||||
mapDraft,
|
||||
objects,
|
||||
selectedObjectKey,
|
||||
setMapState,
|
||||
setMode,
|
||||
setPreviewDraft,
|
||||
syncMapDraftFromState,
|
||||
toast,
|
||||
});
|
||||
|
||||
const {
|
||||
handleAddObject,
|
||||
handleDeleteObject,
|
||||
handleDuplicateObject,
|
||||
handleObjectField,
|
||||
handleShowHiddenAreas,
|
||||
handleZoneLinkChange,
|
||||
expandMapToFitObject,
|
||||
pendingDeleteObject,
|
||||
remember,
|
||||
requestDeleteObject,
|
||||
setPendingDeleteObject,
|
||||
updateObjects,
|
||||
} = useLocationMapObjectActions({
|
||||
canManage,
|
||||
hiddenAreaCount,
|
||||
mapSize,
|
||||
mapState,
|
||||
objects,
|
||||
selectedObject,
|
||||
selectedObjectKey,
|
||||
setFuture,
|
||||
setHasUnsavedChanges,
|
||||
setHistory,
|
||||
setMapDraft,
|
||||
setObjects,
|
||||
setSelectedObjectKey,
|
||||
toast,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setPendingDeleteObject(null);
|
||||
}, [activeHousehold?.id, locationId, setPendingDeleteObject]);
|
||||
|
||||
const {
|
||||
handleEditMode,
|
||||
handleFitMap,
|
||||
handlePreviewDraft,
|
||||
handleRedo,
|
||||
handleUndo,
|
||||
handleViewMode,
|
||||
} = useLocationMapViewControls({
|
||||
filters,
|
||||
future,
|
||||
hasUnsavedChanges,
|
||||
history,
|
||||
mapSize,
|
||||
mapState,
|
||||
mode,
|
||||
objects,
|
||||
previewDraft,
|
||||
saving,
|
||||
selectedObjectKey,
|
||||
setFuture,
|
||||
setHasUnsavedChanges,
|
||||
setHistory,
|
||||
setLayersOpen,
|
||||
setMode,
|
||||
setObjects,
|
||||
setPreviewDraft,
|
||||
setSelectedObjectKey,
|
||||
setZoom,
|
||||
svgRef,
|
||||
syncMapDraftFromState,
|
||||
zoom,
|
||||
});
|
||||
|
||||
const performBackNavigation = useCallback(() => {
|
||||
if (typeof returnPath === "string" && returnPath.startsWith("/")) {
|
||||
navigate(returnPath, { replace: true });
|
||||
return;
|
||||
}
|
||||
navigate("/");
|
||||
}, [navigate, returnPath]);
|
||||
|
||||
const {
|
||||
pendingLeaveTarget,
|
||||
requestBackNavigation,
|
||||
clearPendingLeave,
|
||||
confirmPendingLeave,
|
||||
} = useUnsavedMapLeaveGuard({
|
||||
shouldGuardLeave,
|
||||
navigate,
|
||||
performBackNavigation,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const hasOpenModal = Boolean(buyModalItem || pendingDeleteObject || pendingLeaveTarget);
|
||||
if (!selectedObjectKey || layersOpen || hasOpenModal || typeof window === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.key !== "Escape" ||
|
||||
isTextEditingTarget(event.target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedObjectKey(null);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [buyModalItem, layersOpen, pendingDeleteObject, pendingLeaveTarget, selectedObjectKey, setSelectedObjectKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const hasOpenModal = Boolean(buyModalItem || pendingDeleteObject || pendingLeaveTarget);
|
||||
if (mode !== "edit" || !canManage || hasOpenModal || typeof window === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
const wantsSave =
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
!event.altKey &&
|
||||
event.key.toLowerCase() === "s";
|
||||
|
||||
if (!wantsSave) return;
|
||||
event.preventDefault();
|
||||
if (saving || !hasUnsavedChanges) return;
|
||||
handleSaveDraft();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, [
|
||||
buyModalItem,
|
||||
canManage,
|
||||
handleSaveDraft,
|
||||
hasUnsavedChanges,
|
||||
mode,
|
||||
pendingDeleteObject,
|
||||
pendingLeaveTarget,
|
||||
saving,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const hasOpenModal = Boolean(buyModalItem || pendingDeleteObject || pendingLeaveTarget);
|
||||
if (
|
||||
!selectedObjectKey ||
|
||||
mode !== "edit" ||
|
||||
!canManage ||
|
||||
saving ||
|
||||
layersOpen ||
|
||||
hasOpenModal ||
|
||||
typeof window === "undefined"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
!["Backspace", "Delete"].includes(event.key) ||
|
||||
isTextEditingTarget(event.target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
requestDeleteObject();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, [
|
||||
buyModalItem,
|
||||
canManage,
|
||||
layersOpen,
|
||||
mode,
|
||||
pendingDeleteObject,
|
||||
pendingLeaveTarget,
|
||||
requestDeleteObject,
|
||||
saving,
|
||||
selectedObjectKey,
|
||||
]);
|
||||
|
||||
if (!hasLoaded || householdLoading || loading) {
|
||||
return (
|
||||
<LocationMapMessageState
|
||||
message="Loading map..."
|
||||
location={location}
|
||||
status="Loading"
|
||||
onBack={requestBackNavigation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeHousehold) {
|
||||
return (
|
||||
<LocationMapMessageState
|
||||
status="Select Household"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<LocationMapLoadErrorState
|
||||
location={location}
|
||||
loadError={loadError}
|
||||
onBack={requestBackNavigation}
|
||||
onRetry={loadMap}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const hasAnyMap = Boolean(mapState?.draft_map || mapState?.published_map);
|
||||
const hasVisibleOrManageableMap = Boolean(mapState?.published_map || (mapState?.draft_map && canManage));
|
||||
const status = getMapStatus(mapState, { mode, previewDraft, hasUnsavedChanges });
|
||||
|
||||
return (
|
||||
<div className="location-map-page">
|
||||
<LocationMapTopbar location={location} status={status} onBack={requestBackNavigation} />
|
||||
|
||||
<main className="location-map-workspace">
|
||||
{hasAnyMap && mode !== "setup" ? (
|
||||
<LocationMapToolbar
|
||||
mode={mode}
|
||||
hasAnyMap={hasAnyMap}
|
||||
canManage={canManage}
|
||||
saving={saving}
|
||||
history={history}
|
||||
future={future}
|
||||
zoom={zoom}
|
||||
setZoom={setZoom}
|
||||
onFit={handleFitMap}
|
||||
onView={handleViewMode}
|
||||
onEdit={handleEditMode}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!hasAnyMap || (mode === "setup" && mapState?.draft_map && !mapState?.published_map) ? (
|
||||
<LocationMapSetupPanel
|
||||
hasAnyMap={hasVisibleOrManageableMap}
|
||||
canManage={canManage}
|
||||
zoneCount={mapState?.zones?.length || 0}
|
||||
saving={saving}
|
||||
savingAction={savingAction}
|
||||
onContinue={() => {
|
||||
setMode("edit");
|
||||
setPreviewDraft(true);
|
||||
}}
|
||||
onPreview={handlePreviewDraft}
|
||||
onPublish={handlePublish}
|
||||
onCreateFromZones={handleCreateFromZones}
|
||||
onCreateBlank={handleCreateBlank}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<LocationMapCanvas
|
||||
mode={mode}
|
||||
objects={objects}
|
||||
filters={filters}
|
||||
mapSize={mapSize}
|
||||
zoom={zoom}
|
||||
selectedObjectKey={selectedObjectKey}
|
||||
mapItems={mapItems}
|
||||
username={username}
|
||||
svgRef={svgRef}
|
||||
dragState={dragState}
|
||||
editingLocked={saving}
|
||||
hiddenAreaCount={hiddenAreaCount}
|
||||
onAddObject={handleAddObject}
|
||||
setDragState={setDragState}
|
||||
setSelectedObjectKey={setSelectedObjectKey}
|
||||
remember={remember}
|
||||
updateObjects={updateObjects}
|
||||
expandMapToFitObject={expandMapToFitObject}
|
||||
onShowZones={() =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
showZones: true,
|
||||
}))
|
||||
}
|
||||
onShowHiddenAreas={handleShowHiddenAreas}
|
||||
/>
|
||||
<LocationMapBottomSheet
|
||||
mode={mode}
|
||||
canManage={canManage}
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
layersOpen={layersOpen}
|
||||
setLayersOpen={setLayersOpen}
|
||||
selectedObject={selectedObject}
|
||||
mapObjects={objects}
|
||||
selectedZoneItems={selectedZoneItems}
|
||||
visibleAssignedItemCount={visibleAssignedItemCount}
|
||||
visibleUnmappedItems={visibleUnmappedItems}
|
||||
unmappedItemCount={unmappedItemCount}
|
||||
hiddenAreaCount={hiddenAreaCount}
|
||||
saving={saving}
|
||||
savingAction={savingAction}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
mapState={mapState}
|
||||
onAddObject={handleAddObject}
|
||||
onSaveDraft={handleSaveDraft}
|
||||
onPublish={handlePublish}
|
||||
onObjectField={handleObjectField}
|
||||
onZoneLinkChange={handleZoneLinkChange}
|
||||
onDuplicateObject={handleDuplicateObject}
|
||||
onDeleteObject={requestDeleteObject}
|
||||
onShowHiddenAreas={handleShowHiddenAreas}
|
||||
onClearSelection={() => setSelectedObjectKey(null)}
|
||||
onSelectZoneItem={setBuyModalItem}
|
||||
onSelectUnmappedItem={setBuyModalItem}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<LocationMapDialogs
|
||||
buyModalItem={buyModalItem}
|
||||
buyModalItems={buyModalItems}
|
||||
onBuyNavigate={handleMapBuyNavigate}
|
||||
onBuyCancel={handleMapBuyCancel}
|
||||
onBuyConfirm={handleMapBuyConfirm}
|
||||
pendingDeleteObject={pendingDeleteObject}
|
||||
onDeleteClose={() => setPendingDeleteObject(null)}
|
||||
onDeleteConfirm={handleDeleteObject}
|
||||
pendingLeaveTarget={pendingLeaveTarget}
|
||||
onLeaveClose={clearPendingLeave}
|
||||
onLeaveConfirm={confirmPendingLeave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -52,13 +52,12 @@
|
||||
}
|
||||
|
||||
.confirm-buy-nav-btn {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: var(--border-width-medium) solid var(--color-primary);
|
||||
border-radius: var(--border-radius-full);
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-primary);
|
||||
font-size: 1.8em;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base);
|
||||
@ -70,6 +69,18 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.confirm-buy-nav-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: block;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2.4;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.confirm-buy-nav-btn:hover:not(:disabled) {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
@ -100,8 +111,21 @@
|
||||
}
|
||||
|
||||
.confirm-buy-image-placeholder {
|
||||
font-size: 4em;
|
||||
width: min(58%, 150px);
|
||||
aspect-ratio: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: var(--border-width-medium) solid var(--color-border-light);
|
||||
border-radius: var(--border-radius-lg);
|
||||
background: var(--color-gray-100);
|
||||
color: var(--color-border-medium);
|
||||
font-size: 4.75em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.confirm-buy-image-placeholder span {
|
||||
transform: translateY(0.02em);
|
||||
}
|
||||
|
||||
.confirm-buy-quantity-section {
|
||||
@ -260,9 +284,8 @@
|
||||
}
|
||||
|
||||
.confirm-buy-nav-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 1.6em;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.confirm-buy-counter-btn {
|
||||
@ -299,8 +322,7 @@
|
||||
}
|
||||
|
||||
.confirm-buy-nav-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 1.4em;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,78 +1,170 @@
|
||||
.store-tabs {
|
||||
background: var(--surface-color);
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
margin-bottom: 1.5rem;
|
||||
background: transparent;
|
||||
border-bottom: none;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.store-tabs-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
overflow-x: auto;
|
||||
padding: 0.5rem 1rem 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.store-tabs-container::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.store-tabs-container::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.store-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
color: var(--text-secondary);
|
||||
padding: 0.5rem 0 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.store-selector-trigger {
|
||||
width: min(100%, 420px);
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 8px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
font-weight: 700;
|
||||
transition: all 0.2s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.store-selector-trigger.is-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.store-selector-trigger.is-interactive:hover,
|
||||
.store-selector-trigger.is-interactive:focus-visible {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-hover);
|
||||
border-color: var(--color-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.store-selector-trigger:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.location-selector-trigger {
|
||||
min-height: 40px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.store-map-button {
|
||||
width: min(100%, 420px);
|
||||
min-height: 38px;
|
||||
padding: 0.6rem 1rem;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.store-tab:hover {
|
||||
color: var(--text-color);
|
||||
background: var(--hover-color);
|
||||
.store-map-button:hover,
|
||||
.store-map-button:focus-visible {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-bg-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.store-tab.active {
|
||||
color: var(--primary-color);
|
||||
border-bottom-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.store-tab:disabled {
|
||||
.store-map-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.store-name {
|
||||
font-weight: 500;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.default-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--primary-color-light);
|
||||
color: var(--primary-color);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
border-radius: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
.store-selector-caret {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
flex: 0 0 auto;
|
||||
border-right: 2px solid currentColor;
|
||||
border-bottom: 2px solid currentColor;
|
||||
transform: rotate(45deg) translateY(-2px);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.store-tabs-empty {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.store-selector-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1400;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 5rem 1rem 1rem;
|
||||
background: rgba(3, 10, 18, 0.58);
|
||||
}
|
||||
|
||||
.store-selector-modal {
|
||||
width: min(100%, 420px);
|
||||
max-height: min(70vh, 560px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-surface);
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.store-selector-modal h2 {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.store-selector-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.store-selector-option {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.store-selector-option:hover,
|
||||
.store-selector-option:focus-visible {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-bg-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.store-selector-option.active {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
@ -39,20 +39,11 @@ body.dark-mode .manage-section {
|
||||
}
|
||||
|
||||
.manage-section-header h2 {
|
||||
margin: 0.15rem 0 0;
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.manage-section-eyebrow {
|
||||
margin: 0;
|
||||
color: var(--primary);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.92rem;
|
||||
@ -131,7 +122,7 @@ body.dark-mode .edit-name-form input {
|
||||
}
|
||||
|
||||
.manage-household-join-policy-toggle {
|
||||
margin-bottom: 0.2rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.pending-requests-summary {
|
||||
@ -243,15 +234,16 @@ body.dark-mode .pending-request-card {
|
||||
|
||||
.invite-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(140px, 180px)) auto;
|
||||
gap: 0.8rem;
|
||||
align-items: end;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.45rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.invite-controls label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
display: grid;
|
||||
grid-template-columns: 4.5rem minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@ -265,14 +257,19 @@ body.dark-mode .pending-request-card {
|
||||
}
|
||||
|
||||
.invite-controls select {
|
||||
min-width: 120px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: 0.7rem 0.75rem;
|
||||
padding: 0.62rem 0.75rem;
|
||||
background: rgba(255, 255, 255, 1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.invite-controls .btn-primary {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .invite-controls select,
|
||||
body.dark-mode .invite-controls select {
|
||||
background: rgba(12, 19, 30, 0.92);
|
||||
@ -377,20 +374,30 @@ body.dark-mode .invite-status-badge.is-used {
|
||||
.members-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(270px, 1fr));
|
||||
gap: 0.85rem;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.member-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
padding: 0.85rem 1rem;
|
||||
gap: 0.3rem;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 0.55rem 0.8rem;
|
||||
background: rgba(255, 255, 255, 1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--border-radius-lg);
|
||||
border-radius: var(--border-radius-md);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.member-card-button {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .member-card,
|
||||
body.dark-mode .member-card {
|
||||
background: rgba(12, 19, 30, 0.9);
|
||||
@ -408,6 +415,11 @@ body.dark-mode .member-card:hover {
|
||||
background: rgba(20, 32, 48, 0.98);
|
||||
}
|
||||
|
||||
.member-card-button:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.member-main {
|
||||
min-width: 0;
|
||||
}
|
||||
@ -415,7 +427,7 @@ body.dark-mode .member-card:hover {
|
||||
.member-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
@ -427,15 +439,15 @@ body.dark-mode .member-card:hover {
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.member-role {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
gap: 0.28rem;
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.88rem;
|
||||
font-size: 0.82rem;
|
||||
text-transform: capitalize;
|
||||
font-weight: 700;
|
||||
}
|
||||
@ -466,16 +478,6 @@ body.dark-mode .member-card:hover {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.member-actions {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid color-mix(in srgb, var(--color-border-light) 82%, transparent);
|
||||
}
|
||||
|
||||
.member-owner-action {
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
@ -506,11 +508,6 @@ body.dark-mode .member-role-action:hover:not(:disabled) {
|
||||
color: #f3f9ff;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .member-actions,
|
||||
body.dark-mode .member-actions {
|
||||
border-top-color: color-mix(in srgb, var(--color-border-medium) 88%, transparent);
|
||||
}
|
||||
|
||||
/* Danger Zone */
|
||||
.danger-zone {
|
||||
border-color: color-mix(in srgb, var(--danger) 30%, transparent);
|
||||
@ -527,8 +524,7 @@ body.dark-mode .danger-zone {
|
||||
border-color: color-mix(in srgb, var(--danger) 42%, transparent);
|
||||
}
|
||||
|
||||
.danger-zone h2,
|
||||
.danger-zone .manage-section-eyebrow {
|
||||
.danger-zone h2 {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@ -559,16 +555,88 @@ body.dark-mode .danger-zone {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.member-actions-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-md);
|
||||
background: var(--modal-backdrop-bg);
|
||||
}
|
||||
|
||||
.member-actions-modal {
|
||||
width: min(420px, calc(100vw - (2 * var(--spacing-md))));
|
||||
max-height: calc(100vh - (2 * var(--spacing-md)));
|
||||
overflow: auto;
|
||||
padding: 1.2rem;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--border-radius-lg);
|
||||
background: var(--modal-bg);
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.member-actions-modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.member-actions-modal-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.member-actions-modal-copy h3 {
|
||||
margin: 0.15rem 0 0.45rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.25rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.member-actions-modal-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--border-radius-full);
|
||||
background: var(--button-ghost-bg);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.member-actions-modal-close:hover,
|
||||
.member-actions-modal-close:focus-visible {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.member-actions-modal-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
margin-top: 1.1rem;
|
||||
}
|
||||
|
||||
.member-actions-modal-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.member-actions-modal-empty {
|
||||
margin: 1rem 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 900px) {
|
||||
.invite-controls {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.invite-controls .btn-primary {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.invite-link-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@ -616,12 +684,6 @@ body.dark-mode .danger-zone {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.member-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.member-actions button,
|
||||
.pending-request-actions button {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
@ -22,6 +22,14 @@
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.manage-stores-topline {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(240px, 360px);
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@ -50,11 +58,11 @@
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
padding: 1rem;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.store-card:hover {
|
||||
@ -62,16 +70,23 @@
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.store-card-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.store-info h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 0.5rem 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.store-location {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@ -87,44 +102,65 @@
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.store-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.store-location-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.store-location-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.store-location-row:first-child {
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.store-location-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.store-location-copy strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
|
||||
.store-location-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.store-location-manager-trigger,
|
||||
.store-zone-manager-trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.store-card-header > .store-location-manager-trigger {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.store-items-modal-toolbar.store-management-create-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.store-location-row > .store-zones-panel,
|
||||
.store-location-row > .store-available-items-trigger {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.add-location-panel,
|
||||
.store-zone-create-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.add-location-panel input,
|
||||
.add-store-panel input,
|
||||
.store-zone-create-row input {
|
||||
.store-items-modal-toolbar.store-location-create-row {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.add-store-inline input,
|
||||
.store-management-create-row input,
|
||||
.store-settings-form input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.75rem;
|
||||
@ -134,99 +170,128 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.store-zones-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
.store-management-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.store-zones-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--background);
|
||||
.store-management-row-with-order {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.store-zone-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
.store-management-row-with-order.is-delete-selectable {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.store-zone-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2rem minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
.store-management-row-with-drag {
|
||||
grid-template-columns: auto auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.store-zone-order {
|
||||
.store-management-order {
|
||||
width: 2rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.store-zone-name {
|
||||
.store-management-name {
|
||||
min-width: 0;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-zone-actions {
|
||||
.store-zone-drag-handle {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: inline-flex;
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
background-image: radial-gradient(currentColor 1.4px, transparent 1.4px);
|
||||
background-position: center;
|
||||
background-size: 6px 6px;
|
||||
background-repeat: repeat;
|
||||
}
|
||||
|
||||
.store-zone-drag-handle:hover,
|
||||
.store-zone-drag-handle:focus-visible {
|
||||
color: var(--color-primary);
|
||||
background-color: var(--color-primary-light);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.store-management-row.is-dragging {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.store-management-row.is-drag-target {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: inset 3px 0 0 var(--color-primary);
|
||||
}
|
||||
|
||||
.store-management-meta {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-management-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: var(--spacing-xs);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--border-radius-full);
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.store-settings-modal {
|
||||
width: min(520px, 100%);
|
||||
}
|
||||
|
||||
.store-settings-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.store-settings-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.store-settings-form label span {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.store-settings-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Add Store Panel */
|
||||
.add-store-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.available-stores {
|
||||
.add-store-inline {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.available-store-card {
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.available-store-card:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.available-store-card .store-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.available-store-card h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.available-store-card .store-location {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
.add-store-inline .btn-primary {
|
||||
min-width: 4.5rem;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
@ -239,44 +304,49 @@
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
.stores-list,
|
||||
.available-stores {
|
||||
.stores-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.store-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.store-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.available-store-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.available-store-card button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.store-location-row,
|
||||
.add-location-panel,
|
||||
.store-zone-create-row,
|
||||
.store-zone-row {
|
||||
.store-card-header,
|
||||
.store-location-controls,
|
||||
.manage-stores-topline,
|
||||
.add-store-inline,
|
||||
.store-items-modal-toolbar.store-management-create-row,
|
||||
.store-items-modal-toolbar.store-location-create-row,
|
||||
.store-management-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.store-zone-order {
|
||||
.store-card-header > .store-location-manager-trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.add-store-inline .btn-primary {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.store-management-order {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.store-zone-actions {
|
||||
.store-management-row-with-order {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.store-management-row-with-order.is-delete-selectable {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.store-management-row-with-drag {
|
||||
grid-template-columns: auto auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.store-settings-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.store-zone-actions button {
|
||||
.store-settings-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,11 +60,35 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
background: var(--modal-bg);
|
||||
}
|
||||
|
||||
.store-items-modal-toolbar .btn-small {
|
||||
flex: 0 0 auto;
|
||||
min-height: 40px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-items-bulk-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.store-items-delete-toggle,
|
||||
.store-items-delete-cancel {
|
||||
min-height: 38px;
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
.store-available-items-search {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: var(--input-padding-y) var(--input-padding-x);
|
||||
border: var(--border-width-thin) solid var(--input-border-color);
|
||||
border-radius: var(--input-border-radius);
|
||||
@ -92,24 +116,20 @@
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.store-items-table-head,
|
||||
.store-items-table-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 2fr) minmax(180px, 2fr) minmax(170px, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--spacing-md);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.store-items-table-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
padding: 0 var(--spacing-sm) var(--spacing-xs);
|
||||
background: var(--modal-bg);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
.store-items-table-row-button {
|
||||
width: 100%;
|
||||
appearance: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.store-items-table-body {
|
||||
@ -125,6 +145,22 @@
|
||||
background: var(--color-bg-surface);
|
||||
}
|
||||
|
||||
.store-items-table-row-button:hover,
|
||||
.store-items-table-row-button:focus-visible {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-bg-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.store-items-table-row-button.is-delete-selectable {
|
||||
border-color: color-mix(in srgb, var(--color-danger) 35%, var(--color-border-light));
|
||||
}
|
||||
|
||||
.store-items-table-row-button.is-selected {
|
||||
border-color: var(--color-danger);
|
||||
background: var(--color-danger-light);
|
||||
}
|
||||
|
||||
.store-items-table-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
@ -153,8 +189,11 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
border: var(--border-width-medium) solid var(--color-border-light);
|
||||
background: var(--color-gray-100);
|
||||
color: var(--color-border-medium);
|
||||
font-size: 1.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.store-available-items-copy {
|
||||
@ -171,24 +210,27 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-items-defaults-text {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.store-items-table-actions {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.store-available-items-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
.store-items-delete-indicator {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: var(--border-width-thin) solid var(--color-border-light);
|
||||
border-radius: var(--border-radius-full);
|
||||
color: var(--color-text-inverse);
|
||||
background: var(--color-bg-surface);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.store-items-mobile-label {
|
||||
display: none;
|
||||
.store-items-table-row-button.is-selected .store-items-delete-indicator {
|
||||
border-color: var(--color-danger);
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@ -197,37 +239,21 @@
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.store-items-table-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.store-items-table-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.store-items-mobile-label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
.store-items-table-row-button.is-delete-selectable {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.store-items-table-actions {
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.store-available-items-actions {
|
||||
.store-items-bulk-toolbar {
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.store-available-items-actions button {
|
||||
.store-items-bulk-toolbar button {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
}
|
||||
|
||||
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
@ -36,15 +36,24 @@ test("manage stores opens a modal to edit and delete household store items", asy
|
||||
},
|
||||
];
|
||||
|
||||
await page.route("**/stores", async (route) => {
|
||||
await page.route("**/households/1/stores", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([{ id: 10, name: "Costco" }]),
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 10,
|
||||
household_store_id: 100,
|
||||
name: "Costco",
|
||||
is_default: true,
|
||||
zone_count: 0,
|
||||
item_count: availableItems.length,
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/10/available-items*", async (route) => {
|
||||
await page.route("**/households/1/locations/10/available-items*", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const query = (url.searchParams.get("query") || "").toLowerCase();
|
||||
@ -62,7 +71,7 @@ test("manage stores opens a modal to edit and delete household store items", asy
|
||||
await route.fulfill({ status: 500 });
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/10/available-items/777", async (route) => {
|
||||
await page.route("**/households/1/locations/10/available-items/777", async (route) => {
|
||||
if (route.request().method() === "PATCH") {
|
||||
availableItems = availableItems.map((item) =>
|
||||
item.item_id === 777
|
||||
@ -89,7 +98,7 @@ test("manage stores opens a modal to edit and delete household store items", asy
|
||||
await route.fulfill({ status: 500 });
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/10/available-items/501", async (route) => {
|
||||
await page.route("**/households/1/locations/10/available-items/501", async (route) => {
|
||||
if (route.request().method() === "DELETE") {
|
||||
availableItems = availableItems.filter((item) => item.item_id !== 501);
|
||||
await route.fulfill({
|
||||
@ -105,18 +114,46 @@ test("manage stores opens a modal to edit and delete household store items", asy
|
||||
|
||||
await page.goto("/manage?tab=stores");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Add Store" })).toHaveCount(0);
|
||||
await expect(page.getByPlaceholder("Store name")).toBeVisible();
|
||||
await expect(page.getByPlaceholder("Location name")).toHaveCount(0);
|
||||
await expect(page.getByPlaceholder("Address or notes")).toHaveCount(0);
|
||||
|
||||
const storeCard = page.locator(".store-card").filter({ hasText: "Costco" });
|
||||
await expect(storeCard).toBeVisible();
|
||||
await expect(storeCard.getByRole("button", { name: "Manage Items" })).toBeVisible();
|
||||
await expect(storeCard.getByText("Costco", { exact: true })).toHaveCount(1);
|
||||
await expect(storeCard.getByText("Default location")).toHaveCount(0);
|
||||
await expect(storeCard.getByText("Default shopping location")).toHaveCount(0);
|
||||
await expect(storeCard.getByRole("button", { name: "Manage Items (2)" })).toBeVisible();
|
||||
|
||||
await storeCard.getByRole("button", { name: "Manage Items" }).click();
|
||||
await storeCard.getByRole("button", { name: "Manage Items (2)" }).click();
|
||||
|
||||
const managerModal = page.locator(".store-items-modal");
|
||||
await expect(managerModal).toBeVisible();
|
||||
await expect(managerModal.getByText("milk", { exact: true })).toBeVisible();
|
||||
await expect(managerModal.getByText("apples", { exact: true })).toBeVisible();
|
||||
await expect(managerModal.locator(".store-available-items-thumb-placeholder").first()).toHaveText("\uD83D\uDCE6");
|
||||
await expect(managerModal.getByText("Store Defaults")).toHaveCount(0);
|
||||
await expect(managerModal.getByText("No store defaults set")).toHaveCount(0);
|
||||
await expect(managerModal.getByText("Edit Settings", { exact: true })).toHaveCount(0);
|
||||
await expect(managerModal.getByText("Delete Item", { exact: true })).toHaveCount(0);
|
||||
await expect(managerModal.locator(".store-available-items-action")).toHaveCount(0);
|
||||
|
||||
await managerModal.locator(".store-items-table-row").filter({ hasText: "apples" }).getByRole("button", { name: "Edit Settings" }).click();
|
||||
const searchBox = await managerModal.getByPlaceholder("Search household/store items").boundingBox();
|
||||
const addButtonBox = await managerModal.getByRole("button", { name: "Add Item" }).boundingBox();
|
||||
expect(searchBox).not.toBeNull();
|
||||
expect(addButtonBox).not.toBeNull();
|
||||
expect(
|
||||
Math.abs(
|
||||
((searchBox?.y ?? 0) + (searchBox?.height ?? 0) / 2) -
|
||||
((addButtonBox?.y ?? 0) + (addButtonBox?.height ?? 0) / 2)
|
||||
)
|
||||
).toBeLessThan(2);
|
||||
|
||||
const appleRow = managerModal.getByRole("button", { name: "Edit settings for apples" });
|
||||
const milkRow = managerModal.locator(".store-items-table-row").filter({ hasText: "milk" });
|
||||
|
||||
await appleRow.click();
|
||||
const editorModal = page.locator(".available-item-editor-modal");
|
||||
await expect(editorModal).toBeVisible();
|
||||
await expect(editorModal.getByLabel("Item Name")).toBeDisabled();
|
||||
@ -126,9 +163,22 @@ test("manage stores opens a modal to edit and delete household store items", asy
|
||||
await editorModal.getByRole("button", { name: "Save Changes" }).click();
|
||||
|
||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated store item");
|
||||
await expect(managerModal.getByText("produce | Fruits | Produce & Fresh Vegetables")).toBeVisible();
|
||||
await expect(managerModal.getByText("produce | Fruits | Produce & Fresh Vegetables")).toHaveCount(0);
|
||||
|
||||
await managerModal.locator(".store-items-table-row").filter({ hasText: "milk" }).getByRole("button", { name: "Delete Item" }).click();
|
||||
await managerModal.getByRole("button", { name: "Delete Items" }).click();
|
||||
await expect(managerModal.getByRole("button", { name: "Confirm Delete (0)" })).toBeDisabled();
|
||||
await expect(managerModal.getByRole("button", { name: "Cancel" })).toBeVisible();
|
||||
|
||||
await milkRow.click();
|
||||
await expect(managerModal.getByRole("button", { name: "Confirm Delete (1)" })).toBeEnabled();
|
||||
await expect(milkRow).toHaveClass(/is-selected/);
|
||||
|
||||
await milkRow.click();
|
||||
await expect(managerModal.getByRole("button", { name: "Confirm Delete (0)" })).toBeDisabled();
|
||||
await expect(milkRow).not.toHaveClass(/is-selected/);
|
||||
|
||||
await milkRow.click();
|
||||
await managerModal.getByRole("button", { name: "Confirm Delete (1)" }).click();
|
||||
const confirmModal = page.locator(".confirm-slide-modal");
|
||||
await expect(confirmModal).toBeVisible();
|
||||
await expect(confirmModal.getByText("Delete milk?")).toBeVisible();
|
||||
@ -141,6 +191,290 @@ test("manage stores opens a modal to edit and delete household store items", asy
|
||||
await expect(managerModal.locator(".store-items-table-row").filter({ hasText: "milk" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("manage stores uses modal flows for locations and zones", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 460, height: 1000 });
|
||||
await seedAuthStorage(page, { username: "store-manager", role: "owner" });
|
||||
await mockConfig(page);
|
||||
await mockHouseholdAndStoreShell(page, {
|
||||
household: { name: "Modal House", role: "owner" },
|
||||
});
|
||||
|
||||
let locations = [
|
||||
{
|
||||
id: 10,
|
||||
household_store_id: 100,
|
||||
name: "Costco",
|
||||
location_name: "Default Location",
|
||||
display_name: "Costco",
|
||||
address: "",
|
||||
is_default: true,
|
||||
zone_count: 2,
|
||||
item_count: 3,
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
household_store_id: 100,
|
||||
name: "Costco",
|
||||
location_name: "Fontana",
|
||||
display_name: "Costco - Fontana",
|
||||
address: "Sierra Ave",
|
||||
is_default: false,
|
||||
zone_count: 0,
|
||||
item_count: 0,
|
||||
},
|
||||
];
|
||||
let zones = [
|
||||
{ id: 901, name: "Bakery", sort_order: 10 },
|
||||
{ id: 902, name: "Frozen Foods", sort_order: 20 },
|
||||
];
|
||||
|
||||
await page.route("**/households/1/stores", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
locations.map((location) =>
|
||||
location.id === 10 ? { ...location, zone_count: zones.length } : location
|
||||
)
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/100/locations", async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() === "POST") {
|
||||
const body = request.postDataJSON() as { name?: string; address?: string };
|
||||
const locationName = body.name || "New Location";
|
||||
locations = [
|
||||
...locations,
|
||||
{
|
||||
id: 12,
|
||||
household_store_id: 100,
|
||||
name: "Costco",
|
||||
location_name: locationName,
|
||||
display_name: `Costco - ${locationName}`,
|
||||
address: body.address || "",
|
||||
is_default: false,
|
||||
zone_count: 0,
|
||||
item_count: 0,
|
||||
},
|
||||
];
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ store: locations[locations.length - 1] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({ status: 405 });
|
||||
});
|
||||
|
||||
await page.route("**/households/1/locations/11", async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() === "PATCH") {
|
||||
const body = request.postDataJSON() as { name?: string; address?: string };
|
||||
locations = locations.map((location) =>
|
||||
location.id === 11
|
||||
? {
|
||||
...location,
|
||||
location_name: body.name || location.location_name,
|
||||
display_name: `Costco - ${body.name || location.location_name}`,
|
||||
address: body.address || "",
|
||||
}
|
||||
: location
|
||||
);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ store: locations.find((location) => location.id === 11) }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === "DELETE") {
|
||||
locations = locations.filter((location) => location.id !== 11);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "Location removed successfully" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({ status: 405 });
|
||||
});
|
||||
|
||||
await page.route("**/households/1/locations/11/default", async (route) => {
|
||||
if (route.request().method() === "PATCH") {
|
||||
locations = locations.map((location) => ({
|
||||
...location,
|
||||
is_default: location.id === 11,
|
||||
}));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "Default location updated successfully" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({ status: 405 });
|
||||
});
|
||||
|
||||
await page.route("**/households/1/locations/10/zones", async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
zones: [...zones].sort((a, b) => a.sort_order - b.sort_order),
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === "POST") {
|
||||
const body = request.postDataJSON() as { name?: string; sort_order?: number };
|
||||
zones = [
|
||||
...zones,
|
||||
{ id: 903, name: body.name || "New Zone", sort_order: body.sort_order || 30 },
|
||||
];
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ zone: zones[zones.length - 1] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({ status: 405 });
|
||||
});
|
||||
|
||||
await page.route("**/households/1/locations/10/zones/*", async (route) => {
|
||||
const request = route.request();
|
||||
const zoneId = Number(new URL(request.url()).pathname.split("/").pop());
|
||||
|
||||
if (request.method() === "PATCH") {
|
||||
const body = request.postDataJSON() as { name?: string; sort_order?: number };
|
||||
zones = zones.map((zone) =>
|
||||
zone.id === zoneId
|
||||
? { ...zone, name: body.name || zone.name, sort_order: body.sort_order ?? zone.sort_order }
|
||||
: zone
|
||||
);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ zone: zones.find((zone) => zone.id === zoneId) }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === "DELETE") {
|
||||
zones = zones.filter((zone) => zone.id !== zoneId);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "Zone removed successfully" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({ status: 405 });
|
||||
});
|
||||
|
||||
await page.goto("/manage?tab=stores");
|
||||
|
||||
const storeCard = page.locator(".store-card").filter({ hasText: "Costco" });
|
||||
await expect(storeCard).toBeVisible();
|
||||
await expect(storeCard.getByRole("button", { name: "Set Default" })).toHaveCount(0);
|
||||
await expect(storeCard.getByRole("button", { name: "Remove" })).toHaveCount(0);
|
||||
await expect(storeCard.getByPlaceholder("Location name")).toHaveCount(0);
|
||||
|
||||
await storeCard.getByRole("button", { name: "Manage Locations (2)" }).click();
|
||||
const locationsModal = page.locator(".store-items-modal").filter({
|
||||
has: page.getByRole("heading", { name: "Costco Locations" }),
|
||||
});
|
||||
await expect(locationsModal).toBeVisible();
|
||||
await expect(locationsModal.getByPlaceholder("Location name")).toBeVisible();
|
||||
await expect(locationsModal.getByRole("button", { name: "Delete Locations" })).toBeVisible();
|
||||
|
||||
await locationsModal.getByRole("button", { name: "Edit location Costco - Fontana" }).click();
|
||||
const locationSettings = page.locator(".store-items-modal").filter({
|
||||
has: page.getByRole("heading", { name: "Costco - Fontana Settings" }),
|
||||
});
|
||||
await expect(locationSettings).toBeVisible();
|
||||
await locationSettings.getByLabel("Location name").fill("Upland");
|
||||
await locationSettings.getByLabel("Address or notes").fill("Mountain Ave");
|
||||
await locationSettings.getByRole("button", { name: "Save Changes" }).click();
|
||||
await expect(
|
||||
page.locator(".action-toast.action-toast-success").filter({ hasText: "Updated location" })
|
||||
).toContainText("Updated location");
|
||||
|
||||
await locationsModal.getByRole("button", { name: "Delete Locations" }).click();
|
||||
await locationsModal.getByRole("button", { name: "Select Costco - Upland for deletion" }).click();
|
||||
await expect(locationsModal.getByRole("button", { name: "Confirm Delete (1)" })).toBeEnabled();
|
||||
await locationsModal.getByRole("button", { name: "Confirm Delete (1)" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Delete Costco - Upland?" })).toBeVisible();
|
||||
await confirmSlide(page);
|
||||
await expect(
|
||||
page.locator(".action-toast.action-toast-success").filter({ hasText: "Removed location" })
|
||||
).toContainText("Removed location");
|
||||
|
||||
await page.getByLabel("Close manage locations modal").click();
|
||||
await expect(storeCard.getByRole("button", { name: "Manage Locations (1)" })).toBeVisible();
|
||||
await storeCard.getByRole("button", { name: "Manage Zones (2)" }).click();
|
||||
const zonesModal = page.locator(".store-items-modal").filter({
|
||||
has: page.getByRole("heading", { name: "Costco Zones" }),
|
||||
});
|
||||
await expect(zonesModal).toBeVisible();
|
||||
await expect(zonesModal.getByPlaceholder("New zone name")).toBeVisible();
|
||||
await expect(zonesModal.getByRole("button", { name: "Up" })).toHaveCount(0);
|
||||
await expect(zonesModal.getByRole("button", { name: "Down" })).toHaveCount(0);
|
||||
await expect(zonesModal.getByRole("button", { name: "Remove" })).toHaveCount(0);
|
||||
|
||||
const bakeryZoneRow = zonesModal.getByRole("button", { name: "Edit zone Bakery" });
|
||||
const frozenZoneRow = zonesModal.getByRole("button", { name: "Edit zone Frozen Foods" });
|
||||
await expect(bakeryZoneRow.locator(".store-zone-drag-handle")).toBeVisible();
|
||||
const bakeryOrderBox = await bakeryZoneRow.locator(".store-management-order").boundingBox();
|
||||
const bakeryNameBox = await bakeryZoneRow.locator(".store-management-name").boundingBox();
|
||||
expect(bakeryOrderBox).not.toBeNull();
|
||||
expect(bakeryNameBox).not.toBeNull();
|
||||
expect(
|
||||
Math.abs(
|
||||
((bakeryOrderBox?.y ?? 0) + (bakeryOrderBox?.height ?? 0) / 2) -
|
||||
((bakeryNameBox?.y ?? 0) + (bakeryNameBox?.height ?? 0) / 2)
|
||||
)
|
||||
).toBeLessThan(3);
|
||||
|
||||
await bakeryZoneRow.locator(".store-zone-drag-handle").dragTo(frozenZoneRow);
|
||||
await expect(
|
||||
page.locator(".action-toast.action-toast-success").filter({ hasText: "Reordered zones" })
|
||||
).toContainText("Reordered zones");
|
||||
await expect(zonesModal.locator(".store-management-row-with-order").first()).toContainText("Frozen Foods");
|
||||
|
||||
await zonesModal.getByRole("button", { name: "Edit zone Bakery" }).click();
|
||||
const zoneSettings = page.locator(".store-items-modal").filter({
|
||||
has: page.getByRole("heading", { name: "Bakery Settings" }),
|
||||
});
|
||||
await expect(zoneSettings).toBeVisible();
|
||||
await zoneSettings.getByLabel("Zone name").fill("Bread");
|
||||
await zoneSettings.getByRole("button", { name: "Save Changes" }).click();
|
||||
await expect(
|
||||
page.locator(".action-toast.action-toast-success").filter({ hasText: "Updated zone" })
|
||||
).toContainText("Updated zone");
|
||||
|
||||
await zonesModal.getByRole("button", { name: "Delete Zones" }).click();
|
||||
await zonesModal.getByRole("button", { name: "Select Bread for deletion" }).click();
|
||||
await zonesModal.getByRole("button", { name: "Confirm Delete (1)" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Delete Bread?" })).toBeVisible();
|
||||
await confirmSlide(page);
|
||||
await expect(
|
||||
page.locator(".action-toast.action-toast-success").filter({ hasText: "Removed zone" })
|
||||
).toContainText("Removed zone");
|
||||
});
|
||||
|
||||
test("grocery page remains unchanged and does not show a store items picker", async ({ page }) => {
|
||||
await seedAuthStorage(page);
|
||||
await mockConfig(page);
|
||||
@ -156,7 +490,7 @@ test("grocery page remains unchanged and does not show a store items picker", as
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/10/list/recent", async (route) => {
|
||||
await page.route("**/households/1/locations/10/list/recent", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@ -164,7 +498,7 @@ test("grocery page remains unchanged and does not show a store items picker", as
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/10/list/suggestions**", async (route) => {
|
||||
await page.route("**/households/1/locations/10/list/suggestions**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@ -172,7 +506,7 @@ test("grocery page remains unchanged and does not show a store items picker", as
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/10/list/item**", async (route) => {
|
||||
await page.route("**/households/1/locations/10/list/item**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
@ -180,7 +514,7 @@ test("grocery page remains unchanged and does not show a store items picker", as
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/10/list", async (route) => {
|
||||
await page.route("**/households/1/locations/10/list", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
|
||||
@ -64,7 +64,19 @@ async function setupBuyModalRoutes(
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/10/list/recent", async (route) => {
|
||||
await page.route("**/households/1/locations/10/zones", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
zones: [
|
||||
{ id: 501, name: "Produce", sort_order: 10 },
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/locations/10/list/recent", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@ -72,7 +84,7 @@ async function setupBuyModalRoutes(
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/10/list/suggestions**", async (route) => {
|
||||
await page.route("**/households/1/locations/10/list/suggestions**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@ -80,7 +92,7 @@ async function setupBuyModalRoutes(
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/10/list/classification**", async (route) => {
|
||||
await page.route("**/households/1/locations/10/list/classification**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@ -88,7 +100,7 @@ async function setupBuyModalRoutes(
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/10/list/item**", async (route) => {
|
||||
await page.route("**/households/1/locations/10/list/item**", async (route) => {
|
||||
const request = route.request();
|
||||
|
||||
if (request.method() === "PATCH") {
|
||||
@ -156,7 +168,7 @@ async function setupBuyModalRoutes(
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores/10/list", async (route) => {
|
||||
await page.route("**/households/1/locations/10/list", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
@ -183,12 +195,11 @@ test("buying an item advances to the next one in the current sort order", async
|
||||
]);
|
||||
|
||||
await page.goto("/");
|
||||
await page.locator(".glist-sort").selectOption("qty-high");
|
||||
|
||||
await openBuyModal(page, "bread");
|
||||
await page.getByRole("button", { name: "Mark as Bought" }).click();
|
||||
|
||||
await expect(page.locator(".confirm-buy-item-name")).toHaveText("apples");
|
||||
await expect(page.locator(".confirm-buy-item-name")).toHaveText("milk");
|
||||
});
|
||||
|
||||
test("buying the last item in the current order wraps to the first remaining item", async ({ page }) => {
|
||||
@ -201,7 +212,6 @@ test("buying the last item in the current order wraps to the first remaining ite
|
||||
]);
|
||||
|
||||
await page.goto("/");
|
||||
await page.locator(".glist-sort").selectOption("az");
|
||||
|
||||
await openBuyModal(page, "milk");
|
||||
await page.getByRole("button", { name: "Mark as Bought" }).click();
|
||||
@ -219,7 +229,6 @@ test("partial buy keeps the item on the list and advances past it", async ({ pag
|
||||
]);
|
||||
|
||||
await page.goto("/");
|
||||
await page.locator(".glist-sort").selectOption("qty-low");
|
||||
|
||||
await openBuyModal(page, "bravo");
|
||||
await page.locator(".confirm-buy-counter-btn").nth(0).click();
|
||||
|
||||
@ -16,8 +16,12 @@ type HouseholdSeed = {
|
||||
|
||||
type StoreSeed = {
|
||||
id?: number;
|
||||
household_store_id?: number;
|
||||
household_id?: number;
|
||||
name?: string;
|
||||
location?: string;
|
||||
location_name?: string;
|
||||
display_name?: string;
|
||||
is_default?: boolean;
|
||||
};
|
||||
|
||||
@ -57,9 +61,21 @@ export async function mockHouseholdAndStoreShell(
|
||||
invite_code: "ABCD1234",
|
||||
...options.household,
|
||||
};
|
||||
const stores = options.stores || [
|
||||
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
|
||||
];
|
||||
const stores = (options.stores || [
|
||||
{ id: 10, household_store_id: 100, name: "Costco", location_name: "Warehouse", is_default: true },
|
||||
]).map((store, index) => {
|
||||
const locationName = store.location_name || store.location || "Default Location";
|
||||
return {
|
||||
household_id: household.id,
|
||||
household_store_id: store.household_store_id ?? store.id ?? index + 1,
|
||||
id: store.id ?? index + 10,
|
||||
name: store.name || "Costco",
|
||||
location_name: locationName,
|
||||
display_name: store.display_name || `${store.name || "Costco"} - ${locationName}`,
|
||||
is_default: store.is_default ?? index === 0,
|
||||
...store,
|
||||
};
|
||||
});
|
||||
|
||||
await page.route("**/households", async (route) => {
|
||||
await route.fulfill({
|
||||
@ -76,6 +92,14 @@ export async function mockHouseholdAndStoreShell(
|
||||
body: JSON.stringify(stores),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(`**/households/${household.id}/stores`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(stores),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function confirmSlide(page: Page) {
|
||||
|
||||
@ -176,7 +176,9 @@ test("household management shows pending invite approvals and can approve them",
|
||||
|
||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Approved join request");
|
||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Pending Pal");
|
||||
await expect(page.getByText("No pending join requests right now.")).toBeVisible();
|
||||
await expect(page.getByText("No pending join requests right now.")).toHaveCount(0);
|
||||
await expect(page.locator(".pending-requests-summary")).toContainText("0");
|
||||
await expect(page.locator(".manage-section-eyebrow")).toHaveCount(0);
|
||||
await expect(page.getByText("Members (2)")).toBeVisible();
|
||||
});
|
||||
|
||||
@ -231,7 +233,11 @@ test("household member removal opens slide confirmation instead of browser dialo
|
||||
await page.goto("/manage?tab=household");
|
||||
|
||||
const memberCard = page.locator(".member-card").filter({ hasText: "remove-me" });
|
||||
await memberCard.getByRole("button", { name: "Remove" }).click();
|
||||
await expect(memberCard.getByRole("button", { name: "Remove" })).toHaveCount(0);
|
||||
await memberCard.click();
|
||||
const memberActionsDialog = page.getByRole("dialog", { name: "remove-me" });
|
||||
await expect(memberActionsDialog).toBeVisible();
|
||||
await memberActionsDialog.getByRole("button", { name: "Remove" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Remove remove-me?" })).toBeVisible();
|
||||
await expect(page.getByText("Slide to confirm. They will lose access to this household.")).toBeVisible();
|
||||
@ -266,11 +272,21 @@ test("household owner can transfer ownership from household settings", async ({
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/stores/household/1", async (route) => {
|
||||
await page.route("**/households/1/stores", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([{ id: 10, name: "Costco", location: "Warehouse", is_default: true }]),
|
||||
body: JSON.stringify([
|
||||
{ id: 10, household_store_id: 100, name: "Costco", location: "Warehouse", is_default: true },
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/locations/10/zones", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ zones: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
@ -346,9 +362,13 @@ test("household owner can transfer ownership from household settings", async ({
|
||||
|
||||
await page.goto("/manage?tab=household");
|
||||
|
||||
await expect(page.getByRole("button", { name: "Make Owner" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Make Owner" })).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: "Make Owner" }).click();
|
||||
const memberCard = page.locator(".member-card").filter({ hasText: "nico-admin" });
|
||||
await memberCard.click();
|
||||
const memberActionsDialog = page.getByRole("dialog", { name: "nico-admin" });
|
||||
await expect(memberActionsDialog).toBeVisible();
|
||||
await memberActionsDialog.getByRole("button", { name: "Make Owner" }).click();
|
||||
await expect(page.getByText("Transfer ownership to nico-admin?")).toBeVisible();
|
||||
await confirmSlide(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
228
frontend/tests/store-selector.spec.ts
Normal file
228
frontend/tests/store-selector.spec.ts
Normal file
@ -0,0 +1,228 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { mockConfig, seedAuthStorage } from "./helpers/e2e";
|
||||
|
||||
const household = { id: 1, name: "Selector House", role: "owner", invite_code: "ABCD1234" };
|
||||
const user = { id: 1, username: "selector-user", role: "owner" };
|
||||
|
||||
async function mockGroceryShell(page: Page, stores: Array<Record<string, unknown>>) {
|
||||
await seedAuthStorage(page, { username: "selector-user", role: "owner" });
|
||||
await mockConfig(page);
|
||||
|
||||
await page.route("**/households", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([household]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/stores", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(stores),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/members", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([user]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/locations/*/zones", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ zones: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/locations/*/map", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
location: stores.find((store) => String(store.id) === route.request().url().match(/locations\/(\d+)\/map/)?.[1]) || stores[0],
|
||||
map: null,
|
||||
draft_map: null,
|
||||
published_map: null,
|
||||
objects: [],
|
||||
draft_objects: [],
|
||||
published_objects: [],
|
||||
zones: [],
|
||||
items: [],
|
||||
unmapped_count: 0,
|
||||
can_manage: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/locations/*/list/recent", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/locations/*/list/suggestions**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/locations/*/list/item**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "Item not found" }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/households/1/locations/*/list", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [] }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("grocery store selector shows one selected store and picks from a modal", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 473, height: 1000 });
|
||||
const storeContextMessages: string[] = [];
|
||||
page.on("console", (message) => {
|
||||
const text = message.text();
|
||||
if (text.includes("[StoreContext]")) {
|
||||
storeContextMessages.push(text);
|
||||
}
|
||||
});
|
||||
await mockGroceryShell(page, [
|
||||
{
|
||||
id: 10,
|
||||
household_store_id: 100,
|
||||
name: "Costco",
|
||||
location_name: "Eastvale",
|
||||
display_name: "Costco - Eastvale",
|
||||
is_default: true,
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
household_store_id: 100,
|
||||
name: "Costco",
|
||||
location_name: "Fontana",
|
||||
display_name: "Costco - Fontana",
|
||||
is_default: false,
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
household_store_id: 200,
|
||||
name: "99 Ranch",
|
||||
location_name: "Eastvale",
|
||||
display_name: "99 Ranch - Eastvale",
|
||||
is_default: false,
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
household_store_id: 300,
|
||||
name: "Stater Bros",
|
||||
location_name: "Ontario Ranch",
|
||||
display_name: "Stater Bros - Ontario Ranch",
|
||||
is_default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
const storeSelector = page.locator(".store-selector-trigger").first();
|
||||
const locationSelector = page.locator(".location-selector-trigger");
|
||||
await expect(storeSelector).toBeVisible();
|
||||
await expect(storeSelector).toContainText("Costco");
|
||||
await expect(locationSelector).toBeVisible();
|
||||
await expect(locationSelector).toContainText("Eastvale");
|
||||
await expect(page.getByRole("button", { name: "Manage Map" })).toBeVisible();
|
||||
await expect(page.locator(".store-tabs")).not.toContainText("Costco - Eastvale");
|
||||
await expect(page.locator(".store-tabs")).not.toContainText("99 Ranch");
|
||||
await expect(page.locator(".store-tabs")).not.toContainText("Stater Bros");
|
||||
|
||||
await storeSelector.click();
|
||||
const modal = page.getByRole("dialog", { name: "Select store" });
|
||||
await expect(modal).toBeVisible();
|
||||
await expect(modal.getByRole("button", { name: "Costco" })).toBeVisible();
|
||||
await expect(modal.getByRole("button", { name: "99 Ranch" })).toBeVisible();
|
||||
await expect(modal.getByRole("button", { name: "Stater Bros" })).toBeVisible();
|
||||
await expect(modal).not.toContainText("Eastvale");
|
||||
await expect(modal).not.toContainText("Ontario Ranch");
|
||||
|
||||
await modal.getByRole("button", { name: "99 Ranch" }).click();
|
||||
await expect(modal).toHaveCount(0);
|
||||
await expect(storeSelector).toContainText("99 Ranch");
|
||||
await expect(locationSelector).toContainText("Eastvale");
|
||||
await expect.poll(() => page.evaluate(() => localStorage.getItem("activeHouseholdStoreId_1"))).toBe("200");
|
||||
await expect.poll(() => page.evaluate(() => localStorage.getItem("activeStoreLocationId_1_200"))).toBe("11");
|
||||
|
||||
await storeSelector.click();
|
||||
await expect(modal).toBeVisible();
|
||||
await page.locator(".store-selector-modal-overlay").click({ position: { x: 4, y: 4 } });
|
||||
await expect(modal).toHaveCount(0);
|
||||
|
||||
await storeSelector.click();
|
||||
await modal.getByRole("button", { name: "Costco" }).click();
|
||||
await locationSelector.click();
|
||||
const locationModal = page.getByRole("dialog", { name: "Select location" });
|
||||
await expect(locationModal).toBeVisible();
|
||||
await expect(locationModal.getByRole("button", { name: "Eastvale" })).toBeVisible();
|
||||
await expect(locationModal.getByRole("button", { name: "Fontana" })).toBeVisible();
|
||||
await expect(locationModal).not.toContainText("Costco - Eastvale");
|
||||
|
||||
await locationModal.getByRole("button", { name: "Fontana" }).click();
|
||||
await expect(locationModal).toHaveCount(0);
|
||||
await expect(storeSelector).toContainText("Costco");
|
||||
await expect(locationSelector).toContainText("Fontana");
|
||||
await expect.poll(() => page.evaluate(() => localStorage.getItem("activeHouseholdStoreId_1"))).toBe("100");
|
||||
await expect.poll(() => page.evaluate(() => localStorage.getItem("activeStoreLocationId_1_100"))).toBe("13");
|
||||
|
||||
await page.getByRole("button", { name: "Manage Map" }).click();
|
||||
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 }) => {
|
||||
await mockGroceryShell(page, [
|
||||
{
|
||||
id: 10,
|
||||
household_store_id: 100,
|
||||
name: "Costco",
|
||||
location_name: "Default Location",
|
||||
display_name: "Costco",
|
||||
is_default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
const storeSelector = page.locator(".store-selector-trigger").first();
|
||||
const locationSelector = page.locator(".location-selector-trigger");
|
||||
await expect(storeSelector).toBeVisible();
|
||||
await expect(storeSelector).toContainText("Costco");
|
||||
await expect(locationSelector).toBeVisible();
|
||||
await expect(locationSelector).toContainText("Default");
|
||||
await storeSelector.click();
|
||||
await expect(page.getByRole("dialog", { name: "Select store" })).toHaveCount(0);
|
||||
await locationSelector.click();
|
||||
await expect(page.getByRole("dialog", { name: "Select location" })).toHaveCount(0);
|
||||
});
|
||||
@ -47,10 +47,15 @@ test("manage stores add success shows success toast", async ({ page }) => {
|
||||
await seedAuthStorage(page);
|
||||
await mockConfig(page);
|
||||
|
||||
let linkedStoreIds = [10];
|
||||
const allStores = [
|
||||
{ id: 10, name: "Costco North", location: "North", is_default: true },
|
||||
{ id: 11, name: "Costco South", location: "South", is_default: false },
|
||||
let stores = [
|
||||
{
|
||||
id: 10,
|
||||
household_store_id: 100,
|
||||
name: "Costco",
|
||||
location_name: "Default Location",
|
||||
display_name: "Costco",
|
||||
is_default: true,
|
||||
},
|
||||
];
|
||||
|
||||
await page.route("**/households", async (route) => {
|
||||
@ -61,65 +66,63 @@ test("manage stores add success shows success toast", async ({ page }) => {
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/stores/household/1", async (route) => {
|
||||
await page.route("**/households/1/stores", async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() === "GET") {
|
||||
const payload = linkedStoreIds.map((id, index) => {
|
||||
const store = allStores.find((candidate) => candidate.id === id);
|
||||
return {
|
||||
...store,
|
||||
is_default: index === 0,
|
||||
};
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify(stores),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === "POST") {
|
||||
const body = request.postDataJSON() as { storeId?: number };
|
||||
if (body.storeId && !linkedStoreIds.includes(body.storeId)) {
|
||||
linkedStoreIds = [...linkedStoreIds, body.storeId];
|
||||
}
|
||||
const body = request.postDataJSON() as { name?: string };
|
||||
const name = body.name || "New Store";
|
||||
stores = [
|
||||
...stores,
|
||||
{
|
||||
id: 11,
|
||||
household_store_id: 101,
|
||||
name,
|
||||
location_name: "Default Location",
|
||||
display_name: name,
|
||||
is_default: false,
|
||||
},
|
||||
];
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
status: 201,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true }),
|
||||
body: JSON.stringify({ store: stores[stores.length - 1] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fallback();
|
||||
await route.fulfill({ status: 405 });
|
||||
});
|
||||
|
||||
await page.route("**/stores", async (route) => {
|
||||
await page.route("**/households/1/locations/10/zones", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(allStores),
|
||||
body: JSON.stringify({ zones: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/manage?tab=stores");
|
||||
await page.getByRole("button", { name: "+ Add Store" }).click();
|
||||
await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click();
|
||||
const addStoreForm = page.locator(".add-store-inline");
|
||||
await addStoreForm.getByLabel("Store name").fill("Stater Bros");
|
||||
await addStoreForm.getByRole("button", { name: "Add" }).click();
|
||||
|
||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Added store");
|
||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Costco South");
|
||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Created store");
|
||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Stater Bros");
|
||||
});
|
||||
|
||||
test("manage stores add failure shows normalized error toast", async ({ page }) => {
|
||||
await seedAuthStorage(page);
|
||||
await mockConfig(page);
|
||||
|
||||
const allStores = [
|
||||
{ id: 10, name: "Costco North", location: "North", is_default: true },
|
||||
{ id: 11, name: "Costco South", location: "South", is_default: false },
|
||||
];
|
||||
|
||||
await page.route("**/households", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@ -128,13 +131,22 @@ test("manage stores add failure shows normalized error toast", async ({ page })
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/stores/household/1", async (route) => {
|
||||
await page.route("**/households/1/stores", async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([{ id: 10, name: "Costco North", location: "North", is_default: true }]),
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 10,
|
||||
household_store_id: 100,
|
||||
name: "Costco",
|
||||
location_name: "Default Location",
|
||||
display_name: "Costco",
|
||||
is_default: true,
|
||||
},
|
||||
]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -150,22 +162,23 @@ test("manage stores add failure shows normalized error toast", async ({ page })
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fallback();
|
||||
await route.fulfill({ status: 405 });
|
||||
});
|
||||
|
||||
await page.route("**/stores", async (route) => {
|
||||
await page.route("**/households/1/locations/10/zones", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(allStores),
|
||||
body: JSON.stringify({ zones: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/manage?tab=stores");
|
||||
await page.getByRole("button", { name: "+ Add Store" }).click();
|
||||
await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click();
|
||||
const addStoreForm = page.locator(".add-store-inline");
|
||||
await addStoreForm.getByLabel("Store name").fill("Costco");
|
||||
await addStoreForm.getByRole("button", { name: "Add" }).click();
|
||||
|
||||
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Add store failed");
|
||||
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Create store failed");
|
||||
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Store already linked to household");
|
||||
});
|
||||
|
||||
|
||||
@ -2,9 +2,12 @@ import react from '@vitejs/plugin-react';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
|
||||
const env = loadEnv('', process.cwd());
|
||||
const allowedHosts = env.VITE_ALLOWED_HOSTS
|
||||
const configuredAllowedHosts = env.VITE_ALLOWED_HOSTS
|
||||
? env.VITE_ALLOWED_HOSTS.split(',').map((host) => host.trim())
|
||||
: [];
|
||||
const allowedHosts = Array.from(
|
||||
new Set(['localhost', '127.0.0.1', '192.168.7.45', ...configuredAllowedHosts].filter(Boolean))
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
|
||||
66
packages/db/migrations/20260602_010000_location_maps.sql
Normal file
66
packages/db/migrations/20260602_010000_location_maps.sql
Normal file
@ -0,0 +1,66 @@
|
||||
-- Location-specific store maps for the mobile map manager.
|
||||
-- Maps are scoped to a household-owned physical store location.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS location_maps (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
|
||||
store_location_id INTEGER NOT NULL REFERENCES store_locations(id) ON DELETE CASCADE,
|
||||
name VARCHAR(120) NOT NULL DEFAULT 'Store Map',
|
||||
width INTEGER NOT NULL DEFAULT 1000 CHECK (width > 0),
|
||||
height INTEGER NOT NULL DEFAULT 700 CHECK (height > 0),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft', 'published', 'archived')),
|
||||
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0),
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
published_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_location_maps_location_status
|
||||
ON location_maps(household_id, store_location_id, status, updated_at DESC);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_location_maps_draft_location
|
||||
ON location_maps(store_location_id)
|
||||
WHERE status = 'draft';
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_location_maps_published_location
|
||||
ON location_maps(store_location_id)
|
||||
WHERE status = 'published';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS location_map_objects (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
location_map_id BIGINT NOT NULL REFERENCES location_maps(id) ON DELETE CASCADE,
|
||||
zone_id INTEGER REFERENCES store_location_zones(id) ON DELETE SET NULL,
|
||||
type VARCHAR(30) NOT NULL DEFAULT 'zone'
|
||||
CHECK (type IN (
|
||||
'zone',
|
||||
'aisle',
|
||||
'entrance',
|
||||
'checkout',
|
||||
'shelf',
|
||||
'wall',
|
||||
'department',
|
||||
'anchor',
|
||||
'other'
|
||||
)),
|
||||
label VARCHAR(160) NOT NULL DEFAULT '',
|
||||
x NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
||||
y NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
||||
width NUMERIC(10, 2) NOT NULL DEFAULT 160 CHECK (width > 0),
|
||||
height NUMERIC(10, 2) NOT NULL DEFAULT 100 CHECK (height > 0),
|
||||
rotation NUMERIC(7, 2) NOT NULL DEFAULT 0,
|
||||
locked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
visible BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_location_map_objects_map_order
|
||||
ON location_map_objects(location_map_id, sort_order, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_location_map_objects_zone
|
||||
ON location_map_objects(zone_id);
|
||||
Loading…
Reference in New Issue
Block a user