Compare commits
No commits in common. "bd945568c8c0050998ddeb86458c36d3d7e6fd6c" and "77ae5be4453b8886700103f48bcced2060f8240d" have entirely different histories.
bd945568c8
...
77ae5be445
@ -155,7 +155,6 @@ For `app/api/**/[param]/route.ts`:
|
|||||||
- Tap targets remain >= 40px on mobile.
|
- Tap targets remain >= 40px on mobile.
|
||||||
- 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.
|
|
||||||
- Progress-type notifications must reuse the existing upload toaster pattern (`UploadQueueContext` + `UploadToaster`) for consistency.
|
- Progress-type notifications must reuse the existing upload toaster pattern (`UploadQueueContext` + `UploadToaster`) for consistency.
|
||||||
- Add Playwright UI tests for new UI features and critical flows.
|
- Add Playwright UI tests for new UI features and critical flows.
|
||||||
|
|
||||||
|
|||||||
@ -1,319 +0,0 @@
|
|||||||
const AvailableItems = require("../models/available-item.model");
|
|
||||||
const List = require("../models/list.model.v2");
|
|
||||||
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
|
|
||||||
const { sendError } = require("../utils/http");
|
|
||||||
const { logError } = require("../utils/logger");
|
|
||||||
|
|
||||||
const LEGACY_ITEM_TYPE_MAP = {
|
|
||||||
beverages: "beverage",
|
|
||||||
snacks: "snack",
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseBoolean(value) {
|
|
||||||
return value === true || value === "true" || value === "1";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCatalogTableMissing(error) {
|
|
||||||
return error?.code === "42P01" && /(household_store_items|household_store_available_items)/i.test(error?.message || "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseClassificationInput(value) {
|
|
||||||
if (value === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === "string") {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmed === "null") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmed.startsWith("{")) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(trimmed);
|
|
||||||
} catch (error) {
|
|
||||||
return Symbol.for("invalid-classification-json");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeClassificationPayload(classification) {
|
|
||||||
if (typeof classification === "string") {
|
|
||||||
const normalizedItemType = LEGACY_ITEM_TYPE_MAP[classification] || classification;
|
|
||||||
return {
|
|
||||||
item_type: normalizedItemType,
|
|
||||||
item_group: null,
|
|
||||||
zone: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!classification || typeof classification !== "object" || Array.isArray(classification)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const item_type =
|
|
||||||
typeof classification.item_type === "string" && classification.item_type.trim() !== ""
|
|
||||||
? classification.item_type.trim()
|
|
||||||
: null;
|
|
||||||
const item_group =
|
|
||||||
typeof classification.item_group === "string" && classification.item_group.trim() !== ""
|
|
||||||
? classification.item_group.trim()
|
|
||||||
: null;
|
|
||||||
const zone =
|
|
||||||
typeof classification.zone === "string" && classification.zone.trim() !== ""
|
|
||||||
? classification.zone.trim()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!item_type && !item_group && !zone) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { item_type, item_group, zone };
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateClassification(res, classification) {
|
|
||||||
if (!classification) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { item_type, item_group, zone } = classification;
|
|
||||||
|
|
||||||
if (item_type && !isValidItemType(item_type)) {
|
|
||||||
sendError(res, 400, "Invalid item_type");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item_group && !item_type) {
|
|
||||||
sendError(res, 400, "Item type is required when item group is provided");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item_group && !isValidItemGroup(item_type, item_group)) {
|
|
||||||
sendError(res, 400, "Invalid item_group for selected item_type");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (zone && !isValidZone(zone)) {
|
|
||||||
sendError(res, 400, "Invalid zone");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseItemId(value) {
|
|
||||||
const parsed = Number.parseInt(String(value), 10);
|
|
||||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.getAvailableItems = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { householdId, storeId } = req.params;
|
|
||||||
const items = await AvailableItems.listAvailableItems(householdId, storeId, req.query.query || "");
|
|
||||||
res.json({ items, catalog_ready: true });
|
|
||||||
} catch (error) {
|
|
||||||
if (isCatalogTableMissing(error)) {
|
|
||||||
return res.json({
|
|
||||||
items: [],
|
|
||||||
catalog_ready: false,
|
|
||||||
message: "Store item management is unavailable until the latest database migration is applied.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
logError(req, "availableItems.getAvailableItems", error);
|
|
||||||
sendError(res, 500, "Failed to load available items");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.createAvailableItem = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { householdId, storeId } = req.params;
|
|
||||||
const { item_name } = req.body;
|
|
||||||
|
|
||||||
if (!item_name || item_name.trim() === "") {
|
|
||||||
return sendError(res, 400, "Item name is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedClassification = parseClassificationInput(req.body.classification);
|
|
||||||
if (parsedClassification === Symbol.for("invalid-classification-json")) {
|
|
||||||
return sendError(res, 400, "Classification payload must be valid JSON");
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedClassification = normalizeClassificationPayload(parsedClassification);
|
|
||||||
if (validateClassification(res, normalizedClassification)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageBuffer = req.processedImage?.buffer || null;
|
|
||||||
const mimeType = req.processedImage?.mimeType || null;
|
|
||||||
|
|
||||||
const item = await AvailableItems.createAvailableItem(
|
|
||||||
householdId,
|
|
||||||
storeId,
|
|
||||||
item_name,
|
|
||||||
imageBuffer,
|
|
||||||
mimeType
|
|
||||||
);
|
|
||||||
|
|
||||||
if (normalizedClassification) {
|
|
||||||
await List.upsertClassification(householdId, storeId, item.item_id, {
|
|
||||||
...normalizedClassification,
|
|
||||||
confidence: 1.0,
|
|
||||||
source: "user",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshedItem = await AvailableItems.getAvailableItemById(householdId, storeId, item.item_id);
|
|
||||||
|
|
||||||
res.status(201).json({
|
|
||||||
message: "Available item added",
|
|
||||||
item: refreshedItem,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (isCatalogTableMissing(error)) {
|
|
||||||
return sendError(
|
|
||||||
res,
|
|
||||||
503,
|
|
||||||
"Store item management is unavailable until the latest database migration is applied"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
logError(req, "availableItems.createAvailableItem", error);
|
|
||||||
if (error.code === "23505") {
|
|
||||||
return sendError(res, 400, "Available item already exists for this store");
|
|
||||||
}
|
|
||||||
sendError(res, 500, "Failed to add available item");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.updateAvailableItem = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { householdId, storeId, itemId: rawItemId } = req.params;
|
|
||||||
const itemId = parseItemId(rawItemId);
|
|
||||||
|
|
||||||
if (!itemId) {
|
|
||||||
return sendError(res, 400, "Item ID must be a positive integer");
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasClassificationField = Object.prototype.hasOwnProperty.call(req.body, "classification");
|
|
||||||
const parsedClassification = parseClassificationInput(req.body.classification);
|
|
||||||
|
|
||||||
if (parsedClassification === Symbol.for("invalid-classification-json")) {
|
|
||||||
return sendError(res, 400, "Classification payload must be valid JSON");
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedClassification = normalizeClassificationPayload(parsedClassification);
|
|
||||||
if (normalizedClassification && validateClassification(res, normalizedClassification)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedItem = await AvailableItems.updateAvailableItem(householdId, storeId, itemId, {
|
|
||||||
itemName: req.body.item_name,
|
|
||||||
imageBuffer: req.processedImage?.buffer || null,
|
|
||||||
mimeType: req.processedImage?.mimeType || null,
|
|
||||||
removeImage: parseBoolean(req.body.remove_image),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!updatedItem) {
|
|
||||||
return sendError(res, 404, "Available item not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasClassificationField) {
|
|
||||||
if (normalizedClassification) {
|
|
||||||
await List.upsertClassification(householdId, storeId, updatedItem.item_id, {
|
|
||||||
...normalizedClassification,
|
|
||||||
confidence: 1.0,
|
|
||||||
source: "user",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await List.deleteClassification(householdId, storeId, updatedItem.item_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshedItem = await AvailableItems.getAvailableItemById(
|
|
||||||
householdId,
|
|
||||||
storeId,
|
|
||||||
updatedItem.item_id
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
message: "Available item updated",
|
|
||||||
item: refreshedItem,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (isCatalogTableMissing(error)) {
|
|
||||||
return sendError(
|
|
||||||
res,
|
|
||||||
503,
|
|
||||||
"Store item management is unavailable until the latest database migration is applied"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
logError(req, "availableItems.updateAvailableItem", error);
|
|
||||||
if (error.code === "23505") {
|
|
||||||
return sendError(res, 400, "Available item already exists for this store");
|
|
||||||
}
|
|
||||||
sendError(res, 500, "Failed to update available item");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.deleteAvailableItem = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { householdId, storeId, itemId: rawItemId } = req.params;
|
|
||||||
const itemId = parseItemId(rawItemId);
|
|
||||||
|
|
||||||
if (!itemId) {
|
|
||||||
return sendError(res, 400, "Item ID must be a positive integer");
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleted = await AvailableItems.deleteAvailableItem(householdId, storeId, itemId);
|
|
||||||
|
|
||||||
if (!deleted) {
|
|
||||||
return sendError(res, 404, "Store item not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ message: "Store item deleted" });
|
|
||||||
} catch (error) {
|
|
||||||
if (isCatalogTableMissing(error)) {
|
|
||||||
return sendError(
|
|
||||||
res,
|
|
||||||
503,
|
|
||||||
"Store item management is unavailable until the latest database migration is applied"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
logError(req, "availableItems.deleteAvailableItem", error);
|
|
||||||
sendError(res, 500, "Failed to delete store item");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.importCurrentItems = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { householdId, storeId } = req.params;
|
|
||||||
const importedCount = await AvailableItems.importCurrentListItems(householdId, storeId);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
message: importedCount > 0 ? "Imported current list items" : "No current list items to import",
|
|
||||||
imported_count: importedCount,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (isCatalogTableMissing(error)) {
|
|
||||||
return sendError(
|
|
||||||
res,
|
|
||||||
503,
|
|
||||||
"Store item management is unavailable until the latest database migration is applied"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
logError(req, "availableItems.importCurrentItems", error);
|
|
||||||
sendError(res, 500, "Failed to import current list items");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -4,45 +4,6 @@ const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants
|
|||||||
const { sendError } = require("../utils/http");
|
const { sendError } = require("../utils/http");
|
||||||
const { logError } = require("../utils/logger");
|
const { logError } = require("../utils/logger");
|
||||||
|
|
||||||
const LEGACY_ITEM_TYPE_MAP = {
|
|
||||||
beverages: "beverage",
|
|
||||||
snacks: "snack",
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeClassificationPayload(classification) {
|
|
||||||
if (typeof classification === "string") {
|
|
||||||
const normalizedItemType = LEGACY_ITEM_TYPE_MAP[classification] || classification;
|
|
||||||
return {
|
|
||||||
item_type: normalizedItemType,
|
|
||||||
item_group: null,
|
|
||||||
zone: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!classification || typeof classification !== "object" || Array.isArray(classification)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const item_type =
|
|
||||||
typeof classification.item_type === "string" && classification.item_type.trim() !== ""
|
|
||||||
? classification.item_type.trim()
|
|
||||||
: null;
|
|
||||||
const item_group =
|
|
||||||
typeof classification.item_group === "string" && classification.item_group.trim() !== ""
|
|
||||||
? classification.item_group.trim()
|
|
||||||
: null;
|
|
||||||
const zone =
|
|
||||||
typeof classification.zone === "string" && classification.zone.trim() !== ""
|
|
||||||
? classification.zone.trim()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!item_type && !item_group && !zone) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { item_type, item_group, zone };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get list items for household and store
|
* Get list items for household and store
|
||||||
* GET /households/:householdId/stores/:storeId/list
|
* GET /households/:householdId/stores/:storeId/list
|
||||||
@ -134,7 +95,7 @@ exports.addItem = async (req, res) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Add history record
|
// Add history record
|
||||||
await List.addHistoryRecord(result.listId, result.householdStoreItemId, quantity || "1", historyUserId);
|
await List.addHistoryRecord(result.listId, quantity || "1", historyUserId);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: result.isNew ? "Item added" : "Item updated",
|
message: result.isNew ? "Item added" : "Item updated",
|
||||||
@ -292,7 +253,7 @@ exports.getClassification = async (req, res) => {
|
|||||||
return res.json({ classification: null });
|
return res.json({ classification: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
const classification = await List.getClassification(householdId, storeId, item.item_id);
|
const classification = await List.getClassification(householdId, item.item_id);
|
||||||
res.json({ classification });
|
res.json({ classification });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(req, "listsV2.getClassification", error);
|
logError(req, "listsV2.getClassification", error);
|
||||||
@ -313,27 +274,14 @@ exports.setClassification = async (req, res) => {
|
|||||||
return sendError(res, 400, "Item name is required");
|
return sendError(res, 400, "Item name is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedClassification = normalizeClassificationPayload(classification);
|
if (!classification) {
|
||||||
if (!normalizedClassification) {
|
|
||||||
return sendError(res, 400, "Classification is required");
|
return sendError(res, 400, "Classification is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { item_type, item_group, zone } = normalizedClassification;
|
// Validate classification
|
||||||
|
const validClassifications = ['produce', 'dairy', 'meat', 'bakery', 'frozen', 'pantry', 'snacks', 'beverages', 'household', 'other'];
|
||||||
if (item_type && !isValidItemType(item_type)) {
|
if (!validClassifications.includes(classification)) {
|
||||||
return sendError(res, 400, "Invalid item_type");
|
return sendError(res, 400, "Invalid classification value");
|
||||||
}
|
|
||||||
|
|
||||||
if (item_group && !item_type) {
|
|
||||||
return sendError(res, 400, "Item type is required when item group is provided");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item_group && !isValidItemGroup(item_type, item_group)) {
|
|
||||||
return sendError(res, 400, "Invalid item_group for selected item_type");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (zone && !isValidZone(zone)) {
|
|
||||||
return sendError(res, 400, "Invalid zone");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get item - add to master items if not exists
|
// Get item - add to master items if not exists
|
||||||
@ -342,25 +290,28 @@ exports.setClassification = async (req, res) => {
|
|||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
// Item doesn't exist in list, need to get from items table or create
|
// Item doesn't exist in list, need to get from items table or create
|
||||||
const itemResult = await List.ensureHouseholdStoreItem(
|
const itemResult = await List.addOrUpdateItem(
|
||||||
householdId,
|
householdId,
|
||||||
storeId,
|
storeId,
|
||||||
item_name
|
item_name,
|
||||||
|
"1",
|
||||||
|
req.user.id,
|
||||||
|
null,
|
||||||
|
null
|
||||||
);
|
);
|
||||||
itemId = itemResult.id;
|
itemId = itemResult.itemId;
|
||||||
} else {
|
} else {
|
||||||
itemId = item.item_id;
|
itemId = item.item_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
await List.upsertClassification(householdId, storeId, itemId, {
|
// Set classification (using item_type field for simplicity)
|
||||||
item_type,
|
await List.upsertClassification(householdId, itemId, {
|
||||||
item_group,
|
item_type: classification,
|
||||||
zone,
|
item_group: null,
|
||||||
confidence: 1.0,
|
zone: null
|
||||||
source: "user",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ message: "Classification set", classification: normalizedClassification });
|
res.json({ message: "Classification set", classification });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(req, "listsV2.setClassification", error);
|
logError(req, "listsV2.setClassification", error);
|
||||||
sendError(res, 500, "Failed to set classification");
|
sendError(res, 500, "Failed to set classification");
|
||||||
|
|||||||
@ -1,273 +0,0 @@
|
|||||||
const pool = require("../db/pool");
|
|
||||||
|
|
||||||
function normalizeItemName(itemName) {
|
|
||||||
return String(itemName || "").trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getHouseholdStoreItemRecord(householdId, storeId, itemId) {
|
|
||||||
const result = await pool.query(
|
|
||||||
`WITH latest_list_items AS (
|
|
||||||
SELECT DISTINCT ON (hl.household_store_item_id)
|
|
||||||
hl.household_store_item_id,
|
|
||||||
hl.custom_image,
|
|
||||||
hl.custom_image_mime_type,
|
|
||||||
hl.modified_on,
|
|
||||||
hl.id
|
|
||||||
FROM household_lists hl
|
|
||||||
WHERE hl.household_id = $1
|
|
||||||
AND hl.store_id = $2
|
|
||||||
ORDER BY hl.household_store_item_id, hl.modified_on DESC NULLS LAST, hl.id DESC
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
hsi.id AS item_id,
|
|
||||||
hsi.name AS item_name,
|
|
||||||
ENCODE(COALESCE(hsi.custom_image, lli.custom_image), 'base64') AS item_image,
|
|
||||||
COALESCE(hsi.custom_image_mime_type, lli.custom_image_mime_type) AS image_mime_type,
|
|
||||||
hic.item_type,
|
|
||||||
hic.item_group,
|
|
||||||
hic.zone
|
|
||||||
FROM household_store_items hsi
|
|
||||||
LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id
|
|
||||||
LEFT JOIN household_item_classifications hic
|
|
||||||
ON hic.household_id = hsi.household_id
|
|
||||||
AND hic.store_id = hsi.store_id
|
|
||||||
AND hic.household_store_item_id = hsi.id
|
|
||||||
WHERE hsi.household_id = $1
|
|
||||||
AND hsi.store_id = $2
|
|
||||||
AND hsi.id = $3`,
|
|
||||||
[householdId, storeId, itemId]
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows[0] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findOrCreateHouseholdStoreItem(householdId, storeId, itemName) {
|
|
||||||
const normalizedName = normalizeItemName(itemName);
|
|
||||||
const existing = await pool.query(
|
|
||||||
`SELECT id, name
|
|
||||||
FROM household_store_items
|
|
||||||
WHERE household_id = $1
|
|
||||||
AND store_id = $2
|
|
||||||
AND normalized_name = $3`,
|
|
||||||
[householdId, storeId, normalizedName]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existing.rowCount > 0) {
|
|
||||||
return {
|
|
||||||
itemId: existing.rows[0].id,
|
|
||||||
itemName: existing.rows[0].name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = await pool.query(
|
|
||||||
`INSERT INTO household_store_items
|
|
||||||
(household_id, store_id, name, normalized_name, updated_at)
|
|
||||||
VALUES ($1, $2, $3, $4, NOW())
|
|
||||||
RETURNING id, name`,
|
|
||||||
[householdId, storeId, normalizedName, normalizedName]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
itemId: created.rows[0].id,
|
|
||||||
itemName: created.rows[0].name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.listAvailableItems = async (householdId, storeId, query = "") => {
|
|
||||||
const trimmedQuery = String(query || "").trim();
|
|
||||||
const values = [householdId, storeId];
|
|
||||||
let filterClause = "";
|
|
||||||
|
|
||||||
if (trimmedQuery) {
|
|
||||||
values.push(`%${trimmedQuery}%`);
|
|
||||||
filterClause = "AND hsi.name ILIKE $3";
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await pool.query(
|
|
||||||
`WITH latest_list_items AS (
|
|
||||||
SELECT DISTINCT ON (hl.household_store_item_id)
|
|
||||||
hl.household_store_item_id,
|
|
||||||
hl.custom_image,
|
|
||||||
hl.custom_image_mime_type,
|
|
||||||
hl.modified_on,
|
|
||||||
hl.id
|
|
||||||
FROM household_lists hl
|
|
||||||
WHERE hl.household_id = $1
|
|
||||||
AND hl.store_id = $2
|
|
||||||
ORDER BY hl.household_store_item_id, hl.modified_on DESC NULLS LAST, hl.id DESC
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
hsi.id AS item_id,
|
|
||||||
hsi.name AS item_name,
|
|
||||||
ENCODE(COALESCE(hsi.custom_image, lli.custom_image), 'base64') AS item_image,
|
|
||||||
COALESCE(hsi.custom_image_mime_type, lli.custom_image_mime_type) AS image_mime_type,
|
|
||||||
hic.item_type,
|
|
||||||
hic.item_group,
|
|
||||||
hic.zone,
|
|
||||||
(
|
|
||||||
hsi.custom_image IS NOT NULL
|
|
||||||
OR hic.household_store_item_id IS NOT NULL
|
|
||||||
) AS has_managed_settings
|
|
||||||
FROM household_store_items hsi
|
|
||||||
LEFT JOIN latest_list_items lli ON lli.household_store_item_id = hsi.id
|
|
||||||
LEFT JOIN household_item_classifications hic
|
|
||||||
ON hic.household_id = hsi.household_id
|
|
||||||
AND hic.store_id = hsi.store_id
|
|
||||||
AND hic.household_store_item_id = hsi.id
|
|
||||||
WHERE hsi.household_id = $1
|
|
||||||
AND hsi.store_id = $2
|
|
||||||
${filterClause}
|
|
||||||
ORDER BY hsi.name ASC
|
|
||||||
LIMIT 100`,
|
|
||||||
values
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.getAvailableItemById = async (householdId, storeId, itemId) =>
|
|
||||||
getHouseholdStoreItemRecord(householdId, storeId, itemId);
|
|
||||||
|
|
||||||
exports.getAvailableItemImageByName = async (householdId, storeId, itemName) => {
|
|
||||||
const normalizedName = normalizeItemName(itemName);
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT
|
|
||||||
id AS item_id,
|
|
||||||
name AS item_name,
|
|
||||||
custom_image,
|
|
||||||
custom_image_mime_type
|
|
||||||
FROM household_store_items
|
|
||||||
WHERE household_id = $1
|
|
||||||
AND store_id = $2
|
|
||||||
AND normalized_name = $3`,
|
|
||||||
[householdId, storeId, normalizedName]
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows[0] || null;
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.createAvailableItem = async (
|
|
||||||
householdId,
|
|
||||||
storeId,
|
|
||||||
itemName,
|
|
||||||
imageBuffer = null,
|
|
||||||
mimeType = null
|
|
||||||
) => {
|
|
||||||
const { itemId } = await findOrCreateHouseholdStoreItem(householdId, storeId, itemName);
|
|
||||||
|
|
||||||
if (imageBuffer && mimeType) {
|
|
||||||
await pool.query(
|
|
||||||
`UPDATE household_store_items
|
|
||||||
SET custom_image = $1,
|
|
||||||
custom_image_mime_type = $2,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $3
|
|
||||||
AND household_id = $4
|
|
||||||
AND store_id = $5`,
|
|
||||||
[imageBuffer, mimeType, itemId, householdId, storeId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return getHouseholdStoreItemRecord(householdId, storeId, itemId);
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.updateAvailableItem = async (householdId, storeId, itemId, updates = {}) => {
|
|
||||||
const {
|
|
||||||
itemName,
|
|
||||||
imageBuffer,
|
|
||||||
mimeType,
|
|
||||||
removeImage = false,
|
|
||||||
} = updates;
|
|
||||||
|
|
||||||
const assignments = ["updated_at = NOW()"];
|
|
||||||
const values = [householdId, storeId, itemId];
|
|
||||||
let parameterIndex = values.length;
|
|
||||||
|
|
||||||
if (itemName !== undefined && String(itemName).trim() !== "") {
|
|
||||||
const normalizedName = normalizeItemName(itemName);
|
|
||||||
parameterIndex += 1;
|
|
||||||
assignments.push(`name = $${parameterIndex}`);
|
|
||||||
values.push(normalizedName);
|
|
||||||
|
|
||||||
parameterIndex += 1;
|
|
||||||
assignments.push(`normalized_name = $${parameterIndex}`);
|
|
||||||
values.push(normalizedName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (removeImage) {
|
|
||||||
assignments.push("custom_image = NULL", "custom_image_mime_type = NULL");
|
|
||||||
} else if (imageBuffer && mimeType) {
|
|
||||||
parameterIndex += 1;
|
|
||||||
assignments.push(`custom_image = $${parameterIndex}`);
|
|
||||||
values.push(imageBuffer);
|
|
||||||
|
|
||||||
parameterIndex += 1;
|
|
||||||
assignments.push(`custom_image_mime_type = $${parameterIndex}`);
|
|
||||||
values.push(mimeType);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await pool.query(
|
|
||||||
`UPDATE household_store_items
|
|
||||||
SET ${assignments.join(", ")}
|
|
||||||
WHERE household_id = $1
|
|
||||||
AND store_id = $2
|
|
||||||
AND id = $3
|
|
||||||
RETURNING id`,
|
|
||||||
values
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.rowCount === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return getHouseholdStoreItemRecord(householdId, storeId, result.rows[0].id);
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.deleteAvailableItem = async (householdId, storeId, itemId) => {
|
|
||||||
const result = await pool.query(
|
|
||||||
`DELETE FROM household_store_items
|
|
||||||
WHERE household_id = $1
|
|
||||||
AND store_id = $2
|
|
||||||
AND id = $3`,
|
|
||||||
[householdId, storeId, itemId]
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rowCount > 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.importCurrentListItems = async (householdId, storeId) => {
|
|
||||||
const result = await pool.query(
|
|
||||||
`INSERT INTO household_store_items
|
|
||||||
(household_id, store_id, name, normalized_name, custom_image, custom_image_mime_type, updated_at)
|
|
||||||
SELECT DISTINCT ON (hl.household_store_item_id)
|
|
||||||
hl.household_id,
|
|
||||||
hl.store_id,
|
|
||||||
hsi.name,
|
|
||||||
hsi.normalized_name,
|
|
||||||
hsi.custom_image,
|
|
||||||
hsi.custom_image_mime_type,
|
|
||||||
NOW()
|
|
||||||
FROM household_lists hl
|
|
||||||
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
|
|
||||||
WHERE hl.household_id = $1
|
|
||||||
AND hl.store_id = $2
|
|
||||||
ON CONFLICT (household_id, store_id, normalized_name) DO NOTHING
|
|
||||||
RETURNING id`,
|
|
||||||
[householdId, storeId]
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rowCount;
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.hasAvailableItems = async (householdId, storeId) => {
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT 1
|
|
||||||
FROM household_store_items
|
|
||||||
WHERE household_id = $1
|
|
||||||
AND store_id = $2
|
|
||||||
LIMIT 1`,
|
|
||||||
[householdId, storeId]
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rowCount > 0;
|
|
||||||
};
|
|
||||||
@ -1,41 +1,5 @@
|
|||||||
const pool = require("../db/pool");
|
const pool = require("../db/pool");
|
||||||
|
|
||||||
function normalizeItemName(itemName) {
|
|
||||||
return String(itemName || "").trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getHouseholdStoreItemByNormalizedName(householdId, storeId, normalizedName) {
|
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT id, name, normalized_name, custom_image, custom_image_mime_type
|
|
||||||
FROM household_store_items
|
|
||||||
WHERE household_id = $1
|
|
||||||
AND store_id = $2
|
|
||||||
AND normalized_name = $3`,
|
|
||||||
[householdId, storeId, normalizedName]
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows[0] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
exports.ensureHouseholdStoreItem = async (householdId, storeId, itemName) => {
|
|
||||||
const normalizedName = normalizeItemName(itemName);
|
|
||||||
let item = await getHouseholdStoreItemByNormalizedName(householdId, storeId, normalizedName);
|
|
||||||
|
|
||||||
if (item) {
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await pool.query(
|
|
||||||
`INSERT INTO household_store_items
|
|
||||||
(household_id, store_id, name, normalized_name, updated_at)
|
|
||||||
VALUES ($1, $2, $3, $4, NOW())
|
|
||||||
RETURNING id, name, normalized_name, custom_image, custom_image_mime_type`,
|
|
||||||
[householdId, storeId, normalizedName, normalizedName]
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.rows[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get list items for a specific household and store
|
* Get list items for a specific household and store
|
||||||
* @param {number} householdId - Household ID
|
* @param {number} householdId - Household ID
|
||||||
@ -45,15 +9,13 @@ exports.ensureHouseholdStoreItem = async (householdId, storeId, itemName) => {
|
|||||||
*/
|
*/
|
||||||
exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = true) => {
|
exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = true) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT
|
`SELECT
|
||||||
hl.id,
|
hl.id,
|
||||||
hl.household_store_item_id AS item_id,
|
i.name AS item_name,
|
||||||
hl.household_store_item_id,
|
|
||||||
hsi.name AS item_name,
|
|
||||||
hl.quantity,
|
hl.quantity,
|
||||||
hl.bought,
|
hl.bought,
|
||||||
ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image,
|
ENCODE(hl.custom_image, 'base64') as item_image,
|
||||||
COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type,
|
hl.custom_image_mime_type as image_mime_type,
|
||||||
${includeHistory ? `
|
${includeHistory ? `
|
||||||
(
|
(
|
||||||
SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label)
|
SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label)
|
||||||
@ -64,20 +26,19 @@ exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = tr
|
|||||||
JOIN users u ON hlh.added_by = u.id
|
JOIN users u ON hlh.added_by = u.id
|
||||||
WHERE hlh.household_list_id = hl.id
|
WHERE hlh.household_list_id = hl.id
|
||||||
) added_by_labels
|
) added_by_labels
|
||||||
) AS added_by_users,
|
) as added_by_users,
|
||||||
` : "NULL AS added_by_users,"}
|
` : 'NULL as added_by_users,'}
|
||||||
hl.modified_on AS last_added_on,
|
hl.modified_on as last_added_on,
|
||||||
hic.item_type,
|
hic.item_type,
|
||||||
hic.item_group,
|
hic.item_group,
|
||||||
hic.zone
|
hic.zone
|
||||||
FROM household_lists hl
|
FROM household_lists hl
|
||||||
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
|
JOIN items i ON hl.item_id = i.id
|
||||||
LEFT JOIN household_item_classifications hic
|
LEFT JOIN household_item_classifications hic
|
||||||
ON hic.household_id = hl.household_id
|
ON hl.household_id = hic.household_id
|
||||||
AND hic.store_id = hl.store_id
|
AND hl.item_id = hic.item_id
|
||||||
AND hic.household_store_item_id = hl.household_store_item_id
|
WHERE hl.household_id = $1
|
||||||
WHERE hl.household_id = $1
|
AND hl.store_id = $2
|
||||||
AND hl.store_id = $2
|
|
||||||
AND hl.bought = FALSE
|
AND hl.bought = FALSE
|
||||||
ORDER BY hl.id ASC`,
|
ORDER BY hl.id ASC`,
|
||||||
[householdId, storeId]
|
[householdId, storeId]
|
||||||
@ -93,17 +54,25 @@ exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = tr
|
|||||||
* @returns {Promise<Object|null>} Item or null
|
* @returns {Promise<Object|null>} Item or null
|
||||||
*/
|
*/
|
||||||
exports.getItemByName = async (householdId, storeId, itemName) => {
|
exports.getItemByName = async (householdId, storeId, itemName) => {
|
||||||
const normalizedName = normalizeItemName(itemName);
|
// First check if item exists in master catalog
|
||||||
|
const itemResult = await pool.query(
|
||||||
|
"SELECT id FROM items WHERE name ILIKE $1",
|
||||||
|
[itemName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (itemResult.rowCount === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemId = itemResult.rows[0].id;
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT
|
`SELECT
|
||||||
hl.id,
|
hl.id,
|
||||||
hl.household_store_item_id AS item_id,
|
i.name AS item_name,
|
||||||
hl.household_store_item_id,
|
|
||||||
hsi.name AS item_name,
|
|
||||||
hl.quantity,
|
hl.quantity,
|
||||||
hl.bought,
|
hl.bought,
|
||||||
ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image,
|
ENCODE(hl.custom_image, 'base64') as item_image,
|
||||||
COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type,
|
hl.custom_image_mime_type as image_mime_type,
|
||||||
(
|
(
|
||||||
SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label)
|
SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label)
|
||||||
FROM (
|
FROM (
|
||||||
@ -113,28 +82,34 @@ exports.getItemByName = async (householdId, storeId, itemName) => {
|
|||||||
JOIN users u ON hlh.added_by = u.id
|
JOIN users u ON hlh.added_by = u.id
|
||||||
WHERE hlh.household_list_id = hl.id
|
WHERE hlh.household_list_id = hl.id
|
||||||
) added_by_labels
|
) added_by_labels
|
||||||
) AS added_by_users,
|
) as added_by_users,
|
||||||
hl.modified_on AS last_added_on,
|
hl.modified_on as last_added_on,
|
||||||
hic.item_type,
|
hic.item_type,
|
||||||
hic.item_group,
|
hic.item_group,
|
||||||
hic.zone
|
hic.zone
|
||||||
FROM household_lists hl
|
FROM household_lists hl
|
||||||
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
|
JOIN items i ON hl.item_id = i.id
|
||||||
LEFT JOIN household_item_classifications hic
|
LEFT JOIN household_item_classifications hic
|
||||||
ON hic.household_id = hl.household_id
|
ON hl.household_id = hic.household_id
|
||||||
AND hic.store_id = hl.store_id
|
AND hl.item_id = hic.item_id
|
||||||
AND hic.household_store_item_id = hl.household_store_item_id
|
WHERE hl.household_id = $1
|
||||||
WHERE hl.household_id = $1
|
AND hl.store_id = $2
|
||||||
AND hl.store_id = $2
|
AND hl.item_id = $3`,
|
||||||
AND hsi.normalized_name = $3`,
|
[householdId, storeId, itemId]
|
||||||
[householdId, storeId, normalizedName]
|
|
||||||
);
|
);
|
||||||
return result.rows[0] || null;
|
return result.rows[0] || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add or update an item in household list
|
* Add or update an item in household list
|
||||||
* @returns {Promise<{listId:number,itemId:number,householdStoreItemId:number,itemName:string,isNew:boolean}>}
|
* @param {number} householdId - Household ID
|
||||||
|
* @param {number} storeId - Store ID
|
||||||
|
* @param {string} itemName - Item name
|
||||||
|
* @param {number} quantity - Quantity
|
||||||
|
* @param {number} userId - User adding the item
|
||||||
|
* @param {Buffer|null} imageBuffer - Image buffer
|
||||||
|
* @param {string|null} mimeType - MIME type
|
||||||
|
* @returns {Promise<number>} List item ID
|
||||||
*/
|
*/
|
||||||
exports.addOrUpdateItem = async (
|
exports.addOrUpdateItem = async (
|
||||||
householdId,
|
householdId,
|
||||||
@ -145,22 +120,38 @@ exports.addOrUpdateItem = async (
|
|||||||
imageBuffer = null,
|
imageBuffer = null,
|
||||||
mimeType = null
|
mimeType = null
|
||||||
) => {
|
) => {
|
||||||
const householdStoreItem = await exports.ensureHouseholdStoreItem(householdId, storeId, itemName);
|
const lowerItemName = itemName.toLowerCase();
|
||||||
|
|
||||||
|
let itemResult = await pool.query(
|
||||||
|
"SELECT id FROM items WHERE name ILIKE $1",
|
||||||
|
[lowerItemName]
|
||||||
|
);
|
||||||
|
|
||||||
|
let itemId;
|
||||||
|
if (itemResult.rowCount === 0) {
|
||||||
|
const insertItem = await pool.query(
|
||||||
|
"INSERT INTO items (name) VALUES ($1) RETURNING id",
|
||||||
|
[lowerItemName]
|
||||||
|
);
|
||||||
|
itemId = insertItem.rows[0].id;
|
||||||
|
} else {
|
||||||
|
itemId = itemResult.rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
const listResult = await pool.query(
|
const listResult = await pool.query(
|
||||||
`SELECT id, bought
|
`SELECT id, bought FROM household_lists
|
||||||
FROM household_lists
|
WHERE household_id = $1
|
||||||
WHERE household_id = $1
|
AND store_id = $2
|
||||||
AND store_id = $2
|
AND item_id = $3`,
|
||||||
AND household_store_item_id = $3`,
|
[householdId, storeId, itemId]
|
||||||
[householdId, storeId, householdStoreItem.id]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (listResult.rowCount > 0) {
|
if (listResult.rowCount > 0) {
|
||||||
const listId = listResult.rows[0].id;
|
const listId = listResult.rows[0].id;
|
||||||
if (imageBuffer && mimeType) {
|
if (imageBuffer && mimeType) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE household_lists
|
`UPDATE household_lists
|
||||||
SET quantity = $1,
|
SET quantity = $1,
|
||||||
bought = FALSE,
|
bought = FALSE,
|
||||||
custom_image = $2,
|
custom_image = $2,
|
||||||
custom_image_mime_type = $3,
|
custom_image_mime_type = $3,
|
||||||
@ -170,43 +161,36 @@ exports.addOrUpdateItem = async (
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE household_lists
|
`UPDATE household_lists
|
||||||
SET quantity = $1,
|
SET quantity = $1,
|
||||||
bought = FALSE,
|
bought = FALSE,
|
||||||
modified_on = NOW()
|
modified_on = NOW()
|
||||||
WHERE id = $2`,
|
WHERE id = $2`,
|
||||||
[quantity, listId]
|
[quantity, listId]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return listId;
|
||||||
return {
|
} else {
|
||||||
listId,
|
const insert = await pool.query(
|
||||||
itemId: householdStoreItem.id,
|
`INSERT INTO household_lists
|
||||||
householdStoreItemId: householdStoreItem.id,
|
(household_id, store_id, item_id, quantity, custom_image, custom_image_mime_type)
|
||||||
itemName: householdStoreItem.name,
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
isNew: false,
|
RETURNING id`,
|
||||||
};
|
[householdId, storeId, itemId, quantity, imageBuffer, mimeType]
|
||||||
|
);
|
||||||
|
return insert.rows[0].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const insert = await pool.query(
|
|
||||||
`INSERT INTO household_lists
|
|
||||||
(household_id, store_id, household_store_item_id, quantity, custom_image, custom_image_mime_type, added_by)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
RETURNING id`,
|
|
||||||
[householdId, storeId, householdStoreItem.id, quantity, imageBuffer, mimeType, userId]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
listId: insert.rows[0].id,
|
|
||||||
itemId: householdStoreItem.id,
|
|
||||||
householdStoreItemId: householdStoreItem.id,
|
|
||||||
itemName: householdStoreItem.name,
|
|
||||||
isNew: true,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark item as bought (full or partial)
|
||||||
|
* @param {number} listId - List item ID
|
||||||
|
* @param {boolean} bought - True to mark as bought, false to unmark
|
||||||
|
* @param {number} quantityBought - Optional quantity bought (for partial purchases)
|
||||||
|
*/
|
||||||
exports.setBought = async (listId, bought, quantityBought = null) => {
|
exports.setBought = async (listId, bought, quantityBought = null) => {
|
||||||
if (bought === false) {
|
if (bought === false) {
|
||||||
|
// Unmarking - just set bought to false
|
||||||
await pool.query(
|
await pool.query(
|
||||||
"UPDATE household_lists SET bought = FALSE, modified_on = NOW() WHERE id = $1",
|
"UPDATE household_lists SET bought = FALSE, modified_on = NOW() WHERE id = $1",
|
||||||
[listId]
|
[listId]
|
||||||
@ -214,7 +198,9 @@ exports.setBought = async (listId, bought, quantityBought = null) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Marking as bought
|
||||||
if (quantityBought && quantityBought > 0) {
|
if (quantityBought && quantityBought > 0) {
|
||||||
|
// Partial purchase - reduce quantity
|
||||||
const item = await pool.query(
|
const item = await pool.query(
|
||||||
"SELECT quantity FROM household_lists WHERE id = $1",
|
"SELECT quantity FROM household_lists WHERE id = $1",
|
||||||
[listId]
|
[listId]
|
||||||
@ -226,17 +212,20 @@ exports.setBought = async (listId, bought, quantityBought = null) => {
|
|||||||
const remainingQuantity = currentQuantity - quantityBought;
|
const remainingQuantity = currentQuantity - quantityBought;
|
||||||
|
|
||||||
if (remainingQuantity <= 0) {
|
if (remainingQuantity <= 0) {
|
||||||
|
// All bought - mark as bought
|
||||||
await pool.query(
|
await pool.query(
|
||||||
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
|
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
|
||||||
[listId]
|
[listId]
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
// Partial - reduce quantity
|
||||||
await pool.query(
|
await pool.query(
|
||||||
"UPDATE household_lists SET quantity = $1, modified_on = NOW() WHERE id = $2",
|
"UPDATE household_lists SET quantity = $1, modified_on = NOW() WHERE id = $2",
|
||||||
[remainingQuantity, listId]
|
[remainingQuantity, listId]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Full purchase - mark as bought
|
||||||
await pool.query(
|
await pool.query(
|
||||||
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
|
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
|
||||||
[listId]
|
[listId]
|
||||||
@ -244,45 +233,61 @@ exports.setBought = async (listId, bought, quantityBought = null) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.addHistoryRecord = async (listId, householdStoreItemId, quantity, userId) => {
|
/**
|
||||||
|
* Add history record for item addition
|
||||||
|
* @param {number} listId - List item ID
|
||||||
|
* @param {number} quantity - Quantity added
|
||||||
|
* @param {number} userId - User who added
|
||||||
|
*/
|
||||||
|
exports.addHistoryRecord = async (listId, quantity, userId) => {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO household_list_history (household_list_id, household_store_item_id, quantity, added_by, added_on)
|
`INSERT INTO household_list_history (household_list_id, quantity, added_by, added_on)
|
||||||
VALUES ($1, $2, $3, $4, NOW())`,
|
VALUES ($1, $2, $3, NOW())`,
|
||||||
[listId, householdStoreItemId, quantity, userId]
|
[listId, quantity, userId]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get suggestions for autocomplete
|
||||||
|
* @param {string} query - Search query
|
||||||
|
* @param {number} householdId - Household ID (for personalized suggestions)
|
||||||
|
* @param {number} storeId - Store ID
|
||||||
|
* @returns {Promise<Array>} Suggestions
|
||||||
|
*/
|
||||||
exports.getSuggestions = async (query, householdId, storeId) => {
|
exports.getSuggestions = async (query, householdId, storeId) => {
|
||||||
|
// Get items from both master catalog and household history
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT DISTINCT
|
`SELECT DISTINCT
|
||||||
hsi.name AS item_name,
|
i.name as item_name,
|
||||||
CASE WHEN hl.id IS NOT NULL AND hl.bought = FALSE THEN 0 ELSE 1 END AS sort_order
|
CASE WHEN hl.id IS NOT NULL THEN 0 ELSE 1 END as sort_order
|
||||||
FROM household_store_items hsi
|
FROM items i
|
||||||
LEFT JOIN household_lists hl
|
LEFT JOIN household_lists hl
|
||||||
ON hl.household_store_item_id = hsi.id
|
ON i.id = hl.item_id
|
||||||
AND hl.household_id = $2
|
AND hl.household_id = $2
|
||||||
AND hl.store_id = $3
|
AND hl.store_id = $3
|
||||||
WHERE hsi.household_id = $2
|
WHERE i.name ILIKE $1
|
||||||
AND hsi.store_id = $3
|
ORDER BY sort_order, i.name
|
||||||
AND hsi.name ILIKE $1
|
|
||||||
ORDER BY sort_order, hsi.name
|
|
||||||
LIMIT 10`,
|
LIMIT 10`,
|
||||||
[`%${query}%`, householdId, storeId]
|
[`%${query}%`, householdId, storeId]
|
||||||
);
|
);
|
||||||
return result.rows;
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recently bought items for household/store
|
||||||
|
* @param {number} householdId - Household ID
|
||||||
|
* @param {number} storeId - Store ID
|
||||||
|
* @returns {Promise<Array>} Recently bought items
|
||||||
|
*/
|
||||||
exports.getRecentlyBoughtItems = async (householdId, storeId) => {
|
exports.getRecentlyBoughtItems = async (householdId, storeId) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT
|
`SELECT
|
||||||
hl.id,
|
hl.id,
|
||||||
hl.household_store_item_id AS item_id,
|
i.name AS item_name,
|
||||||
hl.household_store_item_id,
|
|
||||||
hsi.name AS item_name,
|
|
||||||
hl.quantity,
|
hl.quantity,
|
||||||
hl.bought,
|
hl.bought,
|
||||||
ENCODE(COALESCE(hl.custom_image, hsi.custom_image), 'base64') AS item_image,
|
ENCODE(hl.custom_image, 'base64') as item_image,
|
||||||
COALESCE(hl.custom_image_mime_type, hsi.custom_image_mime_type) AS image_mime_type,
|
hl.custom_image_mime_type as image_mime_type,
|
||||||
(
|
(
|
||||||
SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label)
|
SELECT ARRAY_AGG(added_by_labels.user_label ORDER BY added_by_labels.user_label)
|
||||||
FROM (
|
FROM (
|
||||||
@ -292,13 +297,13 @@ exports.getRecentlyBoughtItems = async (householdId, storeId) => {
|
|||||||
JOIN users u ON hlh.added_by = u.id
|
JOIN users u ON hlh.added_by = u.id
|
||||||
WHERE hlh.household_list_id = hl.id
|
WHERE hlh.household_list_id = hl.id
|
||||||
) added_by_labels
|
) added_by_labels
|
||||||
) AS added_by_users,
|
) as added_by_users,
|
||||||
hl.modified_on AS last_added_on
|
hl.modified_on as last_added_on
|
||||||
FROM household_lists hl
|
FROM household_lists hl
|
||||||
JOIN household_store_items hsi ON hsi.id = hl.household_store_item_id
|
JOIN items i ON hl.item_id = i.id
|
||||||
WHERE hl.household_id = $1
|
WHERE hl.household_id = $1
|
||||||
AND hl.store_id = $2
|
AND hl.store_id = $2
|
||||||
AND hl.bought = TRUE
|
AND hl.bought = TRUE
|
||||||
AND hl.modified_on >= NOW() - INTERVAL '24 hours'
|
AND hl.modified_on >= NOW() - INTERVAL '24 hours'
|
||||||
ORDER BY hl.modified_on DESC`,
|
ORDER BY hl.modified_on DESC`,
|
||||||
[householdId, storeId]
|
[householdId, storeId]
|
||||||
@ -306,82 +311,99 @@ exports.getRecentlyBoughtItems = async (householdId, storeId) => {
|
|||||||
return result.rows;
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.getClassification = async (householdId, storeId, itemId) => {
|
/**
|
||||||
|
* Get classification for household item
|
||||||
|
* @param {number} householdId - Household ID
|
||||||
|
* @param {number} itemId - Item ID
|
||||||
|
* @returns {Promise<Object|null>} Classification or null
|
||||||
|
*/
|
||||||
|
exports.getClassification = async (householdId, itemId) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT item_type, item_group, zone, confidence, source
|
`SELECT item_type, item_group, zone, confidence, source
|
||||||
FROM household_item_classifications
|
FROM household_item_classifications
|
||||||
WHERE household_id = $1 AND store_id = $2 AND household_store_item_id = $3`,
|
WHERE household_id = $1 AND item_id = $2`,
|
||||||
[householdId, storeId, itemId]
|
[householdId, itemId]
|
||||||
);
|
);
|
||||||
return result.rows[0] || null;
|
return result.rows[0] || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.upsertClassification = async (householdId, storeId, itemId, classification) => {
|
/**
|
||||||
|
* Upsert classification for household item
|
||||||
|
* @param {number} householdId - Household ID
|
||||||
|
* @param {number} itemId - Item ID
|
||||||
|
* @param {Object} classification - Classification data
|
||||||
|
* @returns {Promise<Object>} Updated classification
|
||||||
|
*/
|
||||||
|
exports.upsertClassification = async (householdId, itemId, classification) => {
|
||||||
const { item_type, item_group, zone, confidence, source } = classification;
|
const { item_type, item_group, zone, confidence, source } = classification;
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO household_item_classifications
|
`INSERT INTO household_item_classifications
|
||||||
(household_id, store_id, household_store_item_id, item_type, item_group, zone, confidence, source)
|
(household_id, item_id, item_type, item_group, zone, confidence, source)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
ON CONFLICT (household_id, store_id, household_store_item_id)
|
ON CONFLICT (household_id, item_id)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
item_type = EXCLUDED.item_type,
|
item_type = EXCLUDED.item_type,
|
||||||
item_group = EXCLUDED.item_group,
|
item_group = EXCLUDED.item_group,
|
||||||
zone = EXCLUDED.zone,
|
zone = EXCLUDED.zone,
|
||||||
confidence = EXCLUDED.confidence,
|
confidence = EXCLUDED.confidence,
|
||||||
source = EXCLUDED.source
|
source = EXCLUDED.source
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[householdId, storeId, itemId, item_type, item_group, zone, confidence, source]
|
[householdId, itemId, item_type, item_group, zone, confidence, source]
|
||||||
);
|
);
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.deleteClassification = async (householdId, storeId, itemId) => {
|
/**
|
||||||
const result = await pool.query(
|
* Update list item details
|
||||||
`DELETE FROM household_item_classifications
|
* @param {number} listId - List item ID
|
||||||
WHERE household_id = $1
|
* @param {string} itemName - New item name (optional)
|
||||||
AND store_id = $2
|
* @param {number} quantity - New quantity (optional)
|
||||||
AND household_store_item_id = $3`,
|
* @param {string} notes - Notes (optional)
|
||||||
[householdId, storeId, itemId]
|
* @returns {Promise<Object>} Updated item
|
||||||
);
|
*/
|
||||||
return result.rowCount > 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.updateItem = async (listId, itemName, quantity, notes) => {
|
exports.updateItem = async (listId, itemName, quantity, notes) => {
|
||||||
|
// Build dynamic update query
|
||||||
const updates = [];
|
const updates = [];
|
||||||
const values = [listId];
|
const values = [listId];
|
||||||
let paramCount = 1;
|
let paramCount = 1;
|
||||||
|
|
||||||
if (quantity !== undefined) {
|
if (quantity !== undefined) {
|
||||||
paramCount += 1;
|
paramCount++;
|
||||||
updates.push(`quantity = $${paramCount}`);
|
updates.push(`quantity = $${paramCount}`);
|
||||||
values.push(quantity);
|
values.push(quantity);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notes !== undefined) {
|
if (notes !== undefined) {
|
||||||
paramCount += 1;
|
paramCount++;
|
||||||
updates.push(`notes = $${paramCount}`);
|
updates.push(`notes = $${paramCount}`);
|
||||||
values.push(notes);
|
values.push(notes);
|
||||||
}
|
}
|
||||||
|
|
||||||
updates.push("modified_on = NOW()");
|
// Always update modified_on
|
||||||
|
updates.push(`modified_on = NOW()`);
|
||||||
|
|
||||||
if (updates.length === 1) {
|
if (updates.length === 1) {
|
||||||
|
// Only modified_on update
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
"UPDATE household_lists SET modified_on = NOW() WHERE id = $1 RETURNING *",
|
`UPDATE household_lists SET modified_on = NOW() WHERE id = $1 RETURNING *`,
|
||||||
[listId]
|
[listId]
|
||||||
);
|
);
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`UPDATE household_lists SET ${updates.join(", ")} WHERE id = $1 RETURNING *`,
|
`UPDATE household_lists SET ${updates.join(', ')} WHERE id = $1 RETURNING *`,
|
||||||
values
|
values
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a list item
|
||||||
|
* @param {number} listId - List item ID
|
||||||
|
*/
|
||||||
exports.deleteItem = async (listId) => {
|
exports.deleteItem = async (listId) => {
|
||||||
await pool.query("DELETE FROM household_lists WHERE id = $1", [listId]);
|
await pool.query("DELETE FROM household_lists WHERE id = $1", [listId]);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,7 +2,6 @@ const express = require("express");
|
|||||||
const router = express.Router();
|
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 auth = require("../middleware/auth");
|
const auth = require("../middleware/auth");
|
||||||
const {
|
const {
|
||||||
householdAccess,
|
householdAccess,
|
||||||
@ -40,50 +39,6 @@ router.post(
|
|||||||
controller.refreshInviteCode
|
controller.refreshInviteCode
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/:householdId/stores/:storeId/available-items",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
storeAccess,
|
|
||||||
availableItemsController.getAvailableItems
|
|
||||||
);
|
|
||||||
router.post(
|
|
||||||
"/:householdId/stores/:storeId/available-items",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
storeAccess,
|
|
||||||
requireHouseholdAdmin,
|
|
||||||
upload.single("image"),
|
|
||||||
processImage,
|
|
||||||
availableItemsController.createAvailableItem
|
|
||||||
);
|
|
||||||
router.patch(
|
|
||||||
"/:householdId/stores/:storeId/available-items/:itemId",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
storeAccess,
|
|
||||||
requireHouseholdAdmin,
|
|
||||||
upload.single("image"),
|
|
||||||
processImage,
|
|
||||||
availableItemsController.updateAvailableItem
|
|
||||||
);
|
|
||||||
router.delete(
|
|
||||||
"/:householdId/stores/:storeId/available-items/:itemId",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
storeAccess,
|
|
||||||
requireHouseholdAdmin,
|
|
||||||
availableItemsController.deleteAvailableItem
|
|
||||||
);
|
|
||||||
router.post(
|
|
||||||
"/:householdId/stores/:storeId/available-items/import-current",
|
|
||||||
auth,
|
|
||||||
householdAccess,
|
|
||||||
storeAccess,
|
|
||||||
requireHouseholdAdmin,
|
|
||||||
availableItemsController.importCurrentItems
|
|
||||||
);
|
|
||||||
|
|
||||||
// Member management routes
|
// Member management routes
|
||||||
router.get(
|
router.get(
|
||||||
"/:householdId/members",
|
"/:householdId/members",
|
||||||
|
|||||||
@ -1,96 +0,0 @@
|
|||||||
jest.mock("../db/pool", () => ({
|
|
||||||
query: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const pool = require("../db/pool");
|
|
||||||
const AvailableItems = require("../models/available-item.model");
|
|
||||||
|
|
||||||
describe("available-item.model", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
pool.query.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("lists household store items", async () => {
|
|
||||||
pool.query.mockResolvedValueOnce({
|
|
||||||
rowCount: 1,
|
|
||||||
rows: [
|
|
||||||
{
|
|
||||||
item_id: 55,
|
|
||||||
item_name: "milk",
|
|
||||||
item_image: null,
|
|
||||||
image_mime_type: null,
|
|
||||||
item_type: null,
|
|
||||||
item_group: null,
|
|
||||||
zone: null,
|
|
||||||
has_managed_settings: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await AvailableItems.listAvailableItems(1, 2);
|
|
||||||
|
|
||||||
expect(result).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
item_id: 55,
|
|
||||||
item_name: "milk",
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect(pool.query).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("FROM household_store_items hsi"),
|
|
||||||
[1, 2]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("creates a household store item when needed", async () => {
|
|
||||||
pool.query
|
|
||||||
.mockResolvedValueOnce({ rowCount: 0, rows: [] })
|
|
||||||
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 77, name: "granola" }] })
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
rowCount: 1,
|
|
||||||
rows: [{ item_id: 77, item_name: "granola" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await AvailableItems.createAvailableItem(1, 2, "Granola");
|
|
||||||
|
|
||||||
expect(result).toEqual(expect.objectContaining({ item_id: 77, item_name: "granola" }));
|
|
||||||
expect(pool.query).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
expect.stringContaining("INSERT INTO household_store_items"),
|
|
||||||
[1, 2, "granola", "granola"]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("updates household store item images and returns refreshed data", async () => {
|
|
||||||
const imageBuffer = Buffer.from("abc");
|
|
||||||
pool.query
|
|
||||||
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55 }] })
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
rowCount: 1,
|
|
||||||
rows: [{ item_id: 55, item_name: "milk", item_image: "YWJj", image_mime_type: "image/jpeg" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await AvailableItems.updateAvailableItem(1, 2, 55, {
|
|
||||||
imageBuffer,
|
|
||||||
mimeType: "image/jpeg",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual(expect.objectContaining({ item_id: 55, image_mime_type: "image/jpeg" }));
|
|
||||||
expect(pool.query).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
expect.stringContaining("UPDATE household_store_items"),
|
|
||||||
[1, 2, 55, imageBuffer, "image/jpeg"]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("deletes the household store item", async () => {
|
|
||||||
pool.query.mockResolvedValueOnce({ rowCount: 1, rows: [] });
|
|
||||||
|
|
||||||
const deleted = await AvailableItems.deleteAvailableItem(1, 2, 55);
|
|
||||||
|
|
||||||
expect(deleted).toBe(true);
|
|
||||||
expect(pool.query).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("DELETE FROM household_store_items"),
|
|
||||||
[1, 2, 55]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,199 +0,0 @@
|
|||||||
jest.mock("../models/available-item.model", () => ({
|
|
||||||
createAvailableItem: jest.fn(),
|
|
||||||
deleteAvailableItem: jest.fn(),
|
|
||||||
getAvailableItemById: jest.fn(),
|
|
||||||
importCurrentListItems: jest.fn(),
|
|
||||||
listAvailableItems: jest.fn(),
|
|
||||||
updateAvailableItem: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock("../models/list.model.v2", () => ({
|
|
||||||
deleteClassification: jest.fn(),
|
|
||||||
upsertClassification: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock("../utils/logger", () => ({
|
|
||||||
logError: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const AvailableItems = require("../models/available-item.model");
|
|
||||||
const List = require("../models/list.model.v2");
|
|
||||||
const controller = require("../controllers/available-items.controller");
|
|
||||||
|
|
||||||
function createResponse() {
|
|
||||||
const res = {};
|
|
||||||
res.status = jest.fn().mockReturnValue(res);
|
|
||||||
res.json = jest.fn().mockReturnValue(res);
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("available-items.controller", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
AvailableItems.createAvailableItem.mockResolvedValue({ item_id: 99, item_name: "milk" });
|
|
||||||
AvailableItems.getAvailableItemById.mockResolvedValue({
|
|
||||||
item_id: 99,
|
|
||||||
item_name: "milk",
|
|
||||||
item_type: "dairy",
|
|
||||||
item_group: "Milk",
|
|
||||||
zone: "Dairy & Refrigerated",
|
|
||||||
});
|
|
||||||
AvailableItems.updateAvailableItem.mockResolvedValue({ item_id: 99, item_name: "milk" });
|
|
||||||
AvailableItems.deleteAvailableItem.mockResolvedValue(true);
|
|
||||||
AvailableItems.importCurrentListItems.mockResolvedValue(2);
|
|
||||||
AvailableItems.listAvailableItems.mockResolvedValue([]);
|
|
||||||
List.upsertClassification.mockResolvedValue(undefined);
|
|
||||||
List.deleteClassification.mockResolvedValue(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("creates an available item and persists classification metadata", async () => {
|
|
||||||
const req = {
|
|
||||||
params: { householdId: "1", storeId: "2" },
|
|
||||||
body: {
|
|
||||||
item_name: "milk",
|
|
||||||
classification: JSON.stringify({
|
|
||||||
item_type: "dairy",
|
|
||||||
item_group: "Milk",
|
|
||||||
zone: "Dairy & Refrigerated",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
processedImage: null,
|
|
||||||
};
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await controller.createAvailableItem(req, res);
|
|
||||||
|
|
||||||
expect(AvailableItems.createAvailableItem).toHaveBeenCalledWith("1", "2", "milk", null, null);
|
|
||||||
expect(List.upsertClassification).toHaveBeenCalledWith(
|
|
||||||
"1",
|
|
||||||
"2",
|
|
||||||
99,
|
|
||||||
expect.objectContaining({
|
|
||||||
item_type: "dairy",
|
|
||||||
item_group: "Milk",
|
|
||||||
zone: "Dairy & Refrigerated",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(res.status).toHaveBeenCalledWith(201);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects invalid item_group values", async () => {
|
|
||||||
const req = {
|
|
||||||
params: { householdId: "1", storeId: "2" },
|
|
||||||
body: {
|
|
||||||
item_name: "milk",
|
|
||||||
classification: JSON.stringify({
|
|
||||||
item_type: "dairy",
|
|
||||||
item_group: "Bread",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await controller.createAvailableItem(req, res);
|
|
||||||
|
|
||||||
expect(AvailableItems.createAvailableItem).not.toHaveBeenCalled();
|
|
||||||
expect(res.status).toHaveBeenCalledWith(400);
|
|
||||||
expect(res.json).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
error: expect.objectContaining({
|
|
||||||
message: "Invalid item_group for selected item_type",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("clears classification on update when classification is explicitly empty", async () => {
|
|
||||||
const req = {
|
|
||||||
params: { householdId: "1", storeId: "2", itemId: "99" },
|
|
||||||
body: {
|
|
||||||
classification: "null",
|
|
||||||
},
|
|
||||||
processedImage: null,
|
|
||||||
};
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await controller.updateAvailableItem(req, res);
|
|
||||||
|
|
||||||
expect(List.deleteClassification).toHaveBeenCalledWith("1", "2", 99);
|
|
||||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("imports current list items and reports the import count", async () => {
|
|
||||||
const req = {
|
|
||||||
params: { householdId: "1", storeId: "2" },
|
|
||||||
};
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await controller.importCurrentItems(req, res);
|
|
||||||
|
|
||||||
expect(AvailableItems.importCurrentListItems).toHaveBeenCalledWith("1", "2");
|
|
||||||
expect(res.json).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
imported_count: 2,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("deletes a store item", async () => {
|
|
||||||
const req = {
|
|
||||||
params: { householdId: "1", storeId: "2", itemId: "99" },
|
|
||||||
};
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await controller.deleteAvailableItem(req, res);
|
|
||||||
|
|
||||||
expect(AvailableItems.deleteAvailableItem).toHaveBeenCalledWith("1", "2", 99);
|
|
||||||
expect(List.deleteClassification).not.toHaveBeenCalled();
|
|
||||||
expect(res.json).toHaveBeenCalledWith({ message: "Store item deleted" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns an empty catalog payload when the available items table is missing", async () => {
|
|
||||||
const req = {
|
|
||||||
params: { householdId: "1", storeId: "2" },
|
|
||||||
query: {},
|
|
||||||
};
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
AvailableItems.listAvailableItems.mockRejectedValueOnce({
|
|
||||||
code: "42P01",
|
|
||||||
message: 'relation "household_store_items" does not exist',
|
|
||||||
});
|
|
||||||
|
|
||||||
await controller.getAvailableItems(req, res);
|
|
||||||
|
|
||||||
expect(res.json).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
items: [],
|
|
||||||
catalog_ready: false,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns a setup error when creating while the available items table is missing", async () => {
|
|
||||||
const req = {
|
|
||||||
params: { householdId: "1", storeId: "2" },
|
|
||||||
body: {
|
|
||||||
item_name: "milk",
|
|
||||||
},
|
|
||||||
processedImage: null,
|
|
||||||
};
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
AvailableItems.createAvailableItem.mockRejectedValueOnce({
|
|
||||||
code: "42P01",
|
|
||||||
message: 'relation "household_store_items" does not exist',
|
|
||||||
});
|
|
||||||
|
|
||||||
await controller.createAvailableItem(req, res);
|
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(503);
|
|
||||||
expect(res.json).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
error: expect.objectContaining({
|
|
||||||
message: expect.stringContaining("latest database migration"),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,109 +0,0 @@
|
|||||||
jest.mock("../middleware/auth", () => (req, res, next) => {
|
|
||||||
req.user = { id: 42, role: "user" };
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock("../middleware/household", () => ({
|
|
||||||
householdAccess: (req, res, next) => {
|
|
||||||
req.household = {
|
|
||||||
id: Number.parseInt(req.params.householdId, 10),
|
|
||||||
role: req.headers["x-household-role"] || "user",
|
|
||||||
};
|
|
||||||
next();
|
|
||||||
},
|
|
||||||
requireHouseholdAdmin: (req, res, next) => {
|
|
||||||
if (["owner", "admin"].includes(req.household?.role)) {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
return res.status(403).json({
|
|
||||||
error: { code: "FORBIDDEN", message: "Admin role required" },
|
|
||||||
request_id: req.request_id,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
storeAccess: (req, res, next) => next(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock("../middleware/image", () => ({
|
|
||||||
upload: {
|
|
||||||
single: () => (req, res, next) => next(),
|
|
||||||
},
|
|
||||||
processImage: (req, res, next) => next(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock("../controllers/households.controller", () => ({
|
|
||||||
createHousehold: jest.fn(),
|
|
||||||
deleteHousehold: jest.fn(),
|
|
||||||
getHousehold: jest.fn(),
|
|
||||||
getMembers: jest.fn(),
|
|
||||||
getUserHouseholds: jest.fn(),
|
|
||||||
joinHousehold: jest.fn(),
|
|
||||||
refreshInviteCode: jest.fn(),
|
|
||||||
removeMember: jest.fn(),
|
|
||||||
updateHousehold: jest.fn(),
|
|
||||||
updateMemberRole: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock("../controllers/lists.controller.v2", () => ({
|
|
||||||
addItem: jest.fn(),
|
|
||||||
deleteItem: jest.fn(),
|
|
||||||
getClassification: jest.fn(),
|
|
||||||
getItemByName: jest.fn(),
|
|
||||||
getList: jest.fn(),
|
|
||||||
getRecentlyBought: jest.fn(),
|
|
||||||
getSuggestions: jest.fn(),
|
|
||||||
markBought: jest.fn(),
|
|
||||||
setClassification: jest.fn(),
|
|
||||||
updateItem: jest.fn(),
|
|
||||||
updateItemImage: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock("../controllers/available-items.controller", () => ({
|
|
||||||
createAvailableItem: jest.fn((req, res) => res.status(201).json({ message: "created" })),
|
|
||||||
deleteAvailableItem: jest.fn((req, res) => res.json({ message: "deleted" })),
|
|
||||||
getAvailableItems: jest.fn((req, res) => res.json({ items: [] })),
|
|
||||||
importCurrentItems: jest.fn((req, res) => res.json({ imported_count: 1 })),
|
|
||||||
updateAvailableItem: jest.fn((req, res) => res.json({ message: "updated" })),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const express = require("express");
|
|
||||||
const request = require("supertest");
|
|
||||||
const router = require("../routes/households.routes");
|
|
||||||
const availableItemsController = require("../controllers/available-items.controller");
|
|
||||||
|
|
||||||
describe("available-items routes", () => {
|
|
||||||
let app;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use("/households", router);
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("members can read available items", async () => {
|
|
||||||
const response = await request(app).get("/households/1/stores/2/available-items");
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(availableItemsController.getAvailableItems).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("members cannot mutate available items", async () => {
|
|
||||||
const response = await request(app)
|
|
||||||
.post("/households/1/stores/2/available-items")
|
|
||||||
.set("x-household-role", "user")
|
|
||||||
.send({ item_name: "milk" });
|
|
||||||
|
|
||||||
expect(response.status).toBe(403);
|
|
||||||
expect(availableItemsController.createAvailableItem).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("admins can create available items", async () => {
|
|
||||||
const response = await request(app)
|
|
||||||
.post("/households/1/stores/2/available-items")
|
|
||||||
.set("x-household-role", "admin")
|
|
||||||
.send({ item_name: "milk" });
|
|
||||||
|
|
||||||
expect(response.status).toBe(201);
|
|
||||||
expect(availableItemsController.createAvailableItem).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,136 +0,0 @@
|
|||||||
jest.mock("../db/pool", () => ({
|
|
||||||
query: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const pool = require("../db/pool");
|
|
||||||
const List = require("../models/list.model.v2");
|
|
||||||
|
|
||||||
describe("list.model.v2 addOrUpdateItem", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
pool.query.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns household store item metadata when creating a new list item", async () => {
|
|
||||||
pool.query
|
|
||||||
.mockResolvedValueOnce({ rowCount: 0, rows: [] })
|
|
||||||
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] })
|
|
||||||
.mockResolvedValueOnce({ rowCount: 0, rows: [] })
|
|
||||||
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88 }] });
|
|
||||||
|
|
||||||
const result = await List.addOrUpdateItem(1, 2, "Milk", 3, 7);
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
listId: 88,
|
|
||||||
itemId: 55,
|
|
||||||
householdStoreItemId: 55,
|
|
||||||
itemName: "milk",
|
|
||||||
isNew: true,
|
|
||||||
});
|
|
||||||
expect(pool.query).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
expect.stringContaining("FROM household_store_items"),
|
|
||||||
[1, 2, "milk"]
|
|
||||||
);
|
|
||||||
expect(pool.query).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
expect.stringContaining("INSERT INTO household_store_items"),
|
|
||||||
[1, 2, "milk", "milk"]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns household store item metadata when updating an existing list item", async () => {
|
|
||||||
pool.query
|
|
||||||
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 55, name: "milk" }] })
|
|
||||||
.mockResolvedValueOnce({ rowCount: 1, rows: [{ id: 88, bought: false }] })
|
|
||||||
.mockResolvedValueOnce({ rowCount: 1, rows: [] });
|
|
||||||
|
|
||||||
const result = await List.addOrUpdateItem(1, 2, "Milk", 4, 7);
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
listId: 88,
|
|
||||||
itemId: 55,
|
|
||||||
householdStoreItemId: 55,
|
|
||||||
itemName: "milk",
|
|
||||||
isNew: false,
|
|
||||||
});
|
|
||||||
expect(pool.query).toHaveBeenNthCalledWith(
|
|
||||||
3,
|
|
||||||
expect.stringContaining("UPDATE household_lists"),
|
|
||||||
[4, 88]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("list.model.v2 classification helpers", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
pool.query.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("gets classification using household, store, and household-store item ids", async () => {
|
|
||||||
pool.query.mockResolvedValueOnce({
|
|
||||||
rowCount: 1,
|
|
||||||
rows: [
|
|
||||||
{
|
|
||||||
item_type: "dairy",
|
|
||||||
item_group: "Milk",
|
|
||||||
zone: "Dairy & Refrigerated",
|
|
||||||
confidence: 1,
|
|
||||||
source: "user",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await List.getClassification(1, 2, 55);
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
item_type: "dairy",
|
|
||||||
item_group: "Milk",
|
|
||||||
zone: "Dairy & Refrigerated",
|
|
||||||
confidence: 1,
|
|
||||||
source: "user",
|
|
||||||
});
|
|
||||||
expect(pool.query).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("household_store_item_id = $3"),
|
|
||||||
[1, 2, 55]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("upserts classification using household-store item conflict target", async () => {
|
|
||||||
pool.query.mockResolvedValueOnce({
|
|
||||||
rowCount: 1,
|
|
||||||
rows: [
|
|
||||||
{
|
|
||||||
household_id: 1,
|
|
||||||
store_id: 2,
|
|
||||||
household_store_item_id: 55,
|
|
||||||
item_type: "dairy",
|
|
||||||
item_group: "Milk",
|
|
||||||
zone: "Dairy & Refrigerated",
|
|
||||||
confidence: 1,
|
|
||||||
source: "user",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await List.upsertClassification(1, 2, 55, {
|
|
||||||
item_type: "dairy",
|
|
||||||
item_group: "Milk",
|
|
||||||
zone: "Dairy & Refrigerated",
|
|
||||||
confidence: 1,
|
|
||||||
source: "user",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
household_id: 1,
|
|
||||||
store_id: 2,
|
|
||||||
household_store_item_id: 55,
|
|
||||||
item_type: "dairy",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(pool.query).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("ON CONFLICT (household_id, store_id, household_store_item_id)"),
|
|
||||||
[1, 2, 55, "dairy", "Milk", "Dairy & Refrigerated", 1, "user"]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,9 +1,6 @@
|
|||||||
jest.mock("../models/list.model.v2", () => ({
|
jest.mock("../models/list.model.v2", () => ({
|
||||||
addHistoryRecord: jest.fn(),
|
addHistoryRecord: jest.fn(),
|
||||||
addOrUpdateItem: jest.fn(),
|
addOrUpdateItem: jest.fn(),
|
||||||
ensureHouseholdStoreItem: jest.fn(),
|
|
||||||
getItemByName: jest.fn(),
|
|
||||||
upsertClassification: jest.fn(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("../models/household.model", () => ({
|
jest.mock("../models/household.model", () => ({
|
||||||
@ -27,17 +24,12 @@ function createResponse() {
|
|||||||
|
|
||||||
describe("lists.controller.v2 addItem", () => {
|
describe("lists.controller.v2 addItem", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
|
||||||
List.addOrUpdateItem.mockResolvedValue({
|
List.addOrUpdateItem.mockResolvedValue({
|
||||||
listId: 42,
|
listId: 42,
|
||||||
itemId: 99,
|
|
||||||
householdStoreItemId: 99,
|
|
||||||
itemName: "milk",
|
itemName: "milk",
|
||||||
isNew: true,
|
isNew: true,
|
||||||
});
|
});
|
||||||
List.addHistoryRecord.mockResolvedValue(undefined);
|
List.addHistoryRecord.mockResolvedValue(undefined);
|
||||||
List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" });
|
|
||||||
List.upsertClassification.mockResolvedValue(undefined);
|
|
||||||
householdModel.isHouseholdMember.mockResolvedValue(true);
|
householdModel.isHouseholdMember.mockResolvedValue(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -54,7 +46,7 @@ describe("lists.controller.v2 addItem", () => {
|
|||||||
|
|
||||||
expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 9);
|
expect(householdModel.isHouseholdMember).toHaveBeenCalledWith("1", 9);
|
||||||
expect(List.addOrUpdateItem).toHaveBeenCalled();
|
expect(List.addOrUpdateItem).toHaveBeenCalled();
|
||||||
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 9);
|
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, "1", 9);
|
||||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -71,7 +63,7 @@ describe("lists.controller.v2 addItem", () => {
|
|||||||
|
|
||||||
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
|
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
|
||||||
expect(List.addOrUpdateItem).toHaveBeenCalled();
|
expect(List.addOrUpdateItem).toHaveBeenCalled();
|
||||||
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 7);
|
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, "1", 7);
|
||||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -88,7 +80,7 @@ describe("lists.controller.v2 addItem", () => {
|
|||||||
|
|
||||||
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
|
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
|
||||||
expect(List.addOrUpdateItem).toHaveBeenCalled();
|
expect(List.addOrUpdateItem).toHaveBeenCalled();
|
||||||
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, 99, "1", 7);
|
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, "1", 7);
|
||||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -164,216 +156,3 @@ describe("lists.controller.v2 addItem", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("lists.controller.v2 setClassification", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
List.getItemByName.mockResolvedValue({ id: 42, item_id: 99, item_name: "milk" });
|
|
||||||
List.upsertClassification.mockResolvedValue(undefined);
|
|
||||||
List.ensureHouseholdStoreItem.mockResolvedValue({ id: 99, name: "milk" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accepts object classification with type, group, and zone", async () => {
|
|
||||||
const req = {
|
|
||||||
params: { householdId: "1", storeId: "2" },
|
|
||||||
body: {
|
|
||||||
item_name: "milk",
|
|
||||||
classification: {
|
|
||||||
item_type: "dairy",
|
|
||||||
item_group: "Milk",
|
|
||||||
zone: "Dairy & Refrigerated",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
user: { id: 7 },
|
|
||||||
};
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await controller.setClassification(req, res);
|
|
||||||
|
|
||||||
expect(List.upsertClassification).toHaveBeenCalledWith(
|
|
||||||
"1",
|
|
||||||
"2",
|
|
||||||
99,
|
|
||||||
expect.objectContaining({
|
|
||||||
item_type: "dairy",
|
|
||||||
item_group: "Milk",
|
|
||||||
zone: "Dairy & Refrigerated",
|
|
||||||
confidence: 1.0,
|
|
||||||
source: "user",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
|
||||||
expect(res.json).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
message: "Classification set",
|
|
||||||
classification: {
|
|
||||||
item_type: "dairy",
|
|
||||||
item_group: "Milk",
|
|
||||||
zone: "Dairy & Refrigerated",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accepts zone-only classification updates", async () => {
|
|
||||||
const req = {
|
|
||||||
params: { householdId: "1", storeId: "2" },
|
|
||||||
body: {
|
|
||||||
item_name: "milk",
|
|
||||||
classification: {
|
|
||||||
zone: "Checkout Area",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
user: { id: 7 },
|
|
||||||
};
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await controller.setClassification(req, res);
|
|
||||||
|
|
||||||
expect(List.upsertClassification).toHaveBeenCalledWith(
|
|
||||||
"1",
|
|
||||||
"2",
|
|
||||||
99,
|
|
||||||
expect.objectContaining({
|
|
||||||
item_type: null,
|
|
||||||
item_group: null,
|
|
||||||
zone: "Checkout Area",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects invalid item_type", async () => {
|
|
||||||
const req = {
|
|
||||||
params: { householdId: "1", storeId: "2" },
|
|
||||||
body: {
|
|
||||||
item_name: "milk",
|
|
||||||
classification: {
|
|
||||||
item_type: "invalid-type",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
user: { id: 7 },
|
|
||||||
};
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await controller.setClassification(req, res);
|
|
||||||
|
|
||||||
expect(List.upsertClassification).not.toHaveBeenCalled();
|
|
||||||
expect(res.status).toHaveBeenCalledWith(400);
|
|
||||||
expect(res.json).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
error: expect.objectContaining({
|
|
||||||
message: "Invalid item_type",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects invalid item_group for selected item_type", async () => {
|
|
||||||
const req = {
|
|
||||||
params: { householdId: "1", storeId: "2" },
|
|
||||||
body: {
|
|
||||||
item_name: "milk",
|
|
||||||
classification: {
|
|
||||||
item_type: "dairy",
|
|
||||||
item_group: "Bread",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
user: { id: 7 },
|
|
||||||
};
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await controller.setClassification(req, res);
|
|
||||||
|
|
||||||
expect(List.upsertClassification).not.toHaveBeenCalled();
|
|
||||||
expect(res.status).toHaveBeenCalledWith(400);
|
|
||||||
expect(res.json).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
error: expect.objectContaining({
|
|
||||||
message: "Invalid item_group for selected item_type",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects invalid zone", async () => {
|
|
||||||
const req = {
|
|
||||||
params: { householdId: "1", storeId: "2" },
|
|
||||||
body: {
|
|
||||||
item_name: "milk",
|
|
||||||
classification: {
|
|
||||||
zone: "Space Aisle",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
user: { id: 7 },
|
|
||||||
};
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await controller.setClassification(req, res);
|
|
||||||
|
|
||||||
expect(List.upsertClassification).not.toHaveBeenCalled();
|
|
||||||
expect(res.status).toHaveBeenCalledWith(400);
|
|
||||||
expect(res.json).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
error: expect.objectContaining({
|
|
||||||
message: "Invalid zone",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accepts legacy string classification values", async () => {
|
|
||||||
const req = {
|
|
||||||
params: { householdId: "1", storeId: "2" },
|
|
||||||
body: {
|
|
||||||
item_name: "milk",
|
|
||||||
classification: "beverages",
|
|
||||||
},
|
|
||||||
user: { id: 7 },
|
|
||||||
};
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await controller.setClassification(req, res);
|
|
||||||
|
|
||||||
expect(List.upsertClassification).toHaveBeenCalledWith(
|
|
||||||
"1",
|
|
||||||
"2",
|
|
||||||
99,
|
|
||||||
expect.objectContaining({
|
|
||||||
item_type: "beverage",
|
|
||||||
item_group: null,
|
|
||||||
zone: null,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("creates a household store item when classification target is not yet on the list", async () => {
|
|
||||||
List.getItemByName.mockResolvedValueOnce(null);
|
|
||||||
|
|
||||||
const req = {
|
|
||||||
params: { householdId: "1", storeId: "2" },
|
|
||||||
body: {
|
|
||||||
item_name: "granola",
|
|
||||||
classification: {
|
|
||||||
zone: "Snacks & Candy",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
user: { id: 7 },
|
|
||||||
};
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await controller.setClassification(req, res);
|
|
||||||
|
|
||||||
expect(List.ensureHouseholdStoreItem).toHaveBeenCalledWith("1", "2", "granola");
|
|
||||||
expect(List.upsertClassification).toHaveBeenCalledWith(
|
|
||||||
"1",
|
|
||||||
"2",
|
|
||||||
99,
|
|
||||||
expect.objectContaining({
|
|
||||||
zone: "Snacks & Candy",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,55 +0,0 @@
|
|||||||
import api from "./axios";
|
|
||||||
|
|
||||||
function appendClassification(formData, classification) {
|
|
||||||
if (classification === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
formData.append("classification", JSON.stringify(classification));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getAvailableItems = (householdId, storeId, query = "") =>
|
|
||||||
api.get(`/households/${householdId}/stores/${storeId}/available-items`, {
|
|
||||||
params: query ? { query } : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createAvailableItem = (householdId, storeId, payload) => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("item_name", payload.itemName);
|
|
||||||
appendClassification(formData, payload.classification ?? undefined);
|
|
||||||
if (payload.imageFile) {
|
|
||||||
formData.append("image", payload.imageFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
return api.post(`/households/${householdId}/stores/${storeId}/available-items`, formData, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "multipart/form-data",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateAvailableItem = (householdId, storeId, itemId, payload) => {
|
|
||||||
const formData = new FormData();
|
|
||||||
if (payload.itemName !== undefined) {
|
|
||||||
formData.append("item_name", payload.itemName);
|
|
||||||
}
|
|
||||||
appendClassification(formData, payload.classification);
|
|
||||||
if (payload.removeImage) {
|
|
||||||
formData.append("remove_image", "true");
|
|
||||||
}
|
|
||||||
if (payload.imageFile) {
|
|
||||||
formData.append("image", payload.imageFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
return api.patch(`/households/${householdId}/stores/${storeId}/available-items/${itemId}`, formData, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "multipart/form-data",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteAvailableItem = (householdId, storeId, itemId) =>
|
|
||||||
api.delete(`/households/${householdId}/stores/${storeId}/available-items/${itemId}`);
|
|
||||||
|
|
||||||
export const importCurrentAvailableItems = (householdId, storeId) =>
|
|
||||||
api.post(`/households/${householdId}/stores/${storeId}/available-items/import-current`);
|
|
||||||
@ -54,51 +54,25 @@ export const getClassification = (householdId, storeId, itemName) =>
|
|||||||
params: { item_name: itemName }
|
params: { item_name: itemName }
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set item classification
|
* Set item classification
|
||||||
*/
|
*/
|
||||||
export const setClassification = (householdId, storeId, itemName, classification) =>
|
export const setClassification = (householdId, storeId, itemName, classification) =>
|
||||||
api.post(`/households/${householdId}/stores/${storeId}/list/classification`, {
|
api.post(`/households/${householdId}/stores/${storeId}/list/classification`, {
|
||||||
item_name: itemName,
|
item_name: itemName,
|
||||||
classification
|
classification
|
||||||
});
|
});
|
||||||
|
|
||||||
function normalizeClassificationPayload(classification) {
|
/**
|
||||||
if (!classification) return null;
|
* Update item with classification (legacy method - split into separate calls)
|
||||||
if (typeof classification === "string") {
|
*/
|
||||||
return classification.trim() || null;
|
export const updateItemWithClassification = (householdId, storeId, itemName, quantity, classification) => {
|
||||||
}
|
// This is now two operations: update item + set classification
|
||||||
if (typeof classification !== "object" || Array.isArray(classification)) {
|
return Promise.all([
|
||||||
return null;
|
updateItem(householdId, storeId, itemName, quantity),
|
||||||
}
|
classification ? setClassification(householdId, storeId, itemName, classification) : Promise.resolve()
|
||||||
|
]);
|
||||||
const payload = {
|
};
|
||||||
item_type: typeof classification.item_type === "string" && classification.item_type.trim()
|
|
||||||
? classification.item_type.trim()
|
|
||||||
: null,
|
|
||||||
item_group: typeof classification.item_group === "string" && classification.item_group.trim()
|
|
||||||
? classification.item_group.trim()
|
|
||||||
: null,
|
|
||||||
zone: typeof classification.zone === "string" && classification.zone.trim()
|
|
||||||
? classification.zone.trim()
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
return payload.item_type || payload.item_group || payload.zone ? payload : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update item with optional classification details.
|
|
||||||
*/
|
|
||||||
export const updateItemWithClassification = (householdId, storeId, itemName, quantity, classification) => {
|
|
||||||
const normalizedClassification = normalizeClassificationPayload(classification);
|
|
||||||
return Promise.all([
|
|
||||||
updateItem(householdId, storeId, itemName, quantity),
|
|
||||||
normalizedClassification
|
|
||||||
? setClassification(householdId, storeId, itemName, normalizedClassification)
|
|
||||||
: Promise.resolve()
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update item details (quantity, notes)
|
* Update item details (quantity, notes)
|
||||||
|
|||||||
@ -1,15 +1,11 @@
|
|||||||
import { memo, useRef, useState } from "react";
|
import { memo, useRef, useState } from "react";
|
||||||
import AddImageModal from "../modals/AddImageModal";
|
import AddImageModal from "../modals/AddImageModal";
|
||||||
|
import ConfirmBuyModal from "../modals/ConfirmBuyModal";
|
||||||
|
|
||||||
function GroceryListItem({
|
function GroceryListItem({ item, onClick, onImageAdded, onLongPress, allItems = [], compact = false }) {
|
||||||
item,
|
|
||||||
onClick,
|
|
||||||
onOpenBuyModal,
|
|
||||||
onImageAdded,
|
|
||||||
onLongPress,
|
|
||||||
compact = false
|
|
||||||
}) {
|
|
||||||
const [showAddImageModal, setShowAddImageModal] = useState(false);
|
const [showAddImageModal, setShowAddImageModal] = useState(false);
|
||||||
|
const [showConfirmBuyModal, setShowConfirmBuyModal] = useState(false);
|
||||||
|
const [currentItem, setCurrentItem] = useState(item);
|
||||||
|
|
||||||
const longPressTimer = useRef(null);
|
const longPressTimer = useRef(null);
|
||||||
const pressStartPos = useRef({ x: 0, y: 0 });
|
const pressStartPos = useRef({ x: 0, y: 0 });
|
||||||
@ -60,14 +56,32 @@ function GroceryListItem({
|
|||||||
|
|
||||||
const handleItemClick = () => {
|
const handleItemClick = () => {
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
onClick(item);
|
setCurrentItem(item);
|
||||||
|
setShowConfirmBuyModal(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleConfirmBuy = (quantity) => {
|
||||||
|
if (onClick) {
|
||||||
|
onClick(currentItem.id, quantity);
|
||||||
|
}
|
||||||
|
setShowConfirmBuyModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelBuy = () => {
|
||||||
|
setShowConfirmBuyModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNavigate = (newItem) => {
|
||||||
|
setCurrentItem(newItem);
|
||||||
|
};
|
||||||
|
|
||||||
const handleImageClick = (e) => {
|
const handleImageClick = (e) => {
|
||||||
e.stopPropagation(); // Prevent triggering the bought action
|
e.stopPropagation(); // Prevent triggering the bought action
|
||||||
if (item.item_image && onOpenBuyModal) {
|
if (item.item_image) {
|
||||||
onOpenBuyModal(item);
|
// Open buy modal which now shows the image
|
||||||
|
setCurrentItem(item);
|
||||||
|
setShowConfirmBuyModal(true);
|
||||||
} else {
|
} else {
|
||||||
setShowAddImageModal(true);
|
setShowAddImageModal(true);
|
||||||
}
|
}
|
||||||
@ -154,6 +168,16 @@ function GroceryListItem({
|
|||||||
onAddImage={handleAddImage}
|
onAddImage={handleAddImage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showConfirmBuyModal && (
|
||||||
|
<ConfirmBuyModal
|
||||||
|
item={currentItem}
|
||||||
|
onConfirm={handleConfirmBuy}
|
||||||
|
onCancel={handleCancelBuy}
|
||||||
|
allItems={allItems}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -171,9 +195,8 @@ export default memo(GroceryListItem, (prevProps, nextProps) => {
|
|||||||
prevProps.item.zone === nextProps.item.zone &&
|
prevProps.item.zone === nextProps.item.zone &&
|
||||||
prevProps.item.added_by_users?.join(',') === nextProps.item.added_by_users?.join(',') &&
|
prevProps.item.added_by_users?.join(',') === nextProps.item.added_by_users?.join(',') &&
|
||||||
prevProps.onClick === nextProps.onClick &&
|
prevProps.onClick === nextProps.onClick &&
|
||||||
prevProps.onOpenBuyModal === nextProps.onOpenBuyModal &&
|
|
||||||
prevProps.onImageAdded === nextProps.onImageAdded &&
|
prevProps.onImageAdded === nextProps.onImageAdded &&
|
||||||
prevProps.onLongPress === nextProps.onLongPress &&
|
prevProps.onLongPress === nextProps.onLongPress &&
|
||||||
prevProps.compact === nextProps.compact
|
prevProps.allItems?.length === nextProps.allItems?.length
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,13 +5,11 @@ import {
|
|||||||
removeStoreFromHousehold,
|
removeStoreFromHousehold,
|
||||||
setDefaultStore
|
setDefaultStore
|
||||||
} from "../../api/stores";
|
} 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 "../../styles/components/manage/ManageStores.css";
|
||||||
import "../../styles/components/manage/StoreAvailableItemsManager.css";
|
|
||||||
|
|
||||||
export default function ManageStores() {
|
export default function ManageStores() {
|
||||||
const { activeHousehold } = useContext(HouseholdContext);
|
const { activeHousehold } = useContext(HouseholdContext);
|
||||||
@ -91,14 +89,6 @@ export default function ManageStores() {
|
|||||||
{/* Current Stores Section */}
|
{/* Current Stores Section */}
|
||||||
<section className="manage-section">
|
<section className="manage-section">
|
||||||
<h2>Your Stores ({householdStores.length})</h2>
|
<h2>Your Stores ({householdStores.length})</h2>
|
||||||
<p className="manage-stores-help">
|
|
||||||
Use each store card's Manage Items button to edit or delete the household/store item list.
|
|
||||||
</p>
|
|
||||||
{!isAdmin && (
|
|
||||||
<p className="manage-stores-note">
|
|
||||||
Only household owners and admins can manage store item catalogs.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{householdStores.length === 0 ? (
|
{householdStores.length === 0 ? (
|
||||||
<p className="empty-message">No stores added yet.</p>
|
<p className="empty-message">No stores added yet.</p>
|
||||||
) : (
|
) : (
|
||||||
@ -129,11 +119,6 @@ export default function ManageStores() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<StoreAvailableItemsManager
|
|
||||||
householdId={activeHousehold.id}
|
|
||||||
store={store}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,256 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
deleteAvailableItem,
|
|
||||||
getAvailableItems,
|
|
||||||
updateAvailableItem,
|
|
||||||
} from "../../api/availableItems";
|
|
||||||
import useActionToast from "../../hooks/useActionToast";
|
|
||||||
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
|
||||||
import AvailableItemEditorModal from "../modals/AvailableItemEditorModal";
|
|
||||||
import ConfirmSlideModal from "../modals/ConfirmSlideModal";
|
|
||||||
|
|
||||||
function itemImageSource(item) {
|
|
||||||
if (!item?.item_image) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mimeType = item.image_mime_type || "image/jpeg";
|
|
||||||
return `data:${mimeType};base64,${item.item_image}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function StoreAvailableItemsManager({ householdId, store, isAdmin }) {
|
|
||||||
const toast = useActionToast();
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [items, setItems] = useState([]);
|
|
||||||
const [catalogReady, setCatalogReady] = useState(true);
|
|
||||||
const [catalogMessage, setCatalogMessage] = useState("");
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [editorItem, setEditorItem] = useState(null);
|
|
||||||
const [showEditor, setShowEditor] = useState(false);
|
|
||||||
const [pendingDeleteItem, setPendingDeleteItem] = useState(null);
|
|
||||||
|
|
||||||
const loadItems = useCallback(async (search = query) => {
|
|
||||||
if (!householdId || !store?.id) {
|
|
||||||
setItems([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await getAvailableItems(householdId, store.id, search);
|
|
||||||
setItems(response.data.items || []);
|
|
||||||
setCatalogReady(response.data.catalog_ready !== false);
|
|
||||||
setCatalogMessage(response.data.message || "");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load store items:", error);
|
|
||||||
setCatalogReady(false);
|
|
||||||
setCatalogMessage("Store item management is unavailable right now.");
|
|
||||||
const message = getApiErrorMessage(error, "Failed to load store items");
|
|
||||||
toast.error("Load store items failed", `Load store items failed: ${message}`);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [householdId, query, store?.id, toast]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadItems(query);
|
|
||||||
}, [isOpen, query, loadItems]);
|
|
||||||
|
|
||||||
const closeManager = () => {
|
|
||||||
setIsOpen(false);
|
|
||||||
setPendingDeleteItem(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdate = async (payload) => {
|
|
||||||
if (!catalogReady) {
|
|
||||||
toast.info(
|
|
||||||
"Store item management unavailable",
|
|
||||||
catalogMessage || "Store item management is unavailable until the latest database migration is applied."
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateAvailableItem(householdId, store.id, editorItem.item_id, payload);
|
|
||||||
toast.success("Updated store item", `Updated ${editorItem.item_name} for ${store.name}`);
|
|
||||||
setShowEditor(false);
|
|
||||||
setEditorItem(null);
|
|
||||||
await loadItems(query);
|
|
||||||
} catch (error) {
|
|
||||||
const message = getApiErrorMessage(error, "Failed to update store item");
|
|
||||||
toast.error("Update store item failed", `Update store item failed: ${message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteConfirm = async () => {
|
|
||||||
if (!pendingDeleteItem) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteAvailableItem(householdId, store.id, pendingDeleteItem.item_id);
|
|
||||||
toast.success("Deleted store item", `Deleted ${pendingDeleteItem.item_name} from ${store.name}`);
|
|
||||||
setPendingDeleteItem(null);
|
|
||||||
await loadItems(query);
|
|
||||||
} catch (error) {
|
|
||||||
const message = getApiErrorMessage(error, "Failed to delete store item");
|
|
||||||
toast.error("Delete store item failed", `Delete store item failed: ${message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-secondary btn-small store-available-items-trigger"
|
|
||||||
onClick={() => setIsOpen(true)}
|
|
||||||
>
|
|
||||||
Manage Items
|
|
||||||
</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>{store.name} Items</h3>
|
|
||||||
<p>Manage the household/store items used for suggestions and store defaults.</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="store-items-modal-close"
|
|
||||||
onClick={closeManager}
|
|
||||||
aria-label="Close manage items modal"
|
|
||||||
>
|
|
||||||
x
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!catalogReady ? (
|
|
||||||
<p className="store-available-items-notice">
|
|
||||||
{catalogMessage || "Store item management is unavailable until the latest database migration is applied."}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="store-items-modal-toolbar">
|
|
||||||
<input
|
|
||||||
className="store-available-items-search"
|
|
||||||
value={query}
|
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
|
||||||
placeholder="Search household/store items"
|
|
||||||
disabled={!catalogReady}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="store-items-modal-body">
|
|
||||||
{!catalogReady ? (
|
|
||||||
<p className="empty-message">Run the latest database migrations to enable store item management.</p>
|
|
||||||
) : loading ? (
|
|
||||||
<p className="empty-message">Loading store items...</p>
|
|
||||||
) : items.length === 0 ? (
|
|
||||||
<p className="empty-message">No household items found for this store yet.</p>
|
|
||||||
) : (
|
|
||||||
<div className="store-items-table">
|
|
||||||
<div className="store-items-table-head" aria-hidden="true">
|
|
||||||
<span>Item</span>
|
|
||||||
<span>Store Defaults</span>
|
|
||||||
<span>Actions</span>
|
|
||||||
</div>
|
|
||||||
<div className="store-items-table-body">
|
|
||||||
{items.map((item) => {
|
|
||||||
const imageSrc = itemImageSource(item);
|
|
||||||
const details = [item.item_type, item.item_group, item.zone].filter(Boolean);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={item.item_id} className="store-items-table-row">
|
|
||||||
<div className="store-items-table-cell store-items-table-item">
|
|
||||||
<span className="store-items-mobile-label">Item</span>
|
|
||||||
<div className="store-available-items-summary">
|
|
||||||
{imageSrc ? (
|
|
||||||
<img src={imageSrc} alt="" className="store-available-items-thumb" />
|
|
||||||
) : (
|
|
||||||
<span className="store-available-items-thumb store-available-items-thumb-placeholder">
|
|
||||||
{item.item_name?.slice(0, 1).toUpperCase() || "?"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="store-available-items-copy">
|
|
||||||
<strong>{item.item_name}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="store-items-table-cell">
|
|
||||||
<span className="store-items-mobile-label">Store Defaults</span>
|
|
||||||
<span className="store-items-defaults-text">
|
|
||||||
{details.join(" | ") || "No store defaults set"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="store-items-table-cell store-items-table-actions">
|
|
||||||
<span className="store-items-mobile-label">Actions</span>
|
|
||||||
<div className="store-available-items-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-secondary btn-small"
|
|
||||||
onClick={() => {
|
|
||||||
setEditorItem(item);
|
|
||||||
setShowEditor(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Edit Settings
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-danger btn-small"
|
|
||||||
onClick={() => setPendingDeleteItem(item)}
|
|
||||||
>
|
|
||||||
Delete Item
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<AvailableItemEditorModal
|
|
||||||
isOpen={showEditor}
|
|
||||||
item={editorItem}
|
|
||||||
onCancel={() => {
|
|
||||||
setShowEditor(false);
|
|
||||||
setEditorItem(null);
|
|
||||||
}}
|
|
||||||
onSave={handleUpdate}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmSlideModal
|
|
||||||
isOpen={Boolean(pendingDeleteItem)}
|
|
||||||
title={pendingDeleteItem ? `Delete ${pendingDeleteItem.item_name}?` : "Delete item?"}
|
|
||||||
description={
|
|
||||||
pendingDeleteItem
|
|
||||||
? `Slide to confirm. This permanently deletes ${pendingDeleteItem.item_name} from ${store.name} for this household, including current list entries and history.`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
confirmLabel="Delete Item"
|
|
||||||
onClose={() => setPendingDeleteItem(null)}
|
|
||||||
onConfirm={handleDeleteConfirm}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,11 +1,9 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import "../../styles/components/AddItemWithDetailsModal.css";
|
import "../../styles/components/AddItemWithDetailsModal.css";
|
||||||
import ClassificationSection from "../forms/ClassificationSection";
|
import ClassificationSection from "../forms/ClassificationSection";
|
||||||
import useActionToast from "../../hooks/useActionToast";
|
|
||||||
import ImageUploadSection from "../forms/ImageUploadSection";
|
import ImageUploadSection from "../forms/ImageUploadSection";
|
||||||
|
|
||||||
export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, onCancel }) {
|
export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, onCancel }) {
|
||||||
const toast = useActionToast();
|
|
||||||
const [selectedImage, setSelectedImage] = useState(null);
|
const [selectedImage, setSelectedImage] = useState(null);
|
||||||
const [imagePreview, setImagePreview] = useState(null);
|
const [imagePreview, setImagePreview] = useState(null);
|
||||||
const [itemType, setItemType] = useState("");
|
const [itemType, setItemType] = useState("");
|
||||||
@ -32,15 +30,15 @@ export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, o
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
|
// Validate classification if provided
|
||||||
if (itemType && !itemGroup) {
|
if (itemType && !itemGroup) {
|
||||||
toast.error("Add item failed", `Add item failed: Select an item group for ${itemName}`);
|
alert("Please select an item group");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasClassificationDetails = Boolean(itemType || itemGroup || zone);
|
const classification = itemType ? {
|
||||||
const classification = hasClassificationDetails ? {
|
|
||||||
item_type: itemType,
|
item_type: itemType,
|
||||||
item_group: itemGroup || null,
|
item_group: itemGroup,
|
||||||
zone: zone || null
|
zone: zone || null
|
||||||
} : null;
|
} : null;
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import "../../styles/components/AssignItemForModal.css";
|
import "../../styles/components/AssignItemForModal.css";
|
||||||
|
|
||||||
function getMemberLabel(member) {
|
function getMemberLabel(member) {
|
||||||
@ -20,9 +19,7 @@ export default function AssignItemForModal({
|
|||||||
}) {
|
}) {
|
||||||
const [selectedUserId, setSelectedUserId] = useState("");
|
const [selectedUserId, setSelectedUserId] = useState("");
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
const [dropdownStyle, setDropdownStyle] = useState(null);
|
const dropdownRef = useRef(null);
|
||||||
const triggerRef = useRef(null);
|
|
||||||
const menuRef = useRef(null);
|
|
||||||
|
|
||||||
const hasMembers = members.length > 0;
|
const hasMembers = members.length > 0;
|
||||||
const selectedMember = useMemo(
|
const selectedMember = useMemo(
|
||||||
@ -30,39 +27,10 @@ export default function AssignItemForModal({
|
|||||||
[members, selectedUserId]
|
[members, selectedUserId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateDropdownPosition = useCallback(() => {
|
|
||||||
if (!triggerRef.current) return;
|
|
||||||
|
|
||||||
const rect = triggerRef.current.getBoundingClientRect();
|
|
||||||
const viewportPadding = 16;
|
|
||||||
const menuGap = 6;
|
|
||||||
const width = Math.min(rect.width, window.innerWidth - (2 * viewportPadding));
|
|
||||||
const left = Math.min(
|
|
||||||
Math.max(viewportPadding, rect.left),
|
|
||||||
window.innerWidth - width - viewportPadding
|
|
||||||
);
|
|
||||||
const availableBelow = window.innerHeight - rect.bottom - menuGap - viewportPadding;
|
|
||||||
const availableAbove = rect.top - menuGap - viewportPadding;
|
|
||||||
const shouldOpenAbove = availableBelow < 140 && availableAbove > availableBelow;
|
|
||||||
const maxHeight = Math.max(
|
|
||||||
120,
|
|
||||||
Math.min(240, Math.floor(shouldOpenAbove ? availableAbove : availableBelow))
|
|
||||||
);
|
|
||||||
|
|
||||||
setDropdownStyle({
|
|
||||||
left: `${Math.round(left)}px`,
|
|
||||||
width: `${Math.round(width)}px`,
|
|
||||||
maxHeight: `${maxHeight}px`,
|
|
||||||
top: shouldOpenAbove ? "auto" : `${Math.round(rect.bottom + menuGap)}px`,
|
|
||||||
bottom: shouldOpenAbove ? `${Math.round(window.innerHeight - rect.top + menuGap)}px` : "auto",
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
setSelectedUserId(members[0] ? String(members[0].id) : "");
|
setSelectedUserId(members[0] ? String(members[0].id) : "");
|
||||||
setIsDropdownOpen(false);
|
setIsDropdownOpen(false);
|
||||||
setDropdownStyle(null);
|
|
||||||
}, [isOpen, members]);
|
}, [isOpen, members]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -86,10 +54,8 @@ export default function AssignItemForModal({
|
|||||||
if (!isOpen || !isDropdownOpen) return undefined;
|
if (!isOpen || !isDropdownOpen) return undefined;
|
||||||
|
|
||||||
const handlePointerDown = (event) => {
|
const handlePointerDown = (event) => {
|
||||||
const clickedTrigger = triggerRef.current?.contains(event.target);
|
if (!dropdownRef.current) return;
|
||||||
const clickedMenu = menuRef.current?.contains(event.target);
|
if (!dropdownRef.current.contains(event.target)) {
|
||||||
|
|
||||||
if (!clickedTrigger && !clickedMenu) {
|
|
||||||
setIsDropdownOpen(false);
|
setIsDropdownOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -98,24 +64,6 @@ export default function AssignItemForModal({
|
|||||||
return () => window.removeEventListener("pointerdown", handlePointerDown);
|
return () => window.removeEventListener("pointerdown", handlePointerDown);
|
||||||
}, [isDropdownOpen, isOpen]);
|
}, [isDropdownOpen, isOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen || !isDropdownOpen) return undefined;
|
|
||||||
|
|
||||||
updateDropdownPosition();
|
|
||||||
|
|
||||||
const handleViewportChange = () => {
|
|
||||||
updateDropdownPosition();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("resize", handleViewportChange);
|
|
||||||
window.addEventListener("scroll", handleViewportChange, true);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("resize", handleViewportChange);
|
|
||||||
window.removeEventListener("scroll", handleViewportChange, true);
|
|
||||||
};
|
|
||||||
}, [isDropdownOpen, isOpen, updateDropdownPosition]);
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
@ -123,52 +71,6 @@ export default function AssignItemForModal({
|
|||||||
onConfirm(selectedMember.id);
|
onConfirm(selectedMember.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleDropdown = () => {
|
|
||||||
if (isDropdownOpen) {
|
|
||||||
setIsDropdownOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateDropdownPosition();
|
|
||||||
setIsDropdownOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const dropdownMenu = isDropdownOpen && dropdownStyle
|
|
||||||
? createPortal(
|
|
||||||
<div
|
|
||||||
ref={menuRef}
|
|
||||||
className="assign-item-for-dropdown-menu"
|
|
||||||
role="listbox"
|
|
||||||
aria-label="Household member"
|
|
||||||
style={dropdownStyle}
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
{members.map((member) => {
|
|
||||||
const memberId = String(member.id);
|
|
||||||
const isSelected = memberId === String(selectedUserId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={member.id}
|
|
||||||
type="button"
|
|
||||||
className={`assign-item-for-dropdown-option ${isSelected ? "is-selected" : ""}`}
|
|
||||||
role="option"
|
|
||||||
aria-selected={isSelected}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedUserId(memberId);
|
|
||||||
setIsDropdownOpen(false);
|
|
||||||
}}
|
|
||||||
title={getMemberLabel(member)}
|
|
||||||
>
|
|
||||||
{getMemberOptionLabel(member)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onCancel}>
|
<div className="modal-overlay" onClick={onCancel}>
|
||||||
<div className="modal assign-item-for-modal" onClick={(event) => event.stopPropagation()}>
|
<div className="modal assign-item-for-modal" onClick={(event) => event.stopPropagation()}>
|
||||||
@ -179,14 +81,13 @@ export default function AssignItemForModal({
|
|||||||
<label className="form-label">
|
<label className="form-label">
|
||||||
Household member
|
Household member
|
||||||
</label>
|
</label>
|
||||||
<div className="assign-item-for-dropdown">
|
<div className="assign-item-for-dropdown" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
|
||||||
type="button"
|
type="button"
|
||||||
className={`assign-item-for-dropdown-trigger ${isDropdownOpen ? "is-open" : ""}`}
|
className={`assign-item-for-dropdown-trigger ${isDropdownOpen ? "is-open" : ""}`}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
aria-expanded={isDropdownOpen}
|
aria-expanded={isDropdownOpen}
|
||||||
onClick={handleToggleDropdown}
|
onClick={() => setIsDropdownOpen((prev) => !prev)}
|
||||||
>
|
>
|
||||||
<span className="assign-item-for-dropdown-label">
|
<span className="assign-item-for-dropdown-label">
|
||||||
{selectedMember ? getMemberOptionLabel(selectedMember) : "Select member"}
|
{selectedMember ? getMemberOptionLabel(selectedMember) : "Select member"}
|
||||||
@ -195,6 +96,32 @@ export default function AssignItemForModal({
|
|||||||
{isDropdownOpen ? "▲" : "▼"}
|
{isDropdownOpen ? "▲" : "▼"}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{isDropdownOpen ? (
|
||||||
|
<div className="assign-item-for-dropdown-menu" role="listbox" aria-label="Household member">
|
||||||
|
{members.map((member) => {
|
||||||
|
const memberId = String(member.id);
|
||||||
|
const isSelected = memberId === String(selectedUserId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={member.id}
|
||||||
|
type="button"
|
||||||
|
className={`assign-item-for-dropdown-option ${isSelected ? "is-selected" : ""}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedUserId(memberId);
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
title={getMemberLabel(member)}
|
||||||
|
>
|
||||||
|
{getMemberOptionLabel(member)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -217,7 +144,6 @@ export default function AssignItemForModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{dropdownMenu}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,166 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import ClassificationSection from "../forms/ClassificationSection";
|
|
||||||
import ImageUploadSection from "../forms/ImageUploadSection";
|
|
||||||
import useActionToast from "../../hooks/useActionToast";
|
|
||||||
import "../../styles/components/AvailableItemEditorModal.css";
|
|
||||||
|
|
||||||
function buildPreview(item) {
|
|
||||||
if (!item?.item_image) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mimeType = item.image_mime_type || "image/jpeg";
|
|
||||||
return `data:${mimeType};base64,${item.item_image}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AvailableItemEditorModal({ isOpen, item = null, onCancel, onSave }) {
|
|
||||||
const toast = useActionToast();
|
|
||||||
const [itemName, setItemName] = useState("");
|
|
||||||
const [itemType, setItemType] = useState("");
|
|
||||||
const [itemGroup, setItemGroup] = useState("");
|
|
||||||
const [zone, setZone] = useState("");
|
|
||||||
const [selectedImage, setSelectedImage] = useState(null);
|
|
||||||
const [imagePreview, setImagePreview] = useState(null);
|
|
||||||
const [removeImage, setRemoveImage] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setItemName(item?.item_name || "");
|
|
||||||
setItemType(item?.item_type || "");
|
|
||||||
setItemGroup(item?.item_group || "");
|
|
||||||
setZone(item?.zone || "");
|
|
||||||
setSelectedImage(null);
|
|
||||||
setImagePreview(buildPreview(item));
|
|
||||||
setRemoveImage(false);
|
|
||||||
}, [isOpen, item]);
|
|
||||||
|
|
||||||
if (!isOpen) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleItemTypeChange = (nextType) => {
|
|
||||||
setItemType(nextType);
|
|
||||||
setItemGroup("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImageChange = (file) => {
|
|
||||||
setSelectedImage(file);
|
|
||||||
setRemoveImage(false);
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onloadend = () => {
|
|
||||||
setImagePreview(reader.result);
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImageRemove = () => {
|
|
||||||
setSelectedImage(null);
|
|
||||||
setImagePreview(null);
|
|
||||||
setRemoveImage(Boolean(item?.item_image));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!itemName.trim()) {
|
|
||||||
toast.error("Save available item failed", "Save available item failed: Item name is required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemType && !itemGroup) {
|
|
||||||
toast.error(
|
|
||||||
"Save available item failed",
|
|
||||||
`Save available item failed: Select an item group for ${itemName.trim()}`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await onSave({
|
|
||||||
itemName: itemName.trim(),
|
|
||||||
classification: itemType || itemGroup || zone
|
|
||||||
? {
|
|
||||||
item_type: itemType || null,
|
|
||||||
item_group: itemGroup || null,
|
|
||||||
zone: zone || null,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
imageFile: selectedImage,
|
|
||||||
removeImage,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="available-item-editor-overlay" onClick={onCancel}>
|
|
||||||
<div className="available-item-editor-modal" onClick={(event) => event.stopPropagation()}>
|
|
||||||
<h2 className="available-item-editor-title">
|
|
||||||
{item ? `Edit ${item.item_name}` : "Edit Store Item"}
|
|
||||||
</h2>
|
|
||||||
<p className="available-item-editor-subtitle">
|
|
||||||
Save store-specific defaults for this household/store item.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="available-item-editor-field">
|
|
||||||
<label htmlFor="available-item-name">Item Name</label>
|
|
||||||
<input
|
|
||||||
id="available-item-name"
|
|
||||||
className="available-item-editor-input"
|
|
||||||
value={itemName}
|
|
||||||
onChange={(event) => setItemName(event.target.value)}
|
|
||||||
placeholder="Enter item name"
|
|
||||||
disabled={Boolean(item)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="available-item-editor-section">
|
|
||||||
<ImageUploadSection
|
|
||||||
imagePreview={imagePreview}
|
|
||||||
onImageChange={handleImageChange}
|
|
||||||
onImageRemove={handleImageRemove}
|
|
||||||
title="Store Image (Optional)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="available-item-editor-section">
|
|
||||||
<ClassificationSection
|
|
||||||
itemType={itemType}
|
|
||||||
itemGroup={itemGroup}
|
|
||||||
zone={zone}
|
|
||||||
onItemTypeChange={handleItemTypeChange}
|
|
||||||
onItemGroupChange={setItemGroup}
|
|
||||||
onZoneChange={setZone}
|
|
||||||
fieldClass="available-item-editor-field"
|
|
||||||
selectClass="available-item-editor-select"
|
|
||||||
title="Store Classification (Optional)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="available-item-editor-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="available-item-editor-btn available-item-editor-btn-cancel"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="available-item-editor-btn available-item-editor-btn-save"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
{saving ? "Saving..." : "Save Changes"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -9,54 +9,45 @@ export default function ConfirmBuyModal({
|
|||||||
onNavigate
|
onNavigate
|
||||||
}) {
|
}) {
|
||||||
const [quantity, setQuantity] = useState(item.quantity);
|
const [quantity, setQuantity] = useState(item.quantity);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const maxQuantity = item.quantity;
|
const maxQuantity = item.quantity;
|
||||||
|
|
||||||
|
// Update quantity when item changes (navigation)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setQuantity(item.quantity);
|
setQuantity(item.quantity);
|
||||||
setIsSubmitting(false);
|
|
||||||
}, [item.id, item.quantity]);
|
}, [item.id, item.quantity]);
|
||||||
|
|
||||||
const currentIndex = allItems.findIndex((listItem) => listItem.id === item.id);
|
// Find current index and check for prev/next
|
||||||
|
const currentIndex = allItems.findIndex(i => i.id === item.id);
|
||||||
const hasPrev = currentIndex > 0;
|
const hasPrev = currentIndex > 0;
|
||||||
const hasNext = currentIndex < allItems.length - 1;
|
const hasNext = currentIndex < allItems.length - 1;
|
||||||
|
|
||||||
const handleIncrement = () => {
|
const handleIncrement = () => {
|
||||||
if (!isSubmitting && quantity < maxQuantity) {
|
if (quantity < maxQuantity) {
|
||||||
setQuantity((prev) => prev + 1);
|
setQuantity(prev => prev + 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDecrement = () => {
|
const handleDecrement = () => {
|
||||||
if (!isSubmitting && quantity > 1) {
|
if (quantity > 1) {
|
||||||
setQuantity((prev) => prev - 1);
|
setQuantity(prev => prev - 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = () => {
|
||||||
if (isSubmitting) return;
|
onConfirm(quantity);
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
await onConfirm(quantity);
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrev = () => {
|
const handlePrev = () => {
|
||||||
if (isSubmitting) return;
|
|
||||||
|
|
||||||
if (hasPrev && onNavigate) {
|
if (hasPrev && onNavigate) {
|
||||||
onNavigate(allItems[currentIndex - 1]);
|
const prevItem = allItems[currentIndex - 1];
|
||||||
|
onNavigate(prevItem);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNext = () => {
|
const handleNext = () => {
|
||||||
if (isSubmitting) return;
|
|
||||||
|
|
||||||
if (hasNext && onNavigate) {
|
if (hasNext && onNavigate) {
|
||||||
onNavigate(allItems[currentIndex + 1]);
|
const nextItem = allItems[currentIndex + 1];
|
||||||
|
onNavigate(nextItem);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -65,15 +56,8 @@ export default function ConfirmBuyModal({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="confirm-buy-modal-overlay" onClick={onCancel}>
|
||||||
className="confirm-buy-modal-overlay"
|
<div className="confirm-buy-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
onClick={() => {
|
|
||||||
if (!isSubmitting) {
|
|
||||||
onCancel();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="confirm-buy-modal" 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 className="confirm-buy-item-name">{item.item_name}</h2>
|
||||||
@ -83,27 +67,27 @@ export default function ConfirmBuyModal({
|
|||||||
<button
|
<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}
|
||||||
>
|
>
|
||||||
{"<"}
|
‹
|
||||||
</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">📦</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<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}
|
||||||
>
|
>
|
||||||
{">"}
|
›
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -112,9 +96,9 @@ export default function ConfirmBuyModal({
|
|||||||
<button
|
<button
|
||||||
onClick={handleDecrement}
|
onClick={handleDecrement}
|
||||||
className="confirm-buy-counter-btn"
|
className="confirm-buy-counter-btn"
|
||||||
disabled={quantity <= 1 || isSubmitting}
|
disabled={quantity <= 1}
|
||||||
>
|
>
|
||||||
-
|
−
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@ -125,7 +109,7 @@ export default function ConfirmBuyModal({
|
|||||||
<button
|
<button
|
||||||
onClick={handleIncrement}
|
onClick={handleIncrement}
|
||||||
className="confirm-buy-counter-btn"
|
className="confirm-buy-counter-btn"
|
||||||
disabled={quantity >= maxQuantity || isSubmitting}
|
disabled={quantity >= maxQuantity}
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
@ -133,11 +117,11 @@ 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 onClick={onCancel} className="confirm-buy-cancel">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleConfirm} className="confirm-buy-confirm" disabled={isSubmitting}>
|
<button onClick={handleConfirm} className="confirm-buy-confirm">
|
||||||
{isSubmitting ? "Saving..." : "Mark as Bought"}
|
Mark as Bought
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../../constants/classifications";
|
import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../../constants/classifications";
|
||||||
import useActionToast from "../../hooks/useActionToast";
|
import useActionToast from "../../hooks/useActionToast";
|
||||||
|
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
||||||
import "../../styles/components/EditItemModal.css";
|
import "../../styles/components/EditItemModal.css";
|
||||||
import AddImageModal from "./AddImageModal";
|
import AddImageModal from "./AddImageModal";
|
||||||
|
|
||||||
@ -14,54 +15,50 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showImageModal, setShowImageModal] = useState(false);
|
const [showImageModal, setShowImageModal] = useState(false);
|
||||||
|
|
||||||
|
// Load existing classification
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item.classification) {
|
if (item.classification) {
|
||||||
setItemType(item.classification.item_type || "");
|
setItemType(item.classification.item_type || "");
|
||||||
setItemGroup(item.classification.item_group || "");
|
setItemGroup(item.classification.item_group || "");
|
||||||
setZone(item.classification.zone || "");
|
setZone(item.classification.zone || "");
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setItemType("");
|
|
||||||
setItemGroup("");
|
|
||||||
setZone("");
|
|
||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
const handleItemTypeChange = (newType) => {
|
const handleItemTypeChange = (newType) => {
|
||||||
setItemType(newType);
|
setItemType(newType);
|
||||||
setItemGroup("");
|
setItemGroup(""); // Reset group when type changes
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!itemName.trim()) {
|
if (!itemName.trim()) {
|
||||||
toast.error("Save item failed", "Save item failed: Item name is required");
|
alert("Item name is required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (quantity < 1) {
|
if (quantity < 1) {
|
||||||
toast.error("Save item failed", "Save item failed: Quantity must be at least 1");
|
alert("Quantity must be at least 1");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If classification fields are filled, validate them
|
||||||
if (itemType && !itemGroup) {
|
if (itemType && !itemGroup) {
|
||||||
toast.error("Save item failed", `Save item failed: Select an item group for ${itemName}`);
|
alert("Please select an item group");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const hasClassificationDetails = Boolean(itemType || itemGroup || zone);
|
const classification = itemType ? {
|
||||||
const classification = hasClassificationDetails
|
item_type: itemType,
|
||||||
? {
|
item_group: itemGroup,
|
||||||
item_type: itemType,
|
zone: zone || null
|
||||||
item_group: itemGroup || null,
|
} : null;
|
||||||
zone: zone || null
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
await onSave(item.id, itemName, quantity, classification);
|
await onSave(item.id, itemName, quantity, classification);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save:", error);
|
console.error("Failed to save:", error);
|
||||||
|
const message = getApiErrorMessage(error, "Failed to save changes");
|
||||||
|
toast.error("Save item failed", `Save item failed: ${message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -74,18 +71,18 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
|
|||||||
setShowImageModal(false);
|
setShowImageModal(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to upload image:", error);
|
console.error("Failed to upload image:", error);
|
||||||
const message = error?.response?.data?.error?.message || error?.response?.data?.message || "Failed to upload image";
|
const message = getApiErrorMessage(error, "Failed to upload image");
|
||||||
toast.error("Upload image failed", `Upload image failed: ${message}`);
|
toast.error("Upload image failed", `Upload image failed: ${message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const incrementQuantity = () => {
|
const incrementQuantity = () => {
|
||||||
setQuantity((prev) => prev + 1);
|
setQuantity(prev => prev + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const decrementQuantity = () => {
|
const decrementQuantity = () => {
|
||||||
setQuantity((prev) => Math.max(1, prev - 1));
|
setQuantity(prev => Math.max(1, prev - 1));
|
||||||
};
|
};
|
||||||
|
|
||||||
const availableGroups = itemType ? (ITEM_GROUPS[itemType] || []) : [];
|
const availableGroups = itemType ? (ITEM_GROUPS[itemType] || []) : [];
|
||||||
@ -95,6 +92,7 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
|
|||||||
<div className="edit-modal-content" onClick={(e) => e.stopPropagation()}>
|
<div className="edit-modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
<h2 className="edit-modal-title">Edit Item</h2>
|
<h2 className="edit-modal-title">Edit Item</h2>
|
||||||
|
|
||||||
|
{/* Item Name - no label */}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={itemName}
|
value={itemName}
|
||||||
@ -103,6 +101,7 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
|
|||||||
placeholder="Item name"
|
placeholder="Item name"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Quantity Control - like AddItemForm */}
|
||||||
<div className="edit-modal-quantity-control">
|
<div className="edit-modal-quantity-control">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -110,7 +109,7 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
|
|||||||
onClick={decrementQuantity}
|
onClick={decrementQuantity}
|
||||||
disabled={quantity <= 1}
|
disabled={quantity <= 1}
|
||||||
>
|
>
|
||||||
-
|
−
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@ -130,6 +129,7 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
|
|||||||
|
|
||||||
<div className="edit-modal-divider" />
|
<div className="edit-modal-divider" />
|
||||||
|
|
||||||
|
{/* Inline Classification Fields */}
|
||||||
<div className="edit-modal-inline-field">
|
<div className="edit-modal-inline-field">
|
||||||
<label>Type</label>
|
<label>Type</label>
|
||||||
<select
|
<select
|
||||||
@ -172,9 +172,9 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
|
|||||||
className="edit-modal-select"
|
className="edit-modal-select"
|
||||||
>
|
>
|
||||||
<option value="">-- Select Zone --</option>
|
<option value="">-- Select Zone --</option>
|
||||||
{getZoneValues().map((candidateZone) => (
|
{getZoneValues().map((z) => (
|
||||||
<option key={candidateZone} value={candidateZone}>
|
<option key={z} value={z}>
|
||||||
{candidateZone}
|
{z}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@ -188,7 +188,7 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{item.item_image ? "Change Image" : "Set Image"}
|
{item.item_image ? "🖼️ Change Image" : "📷 Set Image"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="edit-modal-actions">
|
<div className="edit-modal-actions">
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import {
|
|||||||
addItem,
|
addItem,
|
||||||
getClassification,
|
getClassification,
|
||||||
getItemByName,
|
getItemByName,
|
||||||
getList,
|
getList,
|
||||||
getRecentlyBought,
|
getRecentlyBought,
|
||||||
getSuggestions,
|
getSuggestions,
|
||||||
markBought,
|
markBought,
|
||||||
@ -13,15 +13,15 @@ import {
|
|||||||
import { getHouseholdMembers } from "../api/households";
|
import { getHouseholdMembers } from "../api/households";
|
||||||
import SortDropdown from "../components/common/SortDropdown";
|
import SortDropdown from "../components/common/SortDropdown";
|
||||||
import AddItemForm from "../components/forms/AddItemForm";
|
import AddItemForm from "../components/forms/AddItemForm";
|
||||||
import GroceryListItem from "../components/items/GroceryListItem";
|
import GroceryListItem from "../components/items/GroceryListItem";
|
||||||
import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal";
|
import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModal";
|
||||||
import ConfirmBuyModal from "../components/modals/ConfirmBuyModal";
|
import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal";
|
||||||
import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal";
|
import EditItemModal from "../components/modals/EditItemModal";
|
||||||
import EditItemModal from "../components/modals/EditItemModal";
|
import SimilarItemModal from "../components/modals/SimilarItemModal";
|
||||||
import SimilarItemModal from "../components/modals/SimilarItemModal";
|
import StoreTabs from "../components/store/StoreTabs";
|
||||||
import StoreTabs from "../components/store/StoreTabs";
|
import { ZONE_FLOW } from "../constants/classifications";
|
||||||
import { ZONE_FLOW } from "../constants/classifications";
|
import { ROLES } from "../constants/roles";
|
||||||
import { AuthContext } from "../context/AuthContext";
|
import { AuthContext } from "../context/AuthContext";
|
||||||
import { HouseholdContext } from "../context/HouseholdContext";
|
import { HouseholdContext } from "../context/HouseholdContext";
|
||||||
import { IMAGE_UPLOAD_SUCCESS_EVENT } from "../context/UploadQueueContext";
|
import { IMAGE_UPLOAD_SUCCESS_EVENT } from "../context/UploadQueueContext";
|
||||||
import { SettingsContext } from "../context/SettingsContext";
|
import { SettingsContext } from "../context/SettingsContext";
|
||||||
@ -31,52 +31,8 @@ import useUploadQueue from "../hooks/useUploadQueue";
|
|||||||
import getApiErrorMessage from "../lib/getApiErrorMessage";
|
import getApiErrorMessage from "../lib/getApiErrorMessage";
|
||||||
import "../styles/pages/GroceryList.css";
|
import "../styles/pages/GroceryList.css";
|
||||||
import { findSimilarItems } from "../utils/stringSimilarity";
|
import { findSimilarItems } from "../utils/stringSimilarity";
|
||||||
|
|
||||||
function sortItemsForMode(items, sortMode) {
|
|
||||||
const sorted = [...items];
|
|
||||||
|
|
||||||
if (sortMode === "az") sorted.sort((a, b) => a.item_name.localeCompare(b.item_name));
|
|
||||||
if (sortMode === "za") sorted.sort((a, b) => b.item_name.localeCompare(a.item_name));
|
|
||||||
if (sortMode === "qty-high") sorted.sort((a, b) => b.quantity - a.quantity);
|
|
||||||
if (sortMode === "qty-low") sorted.sort((a, b) => a.quantity - b.quantity);
|
|
||||||
if (sortMode === "zone") {
|
|
||||||
sorted.sort((a, b) => {
|
|
||||||
if (!a.zone && b.zone) return 1;
|
|
||||||
if (a.zone && !b.zone) return -1;
|
|
||||||
if (!a.zone && !b.zone) return a.item_name.localeCompare(b.item_name);
|
|
||||||
|
|
||||||
const aZoneIndex = ZONE_FLOW.indexOf(a.zone);
|
|
||||||
const bZoneIndex = ZONE_FLOW.indexOf(b.zone);
|
|
||||||
const aIndex = aZoneIndex === -1 ? ZONE_FLOW.length : aZoneIndex;
|
|
||||||
const bIndex = bZoneIndex === -1 ? ZONE_FLOW.length : bZoneIndex;
|
|
||||||
|
|
||||||
const zoneCompare = aIndex - bIndex;
|
|
||||||
if (zoneCompare !== 0) return zoneCompare;
|
|
||||||
|
|
||||||
const typeCompare = (a.item_type || "").localeCompare(b.item_type || "");
|
|
||||||
if (typeCompare !== 0) return typeCompare;
|
|
||||||
|
|
||||||
const groupCompare = (a.item_group || "").localeCompare(b.item_group || "");
|
|
||||||
if (groupCompare !== 0) return groupCompare;
|
|
||||||
|
|
||||||
return a.item_name.localeCompare(b.item_name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return sorted;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNextModalItem(sortedItems, currentIndex, excludedItemId) {
|
|
||||||
const remainingItems = sortedItems.filter((item) => item.id !== excludedItemId);
|
|
||||||
|
|
||||||
if (remainingItems.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return remainingItems[currentIndex] || remainingItems[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export default function GroceryList() {
|
export default function GroceryList() {
|
||||||
const pageTitle = "Grocery List";
|
const pageTitle = "Grocery List";
|
||||||
const { userId } = useContext(AuthContext);
|
const { userId } = useContext(AuthContext);
|
||||||
@ -87,10 +43,9 @@ export default function GroceryList() {
|
|||||||
const { enqueueImageUpload } = useUploadQueue();
|
const { enqueueImageUpload } = useUploadQueue();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Get household role for permissions
|
// Get household role for permissions
|
||||||
const householdRole = activeHousehold?.role;
|
const householdRole = activeHousehold?.role;
|
||||||
const isHouseholdAdmin = ["owner", "admin"].includes(householdRole);
|
const isHouseholdAdmin = ["owner", "admin"].includes(householdRole);
|
||||||
const canEditList = Boolean(householdRole && householdRole !== "viewer");
|
|
||||||
|
|
||||||
// === State === //
|
// === State === //
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
@ -102,16 +57,15 @@ export default function GroceryList() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [buttonText, setButtonText] = useState("Add Item");
|
const [buttonText, setButtonText] = useState("Add Item");
|
||||||
const [pendingItem, setPendingItem] = useState(null);
|
const [pendingItem, setPendingItem] = useState(null);
|
||||||
const [showAddDetailsModal, setShowAddDetailsModal] = useState(false);
|
const [showAddDetailsModal, setShowAddDetailsModal] = useState(false);
|
||||||
const [showSimilarModal, setShowSimilarModal] = useState(false);
|
const [showSimilarModal, setShowSimilarModal] = useState(false);
|
||||||
const [similarItemSuggestion, setSimilarItemSuggestion] = useState(null);
|
const [similarItemSuggestion, setSimilarItemSuggestion] = useState(null);
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [editingItem, setEditingItem] = useState(null);
|
const [editingItem, setEditingItem] = useState(null);
|
||||||
const [recentlyBoughtCollapsed, setRecentlyBoughtCollapsed] = useState(settings.recentlyBoughtCollapsed);
|
const [recentlyBoughtCollapsed, setRecentlyBoughtCollapsed] = useState(settings.recentlyBoughtCollapsed);
|
||||||
const [collapsedZones, setCollapsedZones] = useState({});
|
const [collapsedZones, setCollapsedZones] = useState({});
|
||||||
const [showConfirmAddExisting, setShowConfirmAddExisting] = useState(false);
|
const [showConfirmAddExisting, setShowConfirmAddExisting] = useState(false);
|
||||||
const [confirmAddExistingData, setConfirmAddExistingData] = useState(null);
|
const [confirmAddExistingData, setConfirmAddExistingData] = useState(null);
|
||||||
const [buyModalState, setBuyModalState] = useState(null);
|
|
||||||
|
|
||||||
|
|
||||||
// === Data Loading ===
|
// === Data Loading ===
|
||||||
@ -152,10 +106,6 @@ export default function GroceryList() {
|
|||||||
loadRecentlyBought();
|
loadRecentlyBought();
|
||||||
}, [activeHousehold?.id, activeStore?.id]);
|
}, [activeHousehold?.id, activeStore?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setBuyModalState(null);
|
|
||||||
}, [activeHousehold?.id, activeStore?.id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadHouseholdMembers = async () => {
|
const loadHouseholdMembers = async () => {
|
||||||
if (!activeHousehold?.id) {
|
if (!activeHousehold?.id) {
|
||||||
@ -233,37 +183,46 @@ export default function GroceryList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// === Sorted Items Computation ===
|
// === Sorted Items Computation ===
|
||||||
const sortedItems = useMemo(() => {
|
const sortedItems = useMemo(() => {
|
||||||
return sortItemsForMode(items, sortMode);
|
const sorted = [...items];
|
||||||
}, [items, sortMode]);
|
|
||||||
|
if (sortMode === "az") sorted.sort((a, b) => a.item_name.localeCompare(b.item_name));
|
||||||
const visibleRecentlyBoughtItems = useMemo(
|
if (sortMode === "za") sorted.sort((a, b) => b.item_name.localeCompare(a.item_name));
|
||||||
() => recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount),
|
if (sortMode === "qty-high") sorted.sort((a, b) => b.quantity - a.quantity);
|
||||||
[recentlyBoughtItems, recentlyBoughtDisplayCount]
|
if (sortMode === "qty-low") sorted.sort((a, b) => a.quantity - b.quantity);
|
||||||
);
|
if (sortMode === "zone") {
|
||||||
|
sorted.sort((a, b) => {
|
||||||
const buyModalItems = useMemo(() => {
|
// Items without classification go to the end
|
||||||
if (!buyModalState) return [];
|
if (!a.zone && b.zone) return 1;
|
||||||
|
if (a.zone && !b.zone) return -1;
|
||||||
return buyModalState.source === "active"
|
if (!a.zone && !b.zone) return a.item_name.localeCompare(b.item_name);
|
||||||
? sortedItems
|
|
||||||
: visibleRecentlyBoughtItems;
|
// Sort by ZONE_FLOW order
|
||||||
}, [buyModalState, sortedItems, visibleRecentlyBoughtItems]);
|
const aZoneIndex = ZONE_FLOW.indexOf(a.zone);
|
||||||
|
const bZoneIndex = ZONE_FLOW.indexOf(b.zone);
|
||||||
useEffect(() => {
|
|
||||||
if (!buyModalState) return;
|
// If zone not in ZONE_FLOW, put at end
|
||||||
|
const aIndex = aZoneIndex === -1 ? ZONE_FLOW.length : aZoneIndex;
|
||||||
const refreshedItem = buyModalItems.find((item) => item.id === buyModalState.item.id);
|
const bIndex = bZoneIndex === -1 ? ZONE_FLOW.length : bZoneIndex;
|
||||||
if (!refreshedItem || refreshedItem === buyModalState.item) return;
|
|
||||||
|
const zoneCompare = aIndex - bIndex;
|
||||||
setBuyModalState((prev) => {
|
if (zoneCompare !== 0) return zoneCompare;
|
||||||
if (!prev || prev.item.id !== refreshedItem.id || prev.source !== buyModalState.source) {
|
|
||||||
return prev;
|
// Then by item_type
|
||||||
}
|
const typeCompare = (a.item_type || "").localeCompare(b.item_type || "");
|
||||||
|
if (typeCompare !== 0) return typeCompare;
|
||||||
return { ...prev, item: refreshedItem };
|
|
||||||
});
|
// Then by item_group
|
||||||
}, [buyModalItems, buyModalState]);
|
const groupCompare = (a.item_group || "").localeCompare(b.item_group || "");
|
||||||
|
if (groupCompare !== 0) return groupCompare;
|
||||||
|
|
||||||
|
// Finally by name
|
||||||
|
return a.item_name.localeCompare(b.item_name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}, [items, sortMode]);
|
||||||
|
|
||||||
|
|
||||||
// === Suggestion Handler ===
|
// === Suggestion Handler ===
|
||||||
@ -577,90 +536,35 @@ export default function GroceryList() {
|
|||||||
|
|
||||||
|
|
||||||
// === Item Action Handlers ===
|
// === Item Action Handlers ===
|
||||||
const handleBought = useCallback(async (quantity) => {
|
const handleBought = useCallback(async (id, quantity) => {
|
||||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||||
if (!buyModalState || buyModalState.source !== "active") {
|
|
||||||
setBuyModalState(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = items.find((listItem) => listItem.id === buyModalState.item.id) || buyModalState.item;
|
const item = items.find(i => i.id === id);
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentIndex = sortedItems.findIndex((listItem) => listItem.id === item.id);
|
|
||||||
const resolvedIndex = currentIndex >= 0 ? currentIndex : 0;
|
|
||||||
|
|
||||||
await markBought(activeHousehold.id, activeStore.id, item.item_name, quantity, true);
|
await markBought(activeHousehold.id, activeStore.id, item.item_name, quantity, true);
|
||||||
|
|
||||||
let nextItems = items;
|
// If buying full quantity, remove from list
|
||||||
|
|
||||||
if (quantity >= item.quantity) {
|
if (quantity >= item.quantity) {
|
||||||
nextItems = items.filter((existingItem) => existingItem.id !== item.id);
|
setItems(prevItems => prevItems.filter((existingItem) => existingItem.id !== id));
|
||||||
} else {
|
} else {
|
||||||
|
// If partial, fetch updated item
|
||||||
const response = await getItemByName(activeHousehold.id, activeStore.id, item.item_name);
|
const response = await getItemByName(activeHousehold.id, activeStore.id, item.item_name);
|
||||||
const updatedItem = response.data;
|
const updatedItem = response.data;
|
||||||
|
|
||||||
nextItems = items.map((existingItem) =>
|
setItems((prevItems) =>
|
||||||
existingItem.id === item.id ? updatedItem : existingItem
|
prevItems.map((existingItem) => (existingItem.id === id ? updatedItem : existingItem))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setItems(nextItems);
|
|
||||||
|
|
||||||
const nextSortedItems = sortItemsForMode(nextItems, sortMode);
|
|
||||||
const nextModalItem = getNextModalItem(nextSortedItems, resolvedIndex, item.id);
|
|
||||||
|
|
||||||
setBuyModalState(
|
|
||||||
nextModalItem
|
|
||||||
? {
|
|
||||||
item: nextModalItem,
|
|
||||||
source: "active",
|
|
||||||
canConfirm: true,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
|
|
||||||
toast.success("Marked item bought", `Marked item ${item.item_name} as bought`);
|
toast.success("Marked item bought", `Marked item ${item.item_name} as bought`);
|
||||||
loadRecentlyBought();
|
loadRecentlyBought();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getApiErrorMessage(error, "Failed to mark item as bought");
|
const message = getApiErrorMessage(error, "Failed to mark item as bought");
|
||||||
toast.error("Mark item bought failed", `Mark item bought failed: ${message}`);
|
toast.error("Mark item bought failed", `Mark item bought failed: ${message}`);
|
||||||
}
|
}
|
||||||
}, [activeHousehold?.id, activeStore?.id, buyModalState, items, sortMode, sortedItems, toast]);
|
}, [activeHousehold?.id, activeStore?.id, items, toast]);
|
||||||
|
|
||||||
const openActiveBuyModal = useCallback((item) => {
|
|
||||||
setBuyModalState({
|
|
||||||
item,
|
|
||||||
source: "active",
|
|
||||||
canConfirm: canEditList,
|
|
||||||
});
|
|
||||||
}, [canEditList]);
|
|
||||||
|
|
||||||
const openRecentBuyModal = useCallback((item) => {
|
|
||||||
setBuyModalState({
|
|
||||||
item,
|
|
||||||
source: "recent",
|
|
||||||
canConfirm: false,
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleBuyModalCancel = useCallback(() => {
|
|
||||||
setBuyModalState(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleBuyModalNavigate = useCallback((item) => {
|
|
||||||
setBuyModalState((prev) => (prev ? { ...prev, item } : prev));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleBuyModalConfirm = useCallback(async (quantity) => {
|
|
||||||
if (!buyModalState?.canConfirm) {
|
|
||||||
setBuyModalState(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await handleBought(quantity);
|
|
||||||
}, [buyModalState?.canConfirm, handleBought]);
|
|
||||||
|
|
||||||
const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile, source = "add_image_modal") => {
|
const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile, source = "add_image_modal") => {
|
||||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||||
@ -688,19 +592,19 @@ export default function GroceryList() {
|
|||||||
}, [activeHousehold?.id, activeStore?.id, enqueueImageUpload, toast]);
|
}, [activeHousehold?.id, activeStore?.id, enqueueImageUpload, toast]);
|
||||||
|
|
||||||
|
|
||||||
const handleLongPress = useCallback(async (item) => {
|
const handleLongPress = useCallback(async (item) => {
|
||||||
if (!householdRole || householdRole === 'viewer') return;
|
if (!householdRole || householdRole === 'viewer') return;
|
||||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const classificationResponse = await getClassification(activeHousehold.id, activeStore.id, item.item_name);
|
const classificationResponse = await getClassification(activeHousehold.id, activeStore.id, item.id);
|
||||||
setEditingItem({
|
setEditingItem({
|
||||||
...item,
|
...item,
|
||||||
classification: classificationResponse.data?.classification || null
|
classification: classificationResponse.data
|
||||||
});
|
});
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load classification:", error);
|
console.error("Failed to load classification:", error);
|
||||||
setEditingItem({ ...item, classification: null });
|
setEditingItem({ ...item, classification: null });
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
}
|
}
|
||||||
@ -850,7 +754,7 @@ export default function GroceryList() {
|
|||||||
|
|
||||||
<StoreTabs />
|
<StoreTabs />
|
||||||
|
|
||||||
{canEditList && (
|
{householdRole && householdRole !== 'viewer' && (
|
||||||
<AddItemForm
|
<AddItemForm
|
||||||
onAdd={handleAdd}
|
onAdd={handleAdd}
|
||||||
onSuggest={handleSuggest}
|
onSuggest={handleSuggest}
|
||||||
@ -886,19 +790,21 @@ export default function GroceryList() {
|
|||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
|
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
|
||||||
{grouped[zone].map((item) => (
|
{grouped[zone].map((item) => (
|
||||||
<GroceryListItem
|
<GroceryListItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
compact={settings.compactView}
|
allItems={sortedItems}
|
||||||
onClick={canEditList ? openActiveBuyModal : null}
|
compact={settings.compactView}
|
||||||
onOpenBuyModal={openActiveBuyModal}
|
onClick={(id, quantity) =>
|
||||||
onImageAdded={
|
householdRole && householdRole !== 'viewer' && handleBought(id, quantity)
|
||||||
canEditList ? handleImageAdded : null
|
}
|
||||||
}
|
onImageAdded={
|
||||||
onLongPress={
|
householdRole && householdRole !== 'viewer' ? handleImageAdded : null
|
||||||
canEditList ? handleLongPress : null
|
}
|
||||||
}
|
onLongPress={
|
||||||
/>
|
householdRole && householdRole !== 'viewer' ? handleLongPress : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
@ -909,19 +815,21 @@ export default function GroceryList() {
|
|||||||
) : (
|
) : (
|
||||||
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
|
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
|
||||||
{sortedItems.map((item) => (
|
{sortedItems.map((item) => (
|
||||||
<GroceryListItem
|
<GroceryListItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
compact={settings.compactView}
|
allItems={sortedItems}
|
||||||
onClick={canEditList ? openActiveBuyModal : null}
|
compact={settings.compactView}
|
||||||
onOpenBuyModal={openActiveBuyModal}
|
onClick={(id, quantity) =>
|
||||||
onImageAdded={
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(id, quantity)
|
||||||
canEditList ? handleImageAdded : null
|
}
|
||||||
}
|
onImageAdded={
|
||||||
onLongPress={
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
||||||
canEditList ? handleLongPress : null
|
}
|
||||||
}
|
onLongPress={
|
||||||
/>
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
@ -941,21 +849,21 @@ export default function GroceryList() {
|
|||||||
{!recentlyBoughtCollapsed && (
|
{!recentlyBoughtCollapsed && (
|
||||||
<>
|
<>
|
||||||
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
|
<ul className={`glist-ul ${settings.compactView ? 'compact' : ''}`}>
|
||||||
{visibleRecentlyBoughtItems.map((item) => (
|
{recentlyBoughtItems.slice(0, recentlyBoughtDisplayCount).map((item) => (
|
||||||
<GroceryListItem
|
<GroceryListItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
compact={settings.compactView}
|
allItems={recentlyBoughtItems}
|
||||||
onClick={null}
|
compact={settings.compactView}
|
||||||
onOpenBuyModal={openRecentBuyModal}
|
onClick={null}
|
||||||
onImageAdded={
|
onImageAdded={
|
||||||
canEditList ? handleImageAdded : null
|
householdRole && householdRole !== 'viewer' ? handleImageAdded : null
|
||||||
}
|
}
|
||||||
onLongPress={
|
onLongPress={
|
||||||
canEditList ? handleLongPress : null
|
householdRole && householdRole !== 'viewer' ? handleLongPress : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
{recentlyBoughtDisplayCount < recentlyBoughtItems.length && (
|
{recentlyBoughtDisplayCount < recentlyBoughtItems.length && (
|
||||||
<div style={{ textAlign: 'center', marginTop: '1rem' }}>
|
<div style={{ textAlign: 'center', marginTop: '1rem' }}>
|
||||||
@ -992,37 +900,27 @@ export default function GroceryList() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showEditModal && editingItem && (
|
{showEditModal && editingItem && (
|
||||||
<EditItemModal
|
<EditItemModal
|
||||||
item={editingItem}
|
item={editingItem}
|
||||||
onSave={handleEditSave}
|
onSave={handleEditSave}
|
||||||
onCancel={handleEditCancel}
|
onCancel={handleEditCancel}
|
||||||
onImageUpdate={handleImageAdded}
|
onImageUpdate={handleImageAdded}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{buyModalState && (
|
{showConfirmAddExisting && confirmAddExistingData && (
|
||||||
<ConfirmBuyModal
|
<ConfirmAddExistingModal
|
||||||
item={buyModalState.item}
|
itemName={confirmAddExistingData.itemName}
|
||||||
onConfirm={handleBuyModalConfirm}
|
|
||||||
onCancel={handleBuyModalCancel}
|
|
||||||
allItems={buyModalItems}
|
|
||||||
onNavigate={handleBuyModalNavigate}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showConfirmAddExisting && confirmAddExistingData && (
|
|
||||||
<ConfirmAddExistingModal
|
|
||||||
itemName={confirmAddExistingData.itemName}
|
|
||||||
currentQuantity={confirmAddExistingData.currentQuantity}
|
currentQuantity={confirmAddExistingData.currentQuantity}
|
||||||
addingQuantity={confirmAddExistingData.addingQuantity}
|
addingQuantity={confirmAddExistingData.addingQuantity}
|
||||||
onConfirm={handleConfirmAddExisting}
|
onConfirm={handleConfirmAddExisting}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setShowConfirmAddExisting(false);
|
setShowConfirmAddExisting(false);
|
||||||
setConfirmAddExistingData(null);
|
setConfirmAddExistingData(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,7 +61,7 @@
|
|||||||
|
|
||||||
.add-item-details-image-options {
|
.add-item-details-image-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--spacing-sm);
|
gap: 0.8em;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,7 +69,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
padding: var(--button-padding-y) var(--button-padding-x);
|
padding: var(--button-padding-y) var(--button-padding-x);
|
||||||
font-size: var(--font-size-base);
|
font-size: 0.95em;
|
||||||
border: var(--border-width-medium) solid var(--color-primary);
|
border: var(--border-width-medium) solid var(--color-primary);
|
||||||
background: var(--color-bg-surface);
|
background: var(--color-bg-surface);
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
@ -101,99 +101,97 @@
|
|||||||
|
|
||||||
.add-item-details-remove-image {
|
.add-item-details-remove-image {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: var(--spacing-sm);
|
top: 0.5em;
|
||||||
right: var(--spacing-sm);
|
right: 0.5em;
|
||||||
background: var(--color-danger);
|
background: rgba(220, 53, 69, 0.9);
|
||||||
color: var(--color-text-inverse);
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--border-radius-md);
|
border-radius: 6px;
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
padding: 0.4em 0.8em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: 600;
|
||||||
font-size: var(--font-size-sm);
|
font-size: 0.9em;
|
||||||
transition: var(--transition-base);
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-remove-image:hover {
|
.add-item-details-remove-image:hover {
|
||||||
background: var(--color-danger-hover);
|
background: rgba(220, 53, 69, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Classification Section */
|
/* Classification Section */
|
||||||
.add-item-details-field {
|
.add-item-details-field {
|
||||||
margin-bottom: var(--spacing-md);
|
margin-bottom: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-field label {
|
.add-item-details-field label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: var(--spacing-sm);
|
margin-bottom: 0.4em;
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: 600;
|
||||||
color: var(--color-text-primary);
|
color: #333;
|
||||||
font-size: var(--font-size-sm);
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-select {
|
.add-item-details-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--input-padding-y) var(--input-padding-x);
|
padding: 0.6em;
|
||||||
font-size: var(--font-size-base);
|
font-size: 1em;
|
||||||
border: var(--border-width-thin) solid var(--input-border-color);
|
border: 1px solid #ccc;
|
||||||
border-radius: var(--input-border-radius);
|
border-radius: 6px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition: var(--transition-base);
|
transition: border-color 0.2s;
|
||||||
background: var(--color-bg-surface);
|
background: white;
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-select:focus {
|
.add-item-details-select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--input-focus-border-color);
|
border-color: #007bff;
|
||||||
box-shadow: var(--input-focus-shadow);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Actions */
|
/* Actions */
|
||||||
.add-item-details-actions {
|
.add-item-details-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--spacing-sm);
|
gap: 0.6em;
|
||||||
margin-top: var(--spacing-lg);
|
margin-top: 1.5em;
|
||||||
padding-top: var(--spacing-md);
|
padding-top: 1em;
|
||||||
border-top: var(--border-width-thin) solid var(--color-border-light);
|
border-top: 1px solid #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-btn {
|
.add-item-details-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: var(--button-padding-y) var(--button-padding-x);
|
padding: 0.7em;
|
||||||
font-size: var(--font-size-base);
|
font-size: 1em;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--button-border-radius);
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: var(--button-font-weight);
|
font-weight: 600;
|
||||||
transition: var(--transition-base);
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-btn.cancel {
|
.add-item-details-btn.cancel {
|
||||||
background: var(--color-secondary);
|
background: #6c757d;
|
||||||
color: var(--color-text-inverse);
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-btn.cancel:hover {
|
.add-item-details-btn.cancel:hover {
|
||||||
background: var(--color-secondary-hover);
|
background: #5a6268;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-btn.skip {
|
.add-item-details-btn.skip {
|
||||||
background: var(--color-warning);
|
background: #ffc107;
|
||||||
color: var(--color-text-primary);
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-btn.skip:hover {
|
.add-item-details-btn.skip:hover {
|
||||||
background: var(--color-warning-hover);
|
background: #e0a800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-btn.confirm {
|
.add-item-details-btn.confirm {
|
||||||
background: var(--color-primary);
|
background: #007bff;
|
||||||
color: var(--color-text-inverse);
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-btn.confirm:hover {
|
.add-item-details-btn.confirm:hover {
|
||||||
background: var(--color-primary-hover);
|
background: #0056b3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile responsiveness */
|
/* Mobile responsiveness */
|
||||||
@ -209,7 +207,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-title {
|
.add-item-details-title {
|
||||||
font-size: var(--font-size-xl);
|
font-size: 1.3em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-select {
|
.add-item-details-select {
|
||||||
@ -238,20 +236,20 @@
|
|||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.add-item-details-modal {
|
.add-item-details-modal {
|
||||||
padding: var(--spacing-md);
|
padding: 1rem;
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-title {
|
.add-item-details-title {
|
||||||
font-size: var(--font-size-lg);
|
font-size: 1.15em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-subtitle {
|
.add-item-details-subtitle {
|
||||||
font-size: var(--font-size-sm);
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-section-title {
|
.add-item-details-section-title {
|
||||||
font-size: var(--font-size-base);
|
font-size: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-image-options {
|
.add-item-details-image-options {
|
||||||
@ -260,10 +258,10 @@
|
|||||||
|
|
||||||
.add-item-details-image-btn {
|
.add-item-details-image-btn {
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
font-size: var(--font-size-sm);
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-details-field label {
|
.add-item-details-field label {
|
||||||
font-size: var(--font-size-sm);
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
width: min(420px, calc(100vw - (2 * var(--spacing-md))));
|
width: min(420px, calc(100vw - (2 * var(--spacing-md))));
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: visible;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.assign-item-for-modal-field {
|
.assign-item-for-modal-field {
|
||||||
@ -55,10 +54,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.assign-item-for-dropdown-menu {
|
.assign-item-for-dropdown-menu {
|
||||||
position: fixed;
|
position: absolute;
|
||||||
z-index: var(--z-tooltip);
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 3;
|
||||||
|
max-height: 180px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
|
||||||
background: var(--color-bg-surface);
|
background: var(--color-bg-surface);
|
||||||
border: var(--border-width-thin) solid var(--input-border-color);
|
border: var(--border-width-thin) solid var(--input-border-color);
|
||||||
border-radius: var(--border-radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
|
|||||||
@ -1,109 +0,0 @@
|
|||||||
.available-item-editor-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: var(--modal-backdrop-bg);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: var(--z-modal);
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-item-editor-modal {
|
|
||||||
width: min(560px, 100%);
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: var(--modal-bg);
|
|
||||||
border-radius: var(--border-radius-xl);
|
|
||||||
box-shadow: var(--shadow-xl);
|
|
||||||
padding: var(--spacing-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-item-editor-title {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: var(--font-size-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-item-editor-subtitle {
|
|
||||||
margin: var(--spacing-xs) 0 var(--spacing-lg);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-item-editor-section {
|
|
||||||
margin-top: var(--spacing-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-item-editor-field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-item-editor-field label {
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-weight: var(--font-weight-semibold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-item-editor-input,
|
|
||||||
.available-item-editor-select {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: var(--input-padding-y) var(--input-padding-x);
|
|
||||||
border: var(--border-width-thin) solid var(--input-border-color);
|
|
||||||
border-radius: var(--input-border-radius);
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-item-editor-input:focus,
|
|
||||||
.available-item-editor-select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--input-focus-border-color);
|
|
||||||
box-shadow: var(--input-focus-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-item-editor-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
margin-top: var(--spacing-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-item-editor-btn {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 42px;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--button-border-radius);
|
|
||||||
font-weight: var(--button-font-weight);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: var(--transition-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-item-editor-btn-cancel {
|
|
||||||
background: var(--color-secondary);
|
|
||||||
color: var(--color-text-inverse);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-item-editor-btn-cancel:hover:not(:disabled) {
|
|
||||||
background: var(--color-secondary-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-item-editor-btn-save {
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: var(--color-text-inverse);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-item-editor-btn-save:hover:not(:disabled) {
|
|
||||||
background: var(--color-primary-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.available-item-editor-modal {
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.available-item-editor-actions {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -25,20 +25,6 @@
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.manage-stores-help {
|
|
||||||
margin: -0.25rem 0 1rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.manage-stores-note {
|
|
||||||
margin: -0.25rem 0 1rem;
|
|
||||||
padding: 0.875rem 1rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stores List */
|
/* Stores List */
|
||||||
.stores-list {
|
.stores-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@ -1,233 +0,0 @@
|
|||||||
.store-available-items-trigger {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-items-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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-items-modal {
|
|
||||||
width: min(960px, 100%);
|
|
||||||
max-height: min(80vh, 760px);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
padding: var(--spacing-lg);
|
|
||||||
border: var(--border-width-thin) solid var(--color-border-light);
|
|
||||||
border-radius: var(--border-radius-xl);
|
|
||||||
background: var(--modal-bg);
|
|
||||||
box-shadow: var(--shadow-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-items-modal-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-items-modal-header h3 {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: var(--font-size-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-items-modal-header p {
|
|
||||||
margin: var(--spacing-xs) 0 0;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-items-modal-close {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border: var(--border-width-thin) solid var(--color-border-light);
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-items-modal-toolbar {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 1;
|
|
||||||
background: var(--modal-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-available-items-search {
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--input-padding-y) var(--input-padding-x);
|
|
||||||
border: var(--border-width-thin) solid var(--input-border-color);
|
|
||||||
border-radius: var(--input-border-radius);
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-available-items-notice {
|
|
||||||
margin: 0;
|
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
|
||||||
border: var(--border-width-thin) solid var(--color-border-light);
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-items-modal-body {
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-items-table {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-items-table-head,
|
|
||||||
.store-items-table-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(220px, 2fr) minmax(180px, 2fr) minmax(170px, 1fr);
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-items-table-head {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
padding: 0 var(--spacing-sm) var(--spacing-xs);
|
|
||||||
background: var(--modal-bg);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: var(--font-weight-semibold);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-items-table-body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-items-table-row {
|
|
||||||
padding: var(--spacing-sm);
|
|
||||||
border: var(--border-width-thin) solid var(--color-border-light);
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-items-table-cell {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-items-table-item {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-available-items-summary {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-available-items-thumb {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
object-fit: cover;
|
|
||||||
background: var(--color-bg-muted);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-available-items-thumb-placeholder {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-weight: var(--font-weight-semibold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-available-items-copy {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-available-items-copy strong {
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-items-defaults-text {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-items-table-actions {
|
|
||||||
justify-self: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-available-items-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-items-mobile-label {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.store-items-modal {
|
|
||||||
max-height: min(88vh, 900px);
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-items-table-head {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-items-table-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-items-mobile-label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: var(--font-weight-semibold);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-items-table-actions {
|
|
||||||
justify-self: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-available-items-actions {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.store-available-items-actions button {
|
|
||||||
flex: 1 1 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,242 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
|
||||||
|
|
||||||
function seedAuthStorage(page: import("@playwright/test").Page) {
|
|
||||||
return page.addInitScript(() => {
|
|
||||||
localStorage.setItem("token", "test-token");
|
|
||||||
localStorage.setItem("userId", "1");
|
|
||||||
localStorage.setItem("role", "admin");
|
|
||||||
localStorage.setItem("username", "catalog-user");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mockConfig(page: import("@playwright/test").Page) {
|
|
||||||
await page.route("**/config", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
maxFileSizeMB: 20,
|
|
||||||
maxImageDimension: 800,
|
|
||||||
imageQuality: 85,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mockHouseholdAndStoreShell(page: import("@playwright/test").Page) {
|
|
||||||
await page.route("**/households", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([
|
|
||||||
{ id: 1, name: "Catalog House", role: "admin", invite_code: "ABCD1234" },
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/stores/household/1", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([
|
|
||||||
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("manage stores opens a modal to edit and delete household store items", async ({ page }) => {
|
|
||||||
await seedAuthStorage(page);
|
|
||||||
await mockConfig(page);
|
|
||||||
await mockHouseholdAndStoreShell(page);
|
|
||||||
|
|
||||||
let availableItems = [
|
|
||||||
{
|
|
||||||
item_id: 501,
|
|
||||||
item_name: "milk",
|
|
||||||
item_image: null,
|
|
||||||
image_mime_type: null,
|
|
||||||
item_type: "dairy",
|
|
||||||
item_group: "Milk",
|
|
||||||
zone: "Dairy & Refrigerated",
|
|
||||||
has_managed_settings: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
item_id: 777,
|
|
||||||
item_name: "apples",
|
|
||||||
item_image: null,
|
|
||||||
image_mime_type: null,
|
|
||||||
item_type: null,
|
|
||||||
item_group: null,
|
|
||||||
zone: null,
|
|
||||||
has_managed_settings: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
await page.route("**/stores", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([{ id: 10, name: "Costco" }]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/available-items*", async (route) => {
|
|
||||||
const request = route.request();
|
|
||||||
const url = new URL(request.url());
|
|
||||||
const query = (url.searchParams.get("query") || "").toLowerCase();
|
|
||||||
|
|
||||||
if (request.method() === "GET") {
|
|
||||||
const filteredItems = availableItems.filter((item) => item.item_name.includes(query));
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({ items: filteredItems, catalog_ready: true }),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await route.fulfill({ status: 500 });
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/available-items/777", async (route) => {
|
|
||||||
if (route.request().method() === "PATCH") {
|
|
||||||
availableItems = availableItems.map((item) =>
|
|
||||||
item.item_id === 777
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
item_type: "produce",
|
|
||||||
item_group: "Fruits",
|
|
||||||
zone: "Produce & Fresh Vegetables",
|
|
||||||
has_managed_settings: true,
|
|
||||||
}
|
|
||||||
: item
|
|
||||||
);
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
message: "Available item updated",
|
|
||||||
item: availableItems.find((item) => item.item_id === 777),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await route.fulfill({ status: 500 });
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/available-items/501", async (route) => {
|
|
||||||
if (route.request().method() === "DELETE") {
|
|
||||||
availableItems = availableItems.filter((item) => item.item_id !== 501);
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({ message: "Store item deleted" }),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await route.fulfill({ status: 500 });
|
|
||||||
});
|
|
||||||
|
|
||||||
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: "Manage Items" })).toBeVisible();
|
|
||||||
|
|
||||||
await storeCard.getByRole("button", { name: "Manage Items" }).click();
|
|
||||||
|
|
||||||
const managerModal = page.locator(".store-items-modal");
|
|
||||||
await expect(managerModal).toBeVisible();
|
|
||||||
await expect(managerModal.getByText("milk")).toBeVisible();
|
|
||||||
await expect(managerModal.getByText("apples")).toBeVisible();
|
|
||||||
|
|
||||||
await managerModal.locator(".store-items-table-row").filter({ hasText: "apples" }).getByRole("button", { name: "Edit Settings" }).click();
|
|
||||||
const editorModal = page.locator(".available-item-editor-modal");
|
|
||||||
await expect(editorModal).toBeVisible();
|
|
||||||
await expect(editorModal.getByLabel("Item Name")).toBeDisabled();
|
|
||||||
await editorModal.locator(".available-item-editor-select").nth(0).selectOption("produce");
|
|
||||||
await editorModal.locator(".available-item-editor-select").nth(1).selectOption("Fruits");
|
|
||||||
await editorModal.locator(".available-item-editor-select").nth(2).selectOption("Produce & Fresh Vegetables");
|
|
||||||
await editorModal.getByRole("button", { name: "Save Changes" }).click();
|
|
||||||
|
|
||||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated store item");
|
|
||||||
await expect(managerModal.getByText("produce | Fruits | Produce & Fresh Vegetables")).toBeVisible();
|
|
||||||
|
|
||||||
await managerModal.locator(".store-items-table-row").filter({ hasText: "milk" }).getByRole("button", { name: "Delete Item" }).click();
|
|
||||||
const confirmModal = page.locator(".confirm-slide-modal");
|
|
||||||
await expect(confirmModal).toBeVisible();
|
|
||||||
await expect(confirmModal.getByText("Delete milk?")).toBeVisible();
|
|
||||||
|
|
||||||
const slider = confirmModal.locator(".confirm-slide-handle");
|
|
||||||
const track = confirmModal.locator(".confirm-slide-track");
|
|
||||||
const sliderBox = await slider.boundingBox();
|
|
||||||
const trackBox = await track.boundingBox();
|
|
||||||
|
|
||||||
if (!sliderBox || !trackBox) {
|
|
||||||
throw new Error("Confirm slide control was not measurable");
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.mouse.move(sliderBox.x + sliderBox.width / 2, sliderBox.y + sliderBox.height / 2);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(trackBox.x + trackBox.width - 4, sliderBox.y + sliderBox.height / 2, { steps: 8 });
|
|
||||||
await page.mouse.up();
|
|
||||||
|
|
||||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Deleted store item");
|
|
||||||
await expect(managerModal.getByText("milk")).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("grocery page remains unchanged and does not show a store items picker", async ({ page }) => {
|
|
||||||
await seedAuthStorage(page);
|
|
||||||
await mockConfig(page);
|
|
||||||
await mockHouseholdAndStoreShell(page);
|
|
||||||
|
|
||||||
await page.route("**/households/1/members", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([
|
|
||||||
{ id: 1, username: "owner", name: "Owner User", display_name: "Owner User", role: "owner" },
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/recent", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/suggestions**", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/item**", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 404,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({ message: "Item not found" }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({ items: [] }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto("/");
|
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "Store Items" })).toHaveCount(0);
|
|
||||||
await expect(page.locator(".available-items-picker-modal")).toHaveCount(0);
|
|
||||||
});
|
|
||||||
@ -1,279 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
|
||||||
|
|
||||||
type MockItem = {
|
|
||||||
id: number;
|
|
||||||
item_id: number;
|
|
||||||
item_name: string;
|
|
||||||
quantity: number;
|
|
||||||
bought: boolean;
|
|
||||||
item_image: string | null;
|
|
||||||
image_mime_type: string | null;
|
|
||||||
added_by_users: string[];
|
|
||||||
last_added_on: string;
|
|
||||||
item_type: string | null;
|
|
||||||
item_group: string | null;
|
|
||||||
zone: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function seedAuthStorage(page: import("@playwright/test").Page) {
|
|
||||||
return page.addInitScript(() => {
|
|
||||||
localStorage.setItem("token", "test-token");
|
|
||||||
localStorage.setItem("userId", "1");
|
|
||||||
localStorage.setItem("role", "admin");
|
|
||||||
localStorage.setItem("username", "buy-modal-user");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mockConfig(page: import("@playwright/test").Page) {
|
|
||||||
await page.route("**/config", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
maxFileSizeMB: 20,
|
|
||||||
maxImageDimension: 800,
|
|
||||||
imageQuality: 85,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeItem(
|
|
||||||
id: number,
|
|
||||||
itemName: string,
|
|
||||||
quantity: number,
|
|
||||||
overrides: Partial<MockItem> = {}
|
|
||||||
): MockItem {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
item_id: id + 500,
|
|
||||||
item_name: itemName,
|
|
||||||
quantity,
|
|
||||||
bought: false,
|
|
||||||
item_image: null,
|
|
||||||
image_mime_type: null,
|
|
||||||
added_by_users: ["Owner User"],
|
|
||||||
last_added_on: "2026-03-28T12:00:00.000Z",
|
|
||||||
item_type: null,
|
|
||||||
item_group: null,
|
|
||||||
zone: "Produce",
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setupBuyModalRoutes(
|
|
||||||
page: import("@playwright/test").Page,
|
|
||||||
initialItems: MockItem[]
|
|
||||||
) {
|
|
||||||
let activeItems = initialItems.map((item) => ({ ...item }));
|
|
||||||
let recentItems: MockItem[] = [];
|
|
||||||
|
|
||||||
await page.route("**/households", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([
|
|
||||||
{ id: 1, name: "Auto Advance House", role: "admin", invite_code: "ABCD1234" },
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/stores/household/1", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([
|
|
||||||
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/members", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([
|
|
||||||
{ id: 1, username: "owner", name: "Owner User", display_name: "Owner User", role: "owner" },
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/recent", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify(recentItems),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/suggestions**", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/classification**", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({ classification: null }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/item", async (route) => {
|
|
||||||
const request = route.request();
|
|
||||||
|
|
||||||
if (request.method() === "PATCH") {
|
|
||||||
const body = request.postDataJSON() as {
|
|
||||||
item_name?: string;
|
|
||||||
quantity_bought?: number | null;
|
|
||||||
};
|
|
||||||
const itemName = String(body.item_name || "").toLowerCase();
|
|
||||||
const quantityBought = Number(body.quantity_bought ?? 0);
|
|
||||||
const currentItem = activeItems.find((item) => item.item_name === itemName);
|
|
||||||
|
|
||||||
if (!currentItem) {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 404,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({ error: { message: "Item not found" } }),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remainingQuantity = currentItem.quantity - quantityBought;
|
|
||||||
recentItems = [
|
|
||||||
{
|
|
||||||
...currentItem,
|
|
||||||
quantity: quantityBought,
|
|
||||||
bought: true,
|
|
||||||
},
|
|
||||||
...recentItems,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (remainingQuantity <= 0) {
|
|
||||||
activeItems = activeItems.filter((item) => item.id !== currentItem.id);
|
|
||||||
} else {
|
|
||||||
activeItems = activeItems.map((item) =>
|
|
||||||
item.id === currentItem.id
|
|
||||||
? { ...item, quantity: remainingQuantity }
|
|
||||||
: item
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
message: "Item updated",
|
|
||||||
item: {
|
|
||||||
id: currentItem.id,
|
|
||||||
item_name: currentItem.item_name,
|
|
||||||
quantity: Math.max(remainingQuantity, 0),
|
|
||||||
bought: remainingQuantity <= 0,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(request.url());
|
|
||||||
const itemName = (url.searchParams.get("item_name") || "").toLowerCase();
|
|
||||||
const item = activeItems.find((entry) => entry.item_name === itemName);
|
|
||||||
|
|
||||||
await route.fulfill({
|
|
||||||
status: item ? 200 : 404,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify(item || { message: "Item not found" }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
items: activeItems,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openBuyModal(page: import("@playwright/test").Page, itemName: string) {
|
|
||||||
const row = page.locator(".glist-li").filter({ hasText: itemName });
|
|
||||||
await row.click();
|
|
||||||
await expect(page.locator(".confirm-buy-modal")).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
test("buying an item advances to the next one in the current sort order", async ({ page }) => {
|
|
||||||
await seedAuthStorage(page);
|
|
||||||
await mockConfig(page);
|
|
||||||
await setupBuyModalRoutes(page, [
|
|
||||||
makeItem(1, "milk", 2),
|
|
||||||
makeItem(2, "bread", 5),
|
|
||||||
makeItem(3, "apples", 3),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await page.goto("/");
|
|
||||||
await page.locator(".glist-sort").selectOption("qty-high");
|
|
||||||
|
|
||||||
await openBuyModal(page, "bread");
|
|
||||||
await page.getByRole("button", { name: "Mark as Bought" }).click();
|
|
||||||
|
|
||||||
await expect(page.locator(".confirm-buy-item-name")).toHaveText("apples");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buying the last item in the current order wraps to the first remaining item", async ({ page }) => {
|
|
||||||
await seedAuthStorage(page);
|
|
||||||
await mockConfig(page);
|
|
||||||
await setupBuyModalRoutes(page, [
|
|
||||||
makeItem(1, "apples", 3),
|
|
||||||
makeItem(2, "bread", 5),
|
|
||||||
makeItem(3, "milk", 2),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await page.goto("/");
|
|
||||||
await page.locator(".glist-sort").selectOption("az");
|
|
||||||
|
|
||||||
await openBuyModal(page, "milk");
|
|
||||||
await page.getByRole("button", { name: "Mark as Bought" }).click();
|
|
||||||
|
|
||||||
await expect(page.locator(".confirm-buy-item-name")).toHaveText("apples");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("partial buy keeps the item on the list and advances past it", async ({ page }) => {
|
|
||||||
await seedAuthStorage(page);
|
|
||||||
await mockConfig(page);
|
|
||||||
await setupBuyModalRoutes(page, [
|
|
||||||
makeItem(1, "alpha", 1),
|
|
||||||
makeItem(2, "bravo", 3),
|
|
||||||
makeItem(3, "charlie", 5),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await page.goto("/");
|
|
||||||
await page.locator(".glist-sort").selectOption("qty-low");
|
|
||||||
|
|
||||||
await openBuyModal(page, "bravo");
|
|
||||||
await page.locator(".confirm-buy-counter-btn").nth(0).click();
|
|
||||||
await page.getByRole("button", { name: "Mark as Bought" }).click();
|
|
||||||
|
|
||||||
await expect(page.locator(".confirm-buy-item-name")).toHaveText("charlie");
|
|
||||||
await expect(page.locator(".glist-li").filter({ hasText: "bravo" })).toContainText("x2");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buying the only remaining item closes the modal", async ({ page }) => {
|
|
||||||
await seedAuthStorage(page);
|
|
||||||
await mockConfig(page);
|
|
||||||
await setupBuyModalRoutes(page, [
|
|
||||||
makeItem(1, "solo", 1),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await page.goto("/");
|
|
||||||
|
|
||||||
await openBuyModal(page, "solo");
|
|
||||||
await page.getByRole("button", { name: "Mark as Bought" }).click();
|
|
||||||
|
|
||||||
await expect(page.locator(".confirm-buy-modal")).toBeHidden();
|
|
||||||
});
|
|
||||||
@ -1,318 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
|
||||||
|
|
||||||
function seedAuthStorage(page: import("@playwright/test").Page) {
|
|
||||||
return page.addInitScript(() => {
|
|
||||||
localStorage.setItem("token", "test-token");
|
|
||||||
localStorage.setItem("userId", "1");
|
|
||||||
localStorage.setItem("role", "admin");
|
|
||||||
localStorage.setItem("username", "classification-user");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mockConfig(page: import("@playwright/test").Page) {
|
|
||||||
await page.route("**/config", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
maxFileSizeMB: 20,
|
|
||||||
maxImageDimension: 800,
|
|
||||||
imageQuality: 85,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setupGroceryListRoutes(page: import("@playwright/test").Page) {
|
|
||||||
let currentItem: {
|
|
||||||
id: number;
|
|
||||||
item_id: number;
|
|
||||||
item_name: string;
|
|
||||||
quantity: number;
|
|
||||||
bought: boolean;
|
|
||||||
item_image: string | null;
|
|
||||||
image_mime_type: string | null;
|
|
||||||
added_by_users: string[];
|
|
||||||
last_added_on: string;
|
|
||||||
item_type: string | null;
|
|
||||||
item_group: string | null;
|
|
||||||
zone: string | null;
|
|
||||||
} | null = null;
|
|
||||||
let currentClassification: {
|
|
||||||
item_type: string | null;
|
|
||||||
item_group: string | null;
|
|
||||||
zone: string | null;
|
|
||||||
} | null = null;
|
|
||||||
let classificationRequestMode: "success" | "error" = "success";
|
|
||||||
|
|
||||||
await page.route("**/households", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([
|
|
||||||
{ id: 1, name: "Classification House", role: "admin", invite_code: "ABCD1234" },
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/stores/household/1", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([
|
|
||||||
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/members", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([
|
|
||||||
{ id: 1, username: "owner", name: "Owner User", display_name: "Owner User", role: "owner" },
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/recent", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/suggestions**", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/classification**", async (route) => {
|
|
||||||
const request = route.request();
|
|
||||||
|
|
||||||
if (request.method() === "GET") {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({ classification: currentClassification }),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = request.postDataJSON() as {
|
|
||||||
classification?: string | { item_type?: string | null; item_group?: string | null; zone?: string | null };
|
|
||||||
};
|
|
||||||
|
|
||||||
if (classificationRequestMode === "error") {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 400,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
error: { message: "Invalid zone" },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = typeof body.classification === "string"
|
|
||||||
? { item_type: body.classification, item_group: null, zone: null }
|
|
||||||
: {
|
|
||||||
item_type: body.classification?.item_type ?? null,
|
|
||||||
item_group: body.classification?.item_group ?? null,
|
|
||||||
zone: body.classification?.zone ?? null,
|
|
||||||
};
|
|
||||||
|
|
||||||
currentClassification = payload;
|
|
||||||
if (currentItem) {
|
|
||||||
currentItem = {
|
|
||||||
...currentItem,
|
|
||||||
item_type: payload.item_type,
|
|
||||||
item_group: payload.item_group,
|
|
||||||
zone: payload.zone,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
message: "Classification set",
|
|
||||||
classification: payload,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/item**", async (route) => {
|
|
||||||
const request = route.request();
|
|
||||||
|
|
||||||
if (request.method() === "PUT") {
|
|
||||||
const body = request.postDataJSON() as { item_name?: string; quantity?: number };
|
|
||||||
if (currentItem) {
|
|
||||||
currentItem = {
|
|
||||||
...currentItem,
|
|
||||||
item_name: String(body.item_name || currentItem.item_name).toLowerCase(),
|
|
||||||
quantity: Number(body.quantity || currentItem.quantity),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
message: "Item updated",
|
|
||||||
item: {
|
|
||||||
id: currentItem?.id || 201,
|
|
||||||
item_name: currentItem?.item_name || "yogurt",
|
|
||||||
quantity: currentItem?.quantity || 1,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(request.url());
|
|
||||||
const itemName = (url.searchParams.get("item_name") || "").toLowerCase();
|
|
||||||
const itemMatches = currentItem && currentItem.item_name === itemName;
|
|
||||||
|
|
||||||
await route.fulfill({
|
|
||||||
status: itemMatches ? 200 : 404,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify(itemMatches ? currentItem : { message: "Item not found" }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/add", async (route) => {
|
|
||||||
currentItem = {
|
|
||||||
id: 201,
|
|
||||||
item_id: 501,
|
|
||||||
item_name: "yogurt",
|
|
||||||
quantity: 1,
|
|
||||||
bought: false,
|
|
||||||
item_image: null,
|
|
||||||
image_mime_type: null,
|
|
||||||
added_by_users: ["Owner User"],
|
|
||||||
last_added_on: "2026-03-28T12:00:00.000Z",
|
|
||||||
item_type: currentClassification?.item_type ?? null,
|
|
||||||
item_group: currentClassification?.item_group ?? null,
|
|
||||||
zone: currentClassification?.zone ?? null,
|
|
||||||
};
|
|
||||||
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
message: "Item added",
|
|
||||||
item: {
|
|
||||||
id: 201,
|
|
||||||
item_name: "yogurt",
|
|
||||||
quantity: 1,
|
|
||||||
bought: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
items: currentItem ? [currentItem] : [],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
setClassificationRequestMode(mode: "success" | "error") {
|
|
||||||
classificationRequestMode = mode;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openEditModal(itemRow: ReturnType<import("@playwright/test").Page["locator"]>, page: import("@playwright/test").Page) {
|
|
||||||
await itemRow.dispatchEvent("mousedown");
|
|
||||||
await page.waitForTimeout(650);
|
|
||||||
await itemRow.dispatchEvent("mouseup");
|
|
||||||
await expect(page.locator(".edit-modal-content")).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
test("add-details modal validates with toasts and persists classification details", async ({ page }) => {
|
|
||||||
await seedAuthStorage(page);
|
|
||||||
await mockConfig(page);
|
|
||||||
await setupGroceryListRoutes(page);
|
|
||||||
|
|
||||||
let dialogSeen = false;
|
|
||||||
page.on("dialog", async (dialog) => {
|
|
||||||
dialogSeen = true;
|
|
||||||
await dialog.dismiss();
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto("/");
|
|
||||||
|
|
||||||
await page.getByPlaceholder("Enter item name").fill("yogurt");
|
|
||||||
await page.getByRole("button", { name: "Create + Add" }).click();
|
|
||||||
|
|
||||||
const addDetailsModal = page.locator(".add-item-details-modal");
|
|
||||||
await expect(addDetailsModal).toBeVisible();
|
|
||||||
|
|
||||||
await addDetailsModal.locator(".add-item-details-select").nth(0).selectOption("dairy");
|
|
||||||
await addDetailsModal.getByRole("button", { name: "Add Item" }).click();
|
|
||||||
|
|
||||||
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Select an item group");
|
|
||||||
expect(dialogSeen).toBe(false);
|
|
||||||
|
|
||||||
await addDetailsModal.locator(".add-item-details-select").nth(1).selectOption("Milk");
|
|
||||||
await addDetailsModal.locator(".add-item-details-select").nth(2).selectOption("Dairy & Refrigerated");
|
|
||||||
await addDetailsModal.getByRole("button", { name: "Add Item" }).click();
|
|
||||||
|
|
||||||
const yogurtRow = page.locator(".glist-li").filter({ hasText: "yogurt" });
|
|
||||||
await expect(yogurtRow).toBeVisible();
|
|
||||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Added item");
|
|
||||||
|
|
||||||
await openEditModal(yogurtRow, page);
|
|
||||||
|
|
||||||
const editModal = page.locator(".edit-modal-content");
|
|
||||||
await expect(editModal.locator(".edit-modal-select").nth(0)).toHaveValue("dairy");
|
|
||||||
await expect(editModal.locator(".edit-modal-select").nth(1)).toHaveValue("Milk");
|
|
||||||
await expect(editModal.locator(".edit-modal-select").nth(2)).toHaveValue("Dairy & Refrigerated");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("edit modal supports zone-only updates and shows API error toasts", async ({ page }) => {
|
|
||||||
await seedAuthStorage(page);
|
|
||||||
await mockConfig(page);
|
|
||||||
const routes = await setupGroceryListRoutes(page);
|
|
||||||
|
|
||||||
await page.goto("/");
|
|
||||||
|
|
||||||
await page.getByPlaceholder("Enter item name").fill("yogurt");
|
|
||||||
await page.getByRole("button", { name: "Create + Add" }).click();
|
|
||||||
await page.locator(".add-item-details-modal").getByRole("button", { name: "Skip All" }).click();
|
|
||||||
|
|
||||||
const yogurtRow = page.locator(".glist-li").filter({ hasText: "yogurt" });
|
|
||||||
await expect(yogurtRow).toBeVisible();
|
|
||||||
|
|
||||||
await openEditModal(yogurtRow, page);
|
|
||||||
|
|
||||||
let editModal = page.locator(".edit-modal-content");
|
|
||||||
await editModal.locator(".edit-modal-select").nth(0).selectOption("");
|
|
||||||
await editModal.locator(".edit-modal-select").nth(1).selectOption("Checkout Area");
|
|
||||||
await editModal.getByRole("button", { name: "Save Changes" }).click();
|
|
||||||
|
|
||||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated item");
|
|
||||||
await expect(editModal).toBeHidden();
|
|
||||||
|
|
||||||
await openEditModal(yogurtRow, page);
|
|
||||||
editModal = page.locator(".edit-modal-content");
|
|
||||||
await expect(editModal.locator(".edit-modal-select").nth(0)).toHaveValue("");
|
|
||||||
await expect(editModal.locator(".edit-modal-select").nth(1)).toHaveValue("Checkout Area");
|
|
||||||
|
|
||||||
routes.setClassificationRequestMode("error");
|
|
||||||
await editModal.locator(".edit-modal-select").nth(1).selectOption("Bakery");
|
|
||||||
await editModal.getByRole("button", { name: "Save Changes" }).click();
|
|
||||||
|
|
||||||
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Invalid zone");
|
|
||||||
});
|
|
||||||
@ -1,233 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
|
||||||
|
|
||||||
function seedAuthStorage(page: import("@playwright/test").Page) {
|
|
||||||
return page.addInitScript(() => {
|
|
||||||
localStorage.setItem("token", "test-token");
|
|
||||||
localStorage.setItem("userId", "1");
|
|
||||||
localStorage.setItem("role", "admin");
|
|
||||||
localStorage.setItem("username", "assignment-user");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mockConfig(page: import("@playwright/test").Page) {
|
|
||||||
await page.route("**/config", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
maxFileSizeMB: 20,
|
|
||||||
maxImageDimension: 800,
|
|
||||||
imageQuality: 85,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("assigned items render selected users and keep the picker menu outside the modal", async ({ page }) => {
|
|
||||||
await seedAuthStorage(page);
|
|
||||||
await mockConfig(page);
|
|
||||||
|
|
||||||
const members = [
|
|
||||||
{ id: 1, username: "owner", name: "Owner User", display_name: "Owner User", role: "owner" },
|
|
||||||
{ id: 2, username: "casey", name: "Casey Client", display_name: "Casey Client", role: "member" },
|
|
||||||
{ id: 3, username: "jordan", name: "Jordan Client", display_name: "Jordan Client", role: "member" },
|
|
||||||
{ id: 4, username: "alex", name: "Alex Member", display_name: "Alex Member", role: "member" },
|
|
||||||
{ id: 5, username: "morgan", name: "Morgan Member", display_name: "Morgan Member", role: "member" },
|
|
||||||
{ id: 6, username: "sam", name: "Sam Member", display_name: "Sam Member", role: "member" },
|
|
||||||
{ id: 7, username: "jamie", name: "Jamie Member", display_name: "Jamie Member", role: "member" },
|
|
||||||
{ id: 8, username: "pat", name: "Pat Member", display_name: "Pat Member", role: "member" },
|
|
||||||
{ id: 9, username: "drew", name: "Drew Member", display_name: "Drew Member", role: "member" },
|
|
||||||
{ id: 10, username: "kai", name: "Kai Member", display_name: "Kai Member", role: "member" },
|
|
||||||
{ id: 11, username: "blair", name: "Blair Member", display_name: "Blair Member", role: "member" },
|
|
||||||
{ id: 12, username: "quinn", name: "Quinn Member", display_name: "Quinn Member", role: "member" },
|
|
||||||
{ id: 13, username: "rowan", name: "Rowan Member", display_name: "Rowan Member", role: "member" },
|
|
||||||
{ id: 14, username: "sage", name: "Sage Member", display_name: "Sage Member", role: "member" },
|
|
||||||
{ id: 15, username: "taylor", name: "Taylor Member", display_name: "Taylor Member", role: "member" },
|
|
||||||
{ id: 16, username: "river", name: "River Member", display_name: "River Member", role: "member" },
|
|
||||||
];
|
|
||||||
|
|
||||||
let listItems: Array<{
|
|
||||||
id: number;
|
|
||||||
item_id: number;
|
|
||||||
item_name: string;
|
|
||||||
quantity: number;
|
|
||||||
bought: boolean;
|
|
||||||
item_image: string | null;
|
|
||||||
image_mime_type: string | null;
|
|
||||||
added_by_users: string[];
|
|
||||||
last_added_on: string;
|
|
||||||
item_type: string | null;
|
|
||||||
item_group: string | null;
|
|
||||||
zone: string | null;
|
|
||||||
}> = [];
|
|
||||||
let addCallCount = 0;
|
|
||||||
|
|
||||||
await page.route("**/households", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([
|
|
||||||
{ id: 1, name: "Assignment House", role: "admin", invite_code: "ABCD1234" },
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/stores/household/1", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([
|
|
||||||
{ id: 10, name: "Costco", location: "Warehouse", is_default: true },
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/members", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify(members),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/recent", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/suggestions**", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify([]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/item**", async (route) => {
|
|
||||||
const url = new URL(route.request().url());
|
|
||||||
const itemName = (url.searchParams.get("item_name") || "").toLowerCase();
|
|
||||||
const item = listItems.find((candidate) => candidate.item_name === itemName);
|
|
||||||
|
|
||||||
await route.fulfill({
|
|
||||||
status: item ? 200 : 404,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify(item || { message: "Item not found" }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list/add", async (route) => {
|
|
||||||
addCallCount += 1;
|
|
||||||
|
|
||||||
if (addCallCount === 1) {
|
|
||||||
listItems = [
|
|
||||||
{
|
|
||||||
id: 201,
|
|
||||||
item_id: 501,
|
|
||||||
item_name: "bananas",
|
|
||||||
quantity: 1,
|
|
||||||
bought: false,
|
|
||||||
item_image: null,
|
|
||||||
image_mime_type: null,
|
|
||||||
added_by_users: ["Casey Client"],
|
|
||||||
last_added_on: "2026-03-28T12:00:00.000Z",
|
|
||||||
item_type: null,
|
|
||||||
item_group: null,
|
|
||||||
zone: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
listItems = [
|
|
||||||
{
|
|
||||||
id: 201,
|
|
||||||
item_id: 501,
|
|
||||||
item_name: "bananas",
|
|
||||||
quantity: 2,
|
|
||||||
bought: false,
|
|
||||||
item_image: null,
|
|
||||||
image_mime_type: null,
|
|
||||||
added_by_users: ["Casey Client", "Jordan Client"],
|
|
||||||
last_added_on: "2026-03-28T12:05:00.000Z",
|
|
||||||
item_type: null,
|
|
||||||
item_group: null,
|
|
||||||
zone: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
message: addCallCount === 1 ? "Item added" : "Item updated",
|
|
||||||
item: {
|
|
||||||
id: 201,
|
|
||||||
item_name: "bananas",
|
|
||||||
quantity: addCallCount === 1 ? 1 : 2,
|
|
||||||
bought: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/1/stores/10/list", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({ items: listItems }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto("/");
|
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { name: "Grocery List" })).toBeVisible();
|
|
||||||
await page.getByPlaceholder("Enter item name").fill("bananas");
|
|
||||||
await page.getByRole("button", { name: "Others" }).click();
|
|
||||||
|
|
||||||
const assignModal = page.locator(".assign-item-for-modal");
|
|
||||||
await expect(assignModal).toBeVisible();
|
|
||||||
|
|
||||||
await assignModal.getByRole("button", { name: "Select member" }).click();
|
|
||||||
|
|
||||||
const portalMenu = page.locator("body > .assign-item-for-dropdown-menu");
|
|
||||||
await expect(portalMenu).toBeVisible();
|
|
||||||
await expect(page.locator(".assign-item-for-modal .assign-item-for-dropdown-menu")).toHaveCount(0);
|
|
||||||
|
|
||||||
const dropdownMetrics = await portalMenu.evaluate((element) => {
|
|
||||||
const menu = element as HTMLDivElement;
|
|
||||||
return {
|
|
||||||
position: window.getComputedStyle(menu).position,
|
|
||||||
scrollable: menu.scrollHeight > menu.clientHeight,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(dropdownMetrics.position).toBe("fixed");
|
|
||||||
expect(dropdownMetrics.scrollable).toBe(true);
|
|
||||||
|
|
||||||
await portalMenu.getByRole("option", { name: "Casey Client" }).click();
|
|
||||||
await assignModal.getByRole("button", { name: "Confirm" }).click();
|
|
||||||
|
|
||||||
await expect(page.getByText("Adding for: Casey Client")).toBeVisible();
|
|
||||||
await page.getByRole("button", { name: "Create + Add" }).click();
|
|
||||||
await page.getByRole("button", { name: "Skip All" }).click();
|
|
||||||
|
|
||||||
const bananasRow = page.locator(".glist-li").filter({ hasText: "bananas" });
|
|
||||||
await expect(bananasRow).toContainText("Casey Client");
|
|
||||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Added item");
|
|
||||||
|
|
||||||
await page.getByPlaceholder("Enter item name").fill("bananas");
|
|
||||||
await page.getByRole("button", { name: "Others" }).click();
|
|
||||||
await assignModal.getByRole("button", { name: "Select member" }).click();
|
|
||||||
await portalMenu.getByRole("option", { name: "Jordan Client" }).click();
|
|
||||||
await assignModal.getByRole("button", { name: "Confirm" }).click();
|
|
||||||
|
|
||||||
await expect(page.getByText("Adding for: Jordan Client")).toBeVisible();
|
|
||||||
await page.getByRole("button", { name: "Create + Add" }).click();
|
|
||||||
await page.getByRole("button", { name: "Update Quantity" }).click();
|
|
||||||
|
|
||||||
await expect(bananasRow).toContainText("Casey Client");
|
|
||||||
await expect(bananasRow).toContainText("Jordan Client");
|
|
||||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Updated item quantity");
|
|
||||||
});
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
BEGIN;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS household_store_available_items (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
|
|
||||||
store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE,
|
|
||||||
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
|
||||||
custom_image BYTEA,
|
|
||||||
custom_image_mime_type VARCHAR(50),
|
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
UNIQUE(household_id, store_id, item_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_available_items_household_store
|
|
||||||
ON household_store_available_items(household_id, store_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_available_items_item
|
|
||||||
ON household_store_available_items(item_id);
|
|
||||||
|
|
||||||
COMMENT ON TABLE household_store_available_items IS 'Curated household-store item catalogs';
|
|
||||||
COMMENT ON COLUMN household_store_available_items.custom_image IS 'Optional store-specific image override';
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@ -1,199 +0,0 @@
|
|||||||
BEGIN;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS household_store_items (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
|
|
||||||
store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
normalized_name VARCHAR(255) NOT NULL,
|
|
||||||
custom_image BYTEA,
|
|
||||||
custom_image_mime_type VARCHAR(50),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
UNIQUE(household_id, store_id, normalized_name)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_household_store_items_household_store
|
|
||||||
ON household_store_items(household_id, store_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_household_store_items_lookup
|
|
||||||
ON household_store_items(household_id, store_id, normalized_name);
|
|
||||||
|
|
||||||
COMMENT ON TABLE household_store_items IS 'Household + store owned item records used for list suggestions and management';
|
|
||||||
COMMENT ON COLUMN household_store_items.normalized_name IS 'Lowercased trimmed item name used for exact household/store matching';
|
|
||||||
|
|
||||||
ALTER TABLE household_lists
|
|
||||||
ADD COLUMN IF NOT EXISTS household_store_item_id INTEGER;
|
|
||||||
|
|
||||||
ALTER TABLE household_item_classifications
|
|
||||||
ADD COLUMN IF NOT EXISTS household_store_item_id INTEGER;
|
|
||||||
|
|
||||||
ALTER TABLE household_list_history
|
|
||||||
ADD COLUMN IF NOT EXISTS household_store_item_id INTEGER;
|
|
||||||
|
|
||||||
INSERT INTO household_store_items (
|
|
||||||
household_id,
|
|
||||||
store_id,
|
|
||||||
name,
|
|
||||||
normalized_name,
|
|
||||||
created_at,
|
|
||||||
updated_at
|
|
||||||
)
|
|
||||||
SELECT DISTINCT
|
|
||||||
hl.household_id,
|
|
||||||
hl.store_id,
|
|
||||||
LOWER(TRIM(i.name)) AS name,
|
|
||||||
LOWER(TRIM(i.name)) AS normalized_name,
|
|
||||||
COALESCE(MIN(hl.modified_on) OVER (PARTITION BY hl.household_id, hl.store_id, LOWER(TRIM(i.name))), NOW()) AS created_at,
|
|
||||||
COALESCE(MAX(hl.modified_on) OVER (PARTITION BY hl.household_id, hl.store_id, LOWER(TRIM(i.name))), NOW()) AS updated_at
|
|
||||||
FROM household_lists hl
|
|
||||||
JOIN items i ON i.id = hl.item_id
|
|
||||||
ON CONFLICT (household_id, store_id, normalized_name) DO NOTHING;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF to_regclass('public.household_store_available_items') IS NOT NULL THEN
|
|
||||||
INSERT INTO household_store_items (
|
|
||||||
household_id,
|
|
||||||
store_id,
|
|
||||||
name,
|
|
||||||
normalized_name,
|
|
||||||
custom_image,
|
|
||||||
custom_image_mime_type,
|
|
||||||
created_at,
|
|
||||||
updated_at
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
hsai.household_id,
|
|
||||||
hsai.store_id,
|
|
||||||
LOWER(TRIM(i.name)) AS name,
|
|
||||||
LOWER(TRIM(i.name)) AS normalized_name,
|
|
||||||
hsai.custom_image,
|
|
||||||
hsai.custom_image_mime_type,
|
|
||||||
COALESCE(hsai.created_at, NOW()),
|
|
||||||
COALESCE(hsai.updated_at, NOW())
|
|
||||||
FROM household_store_available_items hsai
|
|
||||||
JOIN items i ON i.id = hsai.item_id
|
|
||||||
ON CONFLICT (household_id, store_id, normalized_name) DO UPDATE
|
|
||||||
SET
|
|
||||||
custom_image = COALESCE(household_store_items.custom_image, EXCLUDED.custom_image),
|
|
||||||
custom_image_mime_type = COALESCE(
|
|
||||||
household_store_items.custom_image_mime_type,
|
|
||||||
EXCLUDED.custom_image_mime_type
|
|
||||||
),
|
|
||||||
updated_at = GREATEST(household_store_items.updated_at, EXCLUDED.updated_at);
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
UPDATE household_lists hl
|
|
||||||
SET household_store_item_id = hsi.id
|
|
||||||
FROM items i,
|
|
||||||
household_store_items hsi
|
|
||||||
WHERE hl.item_id = i.id
|
|
||||||
AND hsi.household_id = hl.household_id
|
|
||||||
AND hsi.store_id = hl.store_id
|
|
||||||
AND hsi.normalized_name = LOWER(TRIM(i.name))
|
|
||||||
AND hl.household_store_item_id IS NULL;
|
|
||||||
|
|
||||||
UPDATE household_item_classifications hic
|
|
||||||
SET household_store_item_id = hsi.id
|
|
||||||
FROM items i,
|
|
||||||
household_store_items hsi
|
|
||||||
WHERE hic.item_id = i.id
|
|
||||||
AND hsi.household_id = hic.household_id
|
|
||||||
AND hsi.store_id = hic.store_id
|
|
||||||
AND hsi.normalized_name = LOWER(TRIM(i.name))
|
|
||||||
AND hic.household_store_item_id IS NULL;
|
|
||||||
|
|
||||||
DELETE FROM household_list_history hlh
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM household_lists hl
|
|
||||||
WHERE hl.id = hlh.household_list_id
|
|
||||||
);
|
|
||||||
|
|
||||||
UPDATE household_list_history hlh
|
|
||||||
SET household_store_item_id = hl.household_store_item_id
|
|
||||||
FROM household_lists hl
|
|
||||||
WHERE hlh.household_list_id = hl.id
|
|
||||||
AND hlh.household_store_item_id IS NULL;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM household_lists
|
|
||||||
WHERE household_store_item_id IS NULL
|
|
||||||
) THEN
|
|
||||||
RAISE EXCEPTION 'Failed to backfill household_lists.household_store_item_id';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM household_item_classifications
|
|
||||||
WHERE household_store_item_id IS NULL
|
|
||||||
) THEN
|
|
||||||
RAISE EXCEPTION 'Failed to backfill household_item_classifications.household_store_item_id';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM household_list_history
|
|
||||||
WHERE household_store_item_id IS NULL
|
|
||||||
) THEN
|
|
||||||
RAISE EXCEPTION 'Failed to backfill household_list_history.household_store_item_id';
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
ALTER TABLE household_lists
|
|
||||||
ALTER COLUMN household_store_item_id SET NOT NULL;
|
|
||||||
|
|
||||||
ALTER TABLE household_item_classifications
|
|
||||||
ALTER COLUMN household_store_item_id SET NOT NULL;
|
|
||||||
|
|
||||||
ALTER TABLE household_list_history
|
|
||||||
ALTER COLUMN household_store_item_id SET NOT NULL;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM pg_constraint
|
|
||||||
WHERE conname = 'household_lists_household_store_item_id_fkey'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE household_lists
|
|
||||||
ADD CONSTRAINT household_lists_household_store_item_id_fkey
|
|
||||||
FOREIGN KEY (household_store_item_id) REFERENCES household_store_items(id) ON DELETE CASCADE;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM pg_constraint
|
|
||||||
WHERE conname = 'household_item_classifications_household_store_item_id_fkey'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE household_item_classifications
|
|
||||||
ADD CONSTRAINT household_item_classifications_household_store_item_id_fkey
|
|
||||||
FOREIGN KEY (household_store_item_id) REFERENCES household_store_items(id) ON DELETE CASCADE;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM pg_constraint
|
|
||||||
WHERE conname = 'household_list_history_household_store_item_id_fkey'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE household_list_history
|
|
||||||
ADD CONSTRAINT household_list_history_household_store_item_id_fkey
|
|
||||||
FOREIGN KEY (household_store_item_id) REFERENCES household_store_items(id) ON DELETE CASCADE;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_household_lists_household_store_item
|
|
||||||
ON household_lists(household_id, store_id, household_store_item_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_household_item_classifications_household_store_item
|
|
||||||
ON household_item_classifications(household_id, store_id, household_store_item_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_household_list_history_household_store_item
|
|
||||||
ON household_list_history(household_store_item_id);
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
BEGIN;
|
|
||||||
|
|
||||||
WITH ranked_classifications AS (
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY household_id, store_id, household_store_item_id
|
|
||||||
ORDER BY updated_at DESC NULLS LAST, id DESC
|
|
||||||
) AS row_rank
|
|
||||||
FROM household_item_classifications
|
|
||||||
WHERE household_store_item_id IS NOT NULL
|
|
||||||
)
|
|
||||||
DELETE FROM household_item_classifications hic
|
|
||||||
USING ranked_classifications ranked
|
|
||||||
WHERE hic.id = ranked.id
|
|
||||||
AND ranked.row_rank > 1;
|
|
||||||
|
|
||||||
DROP INDEX IF EXISTS idx_household_item_classifications_household_store_item;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX idx_household_item_classifications_household_store_item
|
|
||||||
ON household_item_classifications(household_id, store_id, household_store_item_id);
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
Loading…
Reference in New Issue
Block a user