phase 3 - create minimal hooks to tie the new architecture between backend and frontend

This commit is contained in:
Nico 2026-01-25 23:23:00 -08:00
parent 4d5d2f0f6d
commit 9fc25f2274
22 changed files with 1521 additions and 262 deletions

83
POST_MIGRATION_UPDATES.md Normal file
View 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.

View 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" });
}
};

View 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';

View File

@ -14,15 +14,15 @@ exports.getHouseholdStoreList = async (householdId, storeId, includeHistory = tr
i.name AS item_name,
hl.quantity,
hl.bought,
ENCODE(hl.item_image, 'base64') as item_image,
hl.image_mime_type,
ENCODE(hl.custom_image, 'base64') as item_image,
hl.custom_image_mime_type as image_mime_type,
${includeHistory ? `
(
SELECT ARRAY_AGG(DISTINCT u.name)
FROM (
SELECT DISTINCT hlh.added_by
FROM household_list_history hlh
WHERE hlh.list_id = hl.id
WHERE hlh.household_list_id = hl.id
ORDER BY hlh.added_by
) hlh
JOIN users u ON hlh.added_by = u.id
@ -65,22 +65,20 @@ exports.getItemByName = async (householdId, storeId, itemName) => {
}
const itemId = itemResult.rows[0].id;
// Check if item exists in household list
const result = await pool.query(
`SELECT
hl.id,
i.name AS item_name,
hl.quantity,
hl.bought,
ENCODE(hl.item_image, 'base64') as item_image,
hl.image_mime_type,
ENCODE(hl.custom_image, 'base64') as item_image,
hl.custom_image_mime_type as image_mime_type,
(
SELECT ARRAY_AGG(DISTINCT u.name)
FROM (
SELECT DISTINCT hlh.added_by
FROM household_list_history hlh
WHERE hlh.list_id = hl.id
WHERE hlh.household_list_id = hl.id
ORDER BY hlh.added_by
) hlh
JOIN users u ON hlh.added_by = u.id
@ -99,7 +97,7 @@ exports.getItemByName = async (householdId, storeId, itemName) => {
AND hl.item_id = $3`,
[householdId, storeId, itemId]
);
console.log(result.rows);
return result.rows[0] || null;
};
@ -125,7 +123,6 @@ exports.addOrUpdateItem = async (
) => {
const lowerItemName = itemName.toLowerCase();
// First, ensure item exists in master catalog
let itemResult = await pool.query(
"SELECT id FROM items WHERE name ILIKE $1",
[lowerItemName]
@ -133,7 +130,6 @@ exports.addOrUpdateItem = async (
let itemId;
if (itemResult.rowCount === 0) {
// Create new item in master catalog
const insertItem = await pool.query(
"INSERT INTO items (name) VALUES ($1) RETURNING id",
[lowerItemName]
@ -143,7 +139,6 @@ exports.addOrUpdateItem = async (
itemId = itemResult.rows[0].id;
}
// Check if item exists in household list
const listResult = await pool.query(
`SELECT id, bought FROM household_lists
WHERE household_id = $1
@ -153,15 +148,14 @@ exports.addOrUpdateItem = async (
);
if (listResult.rowCount > 0) {
// Update existing list item
const listId = listResult.rows[0].id;
if (imageBuffer && mimeType) {
await pool.query(
`UPDATE household_lists
SET quantity = $1,
bought = FALSE,
item_image = $2,
image_mime_type = $3,
custom_image = $2,
custom_image_mime_type = $3,
modified_on = NOW()
WHERE id = $4`,
[quantity, imageBuffer, mimeType, listId]
@ -178,10 +172,9 @@ exports.addOrUpdateItem = async (
}
return listId;
} else {
// Insert new list item
const insert = await pool.query(
`INSERT INTO household_lists
(household_id, store_id, item_id, quantity, item_image, image_mime_type)
(household_id, store_id, item_id, quantity, custom_image, custom_image_mime_type)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id`,
[householdId, storeId, itemId, quantity, imageBuffer, mimeType]
@ -193,10 +186,22 @@ exports.addOrUpdateItem = async (
/**
* Mark item as bought (full or partial)
* @param {number} listId - List item ID
* @param {number} quantityBought - Quantity bought
* @param {boolean} bought - True to mark as bought, false to unmark
* @param {number} quantityBought - Optional quantity bought (for partial purchases)
*/
exports.setBought = async (listId, quantityBought) => {
// Get current item
exports.setBought = async (listId, bought, quantityBought = null) => {
if (bought === false) {
// Unmarking - just set bought to false
await pool.query(
"UPDATE household_lists SET bought = FALSE, modified_on = NOW() WHERE id = $1",
[listId]
);
return;
}
// Marking as bought
if (quantityBought && quantityBought > 0) {
// Partial purchase - reduce quantity
const item = await pool.query(
"SELECT quantity FROM household_lists WHERE id = $1",
[listId]
@ -208,18 +213,25 @@ exports.setBought = async (listId, quantityBought) => {
const remainingQuantity = currentQuantity - quantityBought;
if (remainingQuantity <= 0) {
// Mark as bought if all quantity is purchased
// All bought - mark as bought
await pool.query(
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
[listId]
);
} else {
// Reduce quantity if partial purchase
// Partial - reduce quantity
await pool.query(
"UPDATE household_lists SET quantity = $1, modified_on = NOW() WHERE id = $2",
[remainingQuantity, listId]
);
}
} else {
// Full purchase - mark as bought
await pool.query(
"UPDATE household_lists SET bought = TRUE, modified_on = NOW() WHERE id = $1",
[listId]
);
}
};
/**
@ -230,7 +242,7 @@ exports.setBought = async (listId, quantityBought) => {
*/
exports.addHistoryRecord = async (listId, quantity, userId) => {
await pool.query(
`INSERT INTO household_list_history (list_id, quantity, added_by, added_on)
`INSERT INTO household_list_history (household_list_id, quantity, added_by, added_on)
VALUES ($1, $2, $3, NOW())`,
[listId, quantity, userId]
);
@ -246,16 +258,16 @@ exports.addHistoryRecord = async (listId, quantity, userId) => {
exports.getSuggestions = async (query, householdId, storeId) => {
// Get items from both master catalog and household history
const result = await pool.query(
`SELECT DISTINCT i.name as item_name
`SELECT DISTINCT
i.name as item_name,
CASE WHEN hl.id IS NOT NULL THEN 0 ELSE 1 END as sort_order
FROM items i
LEFT JOIN household_lists hl
ON i.id = hl.item_id
AND hl.household_id = $2
AND hl.store_id = $3
WHERE i.name ILIKE $1
ORDER BY
CASE WHEN hl.id IS NOT NULL THEN 0 ELSE 1 END,
i.name
ORDER BY sort_order, i.name
LIMIT 10`,
[`%${query}%`, householdId, storeId]
);
@ -275,14 +287,14 @@ exports.getRecentlyBoughtItems = async (householdId, storeId) => {
i.name AS item_name,
hl.quantity,
hl.bought,
ENCODE(hl.item_image, 'base64') as item_image,
hl.image_mime_type,
ENCODE(hl.custom_image, 'base64') as item_image,
hl.custom_image_mime_type as image_mime_type,
(
SELECT ARRAY_AGG(DISTINCT u.name)
FROM (
SELECT DISTINCT hlh.added_by
FROM household_list_history hlh
WHERE hlh.list_id = hl.id
WHERE hlh.household_list_id = hl.id
ORDER BY hlh.added_by
) hlh
JOIN users u ON hlh.added_by = u.id
@ -346,49 +358,44 @@ exports.upsertClassification = async (householdId, itemId, classification) => {
/**
* Update list item details
* @param {number} listId - List item ID
* @param {string} itemName - New item name
* @param {number} quantity - New quantity
* @param {string} itemName - New item name (optional)
* @param {number} quantity - New quantity (optional)
* @param {string} notes - Notes (optional)
* @returns {Promise<Object>} Updated item
*/
exports.updateItem = async (listId, itemName, quantity) => {
// This is more complex now because we need to handle the master catalog
// Get current list item
const listItem = await pool.query(
"SELECT item_id FROM household_lists WHERE id = $1",
exports.updateItem = async (listId, itemName, quantity, notes) => {
// Build dynamic update query
const updates = [];
const values = [listId];
let paramCount = 1;
if (quantity !== undefined) {
paramCount++;
updates.push(`quantity = $${paramCount}`);
values.push(quantity);
}
if (notes !== undefined) {
paramCount++;
updates.push(`notes = $${paramCount}`);
values.push(notes);
}
// Always update modified_on
updates.push(`modified_on = NOW()`);
if (updates.length === 1) {
// Only modified_on update
const result = await pool.query(
`UPDATE household_lists SET modified_on = NOW() WHERE id = $1 RETURNING *`,
[listId]
);
if (listItem.rowCount === 0) {
throw new Error("List item not found");
return result.rows[0];
}
const oldItemId = listItem.rows[0].item_id;
// Check if new item name exists in catalog
let newItemId;
const itemResult = await pool.query(
"SELECT id FROM items WHERE name ILIKE $1",
[itemName.toLowerCase()]
);
if (itemResult.rowCount === 0) {
// Create new item
const insertItem = await pool.query(
"INSERT INTO items (name) VALUES ($1) RETURNING id",
[itemName.toLowerCase()]
);
newItemId = insertItem.rows[0].id;
} else {
newItemId = itemResult.rows[0].id;
}
// Update list item
const result = await pool.query(
`UPDATE household_lists
SET item_id = $2, quantity = $3, modified_on = NOW()
WHERE id = $1
RETURNING *`,
[listId, newItemId, quantity]
`UPDATE household_lists SET ${updates.join(', ')} WHERE id = $1 RETURNING *`,
values
);
return result.rows[0];

View File

@ -1,11 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Test Suite - Grocery List</title>
<link rel="stylesheet" href="test-styles.css">
</head>
<body>
<div class="container">
<h1>🧪 API Test Suite</h1>
@ -57,4 +59,5 @@
<script src="test-runner.js"></script>
<script src="test-ui.js"></script>
</body>
</html>

View File

@ -1,11 +1,14 @@
const express = require("express");
const router = express.Router();
const controller = require("../controllers/households.controller");
const listsController = require("../controllers/lists.controller.v2");
const auth = require("../middleware/auth");
const {
householdAccess,
requireHouseholdAdmin,
storeAccess,
} = require("../middleware/household");
const { upload, processImage } = require("../middleware/image");
// Public routes (authenticated only)
router.get("/", auth, controller.getUserHouseholds);
@ -57,4 +60,110 @@ router.delete(
controller.removeMember
);
// ==================== List Operations Routes ====================
// All list routes require household access AND store access
// Get grocery list
router.get(
"/:householdId/stores/:storeId/list",
auth,
householdAccess,
storeAccess,
listsController.getList
);
// Get specific item by name
router.get(
"/:householdId/stores/:storeId/list/item",
auth,
householdAccess,
storeAccess,
listsController.getItemByName
);
// Add item to list
router.post(
"/:householdId/stores/:storeId/list/add",
auth,
householdAccess,
storeAccess,
upload.single("image"),
processImage,
listsController.addItem
);
// Mark item as bought/unbought
router.patch(
"/:householdId/stores/:storeId/list/item",
auth,
householdAccess,
storeAccess,
listsController.markBought
);
// Update item details (quantity, notes)
router.put(
"/:householdId/stores/:storeId/list/item",
auth,
householdAccess,
storeAccess,
listsController.updateItem
);
// Delete item
router.delete(
"/:householdId/stores/:storeId/list/item",
auth,
householdAccess,
storeAccess,
listsController.deleteItem
);
// Get suggestions
router.get(
"/:householdId/stores/:storeId/list/suggestions",
auth,
householdAccess,
storeAccess,
listsController.getSuggestions
);
// Get recently bought items
router.get(
"/:householdId/stores/:storeId/list/recent",
auth,
householdAccess,
storeAccess,
listsController.getRecentlyBought
);
// Get item classification
router.get(
"/:householdId/stores/:storeId/list/classification",
auth,
householdAccess,
storeAccess,
listsController.getClassification
);
// Set item classification
router.post(
"/:householdId/stores/:storeId/list/classification",
auth,
householdAccess,
storeAccess,
listsController.setClassification
);
// Update item image
router.post(
"/:householdId/stores/:storeId/list/update-image",
auth,
householdAccess,
storeAccess,
upload.single("image"),
processImage,
listsController.updateItemImage
);
module.exports = router;

View File

@ -2,7 +2,9 @@ import { BrowserRouter, Route, Routes } from "react-router-dom";
import { ROLES } from "./constants/roles";
import { AuthProvider } from "./context/AuthContext.jsx";
import { ConfigProvider } from "./context/ConfigContext.jsx";
import { HouseholdProvider } from "./context/HouseholdContext.jsx";
import { SettingsProvider } from "./context/SettingsContext.jsx";
import { StoreProvider } from "./context/StoreContext.jsx";
import AdminPanel from "./pages/AdminPanel.jsx";
import GroceryList from "./pages/GroceryList.jsx";
@ -20,6 +22,8 @@ function App() {
return (
<ConfigProvider>
<AuthProvider>
<HouseholdProvider>
<StoreProvider>
<SettingsProvider>
<BrowserRouter>
<Routes>
@ -52,6 +56,8 @@ function App() {
</Routes>
</BrowserRouter>
</SettingsProvider>
</StoreProvider>
</HouseholdProvider>
</AuthProvider>
</ConfigProvider>
);

View 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}`);

View File

@ -1,44 +1,120 @@
import api from "./axios";
export const getList = () => api.get("/list");
export const getItemByName = (itemName) => api.get("/list/item-by-name", { params: { itemName: itemName } });
/**
* Get grocery list for household and store
*/
export const getList = (householdId, storeId) =>
api.get(`/households/${householdId}/stores/${storeId}/list`);
export const addItem = (itemName, quantity, imageFile = null) => {
/**
* Get specific item by name
*/
export const getItemByName = (householdId, storeId, itemName) =>
api.get(`/households/${householdId}/stores/${storeId}/list/item`, {
params: { item_name: itemName }
});
/**
* Add item to list
*/
export const addItem = (householdId, storeId, itemName, quantity, imageFile = null, notes = null) => {
const formData = new FormData();
formData.append("itemName", itemName);
formData.append("item_name", itemName);
formData.append("quantity", quantity);
if (notes) {
formData.append("notes", notes);
}
if (imageFile) {
formData.append("image", imageFile);
}
return api.post("/list/add", formData, {
return api.post(`/households/${householdId}/stores/${storeId}/list/add`, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
};
export const getClassification = (id) => api.get(`/list/item/${id}/classification`);
export const updateItemWithClassification = (id, itemName, quantity, classification) => {
return api.put(`/list/item/${id}`, {
itemName,
quantity,
/**
* Get item classification
*/
export const getClassification = (householdId, storeId, itemName) =>
api.get(`/households/${householdId}/stores/${storeId}/list/classification`, {
params: { item_name: itemName }
});
/**
* Set item classification
*/
export const setClassification = (householdId, storeId, itemName, classification) =>
api.post(`/households/${householdId}/stores/${storeId}/list/classification`, {
item_name: itemName,
classification
});
};
export const markBought = (id, quantity) => api.post("/list/mark-bought", { id, quantity });
export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } });
export const getRecentlyBought = () => api.get("/list/recently-bought");
export const updateItemImage = (id, itemName, quantity, imageFile) => {
/**
* Update item with classification (legacy method - split into separate calls)
*/
export const updateItemWithClassification = (householdId, storeId, itemName, quantity, classification) => {
// This is now two operations: update item + set classification
return Promise.all([
updateItem(householdId, storeId, itemName, quantity),
classification ? setClassification(householdId, storeId, itemName, classification) : Promise.resolve()
]);
};
/**
* Update item details (quantity, notes)
*/
export const updateItem = (householdId, storeId, itemName, quantity, notes) =>
api.put(`/households/${householdId}/stores/${storeId}/list/item`, {
item_name: itemName,
quantity,
notes
});
/**
* Mark item as bought or unbought
*/
export const markBought = (householdId, storeId, itemName, quantityBought = null, bought = true) =>
api.patch(`/households/${householdId}/stores/${storeId}/list/item`, {
item_name: itemName,
bought,
quantity_bought: quantityBought
});
/**
* Delete item from list
*/
export const deleteItem = (householdId, storeId, itemName) =>
api.delete(`/households/${householdId}/stores/${storeId}/list/item`, {
data: { item_name: itemName }
});
/**
* Get suggestions based on query
*/
export const getSuggestions = (householdId, storeId, query) =>
api.get(`/households/${householdId}/stores/${storeId}/list/suggestions`, {
params: { query }
});
/**
* Get recently bought items
*/
export const getRecentlyBought = (householdId, storeId) =>
api.get(`/households/${householdId}/stores/${storeId}/list/recent`);
/**
* Update item image
*/
export const updateItemImage = (householdId, storeId, itemName, quantity, imageFile) => {
const formData = new FormData();
formData.append("id", id);
formData.append("itemName", itemName);
formData.append("item_name", itemName);
formData.append("quantity", quantity);
formData.append("image", imageFile);
return api.post("/list/update-image", formData, {
return api.post(`/households/${householdId}/stores/${storeId}/list/update-image`, formData, {
headers: {
"Content-Type": "multipart/form-data",
},

View 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}`);

View 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>
);
}

View File

@ -3,6 +3,7 @@ import "../../styles/components/Navbar.css";
import { useContext } from "react";
import { Link } from "react-router-dom";
import { AuthContext } from "../../context/AuthContext";
import HouseholdSwitcher from "../household/HouseholdSwitcher";
export default function Navbar() {
const { role, logout, username } = useContext(AuthContext);
@ -16,6 +17,8 @@ export default function Navbar() {
{role === "admin" && <Link to="/admin">Admin</Link>}
</div>
<HouseholdSwitcher />
<div className="navbar-idcard">
<div className="navbar-idinfo">
<span className="navbar-username">{username}</span>

View 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>
);
}

View 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>
);
};

View 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>
);
};

View File

@ -18,16 +18,21 @@ import AddItemWithDetailsModal from "../components/modals/AddItemWithDetailsModa
import ConfirmAddExistingModal from "../components/modals/ConfirmAddExistingModal";
import EditItemModal from "../components/modals/EditItemModal";
import SimilarItemModal from "../components/modals/SimilarItemModal";
import StoreTabs from "../components/store/StoreTabs";
import { ZONE_FLOW } from "../constants/classifications";
import { ROLES } from "../constants/roles";
import { AuthContext } from "../context/AuthContext";
import { HouseholdContext } from "../context/HouseholdContext";
import { SettingsContext } from "../context/SettingsContext";
import { StoreContext } from "../context/StoreContext";
import "../styles/pages/GroceryList.css";
import { findSimilarItems } from "../utils/stringSimilarity";
export default function GroceryList() {
const { role } = useContext(AuthContext);
const { activeHousehold } = useContext(HouseholdContext);
const { activeStore } = useContext(StoreContext);
const { settings } = useContext(SettingsContext);
// === State === //
@ -53,17 +58,29 @@ export default function GroceryList() {
// === Data Loading ===
const loadItems = async () => {
setLoading(true);
const res = await getList();
console.log(res.data);
setItems(res.data);
if (!activeHousehold?.id || !activeStore?.id) {
setLoading(false);
return;
}
setLoading(true);
try {
const res = await getList(activeHousehold.id, activeStore.id);
console.log('[GroceryList] Items loaded:', res.data);
setItems(res.data.items || res.data || []);
} catch (error) {
console.error('[GroceryList] Failed to load items:', error);
setItems([]);
} finally {
setLoading(false);
}
};
const loadRecentlyBought = async () => {
if (!activeHousehold?.id || !activeStore?.id) return;
try {
const res = await getRecentlyBought();
const res = await getRecentlyBought(activeHousehold.id, activeStore.id);
setRecentlyBoughtItems(res.data);
} catch (error) {
console.error("Failed to load recently bought items:", error);
@ -75,7 +92,7 @@ export default function GroceryList() {
useEffect(() => {
loadItems();
loadRecentlyBought();
}, []);
}, [activeHousehold?.id, activeStore?.id]);
// === Zone Collapse Handler ===
@ -137,10 +154,16 @@ export default function GroceryList() {
return;
}
if (!activeHousehold?.id || !activeStore?.id) {
setSuggestions([]);
setButtonText("Create + Add");
return;
}
const lowerText = text.toLowerCase().trim();
try {
const response = await getSuggestions(text);
const response = await getSuggestions(activeHousehold.id, activeStore.id, text);
const suggestionList = response.data.map(s => s.item_name);
setSuggestions(suggestionList);
@ -157,13 +180,15 @@ export default function GroceryList() {
// === Item Addition Handlers ===
const handleAdd = useCallback(async (itemName, quantity) => {
if (!itemName.trim()) return;
if (!activeHousehold?.id || !activeStore?.id) return;
// Check if item already exists
let existingItem = null;
try {
const response = await getItemByName(itemName);
const response = await getItemByName(activeHousehold.id, activeStore.id, itemName);
existingItem = response.data;
} catch {
existingItem = null;
// Item doesn't exist, continue
}
if (existingItem) {
@ -183,16 +208,19 @@ export default function GroceryList() {
processItemAddition(itemName, quantity);
return prevItems;
});
}, [recentlyBoughtItems]);
}, [activeHousehold?.id, activeStore?.id, recentlyBoughtItems]);
const processItemAddition = useCallback(async (itemName, quantity) => {
if (!activeHousehold?.id || !activeStore?.id) return;
// Fetch current item state from backend
let existingItem = null;
try {
const response = await getItemByName(itemName);
const response = await getItemByName(activeHousehold.id, activeStore.id, itemName);
existingItem = response.data;
} catch {
existingItem = null;
// Item doesn't exist, continue with add
}
if (existingItem?.bought === false) {
@ -209,7 +237,7 @@ export default function GroceryList() {
});
setShowConfirmAddExisting(true);
} else if (existingItem) {
await addItem(itemName, quantity, null);
await addItem(activeHousehold.id, activeStore.id, itemName, quantity, null);
setSuggestions([]);
setButtonText("Add Item");
@ -220,7 +248,7 @@ export default function GroceryList() {
setPendingItem({ itemName, quantity });
setShowAddDetailsModal(true);
}
}, []);
}, [activeHousehold?.id, activeStore?.id, items, loadItems]);
// === Similar Item Modal Handlers ===
@ -249,6 +277,7 @@ export default function GroceryList() {
// === Confirm Add Existing Modal Handlers ===
const handleConfirmAddExisting = useCallback(async () => {
if (!confirmAddExistingData) return;
if (!activeHousehold?.id || !activeStore?.id) return;
const { itemName, newQuantity, existingItem } = confirmAddExistingData;
@ -256,14 +285,11 @@ export default function GroceryList() {
setConfirmAddExistingData(null);
try {
// Update the item
await addItem(itemName, newQuantity, null);
await addItem(activeHousehold.id, activeStore.id, itemName, newQuantity, null);
// Fetch the updated item with properly formatted data
const response = await getItemByName(itemName);
const response = await getItemByName(activeHousehold.id, activeStore.id, itemName);
const updatedItem = response.data;
// Update state with the full item data
setItems(prevItems =>
prevItems.map(item =>
item.id === existingItem.id ? updatedItem : item
@ -274,32 +300,54 @@ export default function GroceryList() {
setButtonText("Add Item");
} catch (error) {
console.error("Failed to update item:", error);
// Fallback to full reload on error
await loadItems();
}
}, [confirmAddExistingData, loadItems]);
const handleCancelAddExisting = useCallback(() => {
setShowConfirmAddExisting(false);
setConfirmAddExistingData(null);
}, []);
// === Add Details Modal Handlers ===
const handleAddDetailsConfirm = useCallback(async (imageFile, classification) => {
const handleAddWithDetails = useCallback(async (imageFile, classification) => {
if (!pendingItem) return;
if (!activeHousehold?.id || !activeStore?.id) return;
try {
const addResponse = await addItem(pendingItem.itemName, pendingItem.quantity, imageFile);
let newItem = addResponse.data;
await addItem(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, imageFile);
if (classification) {
const itemResponse = await getItemByName(pendingItem.itemName);
const itemId = itemResponse.data.id;
const updateResponse = await updateItemWithClassification(itemId, undefined, undefined, classification);
newItem = { ...newItem, ...updateResponse.data };
// Apply classification if provided
await updateItemWithClassification(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, classification);
}
// Fetch the newly added item
const itemResponse = await getItemByName(activeHousehold.id, activeStore.id, pendingItem.itemName);
const newItem = itemResponse.data;
setShowAddDetailsModal(false);
setPendingItem(null);
setSuggestions([]);
setButtonText("Add Item");
// Add to state
if (newItem) {
setItems(prevItems => [...prevItems, newItem]);
}
} catch (error) {
console.error("Failed to add item:", error);
alert("Failed to add item. Please try again.");
}
}, [activeHousehold?.id, activeStore?.id, pendingItem]);
const handleAddDetailsSkip = useCallback(async () => {
if (!pendingItem) return;
if (!activeHousehold?.id || !activeStore?.id) return;
try {
await addItem(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, null);
// Fetch the newly added item
const itemResponse = await getItemByName(activeHousehold.id, activeStore.id, pendingItem.itemName);
const newItem = itemResponse.data;
setShowAddDetailsModal(false);
setPendingItem(null);
setSuggestions([]);
@ -312,28 +360,7 @@ export default function GroceryList() {
console.error("Failed to add item:", error);
alert("Failed to add item. Please try again.");
}
}, [pendingItem]);
const handleAddDetailsSkip = useCallback(async () => {
if (!pendingItem) return;
try {
const response = await addItem(pendingItem.itemName, pendingItem.quantity, null);
setShowAddDetailsModal(false);
setPendingItem(null);
setSuggestions([]);
setButtonText("Add Item");
if (response.data) {
setItems(prevItems => [...prevItems, response.data]);
}
} catch (error) {
console.error("Failed to add item:", error);
alert("Failed to add item. Please try again.");
}
}, [pendingItem]);
}, [activeHousehold?.id, activeStore?.id, pendingItem]);
const handleAddDetailsCancel = useCallback(() => {
@ -346,31 +373,34 @@ export default function GroceryList() {
// === Item Action Handlers ===
const handleBought = useCallback(async (id, quantity) => {
if (!activeHousehold?.id || !activeStore?.id) return;
const item = items.find(i => i.id === id);
if (!item) return;
await markBought(id, quantity);
await markBought(activeHousehold.id, activeStore.id, item.item_name, quantity, true);
// If buying full quantity, remove from list
if (quantity >= item.quantity) {
setItems(prevItems => prevItems.filter(item => item.id !== id));
} else {
// If partial, update quantity
const response = await getItemByName(item.item_name);
if (response.data) {
// If partial, fetch updated item
const response = await getItemByName(activeHousehold.id, activeStore.id, item.item_name);
const updatedItem = response.data;
setItems(prevItems =>
prevItems.map(item => item.id === id ? response.data : item)
prevItems.map(i => i.id === id ? updatedItem : i)
);
}
}
loadRecentlyBought();
}, [items]);
}, [activeHousehold?.id, activeStore?.id, items]);
const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile) => {
if (!activeHousehold?.id || !activeStore?.id) return;
try {
const response = await updateItemImage(id, itemName, quantity, imageFile);
const response = await updateItemImage(activeHousehold.id, activeStore.id, id, itemName, quantity, imageFile);
setItems(prevItems =>
prevItems.map(item =>
@ -387,14 +417,15 @@ export default function GroceryList() {
console.error("Failed to add image:", error);
alert("Failed to add image. Please try again.");
}
}, []);
}, [activeHousehold?.id, activeStore?.id]);
const handleLongPress = useCallback(async (item) => {
if (![ROLES.ADMIN, ROLES.EDITOR].includes(role)) return;
if (!activeHousehold?.id || !activeStore?.id) return;
try {
const classificationResponse = await getClassification(item.id);
const classificationResponse = await getClassification(activeHousehold.id, activeStore.id, item.id);
setEditingItem({
...item,
classification: classificationResponse.data
@ -405,20 +436,26 @@ export default function GroceryList() {
setEditingItem({ ...item, classification: null });
setShowEditModal(true);
}
}, [role]);
}, [activeHousehold?.id, activeStore?.id, role]);
// === Edit Modal Handlers ===
const handleEditSave = useCallback(async (id, itemName, quantity, classification) => {
if (!activeHousehold?.id || !activeStore?.id) return;
try {
const response = await updateItemWithClassification(id, itemName, quantity, classification);
await updateItemWithClassification(activeHousehold.id, activeStore.id, itemName, quantity, classification);
// Fetch the updated item
const response = await getItemByName(activeHousehold.id, activeStore.id, itemName);
const updatedItem = response.data;
setShowEditModal(false);
setEditingItem(null);
const updatedItem = response.data;
setItems(prevItems =>
prevItems.map(item =>
item.id === id ? { ...item, ...updatedItem } : item
item.id === id ? updatedItem : item
)
);
@ -431,7 +468,7 @@ export default function GroceryList() {
console.error("Failed to update item:", error);
throw error;
}
}, []);
}, [activeHousehold?.id, activeStore?.id]);
const handleEditCancel = useCallback(() => {
@ -454,7 +491,30 @@ export default function GroceryList() {
};
if (loading) return <p>Loading...</p>;
if (!activeHousehold || !activeStore) {
return (
<div className="glist-body">
<div className="glist-container">
<h1 className="glist-title">Costco Grocery List</h1>
<p style={{ textAlign: 'center', marginTop: '2rem', color: 'var(--text-secondary)' }}>
{!activeHousehold ? 'Loading households...' : 'Loading stores...'}
</p>
</div>
</div>
);
}
if (loading) {
return (
<div className="glist-body">
<div className="glist-container">
<h1 className="glist-title">Costco Grocery List</h1>
<StoreTabs />
<p style={{ textAlign: 'center', marginTop: '2rem' }}>Loading grocery list...</p>
</div>
</div>
);
}
return (
@ -462,6 +522,7 @@ export default function GroceryList() {
<div className="glist-container">
<h1 className="glist-title">Costco Grocery List</h1>
<StoreTabs />
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && showAddForm && (
<AddItemForm
@ -598,7 +659,7 @@ export default function GroceryList() {
{showAddDetailsModal && pendingItem && (
<AddItemWithDetailsModal
itemName={pendingItem.itemName}
onConfirm={handleAddDetailsConfirm}
onConfirm={handleAddWithDetails}
onSkip={handleAddDetailsSkip}
onCancel={handleAddDetailsCancel}
/>
@ -629,7 +690,10 @@ export default function GroceryList() {
currentQuantity={confirmAddExistingData.currentQuantity}
addingQuantity={confirmAddExistingData.addingQuantity}
onConfirm={handleConfirmAddExisting}
onCancel={handleCancelAddExisting}
onCancel={() => {
setShowConfirmAddExisting(false);
setConfirmAddExistingData(null);
}}
/>
)}
</div>

View 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;
}

View 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;
}