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`
|
- `docker compose -f docker-compose.dev.yml up -d --build backend`
|
||||||
- After backend env/CORS changes, recreate the backend service so `backend/.env` is reloaded:
|
- After backend env/CORS changes, recreate the backend service so `backend/.env` is reloaded:
|
||||||
- `docker compose -f docker-compose.dev.yml up -d --force-recreate --no-deps backend`
|
- `docker compose -f docker-compose.dev.yml up -d --force-recreate --no-deps backend`
|
||||||
- For the Docker frontend on port `3010`, `ALLOWED_ORIGINS` must include the exact browser origin, for example `http://localhost:3010` and `http://127.0.0.1:3010`.
|
- For the Docker frontend on port `3010`, `ALLOWED_ORIGINS` must include exact loopback browser origins such as `http://localhost:3010` and `http://127.0.0.1:3010`; private LAN origins such as `http://192.168.7.45:3010` are allowed by the backend CORS helper for local device testing.
|
||||||
- Verify the restarted API with `GET http://127.0.0.1:5000/` and `GET http://127.0.0.1:5000/config`.
|
- Verify the restarted API with `GET http://127.0.0.1:5000/` and `GET http://127.0.0.1:5000/config`.
|
||||||
- Do not print or commit real `.env` values while checking or updating local Docker env.
|
- Do not print or commit real `.env` values while checking or updating local Docker env.
|
||||||
|
|
||||||
@ -162,6 +162,8 @@ For `app/api/**/[param]/route.ts`:
|
|||||||
- Touch: long-press affordance for item-level actions when no visible button.
|
- Touch: long-press affordance for item-level actions when no visible button.
|
||||||
- Mouse: hover affordance on interactive rows/cards.
|
- Mouse: hover affordance on interactive rows/cards.
|
||||||
- Tap targets remain >= 40px on mobile.
|
- 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.
|
- 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).
|
- 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.
|
- 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=
|
DB_NAME=
|
||||||
PORT=5000
|
PORT=5000
|
||||||
JWT_SECRET=change-me
|
JWT_SECRET=change-me
|
||||||
ALLOWED_ORIGINS=http://localhost:3000
|
ALLOWED_ORIGINS=http://localhost:3010,http://127.0.0.1:3010,http://192.168.7.45:3010
|
||||||
SESSION_COOKIE_NAME=sid
|
SESSION_COOKIE_NAME=sid
|
||||||
SESSION_TTL_DAYS=30
|
SESSION_TTL_DAYS=30
|
||||||
|
|||||||
@ -4,6 +4,7 @@ const path = require("path");
|
|||||||
const User = require("./models/user.model");
|
const User = require("./models/user.model");
|
||||||
const requestIdMiddleware = require("./middleware/request-id");
|
const requestIdMiddleware = require("./middleware/request-id");
|
||||||
const { sendError } = require("./utils/http");
|
const { sendError } = require("./utils/http");
|
||||||
|
const { isAllowedCorsOrigin, parseAllowedOrigins } = require("./utils/cors-origins");
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(requestIdMiddleware);
|
app.use(requestIdMiddleware);
|
||||||
@ -14,17 +15,11 @@ if (process.env.NODE_ENV !== "production") {
|
|||||||
app.use("/test", express.static(path.join(__dirname, "public")));
|
app.use("/test", express.static(path.join(__dirname, "public")));
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowedOrigins = (process.env.ALLOWED_ORIGINS || "")
|
const allowedOrigins = parseAllowedOrigins(process.env.ALLOWED_ORIGINS || "");
|
||||||
.split(",")
|
|
||||||
.map((origin) => origin.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin: function (origin, callback) {
|
origin: function (origin, callback) {
|
||||||
if (!origin) return callback(null, true);
|
if (isAllowedCorsOrigin(origin, allowedOrigins)) return callback(null, true);
|
||||||
if (allowedOrigins.includes(origin)) return callback(null, true);
|
|
||||||
if (/^http:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
|
|
||||||
if (/^https:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
|
|
||||||
console.error(`CORS blocked origin: ${origin}`);
|
console.error(`CORS blocked origin: ${origin}`);
|
||||||
callback(new Error(`CORS blocked: ${origin}. Add this origin to ALLOWED_ORIGINS environment variable.`));
|
callback(new Error(`CORS blocked: ${origin}. Add this origin to ALLOWED_ORIGINS environment variable.`));
|
||||||
},
|
},
|
||||||
|
|||||||
131
backend/controllers/location-maps.controller.js
Normal file
131
backend/controllers/location-maps.controller.js
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
const locationMapModel = require("../models/location-map.model");
|
||||||
|
const { sendError } = require("../utils/http");
|
||||||
|
const { logError } = require("../utils/logger");
|
||||||
|
|
||||||
|
function parsePositiveInteger(value) {
|
||||||
|
const parsed = Number.parseInt(String(value), 10);
|
||||||
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canManageMaps(req) {
|
||||||
|
return ["owner", "admin"].includes(req.household?.role);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScope(req) {
|
||||||
|
return {
|
||||||
|
householdId: parsePositiveInteger(req.params.householdId),
|
||||||
|
locationId: parsePositiveInteger(req.params.locationId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateScope(res, scope) {
|
||||||
|
if (!scope.householdId || !scope.locationId) {
|
||||||
|
sendError(res, 400, "Household ID and location ID must be positive integers");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMapState(res, state, req) {
|
||||||
|
if (!state) {
|
||||||
|
return sendError(res, 404, "Store location not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
...state,
|
||||||
|
can_manage: canManageMaps(req),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.getLocationMap = async (req, res) => {
|
||||||
|
const scope = getScope(req);
|
||||||
|
if (!validateScope(res, scope)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = await locationMapModel.getMapState(
|
||||||
|
scope.householdId,
|
||||||
|
scope.locationId,
|
||||||
|
canManageMaps(req)
|
||||||
|
);
|
||||||
|
sendMapState(res, state, req);
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "locationMaps.getLocationMap", error);
|
||||||
|
sendError(res, 500, "Failed to load location map");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.createBlankMap = async (req, res) => {
|
||||||
|
const scope = getScope(req);
|
||||||
|
if (!validateScope(res, scope)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = await locationMapModel.saveBlankDraft(
|
||||||
|
scope.householdId,
|
||||||
|
scope.locationId,
|
||||||
|
req.user.id,
|
||||||
|
req.body?.map || req.body || {}
|
||||||
|
);
|
||||||
|
res.status(201);
|
||||||
|
sendMapState(res, state, req);
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "locationMaps.createBlankMap", error);
|
||||||
|
sendError(res, 500, "Failed to create blank map");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.createMapFromZones = async (req, res) => {
|
||||||
|
const scope = getScope(req);
|
||||||
|
if (!validateScope(res, scope)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = await locationMapModel.createDraftFromZones(
|
||||||
|
scope.householdId,
|
||||||
|
scope.locationId,
|
||||||
|
req.user.id,
|
||||||
|
req.body?.map || req.body || {}
|
||||||
|
);
|
||||||
|
res.status(201);
|
||||||
|
sendMapState(res, state, req);
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "locationMaps.createMapFromZones", error);
|
||||||
|
sendError(res, 500, "Failed to create map from zones");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.saveDraft = async (req, res) => {
|
||||||
|
const scope = getScope(req);
|
||||||
|
if (!validateScope(res, scope)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = await locationMapModel.saveDraft(
|
||||||
|
scope.householdId,
|
||||||
|
scope.locationId,
|
||||||
|
req.user.id,
|
||||||
|
req.body || {}
|
||||||
|
);
|
||||||
|
sendMapState(res, state, req);
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "locationMaps.saveDraft", error);
|
||||||
|
sendError(res, 500, "Failed to save map draft");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.publishDraft = async (req, res) => {
|
||||||
|
const scope = getScope(req);
|
||||||
|
if (!validateScope(res, scope)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = await locationMapModel.publishDraft(
|
||||||
|
scope.householdId,
|
||||||
|
scope.locationId,
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
if (!state) {
|
||||||
|
return sendError(res, 404, "No draft map to publish");
|
||||||
|
}
|
||||||
|
sendMapState(res, state, req);
|
||||||
|
} catch (error) {
|
||||||
|
logError(req, "locationMaps.publishDraft", error);
|
||||||
|
sendError(res, 500, "Failed to publish map");
|
||||||
|
}
|
||||||
|
};
|
||||||
536
backend/models/location-map.model.js
Normal file
536
backend/models/location-map.model.js
Normal file
@ -0,0 +1,536 @@
|
|||||||
|
const pool = require("../db/pool");
|
||||||
|
const storeModel = require("./store.model");
|
||||||
|
const {
|
||||||
|
buildStarterObjectsFromZones,
|
||||||
|
getStarterMapSize,
|
||||||
|
} = require("../services/location-map-layout.service");
|
||||||
|
|
||||||
|
const DEFAULT_MAP_WIDTH = 1000;
|
||||||
|
const DEFAULT_MAP_HEIGHT = 700;
|
||||||
|
const DEFAULT_MAP_NAME = "Store Map";
|
||||||
|
const MAP_OBJECT_TYPES = new Set([
|
||||||
|
"zone",
|
||||||
|
"aisle",
|
||||||
|
"entrance",
|
||||||
|
"checkout",
|
||||||
|
"shelf",
|
||||||
|
"wall",
|
||||||
|
"department",
|
||||||
|
"anchor",
|
||||||
|
"other",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function toNumber(value, fallback = 0) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPositiveNumber(value, fallback) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPositiveInteger(value, fallback) {
|
||||||
|
const parsed = Number.parseInt(String(value), 10);
|
||||||
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMapRow(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
width: toPositiveInteger(row.width, DEFAULT_MAP_WIDTH),
|
||||||
|
height: toPositiveInteger(row.height, DEFAULT_MAP_HEIGHT),
|
||||||
|
version: toPositiveInteger(row.version, 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeObjectRow(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
x: toNumber(row.x),
|
||||||
|
y: toNumber(row.y),
|
||||||
|
width: toPositiveNumber(row.width, 160),
|
||||||
|
height: toPositiveNumber(row.height, 100),
|
||||||
|
rotation: toNumber(row.rotation),
|
||||||
|
locked: Boolean(row.locked),
|
||||||
|
visible: row.visible !== false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeMapPayload(payload = {}) {
|
||||||
|
return {
|
||||||
|
name:
|
||||||
|
typeof payload.name === "string" && payload.name.trim()
|
||||||
|
? payload.name.trim().slice(0, 120)
|
||||||
|
: DEFAULT_MAP_NAME,
|
||||||
|
width: toPositiveInteger(payload.width, DEFAULT_MAP_WIDTH),
|
||||||
|
height: toPositiveInteger(payload.height, DEFAULT_MAP_HEIGHT),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeObjectPayload(object = {}, index = 0) {
|
||||||
|
const type = MAP_OBJECT_TYPES.has(object.type) ? object.type : "zone";
|
||||||
|
const rawZoneId = object.zone_id ?? object.zoneId;
|
||||||
|
const zoneId = rawZoneId === null || rawZoneId === "" || rawZoneId === undefined
|
||||||
|
? null
|
||||||
|
: toPositiveInteger(rawZoneId, null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
zone_id: zoneId,
|
||||||
|
type,
|
||||||
|
label:
|
||||||
|
typeof object.label === "string" && object.label.trim()
|
||||||
|
? object.label.trim().slice(0, 160)
|
||||||
|
: "",
|
||||||
|
x: toNumber(object.x),
|
||||||
|
y: toNumber(object.y),
|
||||||
|
width: toPositiveNumber(object.width, 160),
|
||||||
|
height: toPositiveNumber(object.height, 100),
|
||||||
|
rotation: toNumber(object.rotation),
|
||||||
|
locked: Boolean(object.locked),
|
||||||
|
visible: object.visible !== false,
|
||||||
|
sort_order: toPositiveInteger(object.sort_order ?? object.sortOrder, index + 1),
|
||||||
|
metadata_json:
|
||||||
|
object.metadata_json && typeof object.metadata_json === "object" && !Array.isArray(object.metadata_json)
|
||||||
|
? object.metadata_json
|
||||||
|
: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMaps(db, householdId, locationId, includeDraft) {
|
||||||
|
const statuses = includeDraft ? ["draft", "published"] : ["published"];
|
||||||
|
const result = await db.query(
|
||||||
|
`SELECT
|
||||||
|
id,
|
||||||
|
household_id,
|
||||||
|
store_location_id,
|
||||||
|
name,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
status,
|
||||||
|
version,
|
||||||
|
created_by,
|
||||||
|
updated_by,
|
||||||
|
published_at,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM location_maps
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_location_id = $2
|
||||||
|
AND status = ANY($3::text[])
|
||||||
|
ORDER BY
|
||||||
|
CASE status WHEN 'draft' THEN 0 WHEN 'published' THEN 1 ELSE 2 END,
|
||||||
|
updated_at DESC`,
|
||||||
|
[householdId, locationId, statuses]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows.map(normalizeMapRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getObjects(db, mapId) {
|
||||||
|
if (!mapId) return [];
|
||||||
|
const result = await db.query(
|
||||||
|
`SELECT
|
||||||
|
lmo.id,
|
||||||
|
lmo.location_map_id,
|
||||||
|
lmo.zone_id,
|
||||||
|
lmo.type,
|
||||||
|
lmo.label,
|
||||||
|
lmo.x,
|
||||||
|
lmo.y,
|
||||||
|
lmo.width,
|
||||||
|
lmo.height,
|
||||||
|
lmo.rotation,
|
||||||
|
lmo.locked,
|
||||||
|
lmo.visible,
|
||||||
|
lmo.sort_order,
|
||||||
|
lmo.metadata_json,
|
||||||
|
slz.name AS zone_name,
|
||||||
|
lmo.created_at,
|
||||||
|
lmo.updated_at
|
||||||
|
FROM location_map_objects lmo
|
||||||
|
LEFT JOIN store_location_zones slz ON slz.id = lmo.zone_id
|
||||||
|
WHERE lmo.location_map_id = $1
|
||||||
|
ORDER BY lmo.sort_order ASC, lmo.id ASC`,
|
||||||
|
[mapId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows.map(normalizeObjectRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getZoneSummaries(db, householdId, locationId) {
|
||||||
|
const result = await db.query(
|
||||||
|
`WITH map_items AS (
|
||||||
|
SELECT
|
||||||
|
hl.id,
|
||||||
|
resolved_zone.id AS zone_id
|
||||||
|
FROM household_lists hl
|
||||||
|
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
|
||||||
|
LEFT JOIN household_item_classifications hic
|
||||||
|
ON hic.household_id = hl.household_id
|
||||||
|
AND hic.store_location_id = hl.store_location_id
|
||||||
|
AND hic.household_store_item_id = hl.household_store_item_id
|
||||||
|
LEFT JOIN store_location_zones resolved_zone
|
||||||
|
ON resolved_zone.household_id = hl.household_id
|
||||||
|
AND resolved_zone.store_location_id = hl.store_location_id
|
||||||
|
AND resolved_zone.is_active = TRUE
|
||||||
|
AND (
|
||||||
|
resolved_zone.id = hic.zone_id
|
||||||
|
OR (
|
||||||
|
hic.zone_id IS NULL
|
||||||
|
AND hic.zone IS NOT NULL
|
||||||
|
AND resolved_zone.normalized_name = LOWER(BTRIM(hic.zone))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WHERE hl.household_id = $1
|
||||||
|
AND hl.store_location_id = $2
|
||||||
|
AND hl.bought = FALSE
|
||||||
|
),
|
||||||
|
item_counts AS (
|
||||||
|
SELECT zone_id, COUNT(*)::int AS item_count
|
||||||
|
FROM map_items
|
||||||
|
WHERE zone_id IS NOT NULL
|
||||||
|
GROUP BY zone_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
slz.id,
|
||||||
|
slz.name,
|
||||||
|
slz.sort_order,
|
||||||
|
slz.color,
|
||||||
|
slz.map_metadata,
|
||||||
|
slz.is_active,
|
||||||
|
COALESCE(item_counts.item_count, 0)::int AS item_count
|
||||||
|
FROM store_location_zones slz
|
||||||
|
LEFT JOIN item_counts ON item_counts.zone_id = slz.id
|
||||||
|
WHERE slz.household_id = $1
|
||||||
|
AND slz.store_location_id = $2
|
||||||
|
AND slz.is_active = TRUE
|
||||||
|
ORDER BY slz.sort_order ASC, slz.name ASC`,
|
||||||
|
[householdId, locationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMapItems(db, householdId, locationId) {
|
||||||
|
const result = await db.query(
|
||||||
|
`SELECT
|
||||||
|
hl.id,
|
||||||
|
hl.household_store_item_id AS item_id,
|
||||||
|
hsi.name AS item_name,
|
||||||
|
hl.quantity,
|
||||||
|
hl.bought,
|
||||||
|
resolved_zone.id AS zone_id,
|
||||||
|
COALESCE(resolved_zone.name, hic.zone) AS zone,
|
||||||
|
(
|
||||||
|
SELECT ARRAY_AGG(active_added_by_users.user_label ORDER BY active_added_by_users.last_added_on DESC, active_added_by_users.user_label)
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
COALESCE(NULLIF(TRIM(u.display_name), ''), NULLIF(TRIM(u.name), ''), u.username) AS user_label,
|
||||||
|
MAX(active_history.added_on) AS last_added_on
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
hlh.*,
|
||||||
|
COALESCE(
|
||||||
|
SUM(hlh.quantity) OVER (
|
||||||
|
PARTITION BY hlh.household_list_id
|
||||||
|
ORDER BY hlh.added_on DESC, hlh.id DESC
|
||||||
|
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
|
||||||
|
),
|
||||||
|
0
|
||||||
|
) AS newer_quantity
|
||||||
|
FROM household_list_history hlh
|
||||||
|
WHERE hlh.household_list_id = hl.id
|
||||||
|
) active_history
|
||||||
|
JOIN users u ON active_history.added_by = u.id
|
||||||
|
WHERE active_history.newer_quantity < GREATEST(hl.quantity, 0)
|
||||||
|
GROUP BY user_label
|
||||||
|
) active_added_by_users
|
||||||
|
) AS added_by_users
|
||||||
|
FROM household_lists hl
|
||||||
|
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
|
||||||
|
LEFT JOIN household_item_classifications hic
|
||||||
|
ON hic.household_id = hl.household_id
|
||||||
|
AND hic.store_location_id = hl.store_location_id
|
||||||
|
AND hic.household_store_item_id = hl.household_store_item_id
|
||||||
|
LEFT JOIN store_location_zones resolved_zone
|
||||||
|
ON resolved_zone.household_id = hl.household_id
|
||||||
|
AND resolved_zone.store_location_id = hl.store_location_id
|
||||||
|
AND resolved_zone.is_active = TRUE
|
||||||
|
AND (
|
||||||
|
resolved_zone.id = hic.zone_id
|
||||||
|
OR (
|
||||||
|
hic.zone_id IS NULL
|
||||||
|
AND hic.zone IS NOT NULL
|
||||||
|
AND resolved_zone.normalized_name = LOWER(BTRIM(hic.zone))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WHERE hl.household_id = $1
|
||||||
|
AND hl.store_location_id = $2
|
||||||
|
AND (
|
||||||
|
hl.bought = FALSE
|
||||||
|
OR hl.modified_on >= NOW() - INTERVAL '24 hours'
|
||||||
|
)
|
||||||
|
ORDER BY resolved_zone.sort_order ASC NULLS LAST, hsi.name ASC`,
|
||||||
|
[householdId, locationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
added_by_users: Array.isArray(row.added_by_users) ? row.added_by_users : [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getActiveZoneIds(db, householdId, locationId) {
|
||||||
|
const result = await db.query(
|
||||||
|
`SELECT id
|
||||||
|
FROM store_location_zones
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_location_id = $2
|
||||||
|
AND is_active = TRUE`,
|
||||||
|
[householdId, locationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Set(result.rows.map((row) => Number(row.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertObjects(db, mapId, objects, validZoneIds = null) {
|
||||||
|
for (let index = 0; index < objects.length; index += 1) {
|
||||||
|
const object = sanitizeObjectPayload(objects[index], index);
|
||||||
|
const scopedZoneId =
|
||||||
|
object.zone_id && validZoneIds && !validZoneIds.has(Number(object.zone_id))
|
||||||
|
? null
|
||||||
|
: object.zone_id;
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO location_map_objects
|
||||||
|
(
|
||||||
|
location_map_id,
|
||||||
|
zone_id,
|
||||||
|
type,
|
||||||
|
label,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rotation,
|
||||||
|
locked,
|
||||||
|
visible,
|
||||||
|
sort_order,
|
||||||
|
metadata_json
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)`,
|
||||||
|
[
|
||||||
|
mapId,
|
||||||
|
scopedZoneId,
|
||||||
|
object.type,
|
||||||
|
object.label,
|
||||||
|
object.x,
|
||||||
|
object.y,
|
||||||
|
object.width,
|
||||||
|
object.height,
|
||||||
|
object.rotation,
|
||||||
|
object.locked,
|
||||||
|
object.visible,
|
||||||
|
object.sort_order,
|
||||||
|
JSON.stringify(object.metadata_json),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertDraftMap(db, householdId, locationId, userId, mapPayload) {
|
||||||
|
const map = sanitizeMapPayload(mapPayload);
|
||||||
|
const result = await db.query(
|
||||||
|
`INSERT INTO location_maps
|
||||||
|
(household_id, store_location_id, name, width, height, status, version, created_by, updated_by, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 'draft', 1, $6, $6, NOW())
|
||||||
|
ON CONFLICT (store_location_id) WHERE status = 'draft'
|
||||||
|
DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
width = EXCLUDED.width,
|
||||||
|
height = EXCLUDED.height,
|
||||||
|
updated_by = EXCLUDED.updated_by,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
household_id,
|
||||||
|
store_location_id,
|
||||||
|
name,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
status,
|
||||||
|
version,
|
||||||
|
created_by,
|
||||||
|
updated_by,
|
||||||
|
published_at,
|
||||||
|
created_at,
|
||||||
|
updated_at`,
|
||||||
|
[householdId, locationId, map.name, map.width, map.height, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return normalizeMapRow(result.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.getMapState = async (householdId, locationId, includeDraft = false) => {
|
||||||
|
const location = await storeModel.getLocationById(householdId, locationId);
|
||||||
|
if (!location) return null;
|
||||||
|
|
||||||
|
const maps = await getMaps(pool, householdId, locationId, includeDraft);
|
||||||
|
const draftMap = includeDraft ? maps.find((map) => map.status === "draft") || null : null;
|
||||||
|
const publishedMap = maps.find((map) => map.status === "published") || null;
|
||||||
|
const activeMap = draftMap || publishedMap;
|
||||||
|
const [draftObjects, publishedObjects, zones, items] = await Promise.all([
|
||||||
|
getObjects(pool, draftMap?.id),
|
||||||
|
getObjects(pool, publishedMap?.id),
|
||||||
|
getZoneSummaries(pool, householdId, locationId),
|
||||||
|
getMapItems(pool, householdId, locationId),
|
||||||
|
]);
|
||||||
|
const objects = draftMap ? draftObjects : publishedObjects;
|
||||||
|
|
||||||
|
return {
|
||||||
|
location,
|
||||||
|
map: activeMap || null,
|
||||||
|
draft_map: draftMap,
|
||||||
|
published_map: publishedMap,
|
||||||
|
objects,
|
||||||
|
draft_objects: draftObjects,
|
||||||
|
published_objects: publishedObjects,
|
||||||
|
zones,
|
||||||
|
items,
|
||||||
|
unmapped_count: items.filter((item) => !item.zone_id).length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.saveBlankDraft = async (householdId, locationId, userId, mapPayload = {}) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
const map = await upsertDraftMap(client, householdId, locationId, userId, mapPayload);
|
||||||
|
await client.query("DELETE FROM location_map_objects WHERE location_map_id = $1", [map.id]);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return exports.getMapState(householdId, locationId, true);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.createDraftFromZones = async (householdId, locationId, userId, mapPayload = {}) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
const zones = await getZoneSummaries(client, householdId, locationId);
|
||||||
|
const map = await upsertDraftMap(client, householdId, locationId, userId, mapPayload);
|
||||||
|
await client.query("DELETE FROM location_map_objects WHERE location_map_id = $1", [map.id]);
|
||||||
|
|
||||||
|
const starterSize = getStarterMapSize(zones, map);
|
||||||
|
if (starterSize.width !== map.width || starterSize.height !== map.height) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE location_maps
|
||||||
|
SET width = $1,
|
||||||
|
height = $2,
|
||||||
|
updated_at = NOW(),
|
||||||
|
updated_by = $3
|
||||||
|
WHERE id = $4`,
|
||||||
|
[starterSize.width, starterSize.height, userId, map.id]
|
||||||
|
);
|
||||||
|
map.width = starterSize.width;
|
||||||
|
map.height = starterSize.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
await insertObjects(
|
||||||
|
client,
|
||||||
|
map.id,
|
||||||
|
buildStarterObjectsFromZones(zones, map.width, map.height)
|
||||||
|
);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return exports.getMapState(householdId, locationId, true);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.saveDraft = async (householdId, locationId, userId, payload = {}) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
const map = await upsertDraftMap(client, householdId, locationId, userId, payload.map || {});
|
||||||
|
const validZoneIds = await getActiveZoneIds(client, householdId, locationId);
|
||||||
|
await client.query("DELETE FROM location_map_objects WHERE location_map_id = $1", [map.id]);
|
||||||
|
await insertObjects(client, map.id, Array.isArray(payload.objects) ? payload.objects : [], validZoneIds);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return exports.getMapState(householdId, locationId, true);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.publishDraft = async (householdId, locationId, userId) => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
const draftResult = await client.query(
|
||||||
|
`SELECT id
|
||||||
|
FROM location_maps
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_location_id = $2
|
||||||
|
AND status = 'draft'
|
||||||
|
FOR UPDATE`,
|
||||||
|
[householdId, locationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (draftResult.rowCount === 0) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionResult = await client.query(
|
||||||
|
`SELECT COALESCE(MAX(version), 0)::int AS version
|
||||||
|
FROM location_maps
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_location_id = $2`,
|
||||||
|
[householdId, locationId]
|
||||||
|
);
|
||||||
|
const nextVersion = (versionResult.rows[0]?.version || 0) + 1;
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE location_maps
|
||||||
|
SET status = 'archived', updated_by = $3, updated_at = NOW()
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_location_id = $2
|
||||||
|
AND status = 'published'`,
|
||||||
|
[householdId, locationId, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE location_maps
|
||||||
|
SET status = 'published',
|
||||||
|
version = $3,
|
||||||
|
published_at = NOW(),
|
||||||
|
updated_by = $4,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE household_id = $1
|
||||||
|
AND store_location_id = $2
|
||||||
|
AND id = $5`,
|
||||||
|
[householdId, locationId, nextVersion, userId, draftResult.rows[0].id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return exports.getMapState(householdId, locationId, true);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -134,6 +134,8 @@ exports.getHouseholdStores = async (householdId) => {
|
|||||||
sl.address,
|
sl.address,
|
||||||
sl.is_default,
|
sl.is_default,
|
||||||
sl.map_data,
|
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.created_at,
|
||||||
sl.updated_at,
|
sl.updated_at,
|
||||||
CASE
|
CASE
|
||||||
@ -142,6 +144,19 @@ exports.getHouseholdStores = async (householdId) => {
|
|||||||
END AS display_name
|
END AS display_name
|
||||||
FROM store_locations sl
|
FROM store_locations sl
|
||||||
JOIN household_custom_stores hcs ON hcs.id = sl.household_store_id
|
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
|
WHERE sl.household_id = $1
|
||||||
ORDER BY sl.is_default DESC, hcs.name ASC, sl.name ASC`,
|
ORDER BY sl.is_default DESC, hcs.name ASC, sl.name ASC`,
|
||||||
[householdId, DEFAULT_LOCATION_NAME]
|
[householdId, DEFAULT_LOCATION_NAME]
|
||||||
|
|||||||
@ -3,6 +3,7 @@ const router = express.Router();
|
|||||||
const controller = require("../controllers/households.controller");
|
const controller = require("../controllers/households.controller");
|
||||||
const listsController = require("../controllers/lists.controller.v2");
|
const listsController = require("../controllers/lists.controller.v2");
|
||||||
const availableItemsController = require("../controllers/available-items.controller");
|
const availableItemsController = require("../controllers/available-items.controller");
|
||||||
|
const locationMapsController = require("../controllers/location-maps.controller");
|
||||||
const storesController = require("../controllers/stores.controller");
|
const storesController = require("../controllers/stores.controller");
|
||||||
const auth = require("../middleware/auth");
|
const auth = require("../middleware/auth");
|
||||||
const {
|
const {
|
||||||
@ -134,6 +135,46 @@ router.delete(
|
|||||||
storesController.deleteZone
|
storesController.deleteZone
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/:householdId/locations/:locationId/map",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
locationMapsController.getLocationMap
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/:householdId/locations/:locationId/map/blank",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
locationMapsController.createBlankMap
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/:householdId/locations/:locationId/map/from-zones",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
locationMapsController.createMapFromZones
|
||||||
|
);
|
||||||
|
router.put(
|
||||||
|
"/:householdId/locations/:locationId/map/draft",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
locationMapsController.saveDraft
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/:householdId/locations/:locationId/map/publish",
|
||||||
|
auth,
|
||||||
|
householdAccess,
|
||||||
|
locationAccess,
|
||||||
|
requireHouseholdAdmin,
|
||||||
|
locationMapsController.publishDraft
|
||||||
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
"/:householdId/locations/:locationId/available-items",
|
"/:householdId/locations/:locationId/available-items",
|
||||||
auth,
|
auth,
|
||||||
|
|||||||
71
backend/services/location-map-layout.service.js
Normal file
71
backend/services/location-map-layout.service.js
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
const DEFAULT_MAP_WIDTH = 1000;
|
||||||
|
const DEFAULT_MAP_HEIGHT = 700;
|
||||||
|
const STARTER_GAP = 28;
|
||||||
|
const STARTER_MIN_OBJECT_WIDTH = 120;
|
||||||
|
const STARTER_MIN_OBJECT_HEIGHT = 80;
|
||||||
|
const STARTER_TARGET_ROW_HEIGHT = 210;
|
||||||
|
|
||||||
|
function toPositiveInteger(value, fallback) {
|
||||||
|
const parsed = Number.parseInt(String(value), 10);
|
||||||
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundToTwo(value) {
|
||||||
|
return Math.round(value * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStarterGrid(zones) {
|
||||||
|
const zoneCount = Array.isArray(zones) ? zones.length : 0;
|
||||||
|
const columnCount = zoneCount <= 4 ? 2 : 3;
|
||||||
|
const rowCount = Math.ceil(Math.max(zoneCount, 1) / columnCount);
|
||||||
|
return { columnCount, rowCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStarterMapSize(zones, mapSize = {}) {
|
||||||
|
const { columnCount, rowCount } = getStarterGrid(zones);
|
||||||
|
const currentWidth = toPositiveInteger(mapSize.width, DEFAULT_MAP_WIDTH);
|
||||||
|
const currentHeight = toPositiveInteger(mapSize.height, DEFAULT_MAP_HEIGHT);
|
||||||
|
const requiredWidth =
|
||||||
|
columnCount * STARTER_MIN_OBJECT_WIDTH + STARTER_GAP * (columnCount + 1);
|
||||||
|
const minimumGridHeight =
|
||||||
|
rowCount * STARTER_MIN_OBJECT_HEIGHT + STARTER_GAP * (rowCount + 1);
|
||||||
|
const preferredGridHeight = rowCount * STARTER_TARGET_ROW_HEIGHT;
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: Math.max(currentWidth, requiredWidth),
|
||||||
|
height: Math.max(currentHeight, minimumGridHeight, preferredGridHeight),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStarterObjectsFromZones(zones, width, height) {
|
||||||
|
if (!Array.isArray(zones) || !zones.length) return [];
|
||||||
|
|
||||||
|
const mapSize = getStarterMapSize(zones, { width, height });
|
||||||
|
const { columnCount, rowCount } = getStarterGrid(zones);
|
||||||
|
const cellWidth = (mapSize.width - STARTER_GAP * (columnCount + 1)) / columnCount;
|
||||||
|
const cellHeight = (mapSize.height - STARTER_GAP * (rowCount + 1)) / rowCount;
|
||||||
|
|
||||||
|
return zones.map((zone, index) => {
|
||||||
|
const column = index % columnCount;
|
||||||
|
const row = Math.floor(index / columnCount);
|
||||||
|
return {
|
||||||
|
zone_id: zone.id,
|
||||||
|
type: "zone",
|
||||||
|
label: zone.name,
|
||||||
|
x: roundToTwo(STARTER_GAP + column * (cellWidth + STARTER_GAP)),
|
||||||
|
y: roundToTwo(STARTER_GAP + row * (cellHeight + STARTER_GAP)),
|
||||||
|
width: Math.max(STARTER_MIN_OBJECT_WIDTH, roundToTwo(cellWidth)),
|
||||||
|
height: Math.max(STARTER_MIN_OBJECT_HEIGHT, roundToTwo(cellHeight)),
|
||||||
|
rotation: 0,
|
||||||
|
locked: false,
|
||||||
|
visible: true,
|
||||||
|
sort_order: index + 1,
|
||||||
|
metadata_json: { starter: true },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildStarterObjectsFromZones,
|
||||||
|
getStarterMapSize,
|
||||||
|
};
|
||||||
37
backend/tests/cors-origins.test.js
Normal file
37
backend/tests/cors-origins.test.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
const {
|
||||||
|
isAllowedCorsOrigin,
|
||||||
|
isPrivateLanHostname,
|
||||||
|
parseAllowedOrigins,
|
||||||
|
} = require("../utils/cors-origins");
|
||||||
|
|
||||||
|
describe("CORS origin helpers", () => {
|
||||||
|
test("parses configured allowed origins", () => {
|
||||||
|
expect(parseAllowedOrigins(" http://localhost:3010, http://127.0.0.1:3010 ,,")).toEqual([
|
||||||
|
"http://localhost:3010",
|
||||||
|
"http://127.0.0.1:3010",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows exact configured origins", () => {
|
||||||
|
const allowedOrigins = ["http://localhost:3010"];
|
||||||
|
|
||||||
|
expect(isAllowedCorsOrigin("http://localhost:3010", allowedOrigins)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows browser access from the local private network", () => {
|
||||||
|
expect(isAllowedCorsOrigin("http://192.168.7.45:3010", [])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recognizes RFC1918 private IPv4 hostnames", () => {
|
||||||
|
expect(isPrivateLanHostname("10.1.2.3")).toBe(true);
|
||||||
|
expect(isPrivateLanHostname("172.16.0.1")).toBe(true);
|
||||||
|
expect(isPrivateLanHostname("172.31.255.254")).toBe(true);
|
||||||
|
expect(isPrivateLanHostname("192.168.7.45")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects public and lookalike hostnames", () => {
|
||||||
|
expect(isAllowedCorsOrigin("http://192.168.7.45.example.test:3010", [])).toBe(false);
|
||||||
|
expect(isAllowedCorsOrigin("http://203.0.113.10:3010", [])).toBe(false);
|
||||||
|
expect(isAllowedCorsOrigin("ftp://192.168.7.45:3010", [])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
78
backend/tests/location-map-layout.service.test.js
Normal file
78
backend/tests/location-map-layout.service.test.js
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
const {
|
||||||
|
buildStarterObjectsFromZones,
|
||||||
|
getStarterMapSize,
|
||||||
|
} = require("../services/location-map-layout.service");
|
||||||
|
|
||||||
|
function makeZones(count) {
|
||||||
|
return Array.from({ length: count }, (_, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
name: `Zone ${index + 1}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlaps(first, second) {
|
||||||
|
return !(
|
||||||
|
first.x + first.width <= second.x ||
|
||||||
|
second.x + second.width <= first.x ||
|
||||||
|
first.y + first.height <= second.y ||
|
||||||
|
second.y + second.height <= first.y
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("location map starter layout", () => {
|
||||||
|
test("creates bounded starter rectangles for many zones", () => {
|
||||||
|
const zones = makeZones(13);
|
||||||
|
const mapSize = getStarterMapSize(zones, { width: 1000, height: 700 });
|
||||||
|
const objects = buildStarterObjectsFromZones(zones, mapSize.width, mapSize.height);
|
||||||
|
|
||||||
|
expect(mapSize).toEqual({ width: 1000, height: 1050 });
|
||||||
|
expect(objects).toHaveLength(13);
|
||||||
|
|
||||||
|
objects.forEach((object, index) => {
|
||||||
|
expect(object).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
zone_id: zones[index].id,
|
||||||
|
type: "zone",
|
||||||
|
label: zones[index].name,
|
||||||
|
locked: false,
|
||||||
|
visible: true,
|
||||||
|
sort_order: index + 1,
|
||||||
|
metadata_json: { starter: true },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(object.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(object.y).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(object.width).toBeGreaterThanOrEqual(120);
|
||||||
|
expect(object.height).toBeGreaterThanOrEqual(80);
|
||||||
|
expect(object.x + object.width).toBeLessThanOrEqual(mapSize.width);
|
||||||
|
expect(object.y + object.height).toBeLessThanOrEqual(mapSize.height);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let firstIndex = 0; firstIndex < objects.length; firstIndex += 1) {
|
||||||
|
for (let secondIndex = firstIndex + 1; secondIndex < objects.length; secondIndex += 1) {
|
||||||
|
expect(overlaps(objects[firstIndex], objects[secondIndex])).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expands cramped map dimensions before placing starter rectangles", () => {
|
||||||
|
const zones = makeZones(5);
|
||||||
|
const mapSize = getStarterMapSize(zones, { width: 300, height: 180 });
|
||||||
|
const objects = buildStarterObjectsFromZones(zones, 300, 180);
|
||||||
|
|
||||||
|
expect(mapSize).toEqual({ width: 472, height: 420 });
|
||||||
|
expect(objects).toHaveLength(5);
|
||||||
|
objects.forEach((object) => {
|
||||||
|
expect(object.x + object.width).toBeLessThanOrEqual(mapSize.width);
|
||||||
|
expect(object.y + object.height).toBeLessThanOrEqual(mapSize.height);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns no objects for locations with no zones", () => {
|
||||||
|
expect(buildStarterObjectsFromZones([], 1000, 700)).toEqual([]);
|
||||||
|
expect(getStarterMapSize([], { width: 1000, height: 700 })).toEqual({
|
||||||
|
width: 1000,
|
||||||
|
height: 700,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
235
backend/tests/location-map.model.test.js
Normal file
235
backend/tests/location-map.model.test.js
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
jest.mock("../db/pool", () => ({
|
||||||
|
query: jest.fn(),
|
||||||
|
connect: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../models/store.model", () => ({
|
||||||
|
getLocationById: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pool = require("../db/pool");
|
||||||
|
const storeModel = require("../models/store.model");
|
||||||
|
const LocationMap = require("../models/location-map.model");
|
||||||
|
|
||||||
|
function createClient() {
|
||||||
|
return {
|
||||||
|
query: jest.fn(),
|
||||||
|
release: jest.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("location-map.model", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockMapStateQueries() {
|
||||||
|
const location = {
|
||||||
|
id: 10,
|
||||||
|
household_id: 1,
|
||||||
|
name: "Costco",
|
||||||
|
location_name: "Eastvale",
|
||||||
|
};
|
||||||
|
const draftMap = {
|
||||||
|
id: 900,
|
||||||
|
household_id: 1,
|
||||||
|
store_location_id: 10,
|
||||||
|
name: "Draft Map",
|
||||||
|
width: 1000,
|
||||||
|
height: 700,
|
||||||
|
status: "draft",
|
||||||
|
version: 3,
|
||||||
|
};
|
||||||
|
const publishedMap = {
|
||||||
|
id: 901,
|
||||||
|
household_id: 1,
|
||||||
|
store_location_id: 10,
|
||||||
|
name: "Published Map",
|
||||||
|
width: 1000,
|
||||||
|
height: 700,
|
||||||
|
status: "published",
|
||||||
|
version: 2,
|
||||||
|
};
|
||||||
|
const draftObject = {
|
||||||
|
id: 1100,
|
||||||
|
location_map_id: 900,
|
||||||
|
zone_id: 501,
|
||||||
|
type: "zone",
|
||||||
|
label: "Draft Bakery",
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
width: 200,
|
||||||
|
height: 120,
|
||||||
|
rotation: 0,
|
||||||
|
locked: false,
|
||||||
|
visible: true,
|
||||||
|
sort_order: 1,
|
||||||
|
};
|
||||||
|
const publishedObject = {
|
||||||
|
...draftObject,
|
||||||
|
id: 1200,
|
||||||
|
location_map_id: 901,
|
||||||
|
label: "Published Bakery",
|
||||||
|
};
|
||||||
|
const zones = [{ id: 501, name: "Bakery", sort_order: 10, item_count: 1 }];
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
id: 3001,
|
||||||
|
item_id: 2001,
|
||||||
|
item_name: "sourdough",
|
||||||
|
quantity: 1,
|
||||||
|
bought: false,
|
||||||
|
zone_id: 501,
|
||||||
|
zone: "Bakery",
|
||||||
|
added_by_users: ["Owner"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3002,
|
||||||
|
item_id: 2002,
|
||||||
|
item_name: "bagels",
|
||||||
|
quantity: 1,
|
||||||
|
bought: true,
|
||||||
|
zone_id: 501,
|
||||||
|
zone: "Bakery",
|
||||||
|
added_by_users: ["Owner"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
storeModel.getLocationById.mockResolvedValue(location);
|
||||||
|
pool.query.mockImplementation(async (sql, params = []) => {
|
||||||
|
const query = String(sql);
|
||||||
|
if (query.includes("FROM location_maps") && query.includes("status = ANY")) {
|
||||||
|
const statuses = params[2] || [];
|
||||||
|
return {
|
||||||
|
rows: [draftMap, publishedMap].filter((map) => statuses.includes(map.status)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (query.includes("FROM location_map_objects")) {
|
||||||
|
const mapId = params[0];
|
||||||
|
return {
|
||||||
|
rows: mapId === 900 ? [draftObject] : mapId === 901 ? [publishedObject] : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (query.includes("FROM store_location_zones")) {
|
||||||
|
return { rows: zones };
|
||||||
|
}
|
||||||
|
if (query.includes("FROM household_lists hl")) {
|
||||||
|
return { rows: items };
|
||||||
|
}
|
||||||
|
return { rows: [], rowCount: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
return { draftMap, publishedMap, draftObject, publishedObject, location, zones, items };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("hides draft map state from non-managers", async () => {
|
||||||
|
const { publishedMap, publishedObject, location, zones, items } = mockMapStateQueries();
|
||||||
|
|
||||||
|
const state = await LocationMap.getMapState(1, 10, false);
|
||||||
|
|
||||||
|
expect(storeModel.getLocationById).toHaveBeenCalledWith(1, 10);
|
||||||
|
expect(pool.query).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("FROM location_maps"),
|
||||||
|
[1, 10, ["published"]]
|
||||||
|
);
|
||||||
|
expect(state).toEqual(expect.objectContaining({
|
||||||
|
location,
|
||||||
|
map: expect.objectContaining({ id: publishedMap.id, status: "published" }),
|
||||||
|
draft_map: null,
|
||||||
|
published_map: expect.objectContaining({ id: publishedMap.id, status: "published" }),
|
||||||
|
objects: [expect.objectContaining({ id: publishedObject.id, label: "Published Bakery" })],
|
||||||
|
draft_objects: [],
|
||||||
|
published_objects: [expect.objectContaining({ id: publishedObject.id })],
|
||||||
|
zones,
|
||||||
|
items,
|
||||||
|
unmapped_count: 0,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes draft and published map state for managers", async () => {
|
||||||
|
const { draftMap, publishedMap, draftObject, publishedObject } = mockMapStateQueries();
|
||||||
|
|
||||||
|
const state = await LocationMap.getMapState(1, 10, true);
|
||||||
|
|
||||||
|
expect(pool.query).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("FROM location_maps"),
|
||||||
|
[1, 10, ["draft", "published"]]
|
||||||
|
);
|
||||||
|
expect(state.map).toEqual(expect.objectContaining({ id: draftMap.id, status: "draft" }));
|
||||||
|
expect(state.draft_map).toEqual(expect.objectContaining({ id: draftMap.id, status: "draft" }));
|
||||||
|
expect(state.published_map).toEqual(expect.objectContaining({ id: publishedMap.id, status: "published" }));
|
||||||
|
expect(state.objects).toEqual([expect.objectContaining({ id: draftObject.id, label: "Draft Bakery" })]);
|
||||||
|
expect(state.draft_objects).toEqual([expect.objectContaining({ id: draftObject.id })]);
|
||||||
|
expect(state.published_objects).toEqual([expect.objectContaining({ id: publishedObject.id })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loads active and recent bought rows for map item layers", async () => {
|
||||||
|
const { items } = mockMapStateQueries();
|
||||||
|
|
||||||
|
const state = await LocationMap.getMapState(1, 10, true);
|
||||||
|
|
||||||
|
expect(state.items).toEqual(items);
|
||||||
|
expect(state.items.some((item) => item.bought)).toBe(true);
|
||||||
|
expect(pool.query).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("OR hl.modified_on >= NOW() - INTERVAL '24 hours'"),
|
||||||
|
[1, 10]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unlinks saved map objects from zones outside the current location", async () => {
|
||||||
|
const client = createClient();
|
||||||
|
pool.connect.mockResolvedValue(client);
|
||||||
|
const getMapStateSpy = jest.spyOn(LocationMap, "getMapState").mockResolvedValue({
|
||||||
|
draft_map: { id: 900 },
|
||||||
|
draft_objects: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
client.query.mockImplementation(async (sql) => {
|
||||||
|
if (sql === "BEGIN" || sql === "COMMIT") {
|
||||||
|
return { rows: [], rowCount: 0 };
|
||||||
|
}
|
||||||
|
if (String(sql).includes("INSERT INTO location_maps")) {
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 900,
|
||||||
|
household_id: 1,
|
||||||
|
store_location_id: 10,
|
||||||
|
name: "Store Map",
|
||||||
|
width: 1000,
|
||||||
|
height: 700,
|
||||||
|
status: "draft",
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (String(sql).includes("FROM store_location_zones")) {
|
||||||
|
return { rows: [{ id: 501 }] };
|
||||||
|
}
|
||||||
|
return { rows: [], rowCount: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
await LocationMap.saveDraft(1, 10, 42, {
|
||||||
|
objects: [
|
||||||
|
{ zone_id: 501, label: "Valid zone" },
|
||||||
|
{ zone_id: 999, label: "Foreign zone" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const insertCalls = client.query.mock.calls.filter(([sql]) =>
|
||||||
|
String(sql).includes("INSERT INTO location_map_objects")
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(insertCalls).toHaveLength(2);
|
||||||
|
expect(insertCalls[0][1][1]).toBe(501);
|
||||||
|
expect(insertCalls[1][1][1]).toBeNull();
|
||||||
|
expect(client.query).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("FROM store_location_zones"),
|
||||||
|
[1, 10]
|
||||||
|
);
|
||||||
|
expect(getMapStateSpy).toHaveBeenCalledWith(1, 10, true);
|
||||||
|
expect(client.query).toHaveBeenCalledWith("COMMIT");
|
||||||
|
expect(client.release).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -70,6 +70,14 @@ jest.mock("../controllers/available-items.controller", () => ({
|
|||||||
updateAvailableItem: jest.fn(),
|
updateAvailableItem: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock("../controllers/location-maps.controller", () => ({
|
||||||
|
createBlankMap: jest.fn((req, res) => res.status(201).json({ message: "blank map" })),
|
||||||
|
createMapFromZones: jest.fn((req, res) => res.status(201).json({ message: "zone map" })),
|
||||||
|
getLocationMap: jest.fn((req, res) => res.json({ map: null, objects: [] })),
|
||||||
|
publishDraft: jest.fn((req, res) => res.json({ message: "published map" })),
|
||||||
|
saveDraft: jest.fn((req, res) => res.json({ message: "saved draft" })),
|
||||||
|
}));
|
||||||
|
|
||||||
jest.mock("../controllers/stores.controller", () => ({
|
jest.mock("../controllers/stores.controller", () => ({
|
||||||
addLocationToStore: jest.fn((req, res) => res.status(201).json({ message: "location" })),
|
addLocationToStore: jest.fn((req, res) => res.status(201).json({ message: "location" })),
|
||||||
createHouseholdStore: jest.fn((req, res) => res.status(201).json({ message: "store" })),
|
createHouseholdStore: jest.fn((req, res) => res.status(201).json({ message: "store" })),
|
||||||
@ -89,6 +97,7 @@ const express = require("express");
|
|||||||
const request = require("supertest");
|
const request = require("supertest");
|
||||||
const router = require("../routes/households.routes");
|
const router = require("../routes/households.routes");
|
||||||
const householdsController = require("../controllers/households.controller");
|
const householdsController = require("../controllers/households.controller");
|
||||||
|
const locationMapsController = require("../controllers/location-maps.controller");
|
||||||
const storesController = require("../controllers/stores.controller");
|
const storesController = require("../controllers/stores.controller");
|
||||||
|
|
||||||
describe("store location routes", () => {
|
describe("store location routes", () => {
|
||||||
@ -161,4 +170,51 @@ describe("store location routes", () => {
|
|||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(storesController.updateZone).toHaveBeenCalled();
|
expect(storesController.updateZone).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("members can view a location map", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.get("/households/1/locations/2/map")
|
||||||
|
.set("x-household-role", "member");
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(locationMapsController.getLocationMap).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("members cannot create or publish location maps", async () => {
|
||||||
|
const createResponse = await request(app)
|
||||||
|
.post("/households/1/locations/2/map/from-zones")
|
||||||
|
.set("x-household-role", "member")
|
||||||
|
.send({});
|
||||||
|
const publishResponse = await request(app)
|
||||||
|
.post("/households/1/locations/2/map/publish")
|
||||||
|
.set("x-household-role", "member")
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(createResponse.status).toBe(403);
|
||||||
|
expect(publishResponse.status).toBe(403);
|
||||||
|
expect(locationMapsController.createMapFromZones).not.toHaveBeenCalled();
|
||||||
|
expect(locationMapsController.publishDraft).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("admins can create, save, and publish location maps", async () => {
|
||||||
|
const blankResponse = await request(app)
|
||||||
|
.post("/households/1/locations/2/map/blank")
|
||||||
|
.set("x-household-role", "admin")
|
||||||
|
.send({ map: { name: "Costco Map" } });
|
||||||
|
const saveResponse = await request(app)
|
||||||
|
.put("/households/1/locations/2/map/draft")
|
||||||
|
.set("x-household-role", "admin")
|
||||||
|
.send({ objects: [] });
|
||||||
|
const publishResponse = await request(app)
|
||||||
|
.post("/households/1/locations/2/map/publish")
|
||||||
|
.set("x-household-role", "admin")
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(blankResponse.status).toBe(201);
|
||||||
|
expect(saveResponse.status).toBe(200);
|
||||||
|
expect(publishResponse.status).toBe(200);
|
||||||
|
expect(locationMapsController.createBlankMap).toHaveBeenCalled();
|
||||||
|
expect(locationMapsController.saveDraft).toHaveBeenCalled();
|
||||||
|
expect(locationMapsController.publishDraft).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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
|
||||||
- `guides/api-documentation.md`: REST API reference. Verify against current code before changing APIs.
|
- `guides/api-documentation.md`: REST API reference. Verify against current code before changing APIs.
|
||||||
- `guides/frontend-readme.md`: frontend development notes.
|
- `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/MOBILE_RESPONSIVE_AUDIT.md`: mobile design and audit checklist.
|
||||||
- `guides/setup-checklist.md`: older setup checklist; prefer `DEVELOPMENT.md` for current commands.
|
- `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
|
# Leave blank to infer the API host from the browser URL, e.g. http://192.168.7.45:5000.
|
||||||
VITE_ALLOWED_HOSTS=localhost,127.0.0.1
|
VITE_API_URL=
|
||||||
|
VITE_ALLOWED_HOSTS=localhost,127.0.0.1,192.168.7.45
|
||||||
|
|||||||
@ -9,7 +9,8 @@ import { SettingsProvider } from "./context/SettingsContext.jsx";
|
|||||||
import { StoreProvider } from "./context/StoreContext.jsx";
|
import { StoreProvider } from "./context/StoreContext.jsx";
|
||||||
|
|
||||||
import AdminPanel from "./pages/AdminPanel.jsx";
|
import AdminPanel from "./pages/AdminPanel.jsx";
|
||||||
import GroceryList from "./pages/GroceryList.jsx";
|
import GroceryList from "./pages/GroceryList.jsx";
|
||||||
|
import LocationMapManager from "./pages/LocationMapManager.jsx";
|
||||||
import Login from "./pages/Login.jsx";
|
import Login from "./pages/Login.jsx";
|
||||||
import Manage from "./pages/Manage.jsx";
|
import Manage from "./pages/Manage.jsx";
|
||||||
import Register from "./pages/Register.jsx";
|
import Register from "./pages/Register.jsx";
|
||||||
@ -48,6 +49,7 @@ function App() {
|
|||||||
<Route path="/" element={<GroceryList />} />
|
<Route path="/" element={<GroceryList />} />
|
||||||
<Route path="/manage" element={<Manage />} />
|
<Route path="/manage" element={<Manage />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route path="/stores/:storeId/locations/:locationId/map" element={<LocationMapManager />} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/admin"
|
path="/admin"
|
||||||
|
|||||||
16
frontend/src/api/locationMaps.js
Normal file
16
frontend/src/api/locationMaps.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import api from "./axios";
|
||||||
|
|
||||||
|
export const getLocationMap = (householdId, locationId) =>
|
||||||
|
api.get(`/households/${householdId}/locations/${locationId}/map`);
|
||||||
|
|
||||||
|
export const createBlankLocationMap = (householdId, locationId, map = {}) =>
|
||||||
|
api.post(`/households/${householdId}/locations/${locationId}/map/blank`, { map });
|
||||||
|
|
||||||
|
export const createLocationMapFromZones = (householdId, locationId, map = {}) =>
|
||||||
|
api.post(`/households/${householdId}/locations/${locationId}/map/from-zones`, { map });
|
||||||
|
|
||||||
|
export const saveLocationMapDraft = (householdId, locationId, payload) =>
|
||||||
|
api.put(`/households/${householdId}/locations/${locationId}/map/draft`, payload);
|
||||||
|
|
||||||
|
export const publishLocationMapDraft = (householdId, locationId) =>
|
||||||
|
api.post(`/households/${householdId}/locations/${locationId}/map/publish`);
|
||||||
@ -71,6 +71,7 @@ export default function ManageHousehold() {
|
|||||||
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
|
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
|
||||||
const [pendingRoleChange, setPendingRoleChange] = useState(null);
|
const [pendingRoleChange, setPendingRoleChange] = useState(null);
|
||||||
const [pendingMemberRemoval, setPendingMemberRemoval] = useState(null);
|
const [pendingMemberRemoval, setPendingMemberRemoval] = useState(null);
|
||||||
|
const [selectedMember, setSelectedMember] = useState(null);
|
||||||
|
|
||||||
const isManager = ["owner", "admin"].includes(activeHousehold?.role);
|
const isManager = ["owner", "admin"].includes(activeHousehold?.role);
|
||||||
const isOwner = activeHousehold?.role === "owner";
|
const isOwner = activeHousehold?.role === "owner";
|
||||||
@ -85,6 +86,19 @@ export default function ManageHousehold() {
|
|||||||
}
|
}
|
||||||
}, [activeHousehold?.id, isManager]);
|
}, [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 () => {
|
const loadMembers = async () => {
|
||||||
if (!activeHousehold?.id) return;
|
if (!activeHousehold?.id) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -360,13 +374,34 @@ export default function ManageHousehold() {
|
|||||||
|
|
||||||
const managerCount = members.filter((member) => ["owner", "admin"].includes(member.role)).length;
|
const managerCount = members.filter((member) => ["owner", "admin"].includes(member.role)).length;
|
||||||
const memberCount = members.filter((member) => member.role === "member").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 (
|
return (
|
||||||
<div className="manage-household">
|
<div className="manage-household">
|
||||||
<section key="household-name" className="manage-section">
|
<section key="household-name" className="manage-section">
|
||||||
<div className="manage-section-header">
|
<div className="manage-section-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="manage-section-eyebrow">Household</p>
|
|
||||||
<h2>Identity</h2>
|
<h2>Identity</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -411,7 +446,6 @@ export default function ManageHousehold() {
|
|||||||
<section key="join-and-invites" className="manage-section">
|
<section key="join-and-invites" className="manage-section">
|
||||||
<div className="manage-section-header">
|
<div className="manage-section-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="manage-section-eyebrow">Entry Rules</p>
|
|
||||||
<h2>Invite Links</h2>
|
<h2>Invite Links</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -435,9 +469,7 @@ export default function ManageHousehold() {
|
|||||||
|
|
||||||
{inviteLoading ? (
|
{inviteLoading ? (
|
||||||
<p>Loading invite settings...</p>
|
<p>Loading invite settings...</p>
|
||||||
) : pendingRequests.length === 0 ? (
|
) : pendingRequests.length > 0 ? (
|
||||||
<p className="section-description">No pending join requests right now.</p>
|
|
||||||
) : (
|
|
||||||
<div className="pending-requests-list">
|
<div className="pending-requests-list">
|
||||||
{pendingRequests.map((request) => {
|
{pendingRequests.map((request) => {
|
||||||
const requesterLabel = getRequesterLabel(request);
|
const requesterLabel = getRequesterLabel(request);
|
||||||
@ -473,7 +505,7 @@ export default function ManageHousehold() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
<div className="invite-controls">
|
<div className="invite-controls">
|
||||||
<label>
|
<label>
|
||||||
@ -547,7 +579,6 @@ export default function ManageHousehold() {
|
|||||||
<section key="members" className="manage-section">
|
<section key="members" className="manage-section">
|
||||||
<div className="manage-section-header">
|
<div className="manage-section-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="manage-section-eyebrow">People</p>
|
|
||||||
<h2>Members ({members.length})</h2>
|
<h2>Members ({members.length})</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -560,7 +591,13 @@ export default function ManageHousehold() {
|
|||||||
const isSelf = member.id === parseInt(userId, 10);
|
const isSelf = member.id === parseInt(userId, 10);
|
||||||
|
|
||||||
return (
|
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-main">
|
||||||
<div className="member-info">
|
<div className="member-info">
|
||||||
<span className={`member-role member-role-${member.role}`}>
|
<span className={`member-role member-role-${member.role}`}>
|
||||||
@ -570,46 +607,76 @@ export default function ManageHousehold() {
|
|||||||
{isSelf && <span className="member-self-pill">You</span>}
|
{isSelf && <span className="member-self-pill">You</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isManager && !isSelf && member.role !== "owner" && (
|
</button>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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) && (
|
{(isManager || isMemberOnly) && (
|
||||||
<section key="danger-zone" className="manage-section danger-zone">
|
<section key="danger-zone" className="manage-section danger-zone">
|
||||||
<div className="manage-section-header">
|
<div className="manage-section-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="manage-section-eyebrow">Final Actions</p>
|
|
||||||
<h2>Danger Zone</h2>
|
<h2>Danger Zone</h2>
|
||||||
</div>
|
</div>
|
||||||
{isMemberOnly ? (
|
{isMemberOnly ? (
|
||||||
|
|||||||
@ -1,21 +1,14 @@
|
|||||||
import { useContext, useEffect, useMemo, useState } from "react";
|
import { useContext, useMemo, useState } from "react";
|
||||||
import {
|
import { createHouseholdStore } from "../../api/stores";
|
||||||
addLocationToStore,
|
|
||||||
createHouseholdStore,
|
|
||||||
createLocationZone,
|
|
||||||
deleteLocationZone,
|
|
||||||
getLocationZones,
|
|
||||||
removeLocation,
|
|
||||||
setDefaultLocation,
|
|
||||||
updateLocationZone,
|
|
||||||
} from "../../api/stores";
|
|
||||||
import StoreAvailableItemsManager from "./StoreAvailableItemsManager";
|
|
||||||
import { HouseholdContext } from "../../context/HouseholdContext";
|
import { HouseholdContext } from "../../context/HouseholdContext";
|
||||||
import { StoreContext } from "../../context/StoreContext";
|
import { StoreContext } from "../../context/StoreContext";
|
||||||
import useActionToast from "../../hooks/useActionToast";
|
import useActionToast from "../../hooks/useActionToast";
|
||||||
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
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/StoreAvailableItemsManager.css";
|
||||||
|
import "../../styles/components/manage/ManageStores.css";
|
||||||
|
|
||||||
function groupLocationsByStore(locations) {
|
function groupLocationsByStore(locations) {
|
||||||
const grouped = new Map();
|
const grouped = new Map();
|
||||||
@ -36,163 +29,8 @@ function groupLocationsByStore(locations) {
|
|||||||
return Array.from(grouped.values()).sort((a, b) => a.name.localeCompare(b.name));
|
return Array.from(grouped.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
function ZoneManager({ householdId, location, canManage, refreshActiveZones }) {
|
function locationLabel(location) {
|
||||||
const toast = useActionToast();
|
return location.display_name || location.name;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ManageStores() {
|
export default function ManageStores() {
|
||||||
@ -204,12 +42,7 @@ export default function ManageStores() {
|
|||||||
refreshZones,
|
refreshZones,
|
||||||
} = useContext(StoreContext);
|
} = useContext(StoreContext);
|
||||||
const toast = useActionToast();
|
const toast = useActionToast();
|
||||||
const [createForm, setCreateForm] = useState({
|
const [newStoreName, setNewStoreName] = useState("");
|
||||||
name: "",
|
|
||||||
location_name: "",
|
|
||||||
address: "",
|
|
||||||
});
|
|
||||||
const [locationDrafts, setLocationDrafts] = useState({});
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const isAdmin = ["owner", "admin"].includes(activeHousehold?.role);
|
const isAdmin = ["owner", "admin"].includes(activeHousehold?.role);
|
||||||
@ -225,18 +58,17 @@ export default function ManageStores() {
|
|||||||
|
|
||||||
const handleCreateStore = async (event) => {
|
const handleCreateStore = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!createForm.name.trim()) return;
|
const storeName = newStoreName.trim();
|
||||||
|
if (!storeName) return;
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await createHouseholdStore(activeHousehold.id, {
|
await createHouseholdStore(activeHousehold.id, {
|
||||||
name: createForm.name.trim(),
|
name: storeName,
|
||||||
location_name: createForm.location_name.trim() || "Default Location",
|
|
||||||
address: createForm.address.trim() || null,
|
|
||||||
});
|
});
|
||||||
setCreateForm({ name: "", location_name: "", address: "" });
|
setNewStoreName("");
|
||||||
await refreshAfterStoreChange();
|
await refreshAfterStoreChange();
|
||||||
toast.success("Created store", `Created store ${createForm.name.trim()}`);
|
toast.success("Created store", `Created store ${storeName}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getApiErrorMessage(error, "Failed to create store");
|
const message = getApiErrorMessage(error, "Failed to create store");
|
||||||
toast.error("Create store failed", `Create store failed: ${message}`);
|
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 (
|
return (
|
||||||
<div className="manage-stores">
|
<div className="manage-stores">
|
||||||
<section className="manage-section">
|
<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">
|
<p className="manage-stores-help">
|
||||||
Stores and locations are private to this household. Each location has its own zones,
|
Stores are private to this household. Locations define map-specific zones, item
|
||||||
item defaults, and shopping order.
|
placement, and shopping order.
|
||||||
</p>
|
</p>
|
||||||
{householdStores.length === 0 ? (
|
{householdStores.length === 0 ? (
|
||||||
<p className="empty-message">No store locations added yet.</p>
|
<p className="empty-message">No store locations added yet.</p>
|
||||||
@ -306,133 +107,64 @@ export default function ManageStores() {
|
|||||||
<div className="stores-list">
|
<div className="stores-list">
|
||||||
{groupedStores.map((storeGroup) => (
|
{groupedStores.map((storeGroup) => (
|
||||||
<div key={storeGroup.household_store_id} className="store-card">
|
<div key={storeGroup.household_store_id} className="store-card">
|
||||||
<div className="store-info">
|
<div className="store-card-header">
|
||||||
<h3>{storeGroup.name}</h3>
|
<div className="store-info">
|
||||||
|
<h3>{storeGroup.name}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StoreLocationManager
|
||||||
|
householdId={activeHousehold.id}
|
||||||
|
storeGroup={storeGroup}
|
||||||
|
allLocationCount={householdStores.length}
|
||||||
|
canManage={isAdmin}
|
||||||
|
refreshAfterStoreChange={refreshAfterStoreChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="store-location-list">
|
<div className="store-location-list">
|
||||||
{storeGroup.locations.map((location) => (
|
{storeGroup.locations.map((location) => {
|
||||||
<div key={location.id} className="store-location-row">
|
const label = locationLabel(location);
|
||||||
<div className="store-info">
|
const showLocationName =
|
||||||
<strong>{location.display_name || location.name}</strong>
|
storeGroup.locations.length > 1 || label !== storeGroup.name;
|
||||||
{location.address ? (
|
|
||||||
<p className="store-location">{location.address}</p>
|
return (
|
||||||
) : null}
|
<div key={location.id} className="store-location-row">
|
||||||
{location.is_default ? (
|
<div className="store-info store-location-copy">
|
||||||
<p className="store-location">Default shopping location</p>
|
{showLocationName ? <strong>{label}</strong> : null}
|
||||||
) : 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>
|
||||||
|
);
|
||||||
<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>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{isAdmin ? (
|
{!isAdmin && activeStore ? (
|
||||||
<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 ? (
|
|
||||||
<p className="manage-stores-note">
|
<p className="manage-stores-note">
|
||||||
Household members can manage item defaults. Only owners and admins can manage stores,
|
Household members can manage item defaults. Only owners and admins can manage stores,
|
||||||
locations, zones, and item deletion.
|
locations, zones, and item deletion.
|
||||||
|
|||||||
@ -20,10 +20,17 @@ function itemImageSource(item) {
|
|||||||
return `data:${mimeType};base64,${item.item_image}`;
|
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 toast = useActionToast();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
|
const [displayItemCount, setDisplayItemCount] = useState(itemCount);
|
||||||
const [zones, setZones] = useState([]);
|
const [zones, setZones] = useState([]);
|
||||||
const [catalogReady, setCatalogReady] = useState(true);
|
const [catalogReady, setCatalogReady] = useState(true);
|
||||||
const [catalogMessage, setCatalogMessage] = useState("");
|
const [catalogMessage, setCatalogMessage] = useState("");
|
||||||
@ -31,7 +38,12 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [editorItem, setEditorItem] = useState(null);
|
const [editorItem, setEditorItem] = useState(null);
|
||||||
const [showEditor, setShowEditor] = useState(false);
|
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) => {
|
const loadItems = useCallback(async (search = query) => {
|
||||||
if (!householdId || !store?.id) {
|
if (!householdId || !store?.id) {
|
||||||
@ -80,9 +92,15 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
|||||||
loadZones();
|
loadZones();
|
||||||
}, [isOpen, query, loadItems, loadZones]);
|
}, [isOpen, query, loadItems, loadZones]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDisplayItemCount(itemCount);
|
||||||
|
}, [itemCount]);
|
||||||
|
|
||||||
const closeManager = () => {
|
const closeManager = () => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setPendingDeleteItem(null);
|
setDeleteMode(false);
|
||||||
|
setSelectedDeleteIds(new Set());
|
||||||
|
setPendingDeleteItems([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdate = async (payload) => {
|
const handleUpdate = async (payload) => {
|
||||||
@ -108,6 +126,7 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
|||||||
setShowEditor(false);
|
setShowEditor(false);
|
||||||
setEditorItem(null);
|
setEditorItem(null);
|
||||||
await loadItems(query);
|
await loadItems(query);
|
||||||
|
await refreshStoreCounts?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getApiErrorMessage(error, "Failed to update store item");
|
const message = getApiErrorMessage(error, "Failed to update store item");
|
||||||
toast.error("Update store item failed", `Update store item failed: ${message}`);
|
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 () => {
|
const handleDeleteConfirm = async () => {
|
||||||
if (!pendingDeleteItem) {
|
if (pendingDeleteItems.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteAvailableItem(householdId, store.id, pendingDeleteItem.item_id);
|
await Promise.all(
|
||||||
toast.success("Deleted store item", `Deleted ${pendingDeleteItem.item_name} from ${store.display_name || store.name}`);
|
pendingDeleteItems.map((item) => deleteAvailableItem(householdId, store.id, item.item_id))
|
||||||
setPendingDeleteItem(null);
|
);
|
||||||
|
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 loadItems(query);
|
||||||
|
await refreshStoreCounts?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getApiErrorMessage(error, "Failed to delete store item");
|
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"
|
className="btn-secondary btn-small store-available-items-trigger"
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
>
|
>
|
||||||
Manage Items
|
Manage Items ({displayItemCount})
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen ? (
|
{isOpen ? (
|
||||||
@ -186,6 +251,28 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="store-items-modal-body">
|
||||||
{!catalogReady ? (
|
{!catalogReady ? (
|
||||||
<p className="empty-message">Run the latest database migrations to enable store item management.</p>
|
<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>
|
<p className="empty-message">No household items found for this store yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="store-items-table">
|
<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">
|
<div className="store-items-table-body">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const imageSrc = itemImageSource(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 (
|
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">
|
<div className="store-items-table-cell store-items-table-item">
|
||||||
<span className="store-items-mobile-label">Item</span>
|
|
||||||
<div className="store-available-items-summary">
|
<div className="store-available-items-summary">
|
||||||
{imageSrc ? (
|
{imageSrc ? (
|
||||||
<img src={imageSrc} alt="" className="store-available-items-thumb" />
|
<img src={imageSrc} alt="" className="store-available-items-thumb" />
|
||||||
) : (
|
) : (
|
||||||
<span className="store-available-items-thumb store-available-items-thumb-placeholder">
|
<span
|
||||||
{item.item_name?.slice(0, 1).toUpperCase() || "?"}
|
className="store-available-items-thumb store-available-items-thumb-placeholder"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{"\uD83D\uDCE6"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="store-available-items-copy">
|
<div className="store-available-items-copy">
|
||||||
@ -223,38 +324,12 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="store-items-table-cell">
|
{deleteMode ? (
|
||||||
<span className="store-items-mobile-label">Store Defaults</span>
|
<span className="store-items-delete-indicator" aria-hidden="true">
|
||||||
<span className="store-items-defaults-text">
|
{isSelectedForDelete ? "✓" : ""}
|
||||||
{details.join(" | ") || "No store defaults set"}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
) : null}
|
||||||
|
</button>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@ -277,15 +352,19 @@ export default function StoreAvailableItemsManager({ householdId, store, isAdmin
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmSlideModal
|
<ConfirmSlideModal
|
||||||
isOpen={Boolean(pendingDeleteItem)}
|
isOpen={pendingDeleteItems.length > 0}
|
||||||
title={pendingDeleteItem ? `Delete ${pendingDeleteItem.item_name}?` : "Delete item?"}
|
title={
|
||||||
|
pendingDeleteItems.length === 1
|
||||||
|
? `Delete ${pendingDeleteItems[0].item_name}?`
|
||||||
|
: `Delete ${pendingDeleteItems.length} items?`
|
||||||
|
}
|
||||||
description={
|
description={
|
||||||
pendingDeleteItem
|
pendingDeleteItems.length > 0
|
||||||
? `Slide to confirm. This permanently deletes ${pendingDeleteItem.item_name} from ${store.display_name || store.name} for this household, including current list entries and history.`
|
? `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"
|
confirmLabel={pendingDeleteItems.length === 1 ? "Delete Item" : "Delete Items"}
|
||||||
onClose={() => setPendingDeleteItem(null)}
|
onClose={() => setPendingDeleteItems([])}
|
||||||
onConfirm={handleDeleteConfirm}
|
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";
|
import "../../styles/ConfirmBuyModal.css";
|
||||||
|
|
||||||
|
function ChevronIcon({ direction }) {
|
||||||
|
const path = direction === "previous" ? "M15 18l-6-6 6-6" : "M9 18l6-6-6-6";
|
||||||
|
return (
|
||||||
|
<svg className="confirm-buy-nav-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d={path} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ConfirmBuyModal({
|
export default function ConfirmBuyModal({
|
||||||
item,
|
item,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
@ -11,6 +20,7 @@ export default function ConfirmBuyModal({
|
|||||||
const [quantity, setQuantity] = useState(item.quantity);
|
const [quantity, setQuantity] = useState(item.quantity);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const maxQuantity = item.quantity;
|
const maxQuantity = item.quantity;
|
||||||
|
const titleId = "confirm-buy-modal-title";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setQuantity(item.quantity);
|
setQuantity(item.quantity);
|
||||||
@ -21,17 +31,17 @@ export default function ConfirmBuyModal({
|
|||||||
const hasPrev = currentIndex > 0;
|
const hasPrev = currentIndex > 0;
|
||||||
const hasNext = currentIndex < allItems.length - 1;
|
const hasNext = currentIndex < allItems.length - 1;
|
||||||
|
|
||||||
const handleIncrement = () => {
|
const handleIncrement = useCallback(() => {
|
||||||
if (!isSubmitting && quantity < maxQuantity) {
|
if (isSubmitting) return;
|
||||||
setQuantity((prev) => prev + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDecrement = () => {
|
setQuantity((prev) => Math.min(maxQuantity, prev + 1));
|
||||||
if (!isSubmitting && quantity > 1) {
|
}, [isSubmitting, maxQuantity]);
|
||||||
setQuantity((prev) => prev - 1);
|
|
||||||
}
|
const handleDecrement = useCallback(() => {
|
||||||
};
|
if (isSubmitting) return;
|
||||||
|
|
||||||
|
setQuantity((prev) => Math.max(1, prev - 1));
|
||||||
|
}, [isSubmitting]);
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
if (isSubmitting) return;
|
if (isSubmitting) return;
|
||||||
@ -44,21 +54,58 @@ export default function ConfirmBuyModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrev = () => {
|
const handlePrev = useCallback(() => {
|
||||||
if (isSubmitting) return;
|
if (isSubmitting) return;
|
||||||
|
|
||||||
if (hasPrev && onNavigate) {
|
if (hasPrev && onNavigate) {
|
||||||
onNavigate(allItems[currentIndex - 1]);
|
onNavigate(allItems[currentIndex - 1]);
|
||||||
}
|
}
|
||||||
};
|
}, [allItems, currentIndex, hasPrev, isSubmitting, onNavigate]);
|
||||||
|
|
||||||
const handleNext = () => {
|
const handleNext = useCallback(() => {
|
||||||
if (isSubmitting) return;
|
if (isSubmitting) return;
|
||||||
|
|
||||||
if (hasNext && onNavigate) {
|
if (hasNext && onNavigate) {
|
||||||
onNavigate(allItems[currentIndex + 1]);
|
onNavigate(allItems[currentIndex + 1]);
|
||||||
}
|
}
|
||||||
};
|
}, [allItems, currentIndex, hasNext, isSubmitting, onNavigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event) => {
|
||||||
|
if (isSubmitting) return;
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
onCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault();
|
||||||
|
handlePrev();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleIncrement();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleDecrement();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [handleDecrement, handleIncrement, handleNext, handlePrev, isSubmitting, onCancel]);
|
||||||
|
|
||||||
const imageUrl = item.item_image && item.image_mime_type
|
const imageUrl = item.item_image && item.image_mime_type
|
||||||
? `data:${item.image_mime_type};base64,${item.item_image}`
|
? `data:${item.image_mime_type};base64,${item.item_image}`
|
||||||
@ -73,46 +120,62 @@ export default function ConfirmBuyModal({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="confirm-buy-modal" onClick={(event) => event.stopPropagation()}>
|
<div
|
||||||
|
className="confirm-buy-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
<div className="confirm-buy-header">
|
<div className="confirm-buy-header">
|
||||||
{item.zone && <div className="confirm-buy-zone">{item.zone}</div>}
|
{item.zone && <div className="confirm-buy-zone">{item.zone}</div>}
|
||||||
<h2 className="confirm-buy-item-name">{item.item_name}</h2>
|
<h2 id={titleId} className="confirm-buy-item-name">{item.item_name}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="confirm-buy-image-section">
|
<div className="confirm-buy-image-section">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="confirm-buy-nav-btn confirm-buy-nav-prev"
|
className="confirm-buy-nav-btn confirm-buy-nav-prev"
|
||||||
onClick={handlePrev}
|
onClick={handlePrev}
|
||||||
style={{ visibility: hasPrev ? "visible" : "hidden" }}
|
style={{ visibility: hasPrev ? "visible" : "hidden" }}
|
||||||
disabled={!hasPrev || isSubmitting}
|
disabled={!hasPrev || isSubmitting}
|
||||||
|
aria-label="Previous item"
|
||||||
|
title="Previous item"
|
||||||
>
|
>
|
||||||
{"<"}
|
<ChevronIcon direction="previous" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="confirm-buy-image-container">
|
<div className="confirm-buy-image-container">
|
||||||
{imageUrl ? (
|
{imageUrl ? (
|
||||||
<img src={imageUrl} alt={item.item_name} className="confirm-buy-image" />
|
<img src={imageUrl} alt={item.item_name} className="confirm-buy-image" />
|
||||||
) : (
|
) : (
|
||||||
<div className="confirm-buy-image-placeholder">[ ]</div>
|
<div className="confirm-buy-image-placeholder" role="img" aria-label="No item photo">
|
||||||
|
<span aria-hidden="true">{"\uD83D\uDCE6"}</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="confirm-buy-nav-btn confirm-buy-nav-next"
|
className="confirm-buy-nav-btn confirm-buy-nav-next"
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
style={{ visibility: hasNext ? "visible" : "hidden" }}
|
style={{ visibility: hasNext ? "visible" : "hidden" }}
|
||||||
disabled={!hasNext || isSubmitting}
|
disabled={!hasNext || isSubmitting}
|
||||||
|
aria-label="Next item"
|
||||||
|
title="Next item"
|
||||||
>
|
>
|
||||||
{">"}
|
<ChevronIcon direction="next" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="confirm-buy-quantity-section">
|
<div className="confirm-buy-quantity-section">
|
||||||
<div className="confirm-buy-counter">
|
<div className="confirm-buy-counter">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleDecrement}
|
onClick={handleDecrement}
|
||||||
className="confirm-buy-counter-btn"
|
className="confirm-buy-counter-btn"
|
||||||
disabled={quantity <= 1 || isSubmitting}
|
disabled={quantity <= 1 || isSubmitting}
|
||||||
|
aria-label="Decrease quantity"
|
||||||
>
|
>
|
||||||
-
|
-
|
||||||
</button>
|
</button>
|
||||||
@ -121,11 +184,14 @@ export default function ConfirmBuyModal({
|
|||||||
value={quantity}
|
value={quantity}
|
||||||
readOnly
|
readOnly
|
||||||
className="confirm-buy-counter-display"
|
className="confirm-buy-counter-display"
|
||||||
|
aria-label="Quantity to mark bought"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleIncrement}
|
onClick={handleIncrement}
|
||||||
className="confirm-buy-counter-btn"
|
className="confirm-buy-counter-btn"
|
||||||
disabled={quantity >= maxQuantity || isSubmitting}
|
disabled={quantity >= maxQuantity || isSubmitting}
|
||||||
|
aria-label="Increase quantity"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
@ -133,10 +199,10 @@ export default function ConfirmBuyModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="confirm-buy-actions">
|
<div className="confirm-buy-actions">
|
||||||
<button onClick={onCancel} className="confirm-buy-cancel" disabled={isSubmitting}>
|
<button type="button" onClick={onCancel} className="confirm-buy-cancel" disabled={isSubmitting}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleConfirm} className="confirm-buy-confirm" disabled={isSubmitting}>
|
<button type="button" onClick={handleConfirm} className="confirm-buy-confirm" disabled={isSubmitting}>
|
||||||
{isSubmitting ? "Saving..." : "Mark as Bought"}
|
{isSubmitting ? "Saving..." : "Mark as Bought"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,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 { StoreContext } from '../../context/StoreContext';
|
||||||
import '../../styles/components/StoreTabs.css';
|
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() {
|
export default function StoreTabs() {
|
||||||
const { stores, activeStore, setActiveStore, loading } = useContext(StoreContext);
|
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 (
|
return (
|
||||||
<div className="store-tabs">
|
<div className="store-tabs">
|
||||||
<div className="store-tabs-empty">
|
<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 (
|
return (
|
||||||
<div className="store-tabs">
|
<div className="store-tabs">
|
||||||
<div className="store-tabs-container">
|
<div className="store-tabs-container">
|
||||||
{stores.map(store => (
|
<button
|
||||||
<button
|
type="button"
|
||||||
key={store.id}
|
className={`store-selector-trigger ${canPickStore ? "is-interactive" : ""}`}
|
||||||
className={`store-tab ${store.id === activeStore?.id ? 'active' : ''}`}
|
onClick={handleStoreTriggerClick}
|
||||||
onClick={() => setActiveStore(store)}
|
disabled={loading}
|
||||||
disabled={loading}
|
aria-haspopup={canPickStore ? "dialog" : undefined}
|
||||||
>
|
aria-expanded={canPickStore ? activePicker === "store" : undefined}
|
||||||
<span className="store-name">{store.display_name || store.name}</span>
|
aria-label={`Store: ${selectedStoreOption.name}`}
|
||||||
</button>
|
>
|
||||||
))}
|
<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>
|
</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>
|
</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 { AuthContext } from './AuthContext';
|
||||||
import { HouseholdContext } from './HouseholdContext';
|
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({
|
export const StoreContext = createContext({
|
||||||
stores: [],
|
stores: [],
|
||||||
activeStore: null,
|
activeStore: null,
|
||||||
@ -42,24 +84,48 @@ export const StoreProvider = ({ children }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeHousehold || stores.length === 0) return;
|
if (!activeHousehold || stores.length === 0) return;
|
||||||
|
|
||||||
console.log('[StoreContext] Setting active store from:', stores);
|
const householdStoreStorageKey = `activeHouseholdStoreId_${activeHousehold.id}`;
|
||||||
const storageKey = `activeStoreId_${activeHousehold.id}`;
|
const legacyLocationStorageKey = `activeStoreId_${activeHousehold.id}`;
|
||||||
const savedStoreId = localStorage.getItem(storageKey);
|
const savedHouseholdStoreId = localStorage.getItem(householdStoreStorageKey);
|
||||||
|
|
||||||
if (savedStoreId) {
|
if (savedHouseholdStoreId) {
|
||||||
const store = stores.find(s => String(s.id) === String(savedStoreId));
|
const store =
|
||||||
|
chooseLocationByStoredIds(stores, activeHousehold.id, savedHouseholdStoreId) ||
|
||||||
|
chooseStoreByHouseholdStoreId(stores, savedHouseholdStoreId);
|
||||||
if (store) {
|
if (store) {
|
||||||
console.log('[StoreContext] Found saved store:', store);
|
|
||||||
setActiveStoreState(store);
|
setActiveStoreState(store);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No saved store or not found, use default or first one
|
const savedLocationId = localStorage.getItem(legacyLocationStorageKey);
|
||||||
const defaultStore = stores.find(s => s.is_default) || stores[0];
|
if (savedLocationId) {
|
||||||
console.log('[StoreContext] Using store:', defaultStore);
|
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);
|
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]);
|
}, [stores, activeHousehold]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -76,9 +142,7 @@ export const StoreProvider = ({ children }) => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
console.log('[StoreContext] Loading stores for household:', activeHousehold.id);
|
|
||||||
const response = await getHouseholdStores(activeHousehold.id);
|
const response = await getHouseholdStores(activeHousehold.id);
|
||||||
console.log('[StoreContext] Loaded stores:', response.data);
|
|
||||||
setStores(response.data);
|
setStores(response.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[StoreContext] Failed to load stores:', err);
|
console.error('[StoreContext] Failed to load stores:', err);
|
||||||
@ -99,8 +163,13 @@ export const StoreProvider = ({ children }) => {
|
|||||||
const setActiveStore = (store) => {
|
const setActiveStore = (store) => {
|
||||||
setActiveStoreState(store);
|
setActiveStoreState(store);
|
||||||
if (store && activeHousehold) {
|
if (store && activeHousehold) {
|
||||||
const storageKey = `activeStoreId_${activeHousehold.id}`;
|
const storageKey = `activeHouseholdStoreId_${activeHousehold.id}`;
|
||||||
localStorage.setItem(storageKey, String(store.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 {
|
.confirm-buy-nav-btn {
|
||||||
width: 35px;
|
width: 40px;
|
||||||
height: 35px;
|
height: 40px;
|
||||||
border: var(--border-width-medium) solid var(--color-primary);
|
border: var(--border-width-medium) solid var(--color-primary);
|
||||||
border-radius: var(--border-radius-full);
|
border-radius: var(--border-radius-full);
|
||||||
background: var(--color-bg-surface);
|
background: var(--color-bg-surface);
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
font-size: 1.8em;
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition-base);
|
transition: var(--transition-base);
|
||||||
@ -70,6 +69,18 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.confirm-buy-nav-icon {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: block;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2.4;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.confirm-buy-nav-btn:hover:not(:disabled) {
|
.confirm-buy-nav-btn:hover:not(:disabled) {
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: var(--color-text-inverse);
|
color: var(--color-text-inverse);
|
||||||
@ -100,8 +111,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-image-placeholder {
|
.confirm-buy-image-placeholder {
|
||||||
font-size: 4em;
|
width: min(58%, 150px);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: var(--border-width-medium) solid var(--color-border-light);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
background: var(--color-gray-100);
|
||||||
color: var(--color-border-medium);
|
color: var(--color-border-medium);
|
||||||
|
font-size: 4.75em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-buy-image-placeholder span {
|
||||||
|
transform: translateY(0.02em);
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-quantity-section {
|
.confirm-buy-quantity-section {
|
||||||
@ -260,9 +284,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-nav-btn {
|
.confirm-buy-nav-btn {
|
||||||
width: 30px;
|
width: 40px;
|
||||||
height: 30px;
|
height: 40px;
|
||||||
font-size: 1.6em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-counter-btn {
|
.confirm-buy-counter-btn {
|
||||||
@ -299,8 +322,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.confirm-buy-nav-btn {
|
.confirm-buy-nav-btn {
|
||||||
width: 28px;
|
width: 40px;
|
||||||
height: 28px;
|
height: 40px;
|
||||||
font-size: 1.4em;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,78 +1,170 @@
|
|||||||
.store-tabs {
|
.store-tabs {
|
||||||
background: var(--surface-color);
|
background: transparent;
|
||||||
border-bottom: 2px solid var(--border-color);
|
border-bottom: none;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-tabs-container {
|
.store-tabs-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
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;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.5rem 0 0;
|
||||||
background: transparent;
|
width: 100%;
|
||||||
border: none;
|
}
|
||||||
border-bottom: 3px solid transparent;
|
|
||||||
color: var(--text-secondary);
|
.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-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;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
white-space: nowrap;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-tab:hover {
|
.store-map-button:hover,
|
||||||
color: var(--text-color);
|
.store-map-button:focus-visible {
|
||||||
background: var(--hover-color);
|
border-color: var(--color-primary);
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-tab.active {
|
.store-map-button:disabled {
|
||||||
color: var(--primary-color);
|
|
||||||
border-bottom-color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-tab:disabled {
|
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-name {
|
.store-name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.default-badge {
|
.store-selector-caret {
|
||||||
padding: 0.125rem 0.5rem;
|
width: 0.55rem;
|
||||||
background: var(--primary-color-light);
|
height: 0.55rem;
|
||||||
color: var(--primary-color);
|
flex: 0 0 auto;
|
||||||
font-size: 0.75rem;
|
border-right: 2px solid currentColor;
|
||||||
font-weight: 600;
|
border-bottom: 2px solid currentColor;
|
||||||
border-radius: 12px;
|
transform: rotate(45deg) translateY(-2px);
|
||||||
text-transform: uppercase;
|
opacity: 0.8;
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-tabs-empty {
|
.store-tabs-empty {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-style: italic;
|
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 {
|
.manage-section-header h2 {
|
||||||
margin: 0.15rem 0 0;
|
margin: 0;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
color: var(--text-primary);
|
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 {
|
.section-description {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
@ -131,7 +122,7 @@ body.dark-mode .edit-name-form input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.manage-household-join-policy-toggle {
|
.manage-household-join-policy-toggle {
|
||||||
margin-bottom: 0.2rem;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pending-requests-summary {
|
.pending-requests-summary {
|
||||||
@ -243,15 +234,16 @@ body.dark-mode .pending-request-card {
|
|||||||
|
|
||||||
.invite-controls {
|
.invite-controls {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(140px, 180px)) auto;
|
grid-template-columns: 1fr;
|
||||||
gap: 0.8rem;
|
gap: 0.45rem;
|
||||||
align-items: end;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invite-controls label {
|
.invite-controls label {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: 4.5rem minmax(0, 1fr);
|
||||||
gap: 0.35rem;
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
@ -265,14 +257,19 @@ body.dark-mode .pending-request-card {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.invite-controls select {
|
.invite-controls select {
|
||||||
min-width: 120px;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--border-radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
padding: 0.7rem 0.75rem;
|
padding: 0.62rem 0.75rem;
|
||||||
background: rgba(255, 255, 255, 1);
|
background: rgba(255, 255, 255, 1);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invite-controls .btn-primary {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .invite-controls select,
|
[data-theme="dark"] .invite-controls select,
|
||||||
body.dark-mode .invite-controls select {
|
body.dark-mode .invite-controls select {
|
||||||
background: rgba(12, 19, 30, 0.92);
|
background: rgba(12, 19, 30, 0.92);
|
||||||
@ -377,20 +374,30 @@ body.dark-mode .invite-status-badge.is-used {
|
|||||||
.members-list {
|
.members-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(270px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(270px, 1fr));
|
||||||
gap: 0.85rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-card {
|
.member-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.7rem;
|
gap: 0.3rem;
|
||||||
padding: 0.85rem 1rem;
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.55rem 0.8rem;
|
||||||
background: rgba(255, 255, 255, 1);
|
background: rgba(255, 255, 255, 1);
|
||||||
border: 1px solid var(--border);
|
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;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.member-card-button {
|
||||||
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .member-card,
|
[data-theme="dark"] .member-card,
|
||||||
body.dark-mode .member-card {
|
body.dark-mode .member-card {
|
||||||
background: rgba(12, 19, 30, 0.9);
|
background: rgba(12, 19, 30, 0.9);
|
||||||
@ -408,6 +415,11 @@ body.dark-mode .member-card:hover {
|
|||||||
background: rgba(20, 32, 48, 0.98);
|
background: rgba(20, 32, 48, 0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.member-card-button:focus-visible {
|
||||||
|
outline: 2px solid var(--primary);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.member-main {
|
.member-main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@ -415,7 +427,7 @@ body.dark-mode .member-card:hover {
|
|||||||
.member-info {
|
.member-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.35rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@ -427,15 +439,15 @@ body.dark-mode .member-card:hover {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 1rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-role {
|
.member-role {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.28rem;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
font-size: 0.88rem;
|
font-size: 0.82rem;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
@ -466,16 +478,6 @@ body.dark-mode .member-card:hover {
|
|||||||
font-weight: 700;
|
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 {
|
.member-owner-action {
|
||||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
|
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;
|
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 */
|
||||||
.danger-zone {
|
.danger-zone {
|
||||||
border-color: color-mix(in srgb, var(--danger) 30%, transparent);
|
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);
|
border-color: color-mix(in srgb, var(--danger) 42%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger-zone h2,
|
.danger-zone h2 {
|
||||||
.danger-zone .manage-section-eyebrow {
|
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -559,16 +555,88 @@ body.dark-mode .danger-zone {
|
|||||||
cursor: not-allowed;
|
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 */
|
/* Responsive */
|
||||||
@media (max-width: 900px) {
|
@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 {
|
.invite-link-card {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@ -616,12 +684,6 @@ body.dark-mode .danger-zone {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-actions {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-actions button,
|
|
||||||
.pending-request-actions button {
|
.pending-request-actions button {
|
||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,14 @@
|
|||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
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;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,11 +58,11 @@
|
|||||||
background: var(--background);
|
background: var(--background);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1.25rem;
|
padding: 1rem;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-card:hover {
|
.store-card:hover {
|
||||||
@ -62,16 +70,23 @@
|
|||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
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 {
|
.store-info h3 {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-location {
|
.store-location {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.85rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,44 +102,65 @@
|
|||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-location-list {
|
.store-location-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-location-row {
|
.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;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 0.75rem;
|
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;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-location-panel input,
|
.store-items-modal-toolbar.store-location-create-row {
|
||||||
.add-store-panel input,
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||||
.store-zone-create-row input {
|
}
|
||||||
|
|
||||||
|
.add-store-inline input,
|
||||||
|
.store-management-create-row input,
|
||||||
|
.store-settings-form input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
@ -134,99 +170,128 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-zones-panel {
|
.store-management-row {
|
||||||
display: flex;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-zones-content {
|
.store-management-row-with-order {
|
||||||
display: flex;
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--background);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-zone-list {
|
.store-management-row-with-order.is-delete-selectable {
|
||||||
display: flex;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-zone-row {
|
.store-management-row-with-drag {
|
||||||
display: grid;
|
grid-template-columns: auto auto minmax(0, 1fr);
|
||||||
grid-template-columns: 2rem minmax(0, 1fr) auto;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-zone-order {
|
.store-management-order {
|
||||||
|
width: 2rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-zone-name {
|
.store-management-name {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
color: var(--text-primary);
|
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;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Add Store Panel */
|
.add-store-inline {
|
||||||
.add-store-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-stores {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 1rem;
|
gap: 0.5rem;
|
||||||
}
|
|
||||||
|
|
||||||
.available-store-card {
|
|
||||||
background: var(--background);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1.25rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.available-store-card:hover {
|
.add-store-inline .btn-primary {
|
||||||
border-color: var(--primary);
|
min-width: 4.5rem;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Empty State */
|
/* Empty State */
|
||||||
@ -239,44 +304,49 @@
|
|||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.stores-list,
|
.stores-list {
|
||||||
.available-stores {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-actions {
|
.store-card-header,
|
||||||
width: 100%;
|
.store-location-controls,
|
||||||
}
|
.manage-stores-topline,
|
||||||
|
.add-store-inline,
|
||||||
.store-actions button {
|
.store-items-modal-toolbar.store-management-create-row,
|
||||||
flex: 1;
|
.store-items-modal-toolbar.store-location-create-row,
|
||||||
}
|
.store-management-row {
|
||||||
|
|
||||||
.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 {
|
|
||||||
grid-template-columns: 1fr;
|
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;
|
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;
|
justify-content: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-zone-actions button {
|
.store-settings-actions button {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,11 +60,35 @@
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
background: var(--modal-bg);
|
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 {
|
.store-available-items-search {
|
||||||
|
flex: 1 1 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
padding: var(--input-padding-y) var(--input-padding-x);
|
padding: var(--input-padding-y) var(--input-padding-x);
|
||||||
border: var(--border-width-thin) solid var(--input-border-color);
|
border: var(--border-width-thin) solid var(--input-border-color);
|
||||||
border-radius: var(--input-border-radius);
|
border-radius: var(--input-border-radius);
|
||||||
@ -92,24 +116,20 @@
|
|||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-items-table-head,
|
|
||||||
.store-items-table-row {
|
.store-items-table-row {
|
||||||
display: grid;
|
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);
|
gap: var(--spacing-md);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-items-table-head {
|
.store-items-table-row-button {
|
||||||
position: sticky;
|
width: 100%;
|
||||||
top: 0;
|
appearance: none;
|
||||||
padding: 0 var(--spacing-sm) var(--spacing-xs);
|
color: inherit;
|
||||||
background: var(--modal-bg);
|
font: inherit;
|
||||||
color: var(--color-text-secondary);
|
text-align: left;
|
||||||
font-size: var(--font-size-xs);
|
cursor: pointer;
|
||||||
font-weight: var(--font-weight-semibold);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-items-table-body {
|
.store-items-table-body {
|
||||||
@ -125,6 +145,22 @@
|
|||||||
background: var(--color-bg-surface);
|
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 {
|
.store-items-table-cell {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@ -153,8 +189,11 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: var(--color-text-secondary);
|
border: var(--border-width-medium) solid var(--color-border-light);
|
||||||
font-weight: var(--font-weight-semibold);
|
background: var(--color-gray-100);
|
||||||
|
color: var(--color-border-medium);
|
||||||
|
font-size: 1.75rem;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-available-items-copy {
|
.store-available-items-copy {
|
||||||
@ -171,24 +210,27 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-items-defaults-text {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-items-table-actions {
|
.store-items-table-actions {
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-available-items-actions {
|
.store-items-delete-indicator {
|
||||||
display: flex;
|
width: 28px;
|
||||||
gap: var(--spacing-xs);
|
height: 28px;
|
||||||
flex-wrap: wrap;
|
display: inline-flex;
|
||||||
justify-content: flex-end;
|
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 {
|
.store-items-table-row-button.is-selected .store-items-delete-indicator {
|
||||||
display: none;
|
border-color: var(--color-danger);
|
||||||
|
background: var(--color-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
@ -197,37 +239,21 @@
|
|||||||
padding: var(--spacing-md);
|
padding: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-items-table-head {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-items-table-row {
|
.store-items-table-row {
|
||||||
display: flex;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-items-mobile-label {
|
.store-items-table-row-button.is-delete-selectable {
|
||||||
display: block;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
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-actions {
|
.store-items-bulk-toolbar {
|
||||||
justify-self: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-available-items-actions {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-available-items-actions button {
|
.store-items-bulk-toolbar button {
|
||||||
flex: 1 1 0;
|
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({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
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 request = route.request();
|
||||||
const url = new URL(request.url());
|
const url = new URL(request.url());
|
||||||
const query = (url.searchParams.get("query") || "").toLowerCase();
|
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 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") {
|
if (route.request().method() === "PATCH") {
|
||||||
availableItems = availableItems.map((item) =>
|
availableItems = availableItems.map((item) =>
|
||||||
item.item_id === 777
|
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 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") {
|
if (route.request().method() === "DELETE") {
|
||||||
availableItems = availableItems.filter((item) => item.item_id !== 501);
|
availableItems = availableItems.filter((item) => item.item_id !== 501);
|
||||||
await route.fulfill({
|
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 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" });
|
const storeCard = page.locator(".store-card").filter({ hasText: "Costco" });
|
||||||
await expect(storeCard).toBeVisible();
|
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");
|
const managerModal = page.locator(".store-items-modal");
|
||||||
await expect(managerModal).toBeVisible();
|
await expect(managerModal).toBeVisible();
|
||||||
await expect(managerModal.getByText("milk", { exact: true })).toBeVisible();
|
await expect(managerModal.getByText("milk", { exact: true })).toBeVisible();
|
||||||
await expect(managerModal.getByText("apples", { 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");
|
const editorModal = page.locator(".available-item-editor-modal");
|
||||||
await expect(editorModal).toBeVisible();
|
await expect(editorModal).toBeVisible();
|
||||||
await expect(editorModal.getByLabel("Item Name")).toBeDisabled();
|
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 editorModal.getByRole("button", { name: "Save Changes" }).click();
|
||||||
|
|
||||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated store item");
|
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");
|
const confirmModal = page.locator(".confirm-slide-modal");
|
||||||
await expect(confirmModal).toBeVisible();
|
await expect(confirmModal).toBeVisible();
|
||||||
await expect(confirmModal.getByText("Delete milk?")).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);
|
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 }) => {
|
test("grocery page remains unchanged and does not show a store items picker", async ({ page }) => {
|
||||||
await seedAuthStorage(page);
|
await seedAuthStorage(page);
|
||||||
await mockConfig(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({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
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({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
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({
|
await route.fulfill({
|
||||||
status: 404,
|
status: 404,
|
||||||
contentType: "application/json",
|
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({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
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({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
@ -72,7 +84,7 @@ async function setupBuyModalRoutes(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/suggestions**", async (route) => {
|
await page.route("**/households/1/locations/10/list/suggestions**", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
@ -80,7 +92,7 @@ async function setupBuyModalRoutes(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/classification**", async (route) => {
|
await page.route("**/households/1/locations/10/list/classification**", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
@ -88,7 +100,7 @@ async function setupBuyModalRoutes(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/item**", async (route) => {
|
await page.route("**/households/1/locations/10/list/item**", async (route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
|
|
||||||
if (request.method() === "PATCH") {
|
if (request.method() === "PATCH") {
|
||||||
@ -156,7 +168,7 @@ async function setupBuyModalRoutes(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list", async (route) => {
|
await page.route("**/households/1/locations/10/list", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
@ -183,12 +195,11 @@ test("buying an item advances to the next one in the current sort order", async
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.locator(".glist-sort").selectOption("qty-high");
|
|
||||||
|
|
||||||
await openBuyModal(page, "bread");
|
await openBuyModal(page, "bread");
|
||||||
await page.getByRole("button", { name: "Mark as Bought" }).click();
|
await page.getByRole("button", { name: "Mark as Bought" }).click();
|
||||||
|
|
||||||
await expect(page.locator(".confirm-buy-item-name")).toHaveText("apples");
|
await expect(page.locator(".confirm-buy-item-name")).toHaveText("milk");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("buying the last item in the current order wraps to the first remaining item", async ({ page }) => {
|
test("buying the last item in the current order wraps to the first remaining item", async ({ page }) => {
|
||||||
@ -201,7 +212,6 @@ test("buying the last item in the current order wraps to the first remaining ite
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.locator(".glist-sort").selectOption("az");
|
|
||||||
|
|
||||||
await openBuyModal(page, "milk");
|
await openBuyModal(page, "milk");
|
||||||
await page.getByRole("button", { name: "Mark as Bought" }).click();
|
await page.getByRole("button", { name: "Mark as Bought" }).click();
|
||||||
@ -219,7 +229,6 @@ test("partial buy keeps the item on the list and advances past it", async ({ pag
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.locator(".glist-sort").selectOption("qty-low");
|
|
||||||
|
|
||||||
await openBuyModal(page, "bravo");
|
await openBuyModal(page, "bravo");
|
||||||
await page.locator(".confirm-buy-counter-btn").nth(0).click();
|
await page.locator(".confirm-buy-counter-btn").nth(0).click();
|
||||||
|
|||||||
@ -16,8 +16,12 @@ type HouseholdSeed = {
|
|||||||
|
|
||||||
type StoreSeed = {
|
type StoreSeed = {
|
||||||
id?: number;
|
id?: number;
|
||||||
|
household_store_id?: number;
|
||||||
|
household_id?: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
|
location_name?: string;
|
||||||
|
display_name?: string;
|
||||||
is_default?: boolean;
|
is_default?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -57,9 +61,21 @@ export async function mockHouseholdAndStoreShell(
|
|||||||
invite_code: "ABCD1234",
|
invite_code: "ABCD1234",
|
||||||
...options.household,
|
...options.household,
|
||||||
};
|
};
|
||||||
const stores = options.stores || [
|
const stores = (options.stores || [
|
||||||
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
|
{ id: 10, household_store_id: 100, name: "Costco", location_name: "Warehouse", is_default: true },
|
||||||
];
|
]).map((store, index) => {
|
||||||
|
const locationName = store.location_name || store.location || "Default Location";
|
||||||
|
return {
|
||||||
|
household_id: household.id,
|
||||||
|
household_store_id: store.household_store_id ?? store.id ?? index + 1,
|
||||||
|
id: store.id ?? index + 10,
|
||||||
|
name: store.name || "Costco",
|
||||||
|
location_name: locationName,
|
||||||
|
display_name: store.display_name || `${store.name || "Costco"} - ${locationName}`,
|
||||||
|
is_default: store.is_default ?? index === 0,
|
||||||
|
...store,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
await page.route("**/households", async (route) => {
|
await page.route("**/households", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
@ -76,6 +92,14 @@ export async function mockHouseholdAndStoreShell(
|
|||||||
body: JSON.stringify(stores),
|
body: JSON.stringify(stores),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await page.route(`**/households/${household.id}/stores`, async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(stores),
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function confirmSlide(page: Page) {
|
export async function confirmSlide(page: Page) {
|
||||||
|
|||||||
@ -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("Approved join request");
|
||||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Pending Pal");
|
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();
|
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");
|
await page.goto("/manage?tab=household");
|
||||||
|
|
||||||
const memberCard = page.locator(".member-card").filter({ hasText: "remove-me" });
|
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.getByRole("heading", { name: "Remove remove-me?" })).toBeVisible();
|
||||||
await expect(page.getByText("Slide to confirm. They will lose access to this household.")).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({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
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 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 expect(page.getByText("Transfer ownership to nico-admin?")).toBeVisible();
|
||||||
await confirmSlide(page);
|
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 seedAuthStorage(page);
|
||||||
await mockConfig(page);
|
await mockConfig(page);
|
||||||
|
|
||||||
let linkedStoreIds = [10];
|
let stores = [
|
||||||
const allStores = [
|
{
|
||||||
{ id: 10, name: "Costco North", location: "North", is_default: true },
|
id: 10,
|
||||||
{ id: 11, name: "Costco South", location: "South", is_default: false },
|
household_store_id: 100,
|
||||||
|
name: "Costco",
|
||||||
|
location_name: "Default Location",
|
||||||
|
display_name: "Costco",
|
||||||
|
is_default: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
await page.route("**/households", async (route) => {
|
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();
|
const request = route.request();
|
||||||
if (request.method() === "GET") {
|
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({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(stores),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.method() === "POST") {
|
if (request.method() === "POST") {
|
||||||
const body = request.postDataJSON() as { storeId?: number };
|
const body = request.postDataJSON() as { name?: string };
|
||||||
if (body.storeId && !linkedStoreIds.includes(body.storeId)) {
|
const name = body.name || "New Store";
|
||||||
linkedStoreIds = [...linkedStoreIds, body.storeId];
|
stores = [
|
||||||
}
|
...stores,
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
household_store_id: 101,
|
||||||
|
name,
|
||||||
|
location_name: "Default Location",
|
||||||
|
display_name: name,
|
||||||
|
is_default: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 201,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({ ok: true }),
|
body: JSON.stringify({ store: stores[stores.length - 1] }),
|
||||||
});
|
});
|
||||||
return;
|
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({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify(allStores),
|
body: JSON.stringify({ zones: [] }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto("/manage?tab=stores");
|
await page.goto("/manage?tab=stores");
|
||||||
await page.getByRole("button", { name: "+ Add Store" }).click();
|
const addStoreForm = page.locator(".add-store-inline");
|
||||||
await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click();
|
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("Created store");
|
||||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Costco South");
|
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Stater Bros");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("manage stores add failure shows normalized error toast", async ({ page }) => {
|
test("manage stores add failure shows normalized error toast", async ({ page }) => {
|
||||||
await seedAuthStorage(page);
|
await seedAuthStorage(page);
|
||||||
await mockConfig(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 page.route("**/households", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
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();
|
const request = route.request();
|
||||||
if (request.method() === "GET") {
|
if (request.method() === "GET") {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@ -150,22 +162,23 @@ test("manage stores add failure shows normalized error toast", async ({ page })
|
|||||||
return;
|
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({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify(allStores),
|
body: JSON.stringify({ zones: [] }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto("/manage?tab=stores");
|
await page.goto("/manage?tab=stores");
|
||||||
await page.getByRole("button", { name: "+ Add Store" }).click();
|
const addStoreForm = page.locator(".add-store-inline");
|
||||||
await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click();
|
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");
|
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';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
|
|
||||||
const env = loadEnv('', process.cwd());
|
const env = loadEnv('', process.cwd());
|
||||||
const allowedHosts = env.VITE_ALLOWED_HOSTS
|
const configuredAllowedHosts = env.VITE_ALLOWED_HOSTS
|
||||||
? env.VITE_ALLOWED_HOSTS.split(',').map((host) => host.trim())
|
? env.VITE_ALLOWED_HOSTS.split(',').map((host) => host.trim())
|
||||||
: [];
|
: [];
|
||||||
|
const allowedHosts = Array.from(
|
||||||
|
new Set(['localhost', '127.0.0.1', '192.168.7.45', ...configuredAllowedHosts].filter(Boolean))
|
||||||
|
);
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
|||||||
66
packages/db/migrations/20260602_010000_location_maps.sql
Normal file
66
packages/db/migrations/20260602_010000_location_maps.sql
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
-- Location-specific store maps for the mobile map manager.
|
||||||
|
-- Maps are scoped to a household-owned physical store location.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS location_maps (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
|
||||||
|
store_location_id INTEGER NOT NULL REFERENCES store_locations(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(120) NOT NULL DEFAULT 'Store Map',
|
||||||
|
width INTEGER NOT NULL DEFAULT 1000 CHECK (width > 0),
|
||||||
|
height INTEGER NOT NULL DEFAULT 700 CHECK (height > 0),
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'draft'
|
||||||
|
CHECK (status IN ('draft', 'published', 'archived')),
|
||||||
|
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0),
|
||||||
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
published_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_location_maps_location_status
|
||||||
|
ON location_maps(household_id, store_location_id, status, updated_at DESC);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_location_maps_draft_location
|
||||||
|
ON location_maps(store_location_id)
|
||||||
|
WHERE status = 'draft';
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_location_maps_published_location
|
||||||
|
ON location_maps(store_location_id)
|
||||||
|
WHERE status = 'published';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS location_map_objects (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
location_map_id BIGINT NOT NULL REFERENCES location_maps(id) ON DELETE CASCADE,
|
||||||
|
zone_id INTEGER REFERENCES store_location_zones(id) ON DELETE SET NULL,
|
||||||
|
type VARCHAR(30) NOT NULL DEFAULT 'zone'
|
||||||
|
CHECK (type IN (
|
||||||
|
'zone',
|
||||||
|
'aisle',
|
||||||
|
'entrance',
|
||||||
|
'checkout',
|
||||||
|
'shelf',
|
||||||
|
'wall',
|
||||||
|
'department',
|
||||||
|
'anchor',
|
||||||
|
'other'
|
||||||
|
)),
|
||||||
|
label VARCHAR(160) NOT NULL DEFAULT '',
|
||||||
|
x NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
||||||
|
y NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
||||||
|
width NUMERIC(10, 2) NOT NULL DEFAULT 160 CHECK (width > 0),
|
||||||
|
height NUMERIC(10, 2) NOT NULL DEFAULT 100 CHECK (height > 0),
|
||||||
|
rotation NUMERIC(7, 2) NOT NULL DEFAULT 0,
|
||||||
|
locked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
visible BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_location_map_objects_map_order
|
||||||
|
ON location_map_objects(location_map_id, sort_order, id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_location_map_objects_zone
|
||||||
|
ON location_map_objects(zone_id);
|
||||||
Loading…
Reference in New Issue
Block a user