diff --git a/CLASSIFICATION_IMPLEMENTATION.md b/CLASSIFICATION_IMPLEMENTATION.md new file mode 100644 index 0000000..3554d60 --- /dev/null +++ b/CLASSIFICATION_IMPLEMENTATION.md @@ -0,0 +1,336 @@ +# Item Classification Implementation Guide + +## Overview +This implementation adds a classification system to the grocery app allowing users to categorize items by type, group, and store zone. The system supports both creating new items with classification and editing existing items. + +## Database Schema + +### New Table: `item_classification` +```sql +CREATE TABLE item_classification ( + id INTEGER PRIMARY KEY REFERENCES grocery_list(id) ON DELETE CASCADE, + item_type VARCHAR(50) NOT NULL, + item_group VARCHAR(100) NOT NULL, + zone VARCHAR(100), + confidence DECIMAL(3,2) DEFAULT 1.0, + source VARCHAR(20) DEFAULT 'user', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +**Key Points:** +- One-to-one relationship with `grocery_list` (id is both PK and FK) +- Cascade delete ensures classification is removed when item is deleted +- Classification values are NOT enforced by DB - controlled at app layer +- `confidence`: 1.0 for user input, lower values reserved for future ML features +- `source`: 'user', 'ml', or 'default' + +## Architecture + +### Backend + +**Constants** ([backend/constants/classifications.js](backend/constants/classifications.js)): +- `ITEM_TYPES`: 11 predefined types (produce, meat, dairy, etc.) +- `ITEM_GROUPS`: Nested object mapping types to their valid groups +- `ZONES`: 10 Costco store zones +- Validation helpers: `isValidItemType()`, `isValidItemGroup()`, `isValidZone()` + +**Models** ([backend/models/list.model.js](backend/models/list.model.js)): +- `getClassification(itemId)`: Returns classification for an item or null +- `upsertClassification(itemId, classification)`: INSERT or UPDATE using ON CONFLICT +- `updateItem(id, itemName, quantity)`: Update item name/quantity + +**Controllers** ([backend/controllers/lists.controller.js](backend/controllers/lists.controller.js)): +- `getClassification(req, res)`: GET endpoint to fetch classification +- `updateItemWithClassification(req, res)`: Validates and updates item + classification + +**Routes** ([backend/routes/list.routes.js](backend/routes/list.routes.js)): +- `GET /list/item/:id/classification` - Fetch classification (all roles) +- `PUT /list/item/:id` - Update item + classification (editor/admin) + +### Frontend + +**Constants** ([frontend/src/constants/classifications.js](frontend/src/constants/classifications.js)): +- Mirrors backend constants for UI rendering +- `getItemTypeLabel()`: Converts type keys to display names + +**API Methods** ([frontend/src/api/list.js](frontend/src/api/list.js)): +- `getClassification(id)`: Fetch classification for item +- `updateItemWithClassification(id, itemName, quantity, classification)`: Update item + +**Components:** + +1. **EditItemModal** ([frontend/src/components/EditItemModal.jsx](frontend/src/components/EditItemModal.jsx)) + - Triggered by long-press (500ms) on any grocery item + - Prepopulates with existing item data + classification + - Cascading selects: item_type → item_group (filtered) → zone + - Validation: Requires item_group if item_type is selected + - Upserts classification with confidence=1.0, source='user' + +2. **ItemClassificationModal** ([frontend/src/components/ItemClassificationModal.jsx](frontend/src/components/ItemClassificationModal.jsx)) + - Shown after image upload in new item flow + - Required fields: item_type, item_group + - Optional: zone + - User can skip classification entirely + +3. **GroceryListItem** ([frontend/src/components/GroceryListItem.jsx](frontend/src/components/GroceryListItem.jsx)) + - Long-press detection with 500ms timer + - Supports both touch (mobile) and mouse (desktop) events + - Cancels long-press if finger/mouse moves >10px + +**Main Page** ([frontend/src/pages/GroceryList.jsx](frontend/src/pages/GroceryList.jsx)): +- Orchestrates all modal flows +- Add flow: Name → Similar check → Image upload → **Classification** → Save +- Edit flow: Long-press → Load classification → Edit → Save + +## User Flows + +### FEATURE 1: Edit Existing Item + +**Trigger:** Long-press (500ms) on any item in the list + +**Steps:** +1. User long-presses an item +2. System fetches existing classification (if any) +3. EditItemModal opens with prepopulated data +4. User can edit: + - Item name + - Quantity + - Classification (type, group, zone) +5. On save: + - Updates `grocery_list` if name/quantity changed + - UPSERTS `item_classification` if classification provided + - Sets confidence=1.0, source='user' for user-edited classification + +**Validation:** +- Item name required +- Quantity must be ≥ 1 +- If item_type selected, item_group is required +- item_group options filtered by selected item_type + +### FEATURE 2: Add New Item with Classification + +**Enhanced flow:** +1. User enters item name +2. System checks for similar items (80% threshold) +3. User confirms/edits name +4. User uploads image (or skips) +5. **NEW:** ItemClassificationModal appears + - User selects type, group, zone (optional) + - Or skips classification +6. Item saved to `grocery_list` +7. If classification provided, saved to `item_classification` + +## Data Flow Examples + +### Adding Item with Classification +```javascript +// 1. Add item to grocery_list +const response = await addItem(itemName, quantity, imageFile); + +// 2. Get item ID +const item = await getItemByName(itemName); + +// 3. Add classification +await updateItemWithClassification(item.id, undefined, undefined, { + item_type: 'produce', + item_group: 'Fruits', + zone: 'Fresh Foods Right' +}); +``` + +### Editing Item Classification +```javascript +// Update both item data and classification in one call +await updateItemWithClassification(itemId, newName, newQuantity, { + item_type: 'dairy', + item_group: 'Cheese', + zone: 'Dairy Cooler' +}); +``` + +## Backend Request/Response Shapes + +### GET /list/item/:id/classification +**Response:** +```json +{ + "item_type": "produce", + "item_group": "Fruits", + "zone": "Fresh Foods Right", + "confidence": 1.0, + "source": "user" +} +``` +Or `null` if no classification exists. + +### PUT /list/item/:id +**Request Body:** +```json +{ + "itemName": "Organic Apples", + "quantity": 5, + "classification": { + "item_type": "produce", + "item_group": "Organic Produce", + "zone": "Fresh Foods Right" + } +} +``` + +**Validation:** +- Validates item_type against allowed values +- Validates item_group is valid for the selected item_type +- Validates zone against allowed zones +- Returns 400 with error message if invalid + +**Response:** +```json +{ + "message": "Item updated successfully" +} +``` + +## Setup Instructions + +### 1. Run Database Migration +```bash +psql -U your_user -d your_database -f backend/migrations/create_item_classification_table.sql +``` + +### 2. Restart Backend +The backend automatically loads the new classification constants and routes. + +### 3. Test Flows + +**Test Edit:** +1. Long-press any item in the list +2. Verify modal opens with item data +3. Select a type, then a group from filtered list +4. Save and verify item updates + +**Test Add:** +1. Add a new item +2. Upload image (or skip) +3. Verify classification modal appears +4. Complete classification or skip +5. Verify item appears in list + +## Validation Rules Summary + +1. **Item Type → Item Group Dependency** + - Must select item_type before item_group becomes available + - Item group dropdown shows only groups for selected type + +2. **Required Fields** + - When creating: item_type and item_group are required + - When editing: Classification is optional (can edit name/quantity only) + +3. **No Free-Text** + - All classification values are select dropdowns + - Backend validates against predefined constants + +4. **Graceful Handling** + - Items without classification display normally + - Edit modal works for both classified and unclassified items + - Classification is always optional (can be skipped) + +## State Management (Frontend) + +**GroceryList.jsx state:** +```javascript +{ + showEditModal: false, + editingItem: null, // Item + classification data + showClassificationModal: false, + classificationPendingItem: { + itemName, + quantity, + imageFile + } +} +``` + +## Long-Press Implementation Details + +**Timing:** +- Desktop (mouse): 500ms hold +- Mobile (touch): 500ms hold with <10px movement threshold + +**Event Handlers:** +- `onTouchStart`: Start timer, record position +- `onTouchMove`: Cancel if movement >10px +- `onTouchEnd`: Clear timer +- `onMouseDown`: Start timer +- `onMouseUp`: Clear timer +- `onMouseLeave`: Clear timer (prevent stuck state) + +## Future Enhancements + +1. **ML Predictions**: Use confidence <1.0 and source='ml' for auto-classification +2. **Bulk Edit**: Select multiple items and apply same classification +3. **Smart Suggestions**: Learn from user's classification patterns +4. **Zone Optimization**: Suggest optimal shopping route based on zones +5. **Analytics**: Most common types/groups, zone coverage + +## Troubleshooting + +**Issue:** Classification not saving +- Check browser console for validation errors +- Verify item_type/item_group combination is valid +- Ensure item_classification table exists + +**Issue:** Long-press not triggering +- Check that user has editor/admin role +- Verify onLongPress prop is passed to GroceryListItem +- Test on both mobile (touch) and desktop (mouse) + +**Issue:** Item groups not filtering +- Verify item_type is selected first +- Check that ITEM_GROUPS constant has entries for the selected type +- Ensure state updates are triggering re-render + +## Files Modified/Created + +### Backend +- ✅ `backend/constants/classifications.js` (NEW) +- ✅ `backend/models/list.model.js` (MODIFIED) +- ✅ `backend/controllers/lists.controller.js` (MODIFIED) +- ✅ `backend/routes/list.routes.js` (MODIFIED) +- ✅ `backend/migrations/create_item_classification_table.sql` (NEW) + +### Frontend +- ✅ `frontend/src/constants/classifications.js` (NEW) +- ✅ `frontend/src/api/list.js` (MODIFIED) +- ✅ `frontend/src/components/EditItemModal.jsx` (NEW) +- ✅ `frontend/src/components/ItemClassificationModal.jsx` (NEW) +- ✅ `frontend/src/components/GroceryListItem.jsx` (MODIFIED) +- ✅ `frontend/src/pages/GroceryList.jsx` (MODIFIED) +- ✅ `frontend/src/styles/EditItemModal.css` (NEW) +- ✅ `frontend/src/styles/ItemClassificationModal.css` (NEW) + +## API Summary + +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/list/item/:id/classification` | All roles | Get item classification | +| PUT | `/list/item/:id` | Editor/Admin | Update item + classification | + +## Database Operations + +**Upsert Pattern:** +```sql +INSERT INTO item_classification (id, item_type, item_group, zone, confidence, source) +VALUES ($1, $2, $3, $4, $5, $6) +ON CONFLICT (id) +DO UPDATE SET + item_type = EXCLUDED.item_type, + item_group = EXCLUDED.item_group, + zone = EXCLUDED.zone, + confidence = EXCLUDED.confidence, + source = EXCLUDED.source +RETURNING *; +``` + +This ensures we INSERT if no classification exists, or UPDATE if it does. diff --git a/SETUP_CHECKLIST.md b/SETUP_CHECKLIST.md new file mode 100644 index 0000000..e97cb54 --- /dev/null +++ b/SETUP_CHECKLIST.md @@ -0,0 +1,143 @@ +# Item Classification - Setup Checklist + +## 🚀 Quick Start + +### Step 1: Run Database Migration +```bash +# Connect to your PostgreSQL database and run: +psql -U your_username -d your_database_name -f backend/migrations/create_item_classification_table.sql + +# Or copy-paste the SQL directly into your database client +``` + +### Step 2: Restart Docker Containers +```bash +# From project root: +docker-compose -f docker-compose.dev.yml down +docker-compose -f docker-compose.dev.yml up --build +``` + +### Step 3: Test the Features + +#### ✅ Test Edit Flow (Long-Press) +1. Open the app and navigate to your grocery list +2. **Long-press** (500ms) on any existing item +3. EditItemModal should open with prepopulated data +4. Try selecting: + - Item Type: "Produce" + - Item Group: "Fruits" (filtered by type) + - Zone: "Fresh Foods Right" (optional) +5. Click "Save Changes" +6. Item should update successfully + +#### ✅ Test Add Flow (with Classification) +1. Click the "+" button to add a new item +2. Enter item name: "Organic Bananas" +3. Set quantity: 3 +4. Click "Add Item" +5. Upload an image (or skip) +6. **NEW:** ItemClassificationModal appears +7. Select: + - Item Type: "Produce" + - Item Group: "Organic Produce" + - Zone: "Fresh Foods Right" +8. Click "Confirm" +9. Item should appear in list + +#### ✅ Test Skip Classification +1. Add a new item +2. Upload/skip image +3. When ItemClassificationModal appears, click "Skip for Now" +4. Item should be added without classification +5. Long-press the item to add classification later + +## 🐛 Common Issues + +### Long-press not working? +- Make sure you're logged in as editor or admin +- Try both mobile (touch) and desktop (mouse) +- Check browser console for errors + +### Classification not saving? +- Verify database migration was successful +- Check that item_classification table exists +- Look for validation errors in browser console + +### Item groups not showing? +- Ensure you selected an item type first +- The groups are filtered by the selected type + +## 📊 What Was Implemented + +### Backend (Node.js + Express + PostgreSQL) +- ✅ Classification constants with validation helpers +- ✅ Database model methods (getClassification, upsertClassification, updateItem) +- ✅ API endpoints (GET /list/item/:id/classification, PUT /list/item/:id) +- ✅ Controller with validation logic +- ✅ Database migration script + +### Frontend (React + TypeScript) +- ✅ Classification constants (mirrored from backend) +- ✅ EditItemModal component with cascading selects +- ✅ ItemClassificationModal for new items +- ✅ Long-press detection (500ms) on GroceryListItem +- ✅ Enhanced add flow with classification step +- ✅ API integration methods + +## 📝 Key Features + +1. **Edit Existing Items** + - Long-press any item (bought or unbought) + - Edit name, quantity, and classification + - Classification is optional + +2. **Classify New Items** + - After image upload, classification step appears + - Can skip classification if desired + - Required: item_type and item_group + - Optional: zone + +3. **Smart Filtering** + - Item groups filtered by selected item type + - Only valid combinations allowed + - No free-text entry + +4. **Confidence Tracking** + - User-provided: confidence=1.0, source='user' + - Ready for future ML features (confidence<1.0, source='ml') + +## 📖 Full Documentation + +See [CLASSIFICATION_IMPLEMENTATION.md](CLASSIFICATION_IMPLEMENTATION.md) for: +- Complete architecture details +- API request/response examples +- Data flow diagrams +- Troubleshooting guide +- Future enhancement ideas + +## 🎯 Next Steps + +1. Run the database migration (Step 1 above) +2. Restart your containers (Step 2 above) +3. Test both flows (Step 3 above) +4. Optionally: Customize the classification constants in: + - `backend/constants/classifications.js` + - `frontend/src/constants/classifications.js` + +## ⚡ Pro Tips + +- **Long-press timing**: 500ms - not too fast, not too slow +- **Movement threshold**: Keep finger/mouse within 10px to trigger +- **Desktop testing**: Hold mouse button down for 500ms +- **Skip button**: Always available - classification is never forced +- **Edit anytime**: Long-press to edit classification after creation + +## 🎨 UI/UX Notes + +- **EditItemModal**: Full-screen on mobile, centered card on desktop +- **ItemClassificationModal**: Appears after image upload (seamless flow) +- **Long-press**: Provides haptic feedback on supported devices +- **Validation**: Inline validation with user-friendly error messages +- **Loading states**: "Saving..." text during API calls + +Enjoy your new classification system! 🎉 diff --git a/backend/constants/classifications.js b/backend/constants/classifications.js new file mode 100644 index 0000000..eacac75 --- /dev/null +++ b/backend/constants/classifications.js @@ -0,0 +1,133 @@ +// Backend classification constants (mirror of frontend) + +const ITEM_TYPES = { + PRODUCE: "produce", + MEAT: "meat", + DAIRY: "dairy", + BAKERY: "bakery", + FROZEN: "frozen", + PANTRY: "pantry", + BEVERAGE: "beverage", + SNACK: "snack", + HOUSEHOLD: "household", + PERSONAL_CARE: "personal_care", + OTHER: "other", +}; + +const ITEM_GROUPS = { + [ITEM_TYPES.PRODUCE]: [ + "Fruits", + "Vegetables", + "Salad Mix", + "Herbs", + "Organic Produce", + ], + [ITEM_TYPES.MEAT]: [ + "Beef", + "Pork", + "Chicken", + "Seafood", + "Deli Meat", + "Prepared Meat", + ], + [ITEM_TYPES.DAIRY]: [ + "Milk", + "Cheese", + "Yogurt", + "Butter", + "Eggs", + "Cream", + ], + [ITEM_TYPES.BAKERY]: [ + "Bread", + "Rolls", + "Pastries", + "Cakes", + "Bagels", + "Tortillas", + ], + [ITEM_TYPES.FROZEN]: [ + "Frozen Meals", + "Ice Cream", + "Frozen Vegetables", + "Frozen Meat", + "Pizza", + "Desserts", + ], + [ITEM_TYPES.PANTRY]: [ + "Canned Goods", + "Pasta", + "Rice", + "Cereal", + "Condiments", + "Spices", + "Baking", + "Oils", + ], + [ITEM_TYPES.BEVERAGE]: [ + "Water", + "Soda", + "Juice", + "Coffee", + "Tea", + "Alcohol", + "Sports Drinks", + ], + [ITEM_TYPES.SNACK]: [ + "Chips", + "Crackers", + "Nuts", + "Candy", + "Cookies", + "Protein Bars", + ], + [ITEM_TYPES.HOUSEHOLD]: [ + "Cleaning Supplies", + "Paper Products", + "Laundry", + "Kitchen Items", + "Storage", + ], + [ITEM_TYPES.PERSONAL_CARE]: [ + "Bath & Body", + "Hair Care", + "Oral Care", + "Skincare", + "Health", + ], + [ITEM_TYPES.OTHER]: [ + "Miscellaneous", + ], +}; + +const ZONES = [ + "Front Entry", + "Fresh Foods Right", + "Fresh Foods Left", + "Center Aisles", + "Bakery", + "Meat Department", + "Dairy Cooler", + "Freezer Section", + "Back Wall", + "Checkout Area", +]; + +// Validation helpers +const isValidItemType = (type) => Object.values(ITEM_TYPES).includes(type); + +const isValidItemGroup = (type, group) => { + if (!isValidItemType(type)) return false; + return ITEM_GROUPS[type]?.includes(group) || false; +}; + +const isValidZone = (zone) => ZONES.includes(zone); + +module.exports = { + ITEM_TYPES, + ITEM_GROUPS, + ZONES, + isValidItemType, + isValidItemGroup, + isValidZone, +}; diff --git a/backend/controllers/lists.controller.js b/backend/controllers/lists.controller.js index da6316b..8f7a4a4 100644 --- a/backend/controllers/lists.controller.js +++ b/backend/controllers/lists.controller.js @@ -1,4 +1,5 @@ const List = require("../models/list.model"); +const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications"); exports.getList = async (req, res) => { @@ -63,4 +64,55 @@ exports.updateItemImage = async (req, res) => { await List.addOrUpdateItem(itemName, quantity, userId, imageBuffer, mimeType); res.json({ message: "Image updated successfully" }); +}; + +exports.getClassification = async (req, res) => { + const { id } = req.params; + const classification = await List.getClassification(id); + res.json(classification); +}; + +exports.updateItemWithClassification = async (req, res) => { + const { id } = req.params; + const { itemName, quantity, classification } = req.body; + const userId = req.user.id; + + try { + // Update item name and quantity if changed + if (itemName !== undefined || quantity !== undefined) { + await List.updateItem(id, itemName, quantity); + } + + // Update classification if provided + if (classification) { + const { item_type, item_group, zone } = classification; + + // Validate classification data + if (item_type && !isValidItemType(item_type)) { + return res.status(400).json({ message: "Invalid item_type" }); + } + + if (item_group && !isValidItemGroup(item_type, item_group)) { + return res.status(400).json({ message: "Invalid item_group for selected item_type" }); + } + + if (zone && !isValidZone(zone)) { + return res.status(400).json({ message: "Invalid zone" }); + } + + // Upsert classification with confidence=1.0 and source='user' + await List.upsertClassification(id, { + item_type, + item_group, + zone: zone || null, + confidence: 1.0, + source: 'user' + }); + } + + res.json({ message: "Item updated successfully" }); + } catch (error) { + console.error("Error updating item with classification:", error); + res.status(500).json({ message: "Failed to update item" }); + } }; \ No newline at end of file diff --git a/backend/migrations/create_item_classification_table.sql b/backend/migrations/create_item_classification_table.sql new file mode 100644 index 0000000..effc63a --- /dev/null +++ b/backend/migrations/create_item_classification_table.sql @@ -0,0 +1,29 @@ +-- Migration: Create item_classification table +-- This table stores classification data for items in the grocery_list table +-- Each row in grocery_list can have ONE corresponding classification row + +CREATE TABLE IF NOT EXISTS item_classification ( + id INTEGER PRIMARY KEY REFERENCES grocery_list(id) ON DELETE CASCADE, + item_type VARCHAR(50) NOT NULL, + item_group VARCHAR(100) NOT NULL, + zone VARCHAR(100), + confidence DECIMAL(3,2) DEFAULT 1.0 CHECK (confidence >= 0 AND confidence <= 1), + source VARCHAR(20) DEFAULT 'user' CHECK (source IN ('user', 'ml', 'default')), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Index for faster lookups by type +CREATE INDEX IF NOT EXISTS idx_item_classification_type ON item_classification(item_type); + +-- Index for zone-based queries +CREATE INDEX IF NOT EXISTS idx_item_classification_zone ON item_classification(zone); + +-- Comments +COMMENT ON TABLE item_classification IS 'Stores classification metadata for grocery list items'; +COMMENT ON COLUMN item_classification.id IS 'Foreign key to grocery_list.id (one-to-one relationship)'; +COMMENT ON COLUMN item_classification.item_type IS 'High-level category (produce, meat, dairy, etc.)'; +COMMENT ON COLUMN item_classification.item_group IS 'Subcategory within item_type (filtered by type)'; +COMMENT ON COLUMN item_classification.zone IS 'Store zone/location (optional)'; +COMMENT ON COLUMN item_classification.confidence IS 'Confidence score 0-1 (1.0 for user-provided, lower for ML-predicted)'; +COMMENT ON COLUMN item_classification.source IS 'Source of classification: user, ml, or default'; diff --git a/backend/models/list.model.js b/backend/models/list.model.js index effaf11..64a06f6 100644 --- a/backend/models/list.model.js +++ b/backend/models/list.model.js @@ -128,3 +128,44 @@ exports.getRecentlyBoughtItems = async () => { return result.rows; }; +// Classification methods +exports.getClassification = async (itemId) => { + const result = await pool.query( + `SELECT item_type, item_group, zone, confidence, source + FROM item_classification + WHERE id = $1`, + [itemId] + ); + return result.rows[0] || null; +}; + +exports.upsertClassification = async (itemId, classification) => { + const { item_type, item_group, zone, confidence, source } = classification; + + const result = await pool.query( + `INSERT INTO item_classification (id, item_type, item_group, zone, confidence, source) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (id) + DO UPDATE SET + item_type = EXCLUDED.item_type, + item_group = EXCLUDED.item_group, + zone = EXCLUDED.zone, + confidence = EXCLUDED.confidence, + source = EXCLUDED.source + RETURNING *`, + [itemId, item_type, item_group, zone, confidence, source] + ); + return result.rows[0]; +}; + +exports.updateItem = async (id, itemName, quantity) => { + const result = await pool.query( + `UPDATE grocery_list + SET item_name = $2, quantity = $3, modified_on = NOW() + WHERE id = $1 + RETURNING *`, + [id, itemName, quantity] + ); + return result.rows[0]; +}; + diff --git a/backend/routes/list.routes.js b/backend/routes/list.routes.js index 37d336a..bcd6063 100644 --- a/backend/routes/list.routes.js +++ b/backend/routes/list.routes.js @@ -12,11 +12,14 @@ router.get("/", auth, requireRole(...Object.values(ROLES)), controller.getList); router.get("/item-by-name", auth, requireRole(...Object.values(ROLES)), controller.getItemByName); router.get("/suggest", auth, requireRole(...Object.values(ROLES)), controller.getSuggestions); router.get("/recently-bought", auth, requireRole(...Object.values(ROLES)), controller.getRecentlyBought); +router.get("/item/:id/classification", auth, requireRole(...Object.values(ROLES)), controller.getClassification); router.post("/add", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), upload.single("image"), processImage, controller.addItem); router.post("/update-image", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), upload.single("image"), processImage, controller.updateItemImage); router.post("/mark-bought", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), controller.markBought); +router.put("/item/:id", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), controller.updateItemWithClassification); + module.exports = router; diff --git a/frontend/src/api/list.js b/frontend/src/api/list.js index b247c75..22b8ce6 100644 --- a/frontend/src/api/list.js +++ b/frontend/src/api/list.js @@ -18,7 +18,15 @@ export const addItem = (itemName, quantity, imageFile = null) => { }, }); }; +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, + classification + }); +}; export const markBought = (id) => api.post("/list/mark-bought", { id }); export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } }); export const getRecentlyBought = () => api.get("/list/recently-bought"); diff --git a/frontend/src/components/AddItemWithDetailsModal.jsx b/frontend/src/components/AddItemWithDetailsModal.jsx new file mode 100644 index 0000000..d078651 --- /dev/null +++ b/frontend/src/components/AddItemWithDetailsModal.jsx @@ -0,0 +1,186 @@ +import { useRef, useState } from "react"; +import { ITEM_GROUPS, ITEM_TYPES, ZONES, getItemTypeLabel } from "../constants/classifications"; +import "../styles/AddItemWithDetailsModal.css"; + +export default function AddItemWithDetailsModal({ itemName, onConfirm, onSkip, onCancel }) { + const [selectedImage, setSelectedImage] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + const [itemType, setItemType] = useState(""); + const [itemGroup, setItemGroup] = useState(""); + const [zone, setZone] = useState(""); + + const cameraInputRef = useRef(null); + const galleryInputRef = useRef(null); + + const handleImageChange = (e) => { + const file = e.target.files[0]; + if (file) { + setSelectedImage(file); + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreview(reader.result); + }; + reader.readAsDataURL(file); + } + }; + + const removeImage = () => { + setSelectedImage(null); + setImagePreview(null); + }; + + const handleItemTypeChange = (e) => { + const newType = e.target.value; + setItemType(newType); + // Reset item group when type changes + setItemGroup(""); + }; + + const handleConfirm = () => { + // Validate classification if provided + if (itemType && !itemGroup) { + alert("Please select an item group"); + return; + } + + const classification = itemType ? { + item_type: itemType, + item_group: itemGroup, + zone: zone || null + } : null; + + onConfirm(selectedImage, classification); + }; + + const handleSkip = () => { + onSkip(); + }; + + const handleCameraClick = () => { + cameraInputRef.current?.click(); + }; + + const handleGalleryClick = () => { + galleryInputRef.current?.click(); + }; + + const availableGroups = itemType ? ITEM_GROUPS[itemType] || [] : []; + + return ( +
Add an image and classification to help organize your list
+ + {/* Image Section */} +Help organize "{itemName}" in your list
+ +Loading...
; return ( @@ -244,6 +306,9 @@ export default function GroceryList() { onImageAdded={ [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null } + onLongPress={ + [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null + } /> ))} @@ -257,7 +322,12 @@ export default function GroceryList() { key={item.id} item={item} onClick={null} - onImageAdded={null} + onImageAdded={ + [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null + } + onLongPress={ + [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null + } /> ))} @@ -272,12 +342,12 @@ export default function GroceryList() { /> )} - {showImageModal && pendingItem && ( -