Apply item classification feature
This commit is contained in:
parent
29f64a13d5
commit
d2824f8aeb
336
CLASSIFICATION_IMPLEMENTATION.md
Normal file
336
CLASSIFICATION_IMPLEMENTATION.md
Normal file
@ -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.
|
||||||
143
SETUP_CHECKLIST.md
Normal file
143
SETUP_CHECKLIST.md
Normal file
@ -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! 🎉
|
||||||
133
backend/constants/classifications.js
Normal file
133
backend/constants/classifications.js
Normal file
@ -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,
|
||||||
|
};
|
||||||
@ -1,4 +1,5 @@
|
|||||||
const List = require("../models/list.model");
|
const List = require("../models/list.model");
|
||||||
|
const { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
|
||||||
|
|
||||||
|
|
||||||
exports.getList = async (req, res) => {
|
exports.getList = async (req, res) => {
|
||||||
@ -64,3 +65,54 @@ exports.updateItemImage = async (req, res) => {
|
|||||||
|
|
||||||
res.json({ message: "Image updated successfully" });
|
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" });
|
||||||
|
}
|
||||||
|
};
|
||||||
29
backend/migrations/create_item_classification_table.sql
Normal file
29
backend/migrations/create_item_classification_table.sql
Normal file
@ -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';
|
||||||
@ -128,3 +128,44 @@ exports.getRecentlyBoughtItems = async () => {
|
|||||||
return result.rows;
|
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];
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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("/item-by-name", auth, requireRole(...Object.values(ROLES)), controller.getItemByName);
|
||||||
router.get("/suggest", auth, requireRole(...Object.values(ROLES)), controller.getSuggestions);
|
router.get("/suggest", auth, requireRole(...Object.values(ROLES)), controller.getSuggestions);
|
||||||
router.get("/recently-bought", auth, requireRole(...Object.values(ROLES)), controller.getRecentlyBought);
|
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("/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("/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.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;
|
module.exports = router;
|
||||||
|
|||||||
@ -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 markBought = (id) => api.post("/list/mark-bought", { id });
|
||||||
export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } });
|
export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } });
|
||||||
export const getRecentlyBought = () => api.get("/list/recently-bought");
|
export const getRecentlyBought = () => api.get("/list/recently-bought");
|
||||||
|
|||||||
186
frontend/src/components/AddItemWithDetailsModal.jsx
Normal file
186
frontend/src/components/AddItemWithDetailsModal.jsx
Normal file
@ -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 (
|
||||||
|
<div className="add-item-details-overlay" onClick={onCancel}>
|
||||||
|
<div className="add-item-details-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h2 className="add-item-details-title">Add Details for "{itemName}"</h2>
|
||||||
|
<p className="add-item-details-subtitle">Add an image and classification to help organize your list</p>
|
||||||
|
|
||||||
|
{/* Image Section */}
|
||||||
|
<div className="add-item-details-section">
|
||||||
|
<h3 className="add-item-details-section-title">Item Image (Optional)</h3>
|
||||||
|
<div className="add-item-details-image-content">
|
||||||
|
{!imagePreview ? (
|
||||||
|
<div className="add-item-details-image-options">
|
||||||
|
<button onClick={handleCameraClick} className="add-item-details-image-btn camera">
|
||||||
|
📷 Use Camera
|
||||||
|
</button>
|
||||||
|
<button onClick={handleGalleryClick} className="add-item-details-image-btn gallery">
|
||||||
|
🖼️ Choose from Gallery
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="add-item-details-image-preview">
|
||||||
|
<img src={imagePreview} alt="Preview" />
|
||||||
|
<button type="button" onClick={removeImage} className="add-item-details-remove-image">
|
||||||
|
× Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={cameraInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
onChange={handleImageChange}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={galleryInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleImageChange}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Classification Section */}
|
||||||
|
<div className="add-item-details-section">
|
||||||
|
<h3 className="add-item-details-section-title">Item Classification (Optional)</h3>
|
||||||
|
|
||||||
|
<div className="add-item-details-field">
|
||||||
|
<label>Item Type</label>
|
||||||
|
<select
|
||||||
|
value={itemType}
|
||||||
|
onChange={handleItemTypeChange}
|
||||||
|
className="add-item-details-select"
|
||||||
|
>
|
||||||
|
<option value="">-- Select Type --</option>
|
||||||
|
{Object.values(ITEM_TYPES).map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{getItemTypeLabel(type)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{itemType && (
|
||||||
|
<div className="add-item-details-field">
|
||||||
|
<label>Item Group</label>
|
||||||
|
<select
|
||||||
|
value={itemGroup}
|
||||||
|
onChange={(e) => setItemGroup(e.target.value)}
|
||||||
|
className="add-item-details-select"
|
||||||
|
>
|
||||||
|
<option value="">-- Select Group --</option>
|
||||||
|
{availableGroups.map((group) => (
|
||||||
|
<option key={group} value={group}>
|
||||||
|
{group}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="add-item-details-field">
|
||||||
|
<label>Store Zone</label>
|
||||||
|
<select
|
||||||
|
value={zone}
|
||||||
|
onChange={(e) => setZone(e.target.value)}
|
||||||
|
className="add-item-details-select"
|
||||||
|
>
|
||||||
|
<option value="">-- Select Zone --</option>
|
||||||
|
{ZONES.map((z) => (
|
||||||
|
<option key={z} value={z}>
|
||||||
|
{z}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="add-item-details-actions">
|
||||||
|
<button onClick={onCancel} className="add-item-details-btn cancel">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button onClick={handleSkip} className="add-item-details-btn skip">
|
||||||
|
Skip All
|
||||||
|
</button>
|
||||||
|
<button onClick={handleConfirm} className="add-item-details-btn confirm">
|
||||||
|
Add Item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
frontend/src/components/EditItemModal.jsx
Normal file
164
frontend/src/components/EditItemModal.jsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ITEM_GROUPS, ITEM_TYPES, ZONES, getItemTypeLabel } from "../constants/classifications";
|
||||||
|
import "../styles/EditItemModal.css";
|
||||||
|
|
||||||
|
export default function EditItemModal({ item, onSave, onCancel }) {
|
||||||
|
const [itemName, setItemName] = useState(item.item_name || "");
|
||||||
|
const [quantity, setQuantity] = useState(item.quantity || 1);
|
||||||
|
const [itemType, setItemType] = useState("");
|
||||||
|
const [itemGroup, setItemGroup] = useState("");
|
||||||
|
const [zone, setZone] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Load existing classification
|
||||||
|
useEffect(() => {
|
||||||
|
if (item.classification) {
|
||||||
|
setItemType(item.classification.item_type || "");
|
||||||
|
setItemGroup(item.classification.item_group || "");
|
||||||
|
setZone(item.classification.zone || "");
|
||||||
|
}
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const handleItemTypeChange = (e) => {
|
||||||
|
const newType = e.target.value;
|
||||||
|
setItemType(newType);
|
||||||
|
// Reset item group when type changes
|
||||||
|
setItemGroup("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!itemName.trim()) {
|
||||||
|
alert("Item name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quantity < 1) {
|
||||||
|
alert("Quantity must be at least 1");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If classification fields are filled, validate them
|
||||||
|
if (itemType && !itemGroup) {
|
||||||
|
alert("Please select an item group");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const classification = itemType ? {
|
||||||
|
item_type: itemType,
|
||||||
|
item_group: itemGroup,
|
||||||
|
zone: zone || null
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
await onSave(item.id, itemName, quantity, classification);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save:", error);
|
||||||
|
alert("Failed to save changes");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableGroups = itemType ? ITEM_GROUPS[itemType] || [] : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="edit-modal-overlay" onClick={onCancel}>
|
||||||
|
<div className="edit-modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h2 className="edit-modal-title">Edit Item</h2>
|
||||||
|
|
||||||
|
<div className="edit-modal-field">
|
||||||
|
<label>Item Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={itemName}
|
||||||
|
onChange={(e) => setItemName(e.target.value)}
|
||||||
|
className="edit-modal-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="edit-modal-field">
|
||||||
|
<label>Quantity</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={quantity}
|
||||||
|
onChange={(e) => setQuantity(parseInt(e.target.value))}
|
||||||
|
className="edit-modal-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="edit-modal-divider" />
|
||||||
|
|
||||||
|
<h3 className="edit-modal-subtitle">Item Classification</h3>
|
||||||
|
|
||||||
|
<div className="edit-modal-field">
|
||||||
|
<label>Item Type</label>
|
||||||
|
<select
|
||||||
|
value={itemType}
|
||||||
|
onChange={handleItemTypeChange}
|
||||||
|
className="edit-modal-select"
|
||||||
|
>
|
||||||
|
<option value="">-- Select Type --</option>
|
||||||
|
{Object.values(ITEM_TYPES).map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{getItemTypeLabel(type)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{itemType && (
|
||||||
|
<div className="edit-modal-field">
|
||||||
|
<label>Item Group</label>
|
||||||
|
<select
|
||||||
|
value={itemGroup}
|
||||||
|
onChange={(e) => setItemGroup(e.target.value)}
|
||||||
|
className="edit-modal-select"
|
||||||
|
>
|
||||||
|
<option value="">-- Select Group --</option>
|
||||||
|
{availableGroups.map((group) => (
|
||||||
|
<option key={group} value={group}>
|
||||||
|
{group}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="edit-modal-field">
|
||||||
|
<label>Zone (Optional)</label>
|
||||||
|
<select
|
||||||
|
value={zone}
|
||||||
|
onChange={(e) => setZone(e.target.value)}
|
||||||
|
className="edit-modal-select"
|
||||||
|
>
|
||||||
|
<option value="">-- Select Zone --</option>
|
||||||
|
{ZONES.map((z) => (
|
||||||
|
<option key={z} value={z}>
|
||||||
|
{z}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="edit-modal-actions">
|
||||||
|
<button
|
||||||
|
className="edit-modal-btn edit-modal-btn-cancel"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="edit-modal-btn edit-modal-btn-save"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "Saving..." : "Save Changes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,13 +1,60 @@
|
|||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import AddImageModal from "./AddImageModal";
|
import AddImageModal from "./AddImageModal";
|
||||||
import ConfirmBuyModal from "./ConfirmBuyModal";
|
import ConfirmBuyModal from "./ConfirmBuyModal";
|
||||||
import ImageModal from "./ImageModal";
|
import ImageModal from "./ImageModal";
|
||||||
|
|
||||||
export default function GroceryListItem({ item, onClick, onImageAdded }) {
|
export default function GroceryListItem({ item, onClick, onImageAdded, onLongPress }) {
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [showAddImageModal, setShowAddImageModal] = useState(false);
|
const [showAddImageModal, setShowAddImageModal] = useState(false);
|
||||||
const [showConfirmBuyModal, setShowConfirmBuyModal] = useState(false);
|
const [showConfirmBuyModal, setShowConfirmBuyModal] = useState(false);
|
||||||
|
|
||||||
|
const longPressTimer = useRef(null);
|
||||||
|
const pressStartPos = useRef({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
const handleTouchStart = (e) => {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
pressStartPos.current = { x: touch.clientX, y: touch.clientY };
|
||||||
|
|
||||||
|
longPressTimer.current = setTimeout(() => {
|
||||||
|
if (onLongPress) {
|
||||||
|
onLongPress(item);
|
||||||
|
}
|
||||||
|
}, 500); // 500ms for long press
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e) => {
|
||||||
|
// Cancel long press if finger moves too much
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const moveDistance = Math.sqrt(
|
||||||
|
Math.pow(touch.clientX - pressStartPos.current.x, 2) +
|
||||||
|
Math.pow(touch.clientY - pressStartPos.current.y, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (moveDistance > 10) {
|
||||||
|
clearTimeout(longPressTimer.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
clearTimeout(longPressTimer.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseDown = () => {
|
||||||
|
longPressTimer.current = setTimeout(() => {
|
||||||
|
if (onLongPress) {
|
||||||
|
onLongPress(item);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
clearTimeout(longPressTimer.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
clearTimeout(longPressTimer.current);
|
||||||
|
};
|
||||||
|
|
||||||
const handleItemClick = () => {
|
const handleItemClick = () => {
|
||||||
setShowConfirmBuyModal(true);
|
setShowConfirmBuyModal(true);
|
||||||
};
|
};
|
||||||
@ -64,7 +111,16 @@ export default function GroceryListItem({ item, onClick, onImageAdded }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<li className="glist-li" onClick={handleItemClick}>
|
<li
|
||||||
|
className="glist-li"
|
||||||
|
onClick={handleItemClick}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
<div className="glist-item-layout">
|
<div className="glist-item-layout">
|
||||||
<div
|
<div
|
||||||
className={`glist-item-image ${item.item_image ? "has-image" : ""}`}
|
className={`glist-item-image ${item.item_image ? "has-image" : ""}`}
|
||||||
|
|||||||
110
frontend/src/components/ItemClassificationModal.jsx
Normal file
110
frontend/src/components/ItemClassificationModal.jsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ITEM_GROUPS, ITEM_TYPES, ZONES, getItemTypeLabel } from "../constants/classifications";
|
||||||
|
import "../styles/ItemClassificationModal.css";
|
||||||
|
|
||||||
|
export default function ItemClassificationModal({ itemName, onConfirm, onSkip }) {
|
||||||
|
const [itemType, setItemType] = useState("");
|
||||||
|
const [itemGroup, setItemGroup] = useState("");
|
||||||
|
const [zone, setZone] = useState("");
|
||||||
|
|
||||||
|
const handleItemTypeChange = (e) => {
|
||||||
|
const newType = e.target.value;
|
||||||
|
setItemType(newType);
|
||||||
|
// Reset item group when type changes
|
||||||
|
setItemGroup("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (!itemType) {
|
||||||
|
alert("Please select an item type");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!itemGroup) {
|
||||||
|
alert("Please select an item group");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onConfirm({
|
||||||
|
item_type: itemType,
|
||||||
|
item_group: itemGroup,
|
||||||
|
zone: zone || null
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableGroups = itemType ? ITEM_GROUPS[itemType] || [] : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="classification-modal-overlay">
|
||||||
|
<div className="classification-modal-content">
|
||||||
|
<h2 className="classification-modal-title">Classify Item</h2>
|
||||||
|
<p className="classification-modal-subtitle">Help organize "{itemName}" in your list</p>
|
||||||
|
|
||||||
|
<div className="classification-modal-field">
|
||||||
|
<label>Item Type <span className="required">*</span></label>
|
||||||
|
<select
|
||||||
|
value={itemType}
|
||||||
|
onChange={handleItemTypeChange}
|
||||||
|
className="classification-modal-select"
|
||||||
|
>
|
||||||
|
<option value="">-- Select Type --</option>
|
||||||
|
{Object.values(ITEM_TYPES).map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{getItemTypeLabel(type)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{itemType && (
|
||||||
|
<div className="classification-modal-field">
|
||||||
|
<label>Item Group <span className="required">*</span></label>
|
||||||
|
<select
|
||||||
|
value={itemGroup}
|
||||||
|
onChange={(e) => setItemGroup(e.target.value)}
|
||||||
|
className="classification-modal-select"
|
||||||
|
>
|
||||||
|
<option value="">-- Select Group --</option>
|
||||||
|
{availableGroups.map((group) => (
|
||||||
|
<option key={group} value={group}>
|
||||||
|
{group}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="classification-modal-field">
|
||||||
|
<label>Store Zone (Optional)</label>
|
||||||
|
<select
|
||||||
|
value={zone}
|
||||||
|
onChange={(e) => setZone(e.target.value)}
|
||||||
|
className="classification-modal-select"
|
||||||
|
>
|
||||||
|
<option value="">-- Select Zone --</option>
|
||||||
|
{ZONES.map((z) => (
|
||||||
|
<option key={z} value={z}>
|
||||||
|
{z}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="classification-modal-actions">
|
||||||
|
<button
|
||||||
|
className="classification-modal-btn classification-modal-btn-skip"
|
||||||
|
onClick={onSkip}
|
||||||
|
>
|
||||||
|
Skip for Now
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="classification-modal-btn classification-modal-btn-confirm"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
frontend/src/constants/classifications.js
Normal file
134
frontend/src/constants/classifications.js
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
// Item classification constants - app-level controlled values
|
||||||
|
|
||||||
|
export 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",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Item groups filtered by item type
|
||||||
|
export 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",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store zones for Costco layout
|
||||||
|
export const ZONES = [
|
||||||
|
"Front Entry",
|
||||||
|
"Fresh Foods Right",
|
||||||
|
"Fresh Foods Left",
|
||||||
|
"Center Aisles",
|
||||||
|
"Bakery",
|
||||||
|
"Meat Department",
|
||||||
|
"Dairy Cooler",
|
||||||
|
"Freezer Section",
|
||||||
|
"Back Wall",
|
||||||
|
"Checkout Area",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Helper to get display label for item type
|
||||||
|
export const getItemTypeLabel = (type) => {
|
||||||
|
const labels = {
|
||||||
|
[ITEM_TYPES.PRODUCE]: "Produce",
|
||||||
|
[ITEM_TYPES.MEAT]: "Meat & Seafood",
|
||||||
|
[ITEM_TYPES.DAIRY]: "Dairy & Eggs",
|
||||||
|
[ITEM_TYPES.BAKERY]: "Bakery",
|
||||||
|
[ITEM_TYPES.FROZEN]: "Frozen",
|
||||||
|
[ITEM_TYPES.PANTRY]: "Pantry & Dry Goods",
|
||||||
|
[ITEM_TYPES.BEVERAGE]: "Beverages",
|
||||||
|
[ITEM_TYPES.SNACK]: "Snacks",
|
||||||
|
[ITEM_TYPES.HOUSEHOLD]: "Household",
|
||||||
|
[ITEM_TYPES.PERSONAL_CARE]: "Personal Care",
|
||||||
|
[ITEM_TYPES.OTHER]: "Other",
|
||||||
|
};
|
||||||
|
return labels[type] || type;
|
||||||
|
};
|
||||||
@ -1,9 +1,10 @@
|
|||||||
import { useContext, useEffect, useState } from "react";
|
import { useContext, useEffect, useState } from "react";
|
||||||
import { addItem, getItemByName, getList, getRecentlyBought, getSuggestions, markBought, updateItemImage } from "../api/list";
|
import { addItem, getClassification, getItemByName, getList, getRecentlyBought, getSuggestions, markBought, updateItemImage, updateItemWithClassification } from "../api/list";
|
||||||
import AddItemForm from "../components/AddItemForm";
|
import AddItemForm from "../components/AddItemForm";
|
||||||
|
import AddItemWithDetailsModal from "../components/AddItemWithDetailsModal";
|
||||||
|
import EditItemModal from "../components/EditItemModal";
|
||||||
import FloatingActionButton from "../components/FloatingActionButton";
|
import FloatingActionButton from "../components/FloatingActionButton";
|
||||||
import GroceryListItem from "../components/GroceryListItem";
|
import GroceryListItem from "../components/GroceryListItem";
|
||||||
import ImageUploadModal from "../components/ImageUploadModal";
|
|
||||||
import SimilarItemModal from "../components/SimilarItemModal";
|
import SimilarItemModal from "../components/SimilarItemModal";
|
||||||
import SortDropdown from "../components/SortDropdown";
|
import SortDropdown from "../components/SortDropdown";
|
||||||
import { ROLES } from "../constants/roles";
|
import { ROLES } from "../constants/roles";
|
||||||
@ -23,9 +24,11 @@ export default function GroceryList() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [buttonText, setButtonText] = useState("Add Item");
|
const [buttonText, setButtonText] = useState("Add Item");
|
||||||
const [pendingItem, setPendingItem] = useState(null);
|
const [pendingItem, setPendingItem] = useState(null);
|
||||||
const [showImageModal, setShowImageModal] = useState(false);
|
const [showAddDetailsModal, setShowAddDetailsModal] = useState(false);
|
||||||
const [showSimilarModal, setShowSimilarModal] = useState(false);
|
const [showSimilarModal, setShowSimilarModal] = useState(false);
|
||||||
const [similarItemSuggestion, setSimilarItemSuggestion] = useState(null);
|
const [similarItemSuggestion, setSimilarItemSuggestion] = useState(null);
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
const [editingItem, setEditingItem] = useState(null);
|
||||||
|
|
||||||
const loadItems = async () => {
|
const loadItems = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -143,9 +146,9 @@ export default function GroceryList() {
|
|||||||
setButtonText("Add Item");
|
setButtonText("Add Item");
|
||||||
loadItems();
|
loadItems();
|
||||||
} else {
|
} else {
|
||||||
// NEW ITEM - show image upload modal
|
// NEW ITEM - show combined add details modal
|
||||||
setPendingItem({ itemName, quantity });
|
setPendingItem({ itemName, quantity });
|
||||||
setShowImageModal(true);
|
setShowAddDetailsModal(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -170,35 +173,58 @@ export default function GroceryList() {
|
|||||||
setSimilarItemSuggestion(null);
|
setSimilarItemSuggestion(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImageConfirm = async (imageFile) => {
|
const handleAddDetailsConfirm = async (imageFile, classification) => {
|
||||||
if (!pendingItem) return;
|
if (!pendingItem) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Add item to grocery_list with image
|
||||||
await addItem(pendingItem.itemName, pendingItem.quantity, imageFile);
|
await addItem(pendingItem.itemName, pendingItem.quantity, imageFile);
|
||||||
setShowImageModal(false);
|
|
||||||
|
// If classification provided, add it
|
||||||
|
if (classification) {
|
||||||
|
const itemResponse = await getItemByName(pendingItem.itemName);
|
||||||
|
const itemId = itemResponse.data.id;
|
||||||
|
await updateItemWithClassification(itemId, undefined, undefined, classification);
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowAddDetailsModal(false);
|
||||||
setPendingItem(null);
|
setPendingItem(null);
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setButtonText("Add Item");
|
setButtonText("Add Item");
|
||||||
loadItems();
|
loadItems();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to add item:", error);
|
||||||
|
alert("Failed to add item. Please try again.");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImageSkip = async () => {
|
const handleAddDetailsSkip = async () => {
|
||||||
if (!pendingItem) return;
|
if (!pendingItem) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Add item without image or classification
|
||||||
await addItem(pendingItem.itemName, pendingItem.quantity, null);
|
await addItem(pendingItem.itemName, pendingItem.quantity, null);
|
||||||
setShowImageModal(false);
|
|
||||||
|
setShowAddDetailsModal(false);
|
||||||
setPendingItem(null);
|
setPendingItem(null);
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setButtonText("Add Item");
|
setButtonText("Add Item");
|
||||||
loadItems();
|
loadItems();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to add item:", error);
|
||||||
|
alert("Failed to add item. Please try again.");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImageCancel = () => {
|
const handleAddDetailsCancel = () => {
|
||||||
setShowImageModal(false);
|
setShowAddDetailsModal(false);
|
||||||
setPendingItem(null);
|
setPendingItem(null);
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setButtonText("Add Item");
|
setButtonText("Add Item");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleBought = async (id, quantity) => {
|
const handleBought = async (id, quantity) => {
|
||||||
await markBought(id);
|
await markBought(id);
|
||||||
loadItems();
|
loadItems();
|
||||||
@ -215,6 +241,42 @@ export default function GroceryList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLongPress = async (item) => {
|
||||||
|
if (![ROLES.ADMIN, ROLES.EDITOR].includes(role)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch existing classification
|
||||||
|
const classificationResponse = await getClassification(item.id);
|
||||||
|
setEditingItem({
|
||||||
|
...item,
|
||||||
|
classification: classificationResponse.data
|
||||||
|
});
|
||||||
|
setShowEditModal(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load classification:", error);
|
||||||
|
setEditingItem({ ...item, classification: null });
|
||||||
|
setShowEditModal(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSave = async (id, itemName, quantity, classification) => {
|
||||||
|
try {
|
||||||
|
await updateItemWithClassification(id, itemName, quantity, classification);
|
||||||
|
setShowEditModal(false);
|
||||||
|
setEditingItem(null);
|
||||||
|
loadItems();
|
||||||
|
loadRecentlyBought();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update item:", error);
|
||||||
|
throw error; // Re-throw to let modal handle it
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditCancel = () => {
|
||||||
|
setShowEditModal(false);
|
||||||
|
setEditingItem(null);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) return <p>Loading...</p>;
|
if (loading) return <p>Loading...</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -244,6 +306,9 @@ export default function GroceryList() {
|
|||||||
onImageAdded={
|
onImageAdded={
|
||||||
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
||||||
}
|
}
|
||||||
|
onLongPress={
|
||||||
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -257,7 +322,12 @@ export default function GroceryList() {
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
onClick={null}
|
onClick={null}
|
||||||
onImageAdded={null}
|
onImageAdded={
|
||||||
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null
|
||||||
|
}
|
||||||
|
onLongPress={
|
||||||
|
[ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -272,12 +342,12 @@ export default function GroceryList() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showImageModal && pendingItem && (
|
{showAddDetailsModal && pendingItem && (
|
||||||
<ImageUploadModal
|
<AddItemWithDetailsModal
|
||||||
itemName={pendingItem.itemName}
|
itemName={pendingItem.itemName}
|
||||||
onConfirm={handleImageConfirm}
|
onConfirm={handleAddDetailsConfirm}
|
||||||
onSkip={handleImageSkip}
|
onSkip={handleAddDetailsSkip}
|
||||||
onCancel={handleImageCancel}
|
onCancel={handleAddDetailsCancel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -290,6 +360,14 @@ export default function GroceryList() {
|
|||||||
onYes={handleSimilarYes}
|
onYes={handleSimilarYes}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showEditModal && editingItem && (
|
||||||
|
<EditItemModal
|
||||||
|
item={editingItem}
|
||||||
|
onSave={handleEditSave}
|
||||||
|
onCancel={handleEditCancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
218
frontend/src/styles/AddItemWithDetailsModal.css
Normal file
218
frontend/src/styles/AddItemWithDetailsModal.css
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
.add-item-details-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5em;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-title {
|
||||||
|
font-size: 1.4em;
|
||||||
|
margin: 0 0 0.3em 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-subtitle {
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
margin: 0 0 1.5em 0;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-section {
|
||||||
|
margin-bottom: 1.5em;
|
||||||
|
padding-bottom: 1.5em;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-section:last-of-type {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-section-title {
|
||||||
|
font-size: 1.1em;
|
||||||
|
margin: 0 0 1em 0;
|
||||||
|
color: #555;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image Upload Section */
|
||||||
|
.add-item-details-image-content {
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-image-options {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.8em;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-image-btn {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 140px;
|
||||||
|
padding: 0.8em;
|
||||||
|
font-size: 0.95em;
|
||||||
|
border: 2px solid #007bff;
|
||||||
|
background: white;
|
||||||
|
color: #007bff;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-image-btn:hover {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-image-preview {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-image-preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
max-height: 300px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-remove-image {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5em;
|
||||||
|
right: 0.5em;
|
||||||
|
background: rgba(220, 53, 69, 0.9);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.4em 0.8em;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9em;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-remove-image:hover {
|
||||||
|
background: rgba(220, 53, 69, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Classification Section */
|
||||||
|
.add-item-details-field {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-field label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.4em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6em;
|
||||||
|
font-size: 1em;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions */
|
||||||
|
.add-item-details-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6em;
|
||||||
|
margin-top: 1.5em;
|
||||||
|
padding-top: 1em;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.7em;
|
||||||
|
font-size: 1em;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-btn.cancel {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-btn.cancel:hover {
|
||||||
|
background: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-btn.skip {
|
||||||
|
background: #ffc107;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-btn.skip:hover {
|
||||||
|
background: #e0a800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-btn.confirm {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-btn.confirm:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsiveness */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.add-item-details-modal {
|
||||||
|
padding: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-title {
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-image-options {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-image-btn {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-item-details-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
112
frontend/src/styles/EditItemModal.css
Normal file
112
frontend/src/styles/EditItemModal.css
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
.edit-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5em;
|
||||||
|
max-width: 480px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-title {
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin: 0 0 1em 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-subtitle {
|
||||||
|
font-size: 1.1em;
|
||||||
|
margin: 0.5em 0 0.8em 0;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-field {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-field label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.3em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-input,
|
||||||
|
.edit-modal-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6em;
|
||||||
|
font-size: 1em;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-input:focus,
|
||||||
|
.edit-modal-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
margin: 1.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.8em;
|
||||||
|
margin-top: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.7em;
|
||||||
|
font-size: 1em;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-btn-cancel {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-btn-cancel:hover:not(:disabled) {
|
||||||
|
background: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-btn-save {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-modal-btn-save:hover:not(:disabled) {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
102
frontend/src/styles/ItemClassificationModal.css
Normal file
102
frontend/src/styles/ItemClassificationModal.css
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
.classification-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-modal-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5em;
|
||||||
|
max-width: 480px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-modal-title {
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin: 0 0 0.3em 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-modal-subtitle {
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
margin: 0 0 1.5em 0;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-modal-field {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-modal-field label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.3em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-modal-field label .required {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-modal-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6em;
|
||||||
|
font-size: 1em;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-modal-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.8em;
|
||||||
|
margin-top: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-modal-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.7em;
|
||||||
|
font-size: 1em;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-modal-btn-skip {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-modal-btn-skip:hover {
|
||||||
|
background: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-modal-btn-confirm {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classification-modal-btn-confirm:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user