phase 3 - create minimal hooks to tie the new architecture between backend and frontend
This commit is contained in:
parent
4d5d2f0f6d
commit
9fc25f2274
83
POST_MIGRATION_UPDATES.md
Normal file
83
POST_MIGRATION_UPDATES.md
Normal file
@ -0,0 +1,83 @@
|
||||
# Post-Migration Updates Required
|
||||
|
||||
This document outlines the remaining updates needed after migrating to the multi-household architecture.
|
||||
|
||||
## ✅ Completed Fixes
|
||||
|
||||
1. **Column name corrections** in `list.model.v2.js`:
|
||||
- Fixed `item_image` → `custom_image`
|
||||
- Fixed `image_mime_type` → `custom_image_mime_type`
|
||||
- Fixed `hlh.list_id` → `hlh.household_list_id`
|
||||
|
||||
2. **SQL query fixes**:
|
||||
- Fixed ORDER BY with DISTINCT in `getSuggestions`
|
||||
- Fixed `setBought` to use boolean instead of quantity logic
|
||||
|
||||
3. **Created migration**: `add_notes_column.sql` for missing notes column
|
||||
|
||||
## 🔧 Required Database Migration
|
||||
|
||||
**Run this SQL on your PostgreSQL database:**
|
||||
|
||||
```sql
|
||||
-- From backend/migrations/add_notes_column.sql
|
||||
ALTER TABLE household_lists
|
||||
ADD COLUMN IF NOT EXISTS notes TEXT;
|
||||
|
||||
COMMENT ON COLUMN household_lists.notes IS 'Optional user notes/description for the item';
|
||||
```
|
||||
|
||||
## 🧹 Optional Cleanup (Not Critical)
|
||||
|
||||
### Legacy Files Still Present
|
||||
|
||||
These files reference the old `grocery_list` table but are not actively used by the frontend:
|
||||
|
||||
- `backend/models/list.model.js` - Old model
|
||||
- `backend/controllers/lists.controller.js` - Old controller
|
||||
- `backend/routes/list.routes.js` - Old routes (still mounted at `/list`)
|
||||
|
||||
**Recommendation**: Can be safely removed once you confirm the new architecture is working, or kept as fallback.
|
||||
|
||||
### Route Cleanup in app.js
|
||||
|
||||
The old `/list` route is still mounted in `backend/app.js`:
|
||||
|
||||
```javascript
|
||||
const listRoutes = require("./routes/list.routes");
|
||||
app.use("/list", listRoutes); // ← Not used by frontend anymore
|
||||
```
|
||||
|
||||
**Recommendation**: Comment out or remove once migration is confirmed successful.
|
||||
|
||||
## ✅ No Frontend Changes Needed
|
||||
|
||||
The frontend is already correctly calling the new household-scoped endpoints:
|
||||
- All calls use `/households/:householdId/stores/:storeId/list/*` pattern
|
||||
- No references to old `/list/*` endpoints
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
1. **Run the notes column migration** (required for notes feature to work)
|
||||
2. **Test the application** thoroughly:
|
||||
- Add items with images
|
||||
- Mark items as bought/unbought
|
||||
- Update item quantities and notes
|
||||
- Test suggestions/autocomplete
|
||||
- Test recently bought items
|
||||
3. **Remove legacy files** (optional, once confirmed working)
|
||||
|
||||
## 📝 Architecture Notes
|
||||
|
||||
**Current Structure:**
|
||||
- All list operations are scoped to `household_id + store_id`
|
||||
- History tracking uses `household_list_history` table
|
||||
- Image storage uses `custom_image` and `custom_image_mime_type` columns
|
||||
- Classifications use `household_item_classifications` table (per household+store)
|
||||
|
||||
**Middleware Chain:**
|
||||
```javascript
|
||||
auth → householdAccess → storeAccess → controller
|
||||
```
|
||||
|
||||
This ensures users can only access data for households they belong to and stores linked to those households.
|
||||
324
backend/controllers/lists.controller.v2.js
Normal file
324
backend/controllers/lists.controller.v2.js
Normal file
@ -0,0 +1,324 @@
|
||||
const List = require("../models/list.model.v2");
|
||||
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
|
||||
|
||||
/**
|
||||
* Get list items for household and store
|
||||
* GET /households/:householdId/stores/:storeId/list
|
||||
*/
|
||||
exports.getList = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const items = await List.getHouseholdStoreList(householdId, storeId);
|
||||
res.json({ items });
|
||||
} catch (error) {
|
||||
console.error("Error getting list:", error);
|
||||
res.status(500).json({ message: "Failed to get list" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get specific item by name
|
||||
* GET /households/:householdId/stores/:storeId/list/item
|
||||
*/
|
||||
exports.getItemByName = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { item_name } = req.query;
|
||||
|
||||
if (!item_name) {
|
||||
return res.status(400).json({ message: "Item name is required" });
|
||||
}
|
||||
|
||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
||||
if (!item) {
|
||||
return res.status(404).json({ message: "Item not found" });
|
||||
}
|
||||
|
||||
res.json(item);
|
||||
} catch (error) {
|
||||
console.error("Error getting item:", error);
|
||||
res.status(500).json({ message: "Failed to get item" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Add or update item in household store list
|
||||
* POST /households/:householdId/stores/:storeId/list/add
|
||||
*/
|
||||
exports.addItem = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { item_name, quantity, notes } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
if (!item_name || item_name.trim() === "") {
|
||||
return res.status(400).json({ message: "Item name is required" });
|
||||
}
|
||||
|
||||
// Get processed image if uploaded
|
||||
const imageBuffer = req.processedImage?.buffer || null;
|
||||
const mimeType = req.processedImage?.mimeType || null;
|
||||
|
||||
const result = await List.addOrUpdateItem(
|
||||
householdId,
|
||||
storeId,
|
||||
item_name,
|
||||
quantity || "1",
|
||||
userId,
|
||||
imageBuffer,
|
||||
mimeType,
|
||||
notes
|
||||
);
|
||||
|
||||
// Add history record
|
||||
await List.addHistoryRecord(result.listId, quantity || "1", userId);
|
||||
|
||||
res.json({
|
||||
message: result.isNew ? "Item added" : "Item updated",
|
||||
item: {
|
||||
id: result.listId,
|
||||
item_name: result.itemName,
|
||||
quantity: quantity || "1",
|
||||
bought: false
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error adding item:", error);
|
||||
res.status(500).json({ message: "Failed to add item" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Mark item as bought or unbought
|
||||
* PATCH /households/:householdId/stores/:storeId/list/item
|
||||
*/
|
||||
exports.markBought = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { item_name, bought, quantity_bought } = req.body;
|
||||
|
||||
if (!item_name) return res.status(400).json({ message: "Item name is required" });
|
||||
|
||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
||||
console.log('requesting mark ', { item, householdId, storeId, item_name, bought, quantity_bought });
|
||||
if (!item) return res.status(404).json({ message: "Item not found" });
|
||||
|
||||
|
||||
// Update bought status (with optional partial purchase)
|
||||
await List.setBought(item.id, bought, quantity_bought);
|
||||
|
||||
res.json({ message: bought ? "Item marked as bought" : "Item unmarked" });
|
||||
} catch (error) {
|
||||
console.error("Error marking bought:", error);
|
||||
res.status(500).json({ message: "Failed to update item" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update item details (quantity, notes)
|
||||
* PUT /households/:householdId/stores/:storeId/list/item
|
||||
*/
|
||||
exports.updateItem = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { item_name, quantity, notes } = req.body;
|
||||
|
||||
if (!item_name) {
|
||||
return res.status(400).json({ message: "Item name is required" });
|
||||
}
|
||||
|
||||
// Get the list item
|
||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
||||
if (!item) {
|
||||
return res.status(404).json({ message: "Item not found" });
|
||||
}
|
||||
|
||||
// Update item
|
||||
await List.updateItem(item.id, item_name, quantity, notes);
|
||||
|
||||
res.json({
|
||||
message: "Item updated",
|
||||
item: {
|
||||
id: item.id,
|
||||
item_name,
|
||||
quantity,
|
||||
notes
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating item:", error);
|
||||
res.status(500).json({ message: "Failed to update item" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete item from list
|
||||
* DELETE /households/:householdId/stores/:storeId/list/item
|
||||
*/
|
||||
exports.deleteItem = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { item_name } = req.body;
|
||||
|
||||
if (!item_name) {
|
||||
return res.status(400).json({ message: "Item name is required" });
|
||||
}
|
||||
|
||||
// Get the list item
|
||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
||||
if (!item) {
|
||||
return res.status(404).json({ message: "Item not found" });
|
||||
}
|
||||
|
||||
await List.deleteItem(item.id);
|
||||
|
||||
res.json({ message: "Item deleted" });
|
||||
} catch (error) {
|
||||
console.error("Error deleting item:", error);
|
||||
res.status(500).json({ message: "Failed to delete item" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get item suggestions based on query
|
||||
* GET /households/:householdId/stores/:storeId/list/suggestions
|
||||
*/
|
||||
exports.getSuggestions = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { query } = req.query;
|
||||
|
||||
const suggestions = await List.getSuggestions(query || "", householdId, storeId);
|
||||
res.json(suggestions);
|
||||
} catch (error) {
|
||||
console.error("Error getting suggestions:", error);
|
||||
res.status(500).json({ message: "Failed to get suggestions" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get recently bought items
|
||||
* GET /households/:householdId/stores/:storeId/list/recent
|
||||
*/
|
||||
exports.getRecentlyBought = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const items = await List.getRecentlyBoughtItems(householdId, storeId);
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
console.error("Error getting recent items:", error);
|
||||
res.status(500).json({ message: "Failed to get recent items" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get item classification
|
||||
* GET /households/:householdId/stores/:storeId/list/classification
|
||||
*/
|
||||
exports.getClassification = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { item_name } = req.query;
|
||||
|
||||
if (!item_name) {
|
||||
return res.status(400).json({ message: "Item name is required" });
|
||||
}
|
||||
|
||||
// Get item ID from name
|
||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
||||
if (!item) {
|
||||
return res.json({ classification: null });
|
||||
}
|
||||
|
||||
const classification = await List.getClassification(householdId, item.item_id);
|
||||
res.json({ classification });
|
||||
} catch (error) {
|
||||
console.error("Error getting classification:", error);
|
||||
res.status(500).json({ message: "Failed to get classification" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Set/update item classification
|
||||
* POST /households/:householdId/stores/:storeId/list/classification
|
||||
*/
|
||||
exports.setClassification = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { item_name, classification } = req.body;
|
||||
|
||||
if (!item_name) {
|
||||
return res.status(400).json({ message: "Item name is required" });
|
||||
}
|
||||
|
||||
if (!classification) {
|
||||
return res.status(400).json({ message: "Classification is required" });
|
||||
}
|
||||
|
||||
// Validate classification
|
||||
const validClassifications = ['produce', 'dairy', 'meat', 'bakery', 'frozen', 'pantry', 'snacks', 'beverages', 'household', 'other'];
|
||||
if (!validClassifications.includes(classification)) {
|
||||
return res.status(400).json({ message: "Invalid classification value" });
|
||||
}
|
||||
|
||||
// Get item - add to master items if not exists
|
||||
const item = await List.getItemByName(householdId, storeId, item_name);
|
||||
let itemId;
|
||||
|
||||
if (!item) {
|
||||
// Item doesn't exist in list, need to get from items table or create
|
||||
const itemResult = await List.addOrUpdateItem(
|
||||
householdId,
|
||||
storeId,
|
||||
item_name,
|
||||
"1",
|
||||
req.user.id,
|
||||
null,
|
||||
null
|
||||
);
|
||||
itemId = itemResult.itemId;
|
||||
} else {
|
||||
itemId = item.item_id;
|
||||
}
|
||||
|
||||
// Set classification (using item_type field for simplicity)
|
||||
await List.upsertClassification(householdId, itemId, {
|
||||
item_type: classification,
|
||||
item_group: null,
|
||||
zone: null
|
||||
});
|
||||
|
||||
res.json({ message: "Classification set", classification });
|
||||
} catch (error) {
|
||||
console.error("Error setting classification:", error);
|
||||
res.status(500).json({ message: "Failed to set classification" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update item image
|
||||
* POST /households/:householdId/stores/:storeId/list/update-image
|
||||
*/
|
||||
exports.updateItemImage = async (req, res) => {
|
||||
try {
|
||||
const { householdId, storeId } = req.params;
|
||||
const { item_name, quantity } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Get processed image
|
||||
const imageBuffer = req.processedImage?.buffer || null;
|
||||
const mimeType = req.processedImage?.mimeType || null;
|
||||
|
||||
if (!imageBuffer) {
|
||||
return res.status(400).json({ message: "No image provided" });
|
||||
}
|
||||
|
||||
// Update the item with new image
|
||||
await List.addOrUpdateItem(householdId, storeId, item_name, quantity, userId, imageBuffer, mimeType);
|
||||
|
||||
res.json({ message: "Image updated successfully" });
|
||||
} catch (error) {
|
||||
console.error("Error updating image:", error);
|
||||
res.status(500).json({ message: "Failed to update image" });
|
||||
}
|
||||
};
|
||||
7
backend/migrations/add_notes_column.sql
Normal file
7
backend/migrations/add_notes_column.sql
Normal file
@ -0,0 +1,7 @@
|
||||
-- Add notes column to household_lists table
|
||||
-- This allows users to add custom notes/descriptions to list items
|
||||
|
||||
ALTER TABLE household_lists
|
||||
ADD COLUMN IF NOT EXISTS notes TEXT;
|
||||
|
||||
COMMENT ON COLUMN household_lists.notes IS 'Optional user notes/description for the item';
|
||||
@ -14,15 +14,15 @@ exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = tr
|
||||
i.name AS item_name,
|
||||
hl.quantity,
|
||||
hl.bought,
|
||||
ENCODE(hl.item_image, 'base64') as item_image,
|
||||
hl.image_mime_type,
|
||||
ENCODE(hl.custom_image, 'base64') as item_image,
|
||||
hl.custom_image_mime_type as image_mime_type,
|
||||
${includeHistory ? `
|
||||
(
|
||||
SELECT ARRAY_AGG(DISTINCT u.name)
|
||||
FROM (
|
||||
SELECT DISTINCT hlh.added_by
|
||||
FROM household_list_history hlh
|
||||
WHERE hlh.list_id = hl.id
|
||||
WHERE hlh.household_list_id = hl.id
|
||||
ORDER BY hlh.added_by
|
||||
) hlh
|
||||
JOIN users u ON hlh.added_by = u.id
|
||||
@ -65,22 +65,20 @@ exports.getItemByName = async (householdId, storeId, itemName) => {
|
||||
}
|
||||
|
||||
const itemId = itemResult.rows[0].id;
|
||||
|
||||
// Check if item exists in household list
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
hl.id,
|
||||
i.name AS item_name,
|
||||
hl.quantity,
|
||||
hl.bought,
|
||||
ENCODE(hl.item_image, 'base64') as item_image,
|
||||
hl.image_mime_type,
|
||||
ENCODE(hl.custom_image, 'base64') as item_image,
|
||||
hl.custom_image_mime_type as image_mime_type,
|
||||
(
|
||||
SELECT ARRAY_AGG(DISTINCT u.name)
|
||||
FROM (
|
||||
SELECT DISTINCT hlh.added_by
|
||||
FROM household_list_history hlh
|
||||
WHERE hlh.list_id = hl.id
|
||||
WHERE hlh.household_list_id = hl.id
|
||||
ORDER BY hlh.added_by
|
||||
) hlh
|
||||
JOIN users u ON hlh.added_by = u.id
|
||||
@ -99,7 +97,7 @@ exports.getItemByName = async (householdId, storeId, itemName) => {
|
||||
AND hl.item_id = $3`,
|
||||
[householdId, storeId, itemId]
|
||||
);
|
||||
|
||||
console.log(result.rows);
|
||||
return result.rows[0] || null;
|
||||
};
|
||||
|
||||
@ -125,7 +123,6 @@ exports.addOrUpdateItem = async (
|
||||
) => {
|
||||
const lowerItemName = itemName.toLowerCase();
|
||||
|
||||
// First, ensure item exists in master catalog
|
||||
let itemResult = await pool.query(
|
||||
"SELECT id FROM items WHERE name ILIKE $1",
|
||||
[lowerItemName]
|
||||
@ -133,7 +130,6 @@ exports.addOrUpdateItem = async (
|
||||
|
||||
let itemId;
|
||||
if (itemResult.rowCount === 0) {
|
||||
// Create new item in master catalog
|
||||
const insertItem = await pool.query(
|
||||
"INSERT INTO items (name) VALUES ($1) RETURNING id",
|
||||
[lowerItemName]
|
||||
@ -143,7 +139,6 @@ exports.addOrUpdateItem = async (
|
||||
itemId = itemResult.rows[0].id;
|
||||
}
|
||||
|
||||
// Check if item exists in household list
|
||||
const listResult = await pool.query(
|
||||
`SELECT id, bought FROM household_lists
|
||||
WHERE household_id = $1
|
||||
@ -153,15 +148,14 @@ exports.addOrUpdateItem = async (
|
||||
);
|
||||
|
||||
if (listResult.rowCount > 0) {
|
||||
// Update existing list item
|
||||
const listId = listResult.rows[0].id;
|
||||
if (imageBuffer && mimeType) {
|
||||
await pool.query(
|
||||
`UPDATE household_lists
|
||||
SET quantity = $1,
|
||||
bought = FALSE,
|
||||
item_image = $2,
|
||||
image_mime_type = $3,
|
||||
custom_image = $2,
|
||||
custom_image_mime_type = $3,
|
||||
modified_on = NOW()
|
||||
WHERE id = $4`,
|
||||
[quantity, imageBuffer, mimeType, listId]
|
||||
@ -178,10 +172,9 @@ exports.addOrUpdateItem = async (
|
||||
}
|
||||
return listId;
|
||||
} else {
|
||||
// Insert new list item
|
||||
const insert = await pool.query(
|
||||
`INSERT INTO household_lists
|
||||
(household_id, store_id, item_id, quantity, item_image, image_mime_type)
|
||||
(household_id, store_id, item_id, quantity, custom_image, custom_image_mime_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id`,
|
||||
[householdId, storeId, itemId, quantity, imageBuffer, mimeType]
|
||||
@ -193,10 +186,22 @@ exports.addOrUpdateItem = async (
|
||||
/**
|
||||
* Mark item as bought (full or partial)
|
||||
* @param {number} listId - List item ID
|
||||
* @param {number} quantityBought - Quantity bought
|
||||
* @param {boolean} bought - True to mark as bought, false to unmark
|
||||
* @param {number} quantityBought - Optional quantity bought (for partial purchases)
|
||||
*/
|
||||
exports.setBought = async (listId, quantityBought) => {
|
||||
// Get current item
|
||||
exports.setBought = async (listId, bought, quantityBought = null) => {
|
||||
if (bought === false) {
|
||||
// Unmarking - just set bought to false
|
||||
await pool.query(
|
||||
"UPDATE household_lists SET bought = FALSE, modified_on = NOW() WHERE id = $1",
|
||||
[listId]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Marking as bought
|
||||
if (quantityBought && quantityBought > 0) {
|
||||
// Partial purchase - reduce quantity
|
||||
const item = await pool.query(
|
||||
"SELECT quantity FROM household_lists WHERE id = $1",
|
||||
[listId]
|
||||
@ -208,18 +213,25 @@ exports.setBought = async (listId, quantityBought) => {
|
||||
const remainingQuantity = currentQuantity - quantityBought;
|
||||
|
||||
if (remainingQuantity <= 0) {
|
||||
// Mark as bought if all quantity is purchased
|
||||
// All bought - mark as bought
|
||||
await pool.query(
|
||||
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
|
||||
[listId]
|
||||
);
|
||||
} else {
|
||||
// Reduce quantity if partial purchase
|
||||
// Partial - reduce quantity
|
||||
await pool.query(
|
||||
"UPDATE household_lists SET quantity = $1, modified_on = NOW() WHERE id = $2",
|
||||
[remainingQuantity, listId]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Full purchase - mark as bought
|
||||
await pool.query(
|
||||
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
|
||||
[listId]
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@ -230,7 +242,7 @@ exports.setBought = async (listId, quantityBought) => {
|
||||
*/
|
||||
exports.addHistoryRecord = async (listId, quantity, userId) => {
|
||||
await pool.query(
|
||||
`INSERT INTO household_list_history (list_id, quantity, added_by, added_on)
|
||||
`INSERT INTO household_list_history (household_list_id, quantity, added_by, added_on)
|
||||
VALUES ($1, $2, $3, NOW())`,
|
||||
[listId, quantity, userId]
|
||||
);
|
||||
@ -246,16 +258,16 @@ exports.addHistoryRecord = async (listId, quantity, userId) => {
|
||||
exports.getSuggestions = async (query, householdId, storeId) => {
|
||||
// Get items from both master catalog and household history
|
||||
const result = await pool.query(
|
||||
`SELECT DISTINCT i.name as item_name
|
||||
`SELECT DISTINCT
|
||||
i.name as item_name,
|
||||
CASE WHEN hl.id IS NOT NULL THEN 0 ELSE 1 END as sort_order
|
||||
FROM items i
|
||||
LEFT JOIN household_lists hl
|
||||
ON i.id = hl.item_id
|
||||
AND hl.household_id = $2
|
||||
AND hl.store_id = $3
|
||||
WHERE i.name ILIKE $1
|
||||
ORDER BY
|
||||
CASE WHEN hl.id IS NOT NULL THEN 0 ELSE 1 END,
|
||||
i.name
|
||||
ORDER BY sort_order, i.name
|
||||
LIMIT 10`,
|
||||
[`%${query}%`, householdId, storeId]
|
||||
);
|
||||
@ -275,14 +287,14 @@ exports.getRecentlyBoughtItems = async (householdId, storeId) => {
|
||||
i.name AS item_name,
|
||||
hl.quantity,
|
||||
hl.bought,
|
||||
ENCODE(hl.item_image, 'base64') as item_image,
|
||||
hl.image_mime_type,
|
||||
ENCODE(hl.custom_image, 'base64') as item_image,
|
||||
hl.custom_image_mime_type as image_mime_type,
|
||||
(
|
||||
SELECT ARRAY_AGG(DISTINCT u.name)
|
||||
FROM (
|
||||
SELECT DISTINCT hlh.added_by
|
||||
FROM household_list_history hlh
|
||||
WHERE hlh.list_id = hl.id
|
||||
WHERE hlh.household_list_id = hl.id
|
||||
ORDER BY hlh.added_by
|
||||
) hlh
|
||||
JOIN users u ON hlh.added_by = u.id
|
||||
@ -346,49 +358,44 @@ exports.upsertClassification = async (householdId, itemId, classification) => {
|
||||
/**
|
||||
* Update list item details
|
||||
* @param {number} listId - List item ID
|
||||
* @param {string} itemName - New item name
|
||||
* @param {number} quantity - New quantity
|
||||
* @param {string} itemName - New item name (optional)
|
||||
* @param {number} quantity - New quantity (optional)
|
||||
* @param {string} notes - Notes (optional)
|
||||
* @returns {Promise<Object>} Updated item
|
||||
*/
|
||||
exports.updateItem = async (listId, itemName, quantity) => {
|
||||
// This is more complex now because we need to handle the master catalog
|
||||
// Get current list item
|
||||
const listItem = await pool.query(
|
||||
"SELECT item_id FROM household_lists WHERE id = $1",
|
||||
exports.updateItem = async (listId, itemName, quantity, notes) => {
|
||||
// Build dynamic update query
|
||||
const updates = [];
|
||||
const values = [listId];
|
||||
let paramCount = 1;
|
||||
|
||||
if (quantity !== undefined) {
|
||||
paramCount++;
|
||||
updates.push(`quantity = $${paramCount}`);
|
||||
values.push(quantity);
|
||||
}
|
||||
|
||||
if (notes !== undefined) {
|
||||
paramCount++;
|
||||
updates.push(`notes = $${paramCount}`);
|
||||
values.push(notes);
|
||||
}
|
||||
|
||||
// Always update modified_on
|
||||
updates.push(`modified_on = NOW()`);
|
||||
|
||||
if (updates.length === 1) {
|
||||
// Only modified_on update
|
||||
const result = await pool.query(
|
||||
`UPDATE household_lists SET modified_on = NOW() WHERE id = $1 RETURNING *`,
|
||||
[listId]
|
||||
);
|
||||
|
||||
if (listItem.rowCount === 0) {
|
||||
throw new Error("List item not found");
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
const oldItemId = listItem.rows[0].item_id;
|
||||
|
||||
// Check if new item name exists in catalog
|
||||
let newItemId;
|
||||
const itemResult = await pool.query(
|
||||
"SELECT id FROM items WHERE name ILIKE $1",
|
||||
[itemName.toLowerCase()]
|
||||
);
|
||||
|
||||
if (itemResult.rowCount === 0) {
|
||||
// Create new item
|
||||
const insertItem = await pool.query(
|
||||
"INSERT INTO items (name) VALUES ($1) RETURNING id",
|
||||
[itemName.toLowerCase()]
|
||||
);
|
||||
newItemId = insertItem.rows[0].id;
|
||||
} else {
|
||||
newItemId = itemResult.rows[0].id;
|
||||
}
|
||||
|
||||
// Update list item
|
||||
const result = await pool.query(
|
||||
`UPDATE household_lists
|
||||
SET item_id = $2, quantity = $3, modified_on = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[listId, newItemId, quantity]
|
||||
`UPDATE household_lists SET ${updates.join(', ')} WHERE id = $1 RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>API Test Suite - Grocery List</title>
|
||||
<link rel="stylesheet" href="test-styles.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🧪 API Test Suite</h1>
|
||||
@ -57,4 +59,5 @@
|
||||
<script src="test-runner.js"></script>
|
||||
<script src="test-ui.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -1,11 +1,14 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const controller = require("../controllers/households.controller");
|
||||
const listsController = require("../controllers/lists.controller.v2");
|
||||
const auth = require("../middleware/auth");
|
||||
const {
|
||||
householdAccess,
|
||||
requireHouseholdAdmin,
|
||||
storeAccess,
|
||||
} = require("../middleware/household");
|
||||
const { upload, processImage } = require("../middleware/image");
|
||||
|
||||
// Public routes (authenticated only)
|
||||
router.get("/", auth, controller.getUserHouseholds);
|
||||
@ -57,4 +60,110 @@ router.delete(
|
||||
controller.removeMember
|
||||
);
|
||||
|
||||
// ==================== List Operations Routes ====================
|
||||
// All list routes require household access AND store access
|
||||
|
||||
// Get grocery list
|
||||
router.get(
|
||||
"/:householdId/stores/:storeId/list",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
listsController.getList
|
||||
);
|
||||
|
||||
// Get specific item by name
|
||||
router.get(
|
||||
"/:householdId/stores/:storeId/list/item",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
listsController.getItemByName
|
||||
);
|
||||
|
||||
// Add item to list
|
||||
router.post(
|
||||
"/:householdId/stores/:storeId/list/add",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
upload.single("image"),
|
||||
processImage,
|
||||
listsController.addItem
|
||||
);
|
||||
|
||||
// Mark item as bought/unbought
|
||||
router.patch(
|
||||
"/:householdId/stores/:storeId/list/item",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
listsController.markBought
|
||||
);
|
||||
|
||||
// Update item details (quantity, notes)
|
||||
router.put(
|
||||
"/:householdId/stores/:storeId/list/item",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
listsController.updateItem
|
||||
);
|
||||
|
||||
// Delete item
|
||||
router.delete(
|
||||
"/:householdId/stores/:storeId/list/item",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
listsController.deleteItem
|
||||
);
|
||||
|
||||
// Get suggestions
|
||||
router.get(
|
||||
"/:householdId/stores/:storeId/list/suggestions",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
listsController.getSuggestions
|
||||
);
|
||||
|
||||
// Get recently bought items
|
||||
router.get(
|
||||
"/:householdId/stores/:storeId/list/recent",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
listsController.getRecentlyBought
|
||||
);
|
||||
|
||||
// Get item classification
|
||||
router.get(
|
||||
"/:householdId/stores/:storeId/list/classification",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
listsController.getClassification
|
||||
);
|
||||
|
||||
// Set item classification
|
||||
router.post(
|
||||
"/:householdId/stores/:storeId/list/classification",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
listsController.setClassification
|
||||
);
|
||||
|
||||
// Update item image
|
||||
router.post(
|
||||
"/:householdId/stores/:storeId/list/update-image",
|
||||
auth,
|
||||
householdAccess,
|
||||
storeAccess,
|
||||
upload.single("image"),
|
||||
processImage,
|
||||
listsController.updateItemImage
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@ -2,7 +2,9 @@ import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import { ROLES } from "./constants/roles";
|
||||
import { AuthProvider } from "./context/AuthContext.jsx";
|
||||
import { ConfigProvider } from "./context/ConfigContext.jsx";
|
||||
import { HouseholdProvider } from "./context/HouseholdContext.jsx";
|
||||
import { SettingsProvider } from "./context/SettingsContext.jsx";
|
||||
import { StoreProvider } from "./context/StoreContext.jsx";
|
||||
|
||||
import AdminPanel from "./pages/AdminPanel.jsx";
|
||||
import GroceryList from "./pages/GroceryList.jsx";
|
||||
@ -20,6 +22,8 @@ function App() {
|
||||
return (
|
||||
<ConfigProvider>
|
||||
<AuthProvider>
|
||||
<HouseholdProvider>
|
||||
<StoreProvider>
|
||||
<SettingsProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
@ -52,6 +56,8 @@ function App() {
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</SettingsProvider>
|
||||
</StoreProvider>
|
||||
</HouseholdProvider>
|
||||
</AuthProvider>
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
58
frontend/src/api/households.js
Normal file
58
frontend/src/api/households.js
Normal file
@ -0,0 +1,58 @@
|
||||
import api from "./axios";
|
||||
|
||||
/**
|
||||
* Get all households for the current user
|
||||
*/
|
||||
export const getUserHouseholds = () => api.get("/households");
|
||||
|
||||
/**
|
||||
* Get details of a specific household
|
||||
*/
|
||||
export const getHousehold = (householdId) => api.get(`/households/${householdId}`);
|
||||
|
||||
/**
|
||||
* Create a new household
|
||||
*/
|
||||
export const createHousehold = (name) => api.post("/households", { name });
|
||||
|
||||
/**
|
||||
* Update household name
|
||||
*/
|
||||
export const updateHousehold = (householdId, name) =>
|
||||
api.patch(`/households/${householdId}`, { name });
|
||||
|
||||
/**
|
||||
* Delete a household
|
||||
*/
|
||||
export const deleteHousehold = (householdId) =>
|
||||
api.delete(`/households/${householdId}`);
|
||||
|
||||
/**
|
||||
* Refresh household invite code
|
||||
*/
|
||||
export const refreshInviteCode = (householdId) =>
|
||||
api.post(`/households/${householdId}/invite/refresh`);
|
||||
|
||||
/**
|
||||
* Join a household using invite code
|
||||
*/
|
||||
export const joinHousehold = (inviteCode) =>
|
||||
api.post(`/households/join/${inviteCode}`);
|
||||
|
||||
/**
|
||||
* Get household members
|
||||
*/
|
||||
export const getHouseholdMembers = (householdId) =>
|
||||
api.get(`/households/${householdId}/members`);
|
||||
|
||||
/**
|
||||
* Update member role
|
||||
*/
|
||||
export const updateMemberRole = (householdId, userId, role) =>
|
||||
api.patch(`/households/${householdId}/members/${userId}/role`, { role });
|
||||
|
||||
/**
|
||||
* Remove member from household
|
||||
*/
|
||||
export const removeMember = (householdId, userId) =>
|
||||
api.delete(`/households/${householdId}/members/${userId}`);
|
||||
@ -1,44 +1,120 @@
|
||||
import api from "./axios";
|
||||
|
||||
export const getList = () => api.get("/list");
|
||||
export const getItemByName = (itemName) => api.get("/list/item-by-name", { params: { itemName: itemName } });
|
||||
/**
|
||||
* Get grocery list for household and store
|
||||
*/
|
||||
export const getList = (householdId, storeId) =>
|
||||
api.get(`/households/${householdId}/stores/${storeId}/list`);
|
||||
|
||||
export const addItem = (itemName, quantity, imageFile = null) => {
|
||||
/**
|
||||
* Get specific item by name
|
||||
*/
|
||||
export const getItemByName = (householdId, storeId, itemName) =>
|
||||
api.get(`/households/${householdId}/stores/${storeId}/list/item`, {
|
||||
params: { item_name: itemName }
|
||||
});
|
||||
|
||||
/**
|
||||
* Add item to list
|
||||
*/
|
||||
export const addItem = (householdId, storeId, itemName, quantity, imageFile = null, notes = null) => {
|
||||
const formData = new FormData();
|
||||
formData.append("itemName", itemName);
|
||||
formData.append("item_name", itemName);
|
||||
formData.append("quantity", quantity);
|
||||
|
||||
if (notes) {
|
||||
formData.append("notes", notes);
|
||||
}
|
||||
if (imageFile) {
|
||||
formData.append("image", imageFile);
|
||||
}
|
||||
|
||||
return api.post("/list/add", formData, {
|
||||
return api.post(`/households/${householdId}/stores/${storeId}/list/add`, formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
};
|
||||
export const getClassification = (id) => api.get(`/list/item/${id}/classification`);
|
||||
|
||||
export const updateItemWithClassification = (id, itemName, quantity, classification) => {
|
||||
return api.put(`/list/item/${id}`, {
|
||||
itemName,
|
||||
quantity,
|
||||
/**
|
||||
* Get item classification
|
||||
*/
|
||||
export const getClassification = (householdId, storeId, itemName) =>
|
||||
api.get(`/households/${householdId}/stores/${storeId}/list/classification`, {
|
||||
params: { item_name: itemName }
|
||||
});
|
||||
|
||||
/**
|
||||
* Set item classification
|
||||
*/
|
||||
export const setClassification = (householdId, storeId, itemName, classification) =>
|
||||
api.post(`/households/${householdId}/stores/${storeId}/list/classification`, {
|
||||
item_name: itemName,
|
||||
classification
|
||||
});
|
||||
};
|
||||
export const markBought = (id, quantity) => api.post("/list/mark-bought", { id, quantity });
|
||||
export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } });
|
||||
export const getRecentlyBought = () => api.get("/list/recently-bought");
|
||||
|
||||
export const updateItemImage = (id, itemName, quantity, imageFile) => {
|
||||
/**
|
||||
* Update item with classification (legacy method - split into separate calls)
|
||||
*/
|
||||
export const updateItemWithClassification = (householdId, storeId, itemName, quantity, classification) => {
|
||||
// This is now two operations: update item + set classification
|
||||
return Promise.all([
|
||||
updateItem(householdId, storeId, itemName, quantity),
|
||||
classification ? setClassification(householdId, storeId, itemName, classification) : Promise.resolve()
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update item details (quantity, notes)
|
||||
*/
|
||||
export const updateItem = (householdId, storeId, itemName, quantity, notes) =>
|
||||
api.put(`/households/${householdId}/stores/${storeId}/list/item`, {
|
||||
item_name: itemName,
|
||||
quantity,
|
||||
notes
|
||||
});
|
||||
|
||||
/**
|
||||
* Mark item as bought or unbought
|
||||
*/
|
||||
export const markBought = (householdId, storeId, itemName, quantityBought = null, bought = true) =>
|
||||
api.patch(`/households/${householdId}/stores/${storeId}/list/item`, {
|
||||
item_name: itemName,
|
||||
bought,
|
||||
quantity_bought: quantityBought
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete item from list
|
||||
*/
|
||||
export const deleteItem = (householdId, storeId, itemName) =>
|
||||
api.delete(`/households/${householdId}/stores/${storeId}/list/item`, {
|
||||
data: { item_name: itemName }
|
||||
});
|
||||
|
||||
/**
|
||||
* Get suggestions based on query
|
||||
*/
|
||||
export const getSuggestions = (householdId, storeId, query) =>
|
||||
api.get(`/households/${householdId}/stores/${storeId}/list/suggestions`, {
|
||||
params: { query }
|
||||
});
|
||||
|
||||
/**
|
||||
* Get recently bought items
|
||||
*/
|
||||
export const getRecentlyBought = (householdId, storeId) =>
|
||||
api.get(`/households/${householdId}/stores/${storeId}/list/recent`);
|
||||
|
||||
/**
|
||||
* Update item image
|
||||
*/
|
||||
export const updateItemImage = (householdId, storeId, itemName, quantity, imageFile) => {
|
||||
const formData = new FormData();
|
||||
formData.append("id", id);
|
||||
formData.append("itemName", itemName);
|
||||
formData.append("item_name", itemName);
|
||||
formData.append("quantity", quantity);
|
||||
formData.append("image", imageFile);
|
||||
|
||||
return api.post("/list/update-image", formData, {
|
||||
return api.post(`/households/${householdId}/stores/${storeId}/list/update-image`, formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
|
||||
48
frontend/src/api/stores.js
Normal file
48
frontend/src/api/stores.js
Normal file
@ -0,0 +1,48 @@
|
||||
import api from "./axios";
|
||||
|
||||
/**
|
||||
* Get all stores in the system
|
||||
*/
|
||||
export const getAllStores = () => api.get("/stores");
|
||||
|
||||
/**
|
||||
* Get stores linked to a household
|
||||
*/
|
||||
export const getHouseholdStores = (householdId) =>
|
||||
api.get(`/stores/household/${householdId}`);
|
||||
|
||||
/**
|
||||
* Add a store to a household
|
||||
*/
|
||||
export const addStoreToHousehold = (householdId, storeId, isDefault = false) =>
|
||||
api.post(`/stores/household/${householdId}`, { store_id: storeId, is_default: isDefault });
|
||||
|
||||
/**
|
||||
* Remove a store from a household
|
||||
*/
|
||||
export const removeStoreFromHousehold = (householdId, storeId) =>
|
||||
api.delete(`/stores/household/${householdId}/${storeId}`);
|
||||
|
||||
/**
|
||||
* Set a store as default for a household
|
||||
*/
|
||||
export const setDefaultStore = (householdId, storeId) =>
|
||||
api.patch(`/stores/household/${householdId}/${storeId}/default`);
|
||||
|
||||
/**
|
||||
* Create a new store (system admin only)
|
||||
*/
|
||||
export const createStore = (name, location) =>
|
||||
api.post("/stores", { name, location });
|
||||
|
||||
/**
|
||||
* Update store details (system admin only)
|
||||
*/
|
||||
export const updateStore = (storeId, name, location) =>
|
||||
api.patch(`/stores/${storeId}`, { name, location });
|
||||
|
||||
/**
|
||||
* Delete a store (system admin only)
|
||||
*/
|
||||
export const deleteStore = (storeId) =>
|
||||
api.delete(`/stores/${storeId}`);
|
||||
50
frontend/src/components/household/HouseholdSwitcher.jsx
Normal file
50
frontend/src/components/household/HouseholdSwitcher.jsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { useContext, useState } from 'react';
|
||||
import { HouseholdContext } from '../../context/HouseholdContext';
|
||||
import '../../styles/components/HouseholdSwitcher.css';
|
||||
|
||||
export default function HouseholdSwitcher() {
|
||||
const { households, activeHousehold, setActiveHousehold, loading } = useContext(HouseholdContext);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
if (!activeHousehold || households.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSelect = (household) => {
|
||||
setActiveHousehold(household);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="household-switcher">
|
||||
<button
|
||||
className="household-switcher-toggle"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={loading}
|
||||
>
|
||||
<span className="household-name">{activeHousehold.name}</span>
|
||||
<span className={`dropdown-icon ${isOpen ? 'open' : ''}`}>▼</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<div className="household-switcher-overlay" onClick={() => setIsOpen(false)} />
|
||||
<div className="household-switcher-dropdown">
|
||||
{households.map(household => (
|
||||
<button
|
||||
key={household.id}
|
||||
className={`household-option ${household.id === activeHousehold.id ? 'active' : ''}`}
|
||||
onClick={() => handleSelect(household)}
|
||||
>
|
||||
{household.name}
|
||||
{household.id === activeHousehold.id && (
|
||||
<span className="check-mark">✓</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -3,6 +3,7 @@ import "../../styles/components/Navbar.css";
|
||||
import { useContext } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { AuthContext } from "../../context/AuthContext";
|
||||
import HouseholdSwitcher from "../household/HouseholdSwitcher";
|
||||
|
||||
export default function Navbar() {
|
||||
const { role, logout, username } = useContext(AuthContext);
|
||||
@ -16,6 +17,8 @@ export default function Navbar() {
|
||||
{role === "admin" && <Link to="/admin">Admin</Link>}
|
||||
</div>
|
||||
|
||||
<HouseholdSwitcher />
|
||||
|
||||
<div className="navbar-idcard">
|
||||
<div className="navbar-idinfo">
|
||||
<span className="navbar-username">{username}</span>
|
||||
|
||||
35
frontend/src/components/store/StoreTabs.jsx
Normal file
35
frontend/src/components/store/StoreTabs.jsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { useContext } from 'react';
|
||||
import { StoreContext } from '../../context/StoreContext';
|
||||
import '../../styles/components/StoreTabs.css';
|
||||
|
||||
export default function StoreTabs() {
|
||||
const { stores, activeStore, setActiveStore, loading } = useContext(StoreContext);
|
||||
|
||||
if (!stores || stores.length === 0) {
|
||||
return (
|
||||
<div className="store-tabs">
|
||||
<div className="store-tabs-empty">
|
||||
No stores available for this household
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="store-tabs">
|
||||
<div className="store-tabs-container">
|
||||
{stores.map(store => (
|
||||
<button
|
||||
key={store.id}
|
||||
className={`store-tab ${store.id === activeStore?.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveStore(store)}
|
||||
disabled={loading}
|
||||
>
|
||||
<span className="store-name">{store.name}</span>
|
||||
{store.is_default && <span className="default-badge">Default</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
frontend/src/context/HouseholdContext.jsx
Normal file
115
frontend/src/context/HouseholdContext.jsx
Normal file
@ -0,0 +1,115 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { createHousehold as createHouseholdApi, getUserHouseholds } from '../api/households';
|
||||
import { AuthContext } from './AuthContext';
|
||||
|
||||
export const HouseholdContext = createContext({
|
||||
households: [],
|
||||
activeHousehold: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
setActiveHousehold: () => { },
|
||||
refreshHouseholds: () => { },
|
||||
createHousehold: () => { },
|
||||
});
|
||||
|
||||
export const HouseholdProvider = ({ children }) => {
|
||||
const { token } = useContext(AuthContext);
|
||||
const [households, setHouseholds] = useState([]);
|
||||
const [activeHousehold, setActiveHouseholdState] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Load households on mount and when token changes
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
loadHouseholds();
|
||||
} else {
|
||||
// Clear state when logged out
|
||||
setHouseholds([]);
|
||||
setActiveHouseholdState(null);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// Load active household from localStorage on mount
|
||||
useEffect(() => {
|
||||
if (households.length === 0) return;
|
||||
|
||||
console.log('[HouseholdContext] Setting active household from:', households);
|
||||
const savedHouseholdId = localStorage.getItem('activeHouseholdId');
|
||||
if (savedHouseholdId) {
|
||||
const household = households.find(h => h.id === parseInt(savedHouseholdId));
|
||||
if (household) {
|
||||
console.log('[HouseholdContext] Found saved household:', household);
|
||||
setActiveHouseholdState(household);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No saved household or not found, use first one
|
||||
console.log('[HouseholdContext] Using first household:', households[0]);
|
||||
setActiveHouseholdState(households[0]);
|
||||
localStorage.setItem('activeHouseholdId', households[0].id);
|
||||
}, [households]);
|
||||
|
||||
const loadHouseholds = async () => {
|
||||
if (!token) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
console.log('[HouseholdContext] Loading households...');
|
||||
const response = await getUserHouseholds();
|
||||
console.log('[HouseholdContext] Loaded households:', response.data);
|
||||
setHouseholds(response.data);
|
||||
} catch (err) {
|
||||
console.error('[HouseholdContext] Failed to load households:', err);
|
||||
setError(err.response?.data?.message || 'Failed to load households');
|
||||
setHouseholds([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setActiveHousehold = (household) => {
|
||||
setActiveHouseholdState(household);
|
||||
if (household) {
|
||||
localStorage.setItem('activeHouseholdId', household.id);
|
||||
} else {
|
||||
localStorage.removeItem('activeHouseholdId');
|
||||
}
|
||||
};
|
||||
|
||||
const createHousehold = async (name) => {
|
||||
try {
|
||||
const response = await createHouseholdApi(name);
|
||||
const newHousehold = response.data.household;
|
||||
|
||||
// Refresh households list
|
||||
await loadHouseholds();
|
||||
|
||||
// Set new household as active
|
||||
setActiveHousehold(newHousehold);
|
||||
|
||||
return newHousehold;
|
||||
} catch (err) {
|
||||
console.error('Failed to create household:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
households,
|
||||
activeHousehold,
|
||||
loading,
|
||||
error,
|
||||
setActiveHousehold,
|
||||
refreshHouseholds: loadHouseholds,
|
||||
createHousehold,
|
||||
};
|
||||
|
||||
return (
|
||||
<HouseholdContext.Provider value={value}>
|
||||
{children}
|
||||
</HouseholdContext.Provider>
|
||||
);
|
||||
};
|
||||
99
frontend/src/context/StoreContext.jsx
Normal file
99
frontend/src/context/StoreContext.jsx
Normal file
@ -0,0 +1,99 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { getHouseholdStores } from '../api/stores';
|
||||
import { AuthContext } from './AuthContext';
|
||||
import { HouseholdContext } from './HouseholdContext';
|
||||
|
||||
export const StoreContext = createContext({
|
||||
stores: [],
|
||||
activeStore: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
setActiveStore: () => { },
|
||||
refreshStores: () => { },
|
||||
});
|
||||
|
||||
export const StoreProvider = ({ children }) => {
|
||||
const { token } = useContext(AuthContext);
|
||||
const { activeHousehold } = useContext(HouseholdContext);
|
||||
const [stores, setStores] = useState([]);
|
||||
const [activeStore, setActiveStoreState] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Load stores when household changes
|
||||
useEffect(() => {
|
||||
if (token && activeHousehold) {
|
||||
loadStores();
|
||||
} else {
|
||||
// Clear state when logged out or no household
|
||||
setStores([]);
|
||||
setActiveStoreState(null);
|
||||
}
|
||||
}, [token, activeHousehold?.id]);
|
||||
|
||||
// Load active store from localStorage on mount (per household)
|
||||
useEffect(() => {
|
||||
if (!activeHousehold || stores.length === 0) return;
|
||||
|
||||
console.log('[StoreContext] Setting active store from:', stores);
|
||||
const storageKey = `activeStoreId_${activeHousehold.id}`;
|
||||
const savedStoreId = localStorage.getItem(storageKey);
|
||||
|
||||
if (savedStoreId) {
|
||||
const store = stores.find(s => s.id === parseInt(savedStoreId));
|
||||
if (store) {
|
||||
console.log('[StoreContext] Found saved store:', store);
|
||||
setActiveStoreState(store);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No saved store or not found, use default or first one
|
||||
const defaultStore = stores.find(s => s.is_default) || stores[0];
|
||||
console.log('[StoreContext] Using store:', defaultStore);
|
||||
setActiveStoreState(defaultStore);
|
||||
localStorage.setItem(storageKey, defaultStore.id);
|
||||
}, [stores, activeHousehold]);
|
||||
|
||||
const loadStores = async () => {
|
||||
if (!token || !activeHousehold) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
console.log('[StoreContext] Loading stores for household:', activeHousehold.id);
|
||||
const response = await getHouseholdStores(activeHousehold.id);
|
||||
console.log('[StoreContext] Loaded stores:', response.data);
|
||||
setStores(response.data);
|
||||
} catch (err) {
|
||||
console.error('[StoreContext] Failed to load stores:', err);
|
||||
setError(err.response?.data?.message || 'Failed to load stores');
|
||||
setStores([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setActiveStore = (store) => {
|
||||
setActiveStoreState(store);
|
||||
if (store && activeHousehold) {
|
||||
const storageKey = `activeStoreId_${activeHousehold.id}`;
|
||||
localStorage.setItem(storageKey, store.id);
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
stores,
|
||||
activeStore,
|
||||
loading,
|
||||
error,
|
||||
setActiveStore,
|
||||
refreshStores: loadStores,
|
||||
};
|
||||
|
||||
return (
|
||||
<StoreContext.Provider value={value}>
|
||||
{children}
|
||||
</StoreContext.Provider>
|
||||
);
|
||||
};
|
||||
@ -18,16 +18,21 @@ import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModa
|
||||
import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal";
|
||||
import EditItemModal from "../components/modals/EditItemModal";
|
||||
import SimilarItemModal from "../components/modals/SimilarItemModal";
|
||||
import StoreTabs from "../components/store/StoreTabs";
|
||||
import { ZONE_FLOW } from "../constants/classifications";
|
||||
import { ROLES } from "../constants/roles";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import { HouseholdContext } from "../context/HouseholdContext";
|
||||
import { SettingsContext } from "../context/SettingsContext";
|
||||
import { StoreContext } from "../context/StoreContext";
|
||||
import "../styles/pages/GroceryList.css";
|
||||
import { findSimilarItems } from "../utils/stringSimilarity";
|
||||
|
||||
|
||||
export default function GroceryList() {
|
||||
const { role } = useContext(AuthContext);
|
||||
const { activeHousehold } = useContext(HouseholdContext);
|
||||
const { activeStore } = useContext(StoreContext);
|
||||
const { settings } = useContext(SettingsContext);
|
||||
|
||||
// === State === //
|
||||
@ -53,17 +58,29 @@ export default function GroceryList() {
|
||||
|
||||
// === Data Loading ===
|
||||
const loadItems = async () => {
|
||||
setLoading(true);
|
||||
const res = await getList();
|
||||
console.log(res.data);
|
||||
setItems(res.data);
|
||||
if (!activeHousehold?.id || !activeStore?.id) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getList(activeHousehold.id, activeStore.id);
|
||||
console.log('[GroceryList] Items loaded:', res.data);
|
||||
setItems(res.data.items || res.data || []);
|
||||
} catch (error) {
|
||||
console.error('[GroceryList] Failed to load items:', error);
|
||||
setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const loadRecentlyBought = async () => {
|
||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||
try {
|
||||
const res = await getRecentlyBought();
|
||||
const res = await getRecentlyBought(activeHousehold.id, activeStore.id);
|
||||
setRecentlyBoughtItems(res.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load recently bought items:", error);
|
||||
@ -75,7 +92,7 @@ export default function GroceryList() {
|
||||
useEffect(() => {
|
||||
loadItems();
|
||||
loadRecentlyBought();
|
||||
}, []);
|
||||
}, [activeHousehold?.id, activeStore?.id]);
|
||||
|
||||
|
||||
// === Zone Collapse Handler ===
|
||||
@ -137,10 +154,16 @@ export default function GroceryList() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeHousehold?.id || !activeStore?.id) {
|
||||
setSuggestions([]);
|
||||
setButtonText("Create + Add");
|
||||
return;
|
||||
}
|
||||
|
||||
const lowerText = text.toLowerCase().trim();
|
||||
|
||||
try {
|
||||
const response = await getSuggestions(text);
|
||||
const response = await getSuggestions(activeHousehold.id, activeStore.id, text);
|
||||
const suggestionList = response.data.map(s => s.item_name);
|
||||
setSuggestions(suggestionList);
|
||||
|
||||
@ -157,13 +180,15 @@ export default function GroceryList() {
|
||||
// === Item Addition Handlers ===
|
||||
const handleAdd = useCallback(async (itemName, quantity) => {
|
||||
if (!itemName.trim()) return;
|
||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||
|
||||
// Check if item already exists
|
||||
let existingItem = null;
|
||||
try {
|
||||
const response = await getItemByName(itemName);
|
||||
const response = await getItemByName(activeHousehold.id, activeStore.id, itemName);
|
||||
existingItem = response.data;
|
||||
} catch {
|
||||
existingItem = null;
|
||||
// Item doesn't exist, continue
|
||||
}
|
||||
|
||||
if (existingItem) {
|
||||
@ -183,16 +208,19 @@ export default function GroceryList() {
|
||||
processItemAddition(itemName, quantity);
|
||||
return prevItems;
|
||||
});
|
||||
}, [recentlyBoughtItems]);
|
||||
}, [activeHousehold?.id, activeStore?.id, recentlyBoughtItems]);
|
||||
|
||||
|
||||
const processItemAddition = useCallback(async (itemName, quantity) => {
|
||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||
|
||||
// Fetch current item state from backend
|
||||
let existingItem = null;
|
||||
try {
|
||||
const response = await getItemByName(itemName);
|
||||
const response = await getItemByName(activeHousehold.id, activeStore.id, itemName);
|
||||
existingItem = response.data;
|
||||
} catch {
|
||||
existingItem = null;
|
||||
// Item doesn't exist, continue with add
|
||||
}
|
||||
|
||||
if (existingItem?.bought === false) {
|
||||
@ -209,7 +237,7 @@ export default function GroceryList() {
|
||||
});
|
||||
setShowConfirmAddExisting(true);
|
||||
} else if (existingItem) {
|
||||
await addItem(itemName, quantity, null);
|
||||
await addItem(activeHousehold.id, activeStore.id, itemName, quantity, null);
|
||||
setSuggestions([]);
|
||||
setButtonText("Add Item");
|
||||
|
||||
@ -220,7 +248,7 @@ export default function GroceryList() {
|
||||
setPendingItem({ itemName, quantity });
|
||||
setShowAddDetailsModal(true);
|
||||
}
|
||||
}, []);
|
||||
}, [activeHousehold?.id, activeStore?.id, items, loadItems]);
|
||||
|
||||
|
||||
// === Similar Item Modal Handlers ===
|
||||
@ -249,6 +277,7 @@ export default function GroceryList() {
|
||||
// === Confirm Add Existing Modal Handlers ===
|
||||
const handleConfirmAddExisting = useCallback(async () => {
|
||||
if (!confirmAddExistingData) return;
|
||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||
|
||||
const { itemName, newQuantity, existingItem } = confirmAddExistingData;
|
||||
|
||||
@ -256,14 +285,11 @@ export default function GroceryList() {
|
||||
setConfirmAddExistingData(null);
|
||||
|
||||
try {
|
||||
// Update the item
|
||||
await addItem(itemName, newQuantity, null);
|
||||
await addItem(activeHousehold.id, activeStore.id, itemName, newQuantity, null);
|
||||
|
||||
// Fetch the updated item with properly formatted data
|
||||
const response = await getItemByName(itemName);
|
||||
const response = await getItemByName(activeHousehold.id, activeStore.id, itemName);
|
||||
const updatedItem = response.data;
|
||||
|
||||
// Update state with the full item data
|
||||
setItems(prevItems =>
|
||||
prevItems.map(item =>
|
||||
item.id === existingItem.id ? updatedItem : item
|
||||
@ -274,32 +300,54 @@ export default function GroceryList() {
|
||||
setButtonText("Add Item");
|
||||
} catch (error) {
|
||||
console.error("Failed to update item:", error);
|
||||
// Fallback to full reload on error
|
||||
await loadItems();
|
||||
}
|
||||
}, [confirmAddExistingData, loadItems]);
|
||||
|
||||
const handleCancelAddExisting = useCallback(() => {
|
||||
setShowConfirmAddExisting(false);
|
||||
setConfirmAddExistingData(null);
|
||||
}, []);
|
||||
|
||||
|
||||
// === Add Details Modal Handlers ===
|
||||
const handleAddDetailsConfirm = useCallback(async (imageFile, classification) => {
|
||||
const handleAddWithDetails = useCallback(async (imageFile, classification) => {
|
||||
if (!pendingItem) return;
|
||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||
|
||||
try {
|
||||
const addResponse = await addItem(pendingItem.itemName, pendingItem.quantity, imageFile);
|
||||
let newItem = addResponse.data;
|
||||
await addItem(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, imageFile);
|
||||
|
||||
if (classification) {
|
||||
const itemResponse = await getItemByName(pendingItem.itemName);
|
||||
const itemId = itemResponse.data.id;
|
||||
const updateResponse = await updateItemWithClassification(itemId, undefined, undefined, classification);
|
||||
newItem = { ...newItem, ...updateResponse.data };
|
||||
// Apply classification if provided
|
||||
await updateItemWithClassification(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, classification);
|
||||
}
|
||||
|
||||
// Fetch the newly added item
|
||||
const itemResponse = await getItemByName(activeHousehold.id, activeStore.id, pendingItem.itemName);
|
||||
const newItem = itemResponse.data;
|
||||
|
||||
setShowAddDetailsModal(false);
|
||||
setPendingItem(null);
|
||||
setSuggestions([]);
|
||||
setButtonText("Add Item");
|
||||
|
||||
// Add to state
|
||||
if (newItem) {
|
||||
setItems(prevItems => [...prevItems, newItem]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to add item:", error);
|
||||
alert("Failed to add item. Please try again.");
|
||||
}
|
||||
}, [activeHousehold?.id, activeStore?.id, pendingItem]);
|
||||
|
||||
const handleAddDetailsSkip = useCallback(async () => {
|
||||
if (!pendingItem) return;
|
||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||
|
||||
try {
|
||||
await addItem(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, null);
|
||||
|
||||
// Fetch the newly added item
|
||||
const itemResponse = await getItemByName(activeHousehold.id, activeStore.id, pendingItem.itemName);
|
||||
const newItem = itemResponse.data;
|
||||
|
||||
setShowAddDetailsModal(false);
|
||||
setPendingItem(null);
|
||||
setSuggestions([]);
|
||||
@ -312,28 +360,7 @@ export default function GroceryList() {
|
||||
console.error("Failed to add item:", error);
|
||||
alert("Failed to add item. Please try again.");
|
||||
}
|
||||
}, [pendingItem]);
|
||||
|
||||
|
||||
const handleAddDetailsSkip = useCallback(async () => {
|
||||
if (!pendingItem) return;
|
||||
|
||||
try {
|
||||
const response = await addItem(pendingItem.itemName, pendingItem.quantity, null);
|
||||
|
||||
setShowAddDetailsModal(false);
|
||||
setPendingItem(null);
|
||||
setSuggestions([]);
|
||||
setButtonText("Add Item");
|
||||
|
||||
if (response.data) {
|
||||
setItems(prevItems => [...prevItems, response.data]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to add item:", error);
|
||||
alert("Failed to add item. Please try again.");
|
||||
}
|
||||
}, [pendingItem]);
|
||||
}, [activeHousehold?.id, activeStore?.id, pendingItem]);
|
||||
|
||||
|
||||
const handleAddDetailsCancel = useCallback(() => {
|
||||
@ -346,31 +373,34 @@ export default function GroceryList() {
|
||||
|
||||
// === Item Action Handlers ===
|
||||
const handleBought = useCallback(async (id, quantity) => {
|
||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||
|
||||
const item = items.find(i => i.id === id);
|
||||
if (!item) return;
|
||||
|
||||
await markBought(id, quantity);
|
||||
await markBought(activeHousehold.id, activeStore.id, item.item_name, quantity, true);
|
||||
|
||||
// If buying full quantity, remove from list
|
||||
if (quantity >= item.quantity) {
|
||||
setItems(prevItems => prevItems.filter(item => item.id !== id));
|
||||
} else {
|
||||
// If partial, update quantity
|
||||
const response = await getItemByName(item.item_name);
|
||||
if (response.data) {
|
||||
// If partial, fetch updated item
|
||||
const response = await getItemByName(activeHousehold.id, activeStore.id, item.item_name);
|
||||
const updatedItem = response.data;
|
||||
|
||||
setItems(prevItems =>
|
||||
prevItems.map(item => item.id === id ? response.data : item)
|
||||
prevItems.map(i => i.id === id ? updatedItem : i)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
loadRecentlyBought();
|
||||
}, [items]);
|
||||
|
||||
}, [activeHousehold?.id, activeStore?.id, items]);
|
||||
|
||||
const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile) => {
|
||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||
|
||||
try {
|
||||
const response = await updateItemImage(id, itemName, quantity, imageFile);
|
||||
const response = await updateItemImage(activeHousehold.id, activeStore.id, id, itemName, quantity, imageFile);
|
||||
|
||||
setItems(prevItems =>
|
||||
prevItems.map(item =>
|
||||
@ -387,14 +417,15 @@ export default function GroceryList() {
|
||||
console.error("Failed to add image:", error);
|
||||
alert("Failed to add image. Please try again.");
|
||||
}
|
||||
}, []);
|
||||
}, [activeHousehold?.id, activeStore?.id]);
|
||||
|
||||
|
||||
const handleLongPress = useCallback(async (item) => {
|
||||
if (![ROLES.ADMIN, ROLES.EDITOR].includes(role)) return;
|
||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||
|
||||
try {
|
||||
const classificationResponse = await getClassification(item.id);
|
||||
const classificationResponse = await getClassification(activeHousehold.id, activeStore.id, item.id);
|
||||
setEditingItem({
|
||||
...item,
|
||||
classification: classificationResponse.data
|
||||
@ -405,20 +436,26 @@ export default function GroceryList() {
|
||||
setEditingItem({ ...item, classification: null });
|
||||
setShowEditModal(true);
|
||||
}
|
||||
}, [role]);
|
||||
}, [activeHousehold?.id, activeStore?.id, role]);
|
||||
|
||||
|
||||
// === Edit Modal Handlers ===
|
||||
const handleEditSave = useCallback(async (id, itemName, quantity, classification) => {
|
||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||
|
||||
try {
|
||||
const response = await updateItemWithClassification(id, itemName, quantity, classification);
|
||||
await updateItemWithClassification(activeHousehold.id, activeStore.id, itemName, quantity, classification);
|
||||
|
||||
// Fetch the updated item
|
||||
const response = await getItemByName(activeHousehold.id, activeStore.id, itemName);
|
||||
const updatedItem = response.data;
|
||||
|
||||
setShowEditModal(false);
|
||||
setEditingItem(null);
|
||||
|
||||
const updatedItem = response.data;
|
||||
setItems(prevItems =>
|
||||
prevItems.map(item =>
|
||||
item.id === id ? { ...item, ...updatedItem } : item
|
||||
item.id === id ? updatedItem : item
|
||||
)
|
||||
);
|
||||
|
||||
@ -431,7 +468,7 @@ export default function GroceryList() {
|
||||
console.error("Failed to update item:", error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
}, [activeHousehold?.id, activeStore?.id]);
|
||||
|
||||
|
||||
const handleEditCancel = useCallback(() => {
|
||||
@ -454,7 +491,30 @@ export default function GroceryList() {
|
||||
};
|
||||
|
||||
|
||||
if (loading) return <p>Loading...</p>;
|
||||
if (!activeHousehold || !activeStore) {
|
||||
return (
|
||||
<div className="glist-body">
|
||||
<div className="glist-container">
|
||||
<h1 className="glist-title">Costco Grocery List</h1>
|
||||
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}>
|
||||
{!activeHousehold ? 'Loading households...' : 'Loading stores...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="glist-body">
|
||||
<div className="glist-container">
|
||||
<h1 className="glist-title">Costco Grocery List</h1>
|
||||
<StoreTabs />
|
||||
<p style={{ textAlign: 'center', marginTop: '2rem' }}>Loading grocery list...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
@ -462,6 +522,7 @@ export default function GroceryList() {
|
||||
<div className="glist-container">
|
||||
<h1 className="glist-title">Costco Grocery List</h1>
|
||||
|
||||
<StoreTabs />
|
||||
|
||||
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && showAddForm && (
|
||||
<AddItemForm
|
||||
@ -598,7 +659,7 @@ export default function GroceryList() {
|
||||
{showAddDetailsModal && pendingItem && (
|
||||
<AddItemWithDetailsModal
|
||||
itemName={pendingItem.itemName}
|
||||
onConfirm={handleAddDetailsConfirm}
|
||||
onConfirm={handleAddWithDetails}
|
||||
onSkip={handleAddDetailsSkip}
|
||||
onCancel={handleAddDetailsCancel}
|
||||
/>
|
||||
@ -629,7 +690,10 @@ export default function GroceryList() {
|
||||
currentQuantity={confirmAddExistingData.currentQuantity}
|
||||
addingQuantity={confirmAddExistingData.addingQuantity}
|
||||
onConfirm={handleConfirmAddExisting}
|
||||
onCancel={handleCancelAddExisting}
|
||||
onCancel={() => {
|
||||
setShowConfirmAddExisting(false);
|
||||
setConfirmAddExistingData(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
98
frontend/src/styles/components/HouseholdSwitcher.css
Normal file
98
frontend/src/styles/components/HouseholdSwitcher.css
Normal file
@ -0,0 +1,98 @@
|
||||
.household-switcher {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.household-switcher-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.household-switcher-toggle:hover {
|
||||
background: var(--hover-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.household-switcher-toggle:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.household-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 0.75rem;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-icon.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.household-switcher-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.household-switcher-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 0;
|
||||
min-width: 200px;
|
||||
background: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.household-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.household-option:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.household-option:hover {
|
||||
background: var(--hover-color);
|
||||
}
|
||||
|
||||
.household-option.active {
|
||||
background: var(--primary-color-light);
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.check-mark {
|
||||
color: var(--primary-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
74
frontend/src/styles/components/StoreTabs.css
Normal file
74
frontend/src/styles/components/StoreTabs.css
Normal file
@ -0,0 +1,74 @@
|
||||
.store-tabs {
|
||||
background: var(--surface-color);
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.store-tabs-container {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
overflow-x: auto;
|
||||
padding: 0.5rem 1rem 0;
|
||||
}
|
||||
|
||||
.store-tabs-container::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.store-tabs-container::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.store-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-tab:hover {
|
||||
color: var(--text-color);
|
||||
background: var(--hover-color);
|
||||
}
|
||||
|
||||
.store-tab.active {
|
||||
color: var(--primary-color);
|
||||
border-bottom-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.store-tab:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.store-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.default-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--primary-color-light);
|
||||
color: var(--primary-color);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
border-radius: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.store-tabs-empty {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user