Apply item classification feature

This commit is contained in:
Nico 2026-01-02 14:27:39 -08:00
parent 29f64a13d5
commit d2824f8aeb
17 changed files with 1935 additions and 30 deletions

View 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
View 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! 🎉

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

View File

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

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

View File

@ -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];
};

View File

@ -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;

View File

@ -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");

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

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

View File

@ -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" : ""}`}

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

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

View File

@ -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;
await addItem(pendingItem.itemName, pendingItem.quantity, imageFile);
setShowImageModal(false);
setPendingItem(null);
setSuggestions([]);
setButtonText("Add Item");
loadItems();
try {
// Add item to grocery_list with image
await addItem(pendingItem.itemName, pendingItem.quantity, imageFile);
// 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;
await addItem(pendingItem.itemName, pendingItem.quantity, null);
setShowImageModal(false);
setPendingItem(null);
setSuggestions([]);
setButtonText("Add Item");
loadItems();
try {
// Add item without image or classification
await addItem(pendingItem.itemName, pendingItem.quantity, null);
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>
);
}

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

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

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