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 { isValidItemType, isValidItemGroup, isValidZone } = require("../constants/classifications");
|
||||
|
||||
|
||||
exports.getList = async (req, res) => {
|
||||
@ -64,3 +65,54 @@ exports.updateItemImage = async (req, res) => {
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
// 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("/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;
|
||||
|
||||
@ -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");
|
||||
|
||||
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 ConfirmBuyModal from "./ConfirmBuyModal";
|
||||
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 [showAddImageModal, setShowAddImageModal] = 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 = () => {
|
||||
setShowConfirmBuyModal(true);
|
||||
};
|
||||
@ -64,7 +111,16 @@ export default function GroceryListItem({ item, onClick, onImageAdded }) {
|
||||
|
||||
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-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 { 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 AddItemWithDetailsModal from "../components/AddItemWithDetailsModal";
|
||||
import EditItemModal from "../components/EditItemModal";
|
||||
import FloatingActionButton from "../components/FloatingActionButton";
|
||||
import GroceryListItem from "../components/GroceryListItem";
|
||||
import ImageUploadModal from "../components/ImageUploadModal";
|
||||
import SimilarItemModal from "../components/SimilarItemModal";
|
||||
import SortDropdown from "../components/SortDropdown";
|
||||
import { ROLES } from "../constants/roles";
|
||||
@ -23,9 +24,11 @@ export default function GroceryList() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [buttonText, setButtonText] = useState("Add Item");
|
||||
const [pendingItem, setPendingItem] = useState(null);
|
||||
const [showImageModal, setShowImageModal] = useState(false);
|
||||
const [showAddDetailsModal, setShowAddDetailsModal] = useState(false);
|
||||
const [showSimilarModal, setShowSimilarModal] = useState(false);
|
||||
const [similarItemSuggestion, setSimilarItemSuggestion] = useState(null);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
|
||||
const loadItems = async () => {
|
||||
setLoading(true);
|
||||
@ -143,9 +146,9 @@ export default function GroceryList() {
|
||||
setButtonText("Add Item");
|
||||
loadItems();
|
||||
} else {
|
||||
// NEW ITEM - show image upload modal
|
||||
// NEW ITEM - show combined add details modal
|
||||
setPendingItem({ itemName, quantity });
|
||||
setShowImageModal(true);
|
||||
setShowAddDetailsModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
@ -170,35 +173,58 @@ export default function GroceryList() {
|
||||
setSimilarItemSuggestion(null);
|
||||
};
|
||||
|
||||
const handleImageConfirm = async (imageFile) => {
|
||||
const handleAddDetailsConfirm = async (imageFile, classification) => {
|
||||
if (!pendingItem) return;
|
||||
|
||||
try {
|
||||
// Add item to grocery_list with image
|
||||
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);
|
||||
setSuggestions([]);
|
||||
setButtonText("Add Item");
|
||||
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;
|
||||
|
||||
try {
|
||||
// Add item without image or classification
|
||||
await addItem(pendingItem.itemName, pendingItem.quantity, null);
|
||||
setShowImageModal(false);
|
||||
|
||||
setShowAddDetailsModal(false);
|
||||
setPendingItem(null);
|
||||
setSuggestions([]);
|
||||
setButtonText("Add Item");
|
||||
loadItems();
|
||||
} catch (error) {
|
||||
console.error("Failed to add item:", error);
|
||||
alert("Failed to add item. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageCancel = () => {
|
||||
setShowImageModal(false);
|
||||
const handleAddDetailsCancel = () => {
|
||||
setShowAddDetailsModal(false);
|
||||
setPendingItem(null);
|
||||
setSuggestions([]);
|
||||
setButtonText("Add Item");
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleBought = async (id, quantity) => {
|
||||
await markBought(id);
|
||||
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>;
|
||||
|
||||
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
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@ -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
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@ -272,12 +342,12 @@ export default function GroceryList() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showImageModal && pendingItem && (
|
||||
<ImageUploadModal
|
||||
{showAddDetailsModal && pendingItem && (
|
||||
<AddItemWithDetailsModal
|
||||
itemName={pendingItem.itemName}
|
||||
onConfirm={handleImageConfirm}
|
||||
onSkip={handleImageSkip}
|
||||
onCancel={handleImageCancel}
|
||||
onConfirm={handleAddDetailsConfirm}
|
||||
onSkip={handleAddDetailsSkip}
|
||||
onCancel={handleAddDetailsCancel}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -290,6 +360,14 @@ export default function GroceryList() {
|
||||
onYes={handleSimilarYes}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showEditModal && editingItem && (
|
||||
<EditItemModal
|
||||
item={editingItem}
|
||||
onSave={handleEditSave}
|
||||
onCancel={handleEditCancel}
|
||||
/>
|
||||
)}
|
||||
</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