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,
|
i.name AS item_name,
|
||||||
hl.quantity,
|
hl.quantity,
|
||||||
hl.bought,
|
hl.bought,
|
||||||
ENCODE(hl.item_image, 'base64') as item_image,
|
ENCODE(hl.custom_image, 'base64') as item_image,
|
||||||
hl.image_mime_type,
|
hl.custom_image_mime_type as image_mime_type,
|
||||||
${includeHistory ? `
|
${includeHistory ? `
|
||||||
(
|
(
|
||||||
SELECT ARRAY_AGG(DISTINCT u.name)
|
SELECT ARRAY_AGG(DISTINCT u.name)
|
||||||
FROM (
|
FROM (
|
||||||
SELECT DISTINCT hlh.added_by
|
SELECT DISTINCT hlh.added_by
|
||||||
FROM household_list_history hlh
|
FROM household_list_history hlh
|
||||||
WHERE hlh.list_id = hl.id
|
WHERE hlh.household_list_id = hl.id
|
||||||
ORDER BY hlh.added_by
|
ORDER BY hlh.added_by
|
||||||
) hlh
|
) hlh
|
||||||
JOIN users u ON hlh.added_by = u.id
|
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;
|
const itemId = itemResult.rows[0].id;
|
||||||
|
|
||||||
// Check if item exists in household list
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT
|
`SELECT
|
||||||
hl.id,
|
hl.id,
|
||||||
i.name AS item_name,
|
i.name AS item_name,
|
||||||
hl.quantity,
|
hl.quantity,
|
||||||
hl.bought,
|
hl.bought,
|
||||||
ENCODE(hl.item_image, 'base64') as item_image,
|
ENCODE(hl.custom_image, 'base64') as item_image,
|
||||||
hl.image_mime_type,
|
hl.custom_image_mime_type as image_mime_type,
|
||||||
(
|
(
|
||||||
SELECT ARRAY_AGG(DISTINCT u.name)
|
SELECT ARRAY_AGG(DISTINCT u.name)
|
||||||
FROM (
|
FROM (
|
||||||
SELECT DISTINCT hlh.added_by
|
SELECT DISTINCT hlh.added_by
|
||||||
FROM household_list_history hlh
|
FROM household_list_history hlh
|
||||||
WHERE hlh.list_id = hl.id
|
WHERE hlh.household_list_id = hl.id
|
||||||
ORDER BY hlh.added_by
|
ORDER BY hlh.added_by
|
||||||
) hlh
|
) hlh
|
||||||
JOIN users u ON hlh.added_by = u.id
|
JOIN users u ON hlh.added_by = u.id
|
||||||
@ -99,7 +97,7 @@ exports.getItemByName = async (householdId, storeId, itemName) => {
|
|||||||
AND hl.item_id = $3`,
|
AND hl.item_id = $3`,
|
||||||
[householdId, storeId, itemId]
|
[householdId, storeId, itemId]
|
||||||
);
|
);
|
||||||
|
console.log(result.rows);
|
||||||
return result.rows[0] || null;
|
return result.rows[0] || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -125,7 +123,6 @@ exports.addOrUpdateItem = async (
|
|||||||
) => {
|
) => {
|
||||||
const lowerItemName = itemName.toLowerCase();
|
const lowerItemName = itemName.toLowerCase();
|
||||||
|
|
||||||
// First, ensure item exists in master catalog
|
|
||||||
let itemResult = await pool.query(
|
let itemResult = await pool.query(
|
||||||
"SELECT id FROM items WHERE name ILIKE $1",
|
"SELECT id FROM items WHERE name ILIKE $1",
|
||||||
[lowerItemName]
|
[lowerItemName]
|
||||||
@ -133,7 +130,6 @@ exports.addOrUpdateItem = async (
|
|||||||
|
|
||||||
let itemId;
|
let itemId;
|
||||||
if (itemResult.rowCount === 0) {
|
if (itemResult.rowCount === 0) {
|
||||||
// Create new item in master catalog
|
|
||||||
const insertItem = await pool.query(
|
const insertItem = await pool.query(
|
||||||
"INSERT INTO items (name) VALUES ($1) RETURNING id",
|
"INSERT INTO items (name) VALUES ($1) RETURNING id",
|
||||||
[lowerItemName]
|
[lowerItemName]
|
||||||
@ -143,7 +139,6 @@ exports.addOrUpdateItem = async (
|
|||||||
itemId = itemResult.rows[0].id;
|
itemId = itemResult.rows[0].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if item exists in household list
|
|
||||||
const listResult = await pool.query(
|
const listResult = await pool.query(
|
||||||
`SELECT id, bought FROM household_lists
|
`SELECT id, bought FROM household_lists
|
||||||
WHERE household_id = $1
|
WHERE household_id = $1
|
||||||
@ -153,15 +148,14 @@ exports.addOrUpdateItem = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (listResult.rowCount > 0) {
|
if (listResult.rowCount > 0) {
|
||||||
// Update existing list item
|
|
||||||
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,
|
||||||
item_image = $2,
|
custom_image = $2,
|
||||||
image_mime_type = $3,
|
custom_image_mime_type = $3,
|
||||||
modified_on = NOW()
|
modified_on = NOW()
|
||||||
WHERE id = $4`,
|
WHERE id = $4`,
|
||||||
[quantity, imageBuffer, mimeType, listId]
|
[quantity, imageBuffer, mimeType, listId]
|
||||||
@ -178,10 +172,9 @@ exports.addOrUpdateItem = async (
|
|||||||
}
|
}
|
||||||
return listId;
|
return listId;
|
||||||
} else {
|
} else {
|
||||||
// Insert new list item
|
|
||||||
const insert = await pool.query(
|
const insert = await pool.query(
|
||||||
`INSERT INTO household_lists
|
`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)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[householdId, storeId, itemId, quantity, imageBuffer, mimeType]
|
[householdId, storeId, itemId, quantity, imageBuffer, mimeType]
|
||||||
@ -193,32 +186,51 @@ exports.addOrUpdateItem = async (
|
|||||||
/**
|
/**
|
||||||
* Mark item as bought (full or partial)
|
* Mark item as bought (full or partial)
|
||||||
* @param {number} listId - List item ID
|
* @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) => {
|
exports.setBought = async (listId, bought, quantityBought = null) => {
|
||||||
// Get current item
|
if (bought === false) {
|
||||||
const item = await pool.query(
|
// Unmarking - just set bought to false
|
||||||
"SELECT quantity FROM household_lists WHERE id = $1",
|
await pool.query(
|
||||||
[listId]
|
"UPDATE household_lists SET bought = FALSE, modified_on = NOW() WHERE id = $1",
|
||||||
);
|
[listId]
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!item.rows[0]) 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]
|
||||||
|
);
|
||||||
|
|
||||||
const currentQuantity = item.rows[0].quantity;
|
if (!item.rows[0]) return;
|
||||||
const remainingQuantity = currentQuantity - quantityBought;
|
|
||||||
|
|
||||||
if (remainingQuantity <= 0) {
|
const currentQuantity = item.rows[0].quantity;
|
||||||
// Mark as bought if all quantity is purchased
|
const remainingQuantity = currentQuantity - quantityBought;
|
||||||
|
|
||||||
|
if (remainingQuantity <= 0) {
|
||||||
|
// All bought - mark as bought
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
|
||||||
|
[listId]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 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(
|
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 {
|
|
||||||
// Reduce quantity if partial purchase
|
|
||||||
await pool.query(
|
|
||||||
"UPDATE household_lists SET quantity = $1, modified_on = NOW() WHERE id = $2",
|
|
||||||
[remainingQuantity, listId]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -230,7 +242,7 @@ exports.setBought = async (listId, quantityBought) => {
|
|||||||
*/
|
*/
|
||||||
exports.addHistoryRecord = async (listId, quantity, userId) => {
|
exports.addHistoryRecord = async (listId, quantity, userId) => {
|
||||||
await pool.query(
|
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())`,
|
VALUES ($1, $2, $3, NOW())`,
|
||||||
[listId, quantity, userId]
|
[listId, quantity, userId]
|
||||||
);
|
);
|
||||||
@ -246,16 +258,16 @@ exports.addHistoryRecord = async (listId, quantity, userId) => {
|
|||||||
exports.getSuggestions = async (query, householdId, storeId) => {
|
exports.getSuggestions = async (query, householdId, storeId) => {
|
||||||
// Get items from both master catalog and household history
|
// Get items from both master catalog and household history
|
||||||
const result = await pool.query(
|
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
|
FROM items i
|
||||||
LEFT JOIN household_lists hl
|
LEFT JOIN household_lists hl
|
||||||
ON i.id = hl.item_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 i.name ILIKE $1
|
WHERE i.name ILIKE $1
|
||||||
ORDER BY
|
ORDER BY sort_order, i.name
|
||||||
CASE WHEN hl.id IS NOT NULL THEN 0 ELSE 1 END,
|
|
||||||
i.name
|
|
||||||
LIMIT 10`,
|
LIMIT 10`,
|
||||||
[`%${query}%`, householdId, storeId]
|
[`%${query}%`, householdId, storeId]
|
||||||
);
|
);
|
||||||
@ -275,14 +287,14 @@ exports.getRecentlyBoughtItems = async (householdId, storeId) => {
|
|||||||
i.name AS item_name,
|
i.name AS item_name,
|
||||||
hl.quantity,
|
hl.quantity,
|
||||||
hl.bought,
|
hl.bought,
|
||||||
ENCODE(hl.item_image, 'base64') as item_image,
|
ENCODE(hl.custom_image, 'base64') as item_image,
|
||||||
hl.image_mime_type,
|
hl.custom_image_mime_type as image_mime_type,
|
||||||
(
|
(
|
||||||
SELECT ARRAY_AGG(DISTINCT u.name)
|
SELECT ARRAY_AGG(DISTINCT u.name)
|
||||||
FROM (
|
FROM (
|
||||||
SELECT DISTINCT hlh.added_by
|
SELECT DISTINCT hlh.added_by
|
||||||
FROM household_list_history hlh
|
FROM household_list_history hlh
|
||||||
WHERE hlh.list_id = hl.id
|
WHERE hlh.household_list_id = hl.id
|
||||||
ORDER BY hlh.added_by
|
ORDER BY hlh.added_by
|
||||||
) hlh
|
) hlh
|
||||||
JOIN users u ON hlh.added_by = u.id
|
JOIN users u ON hlh.added_by = u.id
|
||||||
@ -346,49 +358,44 @@ exports.upsertClassification = async (householdId, itemId, classification) => {
|
|||||||
/**
|
/**
|
||||||
* Update list item details
|
* Update list item details
|
||||||
* @param {number} listId - List item ID
|
* @param {number} listId - List item ID
|
||||||
* @param {string} itemName - New item name
|
* @param {string} itemName - New item name (optional)
|
||||||
* @param {number} quantity - New quantity
|
* @param {number} quantity - New quantity (optional)
|
||||||
|
* @param {string} notes - Notes (optional)
|
||||||
* @returns {Promise<Object>} Updated item
|
* @returns {Promise<Object>} Updated item
|
||||||
*/
|
*/
|
||||||
exports.updateItem = async (listId, itemName, quantity) => {
|
exports.updateItem = async (listId, itemName, quantity, notes) => {
|
||||||
// This is more complex now because we need to handle the master catalog
|
// Build dynamic update query
|
||||||
// Get current list item
|
const updates = [];
|
||||||
const listItem = await pool.query(
|
const values = [listId];
|
||||||
"SELECT item_id FROM household_lists WHERE id = $1",
|
let paramCount = 1;
|
||||||
[listId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (listItem.rowCount === 0) {
|
if (quantity !== undefined) {
|
||||||
throw new Error("List item not found");
|
paramCount++;
|
||||||
|
updates.push(`quantity = $${paramCount}`);
|
||||||
|
values.push(quantity);
|
||||||
}
|
}
|
||||||
|
|
||||||
const oldItemId = listItem.rows[0].item_id;
|
if (notes !== undefined) {
|
||||||
|
paramCount++;
|
||||||
|
updates.push(`notes = $${paramCount}`);
|
||||||
|
values.push(notes);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if new item name exists in catalog
|
// Always update modified_on
|
||||||
let newItemId;
|
updates.push(`modified_on = NOW()`);
|
||||||
const itemResult = await pool.query(
|
|
||||||
"SELECT id FROM items WHERE name ILIKE $1",
|
|
||||||
[itemName.toLowerCase()]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (itemResult.rowCount === 0) {
|
if (updates.length === 1) {
|
||||||
// Create new item
|
// Only modified_on update
|
||||||
const insertItem = await pool.query(
|
const result = await pool.query(
|
||||||
"INSERT INTO items (name) VALUES ($1) RETURNING id",
|
`UPDATE household_lists SET modified_on = NOW() WHERE id = $1 RETURNING *`,
|
||||||
[itemName.toLowerCase()]
|
[listId]
|
||||||
);
|
);
|
||||||
newItemId = insertItem.rows[0].id;
|
return result.rows[0];
|
||||||
} else {
|
|
||||||
newItemId = itemResult.rows[0].id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update list item
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`UPDATE household_lists
|
`UPDATE household_lists SET ${updates.join(', ')} WHERE id = $1 RETURNING *`,
|
||||||
SET item_id = $2, quantity = $3, modified_on = NOW()
|
values
|
||||||
WHERE id = $1
|
|
||||||
RETURNING *`,
|
|
||||||
[listId, newItemId, quantity]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
|
|||||||
@ -558,8 +558,8 @@
|
|||||||
auth: true,
|
auth: true,
|
||||||
body: { name: `Workflow Test ${Date.now()}` },
|
body: { name: `Workflow Test ${Date.now()}` },
|
||||||
expect: (res) => res.household && res.household.id,
|
expect: (res) => res.household && res.household.id,
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
createdHouseholdId = res.household.id;
|
createdHouseholdId = res.household.id;
|
||||||
inviteCode = res.household.invite_code;
|
inviteCode = res.household.invite_code;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -820,61 +820,61 @@
|
|||||||
const contentEl = document.getElementById(`${testId}-content`);
|
const contentEl = document.getElementById(`${testId}-content`);
|
||||||
const toggleEl = document.getElementById(`${testId}-toggle`);
|
const toggleEl = document.getElementById(`${testId}-toggle`);
|
||||||
const resultEl = testEl.querySelector('.test-result');
|
const resultEl = testEl.querySelector('.test-result');
|
||||||
|
|
||||||
// Auto-expand when running
|
// Auto-expand when running
|
||||||
contentEl.classList.add('expanded');
|
contentEl.classList.add('expanded');
|
||||||
toggleEl.classList.add('expanded');
|
toggleEl.classList.add('expanded');
|
||||||
resultEl.style.display = 'block';
|
resultEl.style.display = 'block';
|
||||||
resultEl.className = 'test-result';
|
resultEl.className = 'test-result';
|
||||||
resultEl.innerHTML = '⚠️ Prerequisites not met';
|
resultEl.innerHTML = '⚠️ Prerequisites not met';
|
||||||
return 'skip';
|
return 'skip';
|
||||||
}
|
}
|
||||||
|
|
||||||
testEl.className = 'test-case running';
|
testEl.className = 'test-case running';
|
||||||
testEl.querySelector('.test-status').textContent = 'RUNNING';
|
testEl.querySelector('.test-status').textContent = 'RUNNING';
|
||||||
testEl.querySelector('.test-status').className = 'test-status running';
|
testEl.querySelector('.test-status').className = 'test-status running';
|
||||||
resultEl.style.display = 'none';
|
resultEl.style.display = 'none';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, status } = await makeRequest(test);
|
const { data, status } = await makeRequest(test);
|
||||||
|
|
||||||
const expectFail = test.expectFail || false;
|
const expectFail = test.expectFail || false;
|
||||||
const passed = test.expect(data, status);
|
const passed = test.expect(data, status);
|
||||||
|
|
||||||
const success = expectFail ? !passed || status >= 400 : passed;
|
const success = expectFail ? !passed || status >= 400 : passed;
|
||||||
|
|
||||||
testEl.className = success ? 'test-case pass' : 'test-case fail';
|
testEl.className = success ? 'test-case pass' : 'test-case fail';
|
||||||
testEl.querySelector('.test-status').textContent = success ? 'PASS' : 'FAIL';
|
testEl.querySelector('.test-status').textContent = success ? 'PASS' : 'FAIL';
|
||||||
testEl.querySelector('.test-status').className = `test-status ${success ? 'pass' : 'fail'}`;
|
testEl.querySelector('.test-status').className = `test-status ${success ? 'pass' : 'fail'}`;
|
||||||
|
|
||||||
// Determine status code class
|
// Determine status code class
|
||||||
let statusClass = 'status-5xx';
|
let statusClass = 'status-5xx';
|
||||||
if (status >= 200 && status < 300) statusClass = 'status-2xx';
|
if (status >= 200 && status < 300) statusClass = 'status-2xx';
|
||||||
else if (status >= 300 && status < 400) statusClass = 'status-3xx';
|
else if (status >= 300 && status < 400) statusClass = 'status-3xx';
|
||||||
else if (status >= 400 && status < 500) statusClass = 'status-4xx';
|
else if (status >= 400 && status < 500) statusClass = 'status-4xx';
|
||||||
|
|
||||||
resultEl.style.display = 'block';
|
resultEl.style.display = 'block';
|
||||||
resultEl.className = 'test-result';
|
resultEl.className = 'test-result';
|
||||||
|
|
||||||
// Check expected fields if defined
|
// Check expected fields if defined
|
||||||
let expectedFieldsHTML = '';
|
let expectedFieldsHTML = '';
|
||||||
if (test.expectedFields) {
|
if (test.expectedFields) {
|
||||||
const fieldChecks = test.expectedFields.map(field => {
|
const fieldChecks = test.expectedFields.map(field => {
|
||||||
const exists = field.split('.').reduce((obj, key) => obj?.[key], data) !== undefined;
|
const exists = field.split('.').reduce((obj, key) => obj?.[key], data) !== undefined;
|
||||||
const icon = exists ? '✓' : '✗';
|
const icon = exists ? '✓' : '✗';
|
||||||
const className = exists ? 'pass' : 'fail';
|
const className = exists ? 'pass' : 'fail';
|
||||||
return `<div class="field-check ${className}">${icon} ${field}</div>`;
|
return `<div class="field-check ${className}">${icon} ${field}</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
expectedFieldsHTML = `
|
expectedFieldsHTML = `
|
||||||
<div class="expected-section">
|
<div class="expected-section">
|
||||||
<div class="expected-label">Expected Fields:</div>
|
<div class="expected-label">Expected Fields:</div>
|
||||||
${fieldChecks}
|
${fieldChecks}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
resultEl.innerHTML = `
|
resultEl.innerHTML = `
|
||||||
<div style="margin-bottom: 8px;">
|
<div style="margin-bottom: 8px;">
|
||||||
<span class="response-status ${statusClass}">HTTP ${status}</span>
|
<span class="response-status ${statusClass}">HTTP ${status}</span>
|
||||||
<span style="color: #666;">${success ? '✓ Test passed' : '✗ Test failed'}</span>
|
<span style="color: #666;">${success ? '✓ Test passed' : '✗ Test failed'}</span>
|
||||||
@ -884,25 +884,25 @@
|
|||||||
<div>${JSON.stringify(data, null, 2)}</div>
|
<div>${JSON.stringify(data, null, 2)}</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (success && test.onSuccess) {
|
if (success && test.onSuccess) {
|
||||||
test.onSuccess(data);
|
test.onSuccess(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
return success ? 'pass' : 'fail';
|
return success ? 'pass' : 'fail';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
testEl.className = 'test-case fail';
|
testEl.className = 'test-case fail';
|
||||||
testEl.querySelector('.test-status').textContent = 'ERROR';
|
testEl.querySelector('.test-status').textContent = 'ERROR';
|
||||||
testEl.querySelector('.test-status').className = 'test-status fail';
|
testEl.querySelector('.test-status').className = 'test-status fail';
|
||||||
|
|
||||||
resultEl.style.display = 'block';
|
resultEl.style.display = 'block';
|
||||||
resultEl.className = 'test-error';
|
resultEl.className = 'test-error';
|
||||||
resultEl.innerHTML = `
|
resultEl.innerHTML = `
|
||||||
<div style="font-weight: bold; margin-bottom: 8px;">❌ Network/Request Error</div>
|
<div style="font-weight: bold; margin-bottom: 8px;">❌ Network/Request Error</div>
|
||||||
<div>${error.message}</div>
|
<div>${error.message}</div>
|
||||||
${error.stack ? `<div style="margin-top: 8px; font-size: 11px; opacity: 0.7;">${error.stack}</div>` : ''}
|
${error.stack ? `<div style="margin-top: 8px; font-size: 11px; opacity: 0.7;">${error.stack}</div>` : ''}
|
||||||
`;
|
`;
|
||||||
return 'fail';
|
return 'fail';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAllTests(event) {
|
async function runAllTests(event) {
|
||||||
@ -945,7 +945,7 @@
|
|||||||
function toggleTest(testId) {
|
function toggleTest(testId) {
|
||||||
const content = document.getElementById(`${testId}-content`);
|
const content = document.getElementById(`${testId}-content`);
|
||||||
const toggle = document.getElementById(`${testId}-toggle`);
|
const toggle = document.getElementById(`${testId}-toggle`);
|
||||||
|
|
||||||
if (content.classList.contains('expanded')) {
|
if (content.classList.contains('expanded')) {
|
||||||
content.classList.remove('expanded');
|
content.classList.remove('expanded');
|
||||||
toggle.classList.remove('expanded');
|
toggle.classList.remove('expanded');
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>API Test Suite - Grocery List</title>
|
<title>API Test Suite - Grocery List</title>
|
||||||
<link rel="stylesheet" href="test-styles.css">
|
<link rel="stylesheet" href="test-styles.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>🧪 API Test Suite</h1>
|
<h1>🧪 API Test Suite</h1>
|
||||||
@ -57,4 +59,5 @@
|
|||||||
<script src="test-runner.js"></script>
|
<script src="test-runner.js"></script>
|
||||||
<script src="test-ui.js"></script>
|
<script src="test-ui.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
|
||||||
|
</html>
|
||||||
@ -184,8 +184,8 @@ const tests = [
|
|||||||
auth: true,
|
auth: true,
|
||||||
body: { name: `Workflow Test ${Date.now()}` },
|
body: { name: `Workflow Test ${Date.now()}` },
|
||||||
expect: (res) => res.household && res.household.id,
|
expect: (res) => res.household && res.household.id,
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
createdHouseholdId = res.household.id;
|
createdHouseholdId = res.household.id;
|
||||||
inviteCode = res.household.invite_code;
|
inviteCode = res.household.invite_code;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -31,7 +31,7 @@ async function runTest(categoryIdx, testIdx) {
|
|||||||
const contentEl = document.getElementById(`${testId}-content`);
|
const contentEl = document.getElementById(`${testId}-content`);
|
||||||
const toggleEl = document.getElementById(`${testId}-toggle`);
|
const toggleEl = document.getElementById(`${testId}-toggle`);
|
||||||
const resultEl = testEl.querySelector('.test-result');
|
const resultEl = testEl.querySelector('.test-result');
|
||||||
|
|
||||||
if (test.skip && test.skip()) {
|
if (test.skip && test.skip()) {
|
||||||
testEl.querySelector('.test-status').textContent = 'SKIPPED';
|
testEl.querySelector('.test-status').textContent = 'SKIPPED';
|
||||||
testEl.querySelector('.test-status').className = 'test-status pending';
|
testEl.querySelector('.test-status').className = 'test-status pending';
|
||||||
@ -73,7 +73,7 @@ async function runTest(categoryIdx, testIdx) {
|
|||||||
const className = exists ? 'pass' : 'fail';
|
const className = exists ? 'pass' : 'fail';
|
||||||
return `<div class="field-check ${className}">${icon} ${field}</div>`;
|
return `<div class="field-check ${className}">${icon} ${field}</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
expectedFieldsHTML = `
|
expectedFieldsHTML = `
|
||||||
<div class="expected-section">
|
<div class="expected-section">
|
||||||
<div class="expected-label">Expected Fields:</div>
|
<div class="expected-label">Expected Fields:</div>
|
||||||
@ -81,7 +81,7 @@ async function runTest(categoryIdx, testIdx) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
resultEl.style.display = 'block';
|
resultEl.style.display = 'block';
|
||||||
resultEl.className = 'test-result';
|
resultEl.className = 'test-result';
|
||||||
resultEl.innerHTML = `
|
resultEl.innerHTML = `
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
function toggleTest(testId) {
|
function toggleTest(testId) {
|
||||||
const content = document.getElementById(`${testId}-content`);
|
const content = document.getElementById(`${testId}-content`);
|
||||||
const toggle = document.getElementById(`${testId}-toggle`);
|
const toggle = document.getElementById(`${testId}-toggle`);
|
||||||
|
|
||||||
if (content.classList.contains('expanded')) {
|
if (content.classList.contains('expanded')) {
|
||||||
content.classList.remove('expanded');
|
content.classList.remove('expanded');
|
||||||
toggle.classList.remove('expanded');
|
toggle.classList.remove('expanded');
|
||||||
@ -80,6 +80,6 @@ function renderTests() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize on page load
|
// Initialize on page load
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
renderTests();
|
renderTests();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
const express = require("express");
|
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 auth = require("../middleware/auth");
|
const auth = require("../middleware/auth");
|
||||||
const {
|
const {
|
||||||
householdAccess,
|
householdAccess,
|
||||||
requireHouseholdAdmin,
|
requireHouseholdAdmin,
|
||||||
|
storeAccess,
|
||||||
} = require("../middleware/household");
|
} = require("../middleware/household");
|
||||||
|
const { upload, processImage } = require("../middleware/image");
|
||||||
|
|
||||||
// Public routes (authenticated only)
|
// Public routes (authenticated only)
|
||||||
router.get("/", auth, controller.getUserHouseholds);
|
router.get("/", auth, controller.getUserHouseholds);
|
||||||
@ -57,4 +60,110 @@ router.delete(
|
|||||||
controller.removeMember
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@ -2,7 +2,9 @@ import { BrowserRouter, Route, Routes } from "react-router-dom";
|
|||||||
import { ROLES } from "./constants/roles";
|
import { ROLES } from "./constants/roles";
|
||||||
import { AuthProvider } from "./context/AuthContext.jsx";
|
import { AuthProvider } from "./context/AuthContext.jsx";
|
||||||
import { ConfigProvider } from "./context/ConfigContext.jsx";
|
import { ConfigProvider } from "./context/ConfigContext.jsx";
|
||||||
|
import { HouseholdProvider } from "./context/HouseholdContext.jsx";
|
||||||
import { SettingsProvider } from "./context/SettingsContext.jsx";
|
import { SettingsProvider } from "./context/SettingsContext.jsx";
|
||||||
|
import { StoreProvider } from "./context/StoreContext.jsx";
|
||||||
|
|
||||||
import AdminPanel from "./pages/AdminPanel.jsx";
|
import AdminPanel from "./pages/AdminPanel.jsx";
|
||||||
import GroceryList from "./pages/GroceryList.jsx";
|
import GroceryList from "./pages/GroceryList.jsx";
|
||||||
@ -20,38 +22,42 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<ConfigProvider>
|
<ConfigProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SettingsProvider>
|
<HouseholdProvider>
|
||||||
<BrowserRouter>
|
<StoreProvider>
|
||||||
<Routes>
|
<SettingsProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
|
||||||
{/* Public route */}
|
{/* Public route */}
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
|
|
||||||
{/* Private routes with layout */}
|
{/* Private routes with layout */}
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<AppLayout />
|
<AppLayout />
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route path="/" element={<GroceryList />} />
|
<Route path="/" element={<GroceryList />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/admin"
|
path="/admin"
|
||||||
element={
|
element={
|
||||||
<RoleGuard allowed={[ROLES.ADMIN]}>
|
<RoleGuard allowed={[ROLES.ADMIN]}>
|
||||||
<AdminPanel />
|
<AdminPanel />
|
||||||
</RoleGuard>
|
</RoleGuard>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
|
</StoreProvider>
|
||||||
|
</HouseholdProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ConfigProvider>
|
</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";
|
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();
|
const formData = new FormData();
|
||||||
formData.append("itemName", itemName);
|
formData.append("item_name", itemName);
|
||||||
formData.append("quantity", quantity);
|
formData.append("quantity", quantity);
|
||||||
|
if (notes) {
|
||||||
|
formData.append("notes", notes);
|
||||||
|
}
|
||||||
if (imageFile) {
|
if (imageFile) {
|
||||||
formData.append("image", imageFile);
|
formData.append("image", imageFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
return api.post("/list/add", formData, {
|
return api.post(`/households/${householdId}/stores/${storeId}/list/add`, formData, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "multipart/form-data",
|
"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}`, {
|
* Get item classification
|
||||||
itemName,
|
*/
|
||||||
quantity,
|
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
|
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();
|
const formData = new FormData();
|
||||||
formData.append("id", id);
|
formData.append("item_name", itemName);
|
||||||
formData.append("itemName", itemName);
|
|
||||||
formData.append("quantity", quantity);
|
formData.append("quantity", quantity);
|
||||||
formData.append("image", imageFile);
|
formData.append("image", imageFile);
|
||||||
|
|
||||||
return api.post("/list/update-image", formData, {
|
return api.post(`/households/${householdId}/stores/${storeId}/list/update-image`, formData, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "multipart/form-data",
|
"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 { useContext } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { AuthContext } from "../../context/AuthContext";
|
import { AuthContext } from "../../context/AuthContext";
|
||||||
|
import HouseholdSwitcher from "../household/HouseholdSwitcher";
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const { role, logout, username } = useContext(AuthContext);
|
const { role, logout, username } = useContext(AuthContext);
|
||||||
@ -16,6 +17,8 @@ export default function Navbar() {
|
|||||||
{role === "admin" && <Link to="/admin">Admin</Link>}
|
{role === "admin" && <Link to="/admin">Admin</Link>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<HouseholdSwitcher />
|
||||||
|
|
||||||
<div className="navbar-idcard">
|
<div className="navbar-idcard">
|
||||||
<div className="navbar-idinfo">
|
<div className="navbar-idinfo">
|
||||||
<span className="navbar-username">{username}</span>
|
<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 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 { ZONE_FLOW } from "../constants/classifications";
|
import { ZONE_FLOW } from "../constants/classifications";
|
||||||
import { ROLES } from "../constants/roles";
|
import { ROLES } from "../constants/roles";
|
||||||
import { AuthContext } from "../context/AuthContext";
|
import { AuthContext } from "../context/AuthContext";
|
||||||
|
import { HouseholdContext } from "../context/HouseholdContext";
|
||||||
import { SettingsContext } from "../context/SettingsContext";
|
import { SettingsContext } from "../context/SettingsContext";
|
||||||
|
import { StoreContext } from "../context/StoreContext";
|
||||||
import "../styles/pages/GroceryList.css";
|
import "../styles/pages/GroceryList.css";
|
||||||
import { findSimilarItems } from "../utils/stringSimilarity";
|
import { findSimilarItems } from "../utils/stringSimilarity";
|
||||||
|
|
||||||
|
|
||||||
export default function GroceryList() {
|
export default function GroceryList() {
|
||||||
const { role } = useContext(AuthContext);
|
const { role } = useContext(AuthContext);
|
||||||
|
const { activeHousehold } = useContext(HouseholdContext);
|
||||||
|
const { activeStore } = useContext(StoreContext);
|
||||||
const { settings } = useContext(SettingsContext);
|
const { settings } = useContext(SettingsContext);
|
||||||
|
|
||||||
// === State === //
|
// === State === //
|
||||||
@ -53,17 +58,29 @@ export default function GroceryList() {
|
|||||||
|
|
||||||
// === Data Loading ===
|
// === Data Loading ===
|
||||||
const loadItems = async () => {
|
const loadItems = async () => {
|
||||||
|
if (!activeHousehold?.id || !activeStore?.id) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await getList();
|
try {
|
||||||
console.log(res.data);
|
const res = await getList(activeHousehold.id, activeStore.id);
|
||||||
setItems(res.data);
|
console.log('[GroceryList] Items loaded:', res.data);
|
||||||
setLoading(false);
|
setItems(res.data.items || res.data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[GroceryList] Failed to load items:', error);
|
||||||
|
setItems([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const loadRecentlyBought = async () => {
|
const loadRecentlyBought = async () => {
|
||||||
|
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||||
try {
|
try {
|
||||||
const res = await getRecentlyBought();
|
const res = await getRecentlyBought(activeHousehold.id, activeStore.id);
|
||||||
setRecentlyBoughtItems(res.data);
|
setRecentlyBoughtItems(res.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load recently bought items:", error);
|
console.error("Failed to load recently bought items:", error);
|
||||||
@ -75,7 +92,7 @@ export default function GroceryList() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadItems();
|
loadItems();
|
||||||
loadRecentlyBought();
|
loadRecentlyBought();
|
||||||
}, []);
|
}, [activeHousehold?.id, activeStore?.id]);
|
||||||
|
|
||||||
|
|
||||||
// === Zone Collapse Handler ===
|
// === Zone Collapse Handler ===
|
||||||
@ -137,10 +154,16 @@ export default function GroceryList() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!activeHousehold?.id || !activeStore?.id) {
|
||||||
|
setSuggestions([]);
|
||||||
|
setButtonText("Create + Add");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const lowerText = text.toLowerCase().trim();
|
const lowerText = text.toLowerCase().trim();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await getSuggestions(text);
|
const response = await getSuggestions(activeHousehold.id, activeStore.id, text);
|
||||||
const suggestionList = response.data.map(s => s.item_name);
|
const suggestionList = response.data.map(s => s.item_name);
|
||||||
setSuggestions(suggestionList);
|
setSuggestions(suggestionList);
|
||||||
|
|
||||||
@ -157,13 +180,15 @@ export default function GroceryList() {
|
|||||||
// === Item Addition Handlers ===
|
// === Item Addition Handlers ===
|
||||||
const handleAdd = useCallback(async (itemName, quantity) => {
|
const handleAdd = useCallback(async (itemName, quantity) => {
|
||||||
if (!itemName.trim()) return;
|
if (!itemName.trim()) return;
|
||||||
|
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||||
|
|
||||||
|
// Check if item already exists
|
||||||
let existingItem = null;
|
let existingItem = null;
|
||||||
try {
|
try {
|
||||||
const response = await getItemByName(itemName);
|
const response = await getItemByName(activeHousehold.id, activeStore.id, itemName);
|
||||||
existingItem = response.data;
|
existingItem = response.data;
|
||||||
} catch {
|
} catch {
|
||||||
existingItem = null;
|
// Item doesn't exist, continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingItem) {
|
if (existingItem) {
|
||||||
@ -183,16 +208,19 @@ export default function GroceryList() {
|
|||||||
processItemAddition(itemName, quantity);
|
processItemAddition(itemName, quantity);
|
||||||
return prevItems;
|
return prevItems;
|
||||||
});
|
});
|
||||||
}, [recentlyBoughtItems]);
|
}, [activeHousehold?.id, activeStore?.id, recentlyBoughtItems]);
|
||||||
|
|
||||||
|
|
||||||
const processItemAddition = useCallback(async (itemName, quantity) => {
|
const processItemAddition = useCallback(async (itemName, quantity) => {
|
||||||
|
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||||
|
|
||||||
|
// Fetch current item state from backend
|
||||||
let existingItem = null;
|
let existingItem = null;
|
||||||
try {
|
try {
|
||||||
const response = await getItemByName(itemName);
|
const response = await getItemByName(activeHousehold.id, activeStore.id, itemName);
|
||||||
existingItem = response.data;
|
existingItem = response.data;
|
||||||
} catch {
|
} catch {
|
||||||
existingItem = null;
|
// Item doesn't exist, continue with add
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingItem?.bought === false) {
|
if (existingItem?.bought === false) {
|
||||||
@ -209,7 +237,7 @@ export default function GroceryList() {
|
|||||||
});
|
});
|
||||||
setShowConfirmAddExisting(true);
|
setShowConfirmAddExisting(true);
|
||||||
} else if (existingItem) {
|
} else if (existingItem) {
|
||||||
await addItem(itemName, quantity, null);
|
await addItem(activeHousehold.id, activeStore.id, itemName, quantity, null);
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setButtonText("Add Item");
|
setButtonText("Add Item");
|
||||||
|
|
||||||
@ -220,7 +248,7 @@ export default function GroceryList() {
|
|||||||
setPendingItem({ itemName, quantity });
|
setPendingItem({ itemName, quantity });
|
||||||
setShowAddDetailsModal(true);
|
setShowAddDetailsModal(true);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [activeHousehold?.id, activeStore?.id, items, loadItems]);
|
||||||
|
|
||||||
|
|
||||||
// === Similar Item Modal Handlers ===
|
// === Similar Item Modal Handlers ===
|
||||||
@ -249,6 +277,7 @@ export default function GroceryList() {
|
|||||||
// === Confirm Add Existing Modal Handlers ===
|
// === Confirm Add Existing Modal Handlers ===
|
||||||
const handleConfirmAddExisting = useCallback(async () => {
|
const handleConfirmAddExisting = useCallback(async () => {
|
||||||
if (!confirmAddExistingData) return;
|
if (!confirmAddExistingData) return;
|
||||||
|
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||||
|
|
||||||
const { itemName, newQuantity, existingItem } = confirmAddExistingData;
|
const { itemName, newQuantity, existingItem } = confirmAddExistingData;
|
||||||
|
|
||||||
@ -256,14 +285,11 @@ export default function GroceryList() {
|
|||||||
setConfirmAddExistingData(null);
|
setConfirmAddExistingData(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Update the item
|
await addItem(activeHousehold.id, activeStore.id, itemName, newQuantity, null);
|
||||||
await addItem(itemName, newQuantity, null);
|
|
||||||
|
|
||||||
// Fetch the updated item with properly formatted data
|
const response = await getItemByName(activeHousehold.id, activeStore.id, itemName);
|
||||||
const response = await getItemByName(itemName);
|
|
||||||
const updatedItem = response.data;
|
const updatedItem = response.data;
|
||||||
|
|
||||||
// Update state with the full item data
|
|
||||||
setItems(prevItems =>
|
setItems(prevItems =>
|
||||||
prevItems.map(item =>
|
prevItems.map(item =>
|
||||||
item.id === existingItem.id ? updatedItem : item
|
item.id === existingItem.id ? updatedItem : item
|
||||||
@ -274,32 +300,54 @@ export default function GroceryList() {
|
|||||||
setButtonText("Add Item");
|
setButtonText("Add Item");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update item:", error);
|
console.error("Failed to update item:", error);
|
||||||
// Fallback to full reload on error
|
|
||||||
await loadItems();
|
await loadItems();
|
||||||
}
|
}
|
||||||
}, [confirmAddExistingData, loadItems]);
|
|
||||||
|
|
||||||
const handleCancelAddExisting = useCallback(() => {
|
|
||||||
setShowConfirmAddExisting(false);
|
|
||||||
setConfirmAddExistingData(null);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
// === Add Details Modal Handlers ===
|
// === Add Details Modal Handlers ===
|
||||||
const handleAddDetailsConfirm = useCallback(async (imageFile, classification) => {
|
const handleAddWithDetails = useCallback(async (imageFile, classification) => {
|
||||||
if (!pendingItem) return;
|
if (!pendingItem) return;
|
||||||
|
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const addResponse = await addItem(pendingItem.itemName, pendingItem.quantity, imageFile);
|
await addItem(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, imageFile);
|
||||||
let newItem = addResponse.data;
|
|
||||||
|
|
||||||
if (classification) {
|
if (classification) {
|
||||||
const itemResponse = await getItemByName(pendingItem.itemName);
|
// Apply classification if provided
|
||||||
const itemId = itemResponse.data.id;
|
await updateItemWithClassification(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, classification);
|
||||||
const updateResponse = await updateItemWithClassification(itemId, undefined, undefined, classification);
|
|
||||||
newItem = { ...newItem, ...updateResponse.data };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
setShowAddDetailsModal(false);
|
||||||
setPendingItem(null);
|
setPendingItem(null);
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
@ -312,28 +360,7 @@ export default function GroceryList() {
|
|||||||
console.error("Failed to add item:", error);
|
console.error("Failed to add item:", error);
|
||||||
alert("Failed to add item. Please try again.");
|
alert("Failed to add item. Please try again.");
|
||||||
}
|
}
|
||||||
}, [pendingItem]);
|
}, [activeHousehold?.id, activeStore?.id, 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]);
|
|
||||||
|
|
||||||
|
|
||||||
const handleAddDetailsCancel = useCallback(() => {
|
const handleAddDetailsCancel = useCallback(() => {
|
||||||
@ -346,31 +373,34 @@ export default function GroceryList() {
|
|||||||
|
|
||||||
// === Item Action Handlers ===
|
// === Item Action Handlers ===
|
||||||
const handleBought = useCallback(async (id, quantity) => {
|
const handleBought = useCallback(async (id, quantity) => {
|
||||||
|
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||||
|
|
||||||
const item = items.find(i => i.id === id);
|
const item = items.find(i => i.id === id);
|
||||||
if (!item) return;
|
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 buying full quantity, remove from list
|
||||||
if (quantity >= item.quantity) {
|
if (quantity >= item.quantity) {
|
||||||
setItems(prevItems => prevItems.filter(item => item.id !== id));
|
setItems(prevItems => prevItems.filter(item => item.id !== id));
|
||||||
} else {
|
} else {
|
||||||
// If partial, update quantity
|
// If partial, fetch updated item
|
||||||
const response = await getItemByName(item.item_name);
|
const response = await getItemByName(activeHousehold.id, activeStore.id, item.item_name);
|
||||||
if (response.data) {
|
const updatedItem = response.data;
|
||||||
setItems(prevItems =>
|
|
||||||
prevItems.map(item => item.id === id ? response.data : item)
|
setItems(prevItems =>
|
||||||
);
|
prevItems.map(i => i.id === id ? updatedItem : i)
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadRecentlyBought();
|
loadRecentlyBought();
|
||||||
}, [items]);
|
}, [activeHousehold?.id, activeStore?.id, items]);
|
||||||
|
|
||||||
|
|
||||||
const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile) => {
|
const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile) => {
|
||||||
|
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await updateItemImage(id, itemName, quantity, imageFile);
|
const response = await updateItemImage(activeHousehold.id, activeStore.id, id, itemName, quantity, imageFile);
|
||||||
|
|
||||||
setItems(prevItems =>
|
setItems(prevItems =>
|
||||||
prevItems.map(item =>
|
prevItems.map(item =>
|
||||||
@ -387,14 +417,15 @@ export default function GroceryList() {
|
|||||||
console.error("Failed to add image:", error);
|
console.error("Failed to add image:", error);
|
||||||
alert("Failed to add image. Please try again.");
|
alert("Failed to add image. Please try again.");
|
||||||
}
|
}
|
||||||
}, []);
|
}, [activeHousehold?.id, activeStore?.id]);
|
||||||
|
|
||||||
|
|
||||||
const handleLongPress = useCallback(async (item) => {
|
const handleLongPress = useCallback(async (item) => {
|
||||||
if (![ROLES.ADMIN, ROLES.EDITOR].includes(role)) return;
|
if (![ROLES.ADMIN, ROLES.EDITOR].includes(role)) return;
|
||||||
|
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const classificationResponse = await getClassification(item.id);
|
const classificationResponse = await getClassification(activeHousehold.id, activeStore.id, item.id);
|
||||||
setEditingItem({
|
setEditingItem({
|
||||||
...item,
|
...item,
|
||||||
classification: classificationResponse.data
|
classification: classificationResponse.data
|
||||||
@ -405,20 +436,26 @@ export default function GroceryList() {
|
|||||||
setEditingItem({ ...item, classification: null });
|
setEditingItem({ ...item, classification: null });
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
}
|
}
|
||||||
}, [role]);
|
}, [activeHousehold?.id, activeStore?.id, role]);
|
||||||
|
|
||||||
|
|
||||||
// === Edit Modal Handlers ===
|
// === Edit Modal Handlers ===
|
||||||
const handleEditSave = useCallback(async (id, itemName, quantity, classification) => {
|
const handleEditSave = useCallback(async (id, itemName, quantity, classification) => {
|
||||||
|
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||||
|
|
||||||
try {
|
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);
|
setShowEditModal(false);
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
|
|
||||||
const updatedItem = response.data;
|
|
||||||
setItems(prevItems =>
|
setItems(prevItems =>
|
||||||
prevItems.map(item =>
|
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);
|
console.error("Failed to update item:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, []);
|
}, [activeHousehold?.id, activeStore?.id]);
|
||||||
|
|
||||||
|
|
||||||
const handleEditCancel = useCallback(() => {
|
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 (
|
return (
|
||||||
@ -462,6 +522,7 @@ export default function GroceryList() {
|
|||||||
<div className="glist-container">
|
<div className="glist-container">
|
||||||
<h1 className="glist-title">Costco Grocery List</h1>
|
<h1 className="glist-title">Costco Grocery List</h1>
|
||||||
|
|
||||||
|
<StoreTabs />
|
||||||
|
|
||||||
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && showAddForm && (
|
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && showAddForm && (
|
||||||
<AddItemForm
|
<AddItemForm
|
||||||
@ -598,7 +659,7 @@ export default function GroceryList() {
|
|||||||
{showAddDetailsModal && pendingItem && (
|
{showAddDetailsModal && pendingItem && (
|
||||||
<AddItemWithDetailsModal
|
<AddItemWithDetailsModal
|
||||||
itemName={pendingItem.itemName}
|
itemName={pendingItem.itemName}
|
||||||
onConfirm={handleAddDetailsConfirm}
|
onConfirm={handleAddWithDetails}
|
||||||
onSkip={handleAddDetailsSkip}
|
onSkip={handleAddDetailsSkip}
|
||||||
onCancel={handleAddDetailsCancel}
|
onCancel={handleAddDetailsCancel}
|
||||||
/>
|
/>
|
||||||
@ -629,9 +690,12 @@ export default function GroceryList() {
|
|||||||
currentQuantity={confirmAddExistingData.currentQuantity}
|
currentQuantity={confirmAddExistingData.currentQuantity}
|
||||||
addingQuantity={confirmAddExistingData.addingQuantity}
|
addingQuantity={confirmAddExistingData.addingQuantity}
|
||||||
onConfirm={handleConfirmAddExisting}
|
onConfirm={handleConfirmAddExisting}
|
||||||
onCancel={handleCancelAddExisting}
|
onCancel={() => {
|
||||||
|
setShowConfirmAddExisting(false);
|
||||||
|
setConfirmAddExistingData(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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