diff --git a/backend/app/api/ingredients.py b/backend/app/api/ingredients.py index 17ab7a4..55d6432 100644 --- a/backend/app/api/ingredients.py +++ b/backend/app/api/ingredients.py @@ -55,6 +55,26 @@ def get_ingredient(ingredient_id: UUID, db: Session = Depends(get_db)) -> Ingred return row +@public_router.post("", response_model=IngredientRead, status_code=status.HTTP_201_CREATED) +def create_ingredient_public(payload: IngredientCreate, db: Session = Depends(get_db)) -> Ingredient: + row = Ingredient( + name=payload.name, + name_lower=payload.name.lower(), + aliases=payload.aliases, + aisle=payload.aisle, + unit=payload.unit, + typical_price=payload.typical_price, + ) + db.add(row) + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(status_code=409, detail="ingredient name already exists") + db.refresh(row) + return row + + @admin_router.post("", response_model=IngredientRead, status_code=status.HTTP_201_CREATED) def create_ingredient(payload: IngredientCreate, db: Session = Depends(get_db)) -> Ingredient: row = Ingredient( diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 1a24c65..201722f 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -29,8 +29,8 @@ export const mealPlannerApi = { get: (id: string) => api.get(`/recipes/${id}`), create: (data: any) => api.post('/recipes', data), delete: (id: string) => api.delete(`/recipes/${id}`), - listIngredients: () => api.get('/recipes/ingredients'), - createIngredient: (data: any) => api.post('/recipes/ingredients', data), + listIngredients: () => api.get('/ingredients?limit=500'), + createIngredient: (data: any) => api.post('/ingredients', data), }, meals: { diff --git a/frontend/src/pages/Pantry.tsx b/frontend/src/pages/Pantry.tsx index 01f701c..be9f88d 100644 --- a/frontend/src/pages/Pantry.tsx +++ b/frontend/src/pages/Pantry.tsx @@ -6,7 +6,6 @@ import type { HomePantryItem, Ingredient } from '../types' import { Button } from '../components/ui/Button' import { Card, CardBody } from '../components/ui/Card' import { Input } from '../components/ui/Input' -import { Select } from '../components/ui/Select' import { Skeleton, SkeletonText } from '../components/ui/Skeleton' import { EmptyState } from '../components/ui/EmptyState' import { showToast } from '../lib/toast' @@ -14,10 +13,13 @@ import { showToast } from '../lib/toast' export default function Pantry() { const queryClient = useQueryClient() const [showAddForm, setShowAddForm] = useState(false) - const [selectedIngredient, setSelectedIngredient] = useState('') + const [searchQuery, setSearchQuery] = useState('') + + /* ingredient name typed by user */ + const [ingredientName, setIngredientName] = useState('') const [quantity, setQuantity] = useState('') const [unit, setUnit] = useState('') - const [searchQuery, setSearchQuery] = useState('') + const [aisle, setAisle] = useState('') const { data: pantryItems, isLoading } = useQuery({ queryKey: ['pantry'], @@ -29,15 +31,23 @@ export default function Pantry() { queryFn: () => mealPlannerApi.recipes.listIngredients().then(r => r.data), }) + /* fuzzy match existing ingredient */ + const matchedIngredient = ingredientName.trim().length > 0 + ? ingredients?.find( + ing => ing.name.toLowerCase() === ingredientName.trim().toLowerCase() + ) + : undefined + const addMutation = useMutation({ mutationFn: (data: { ingredient_id: string; quantity?: number; unit?: string }) => mealPlannerApi.pantry.add(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['pantry'] }) setShowAddForm(false) - setSelectedIngredient('') + setIngredientName('') setQuantity('') setUnit('') + setAisle('') showToast.success('Item added to pantry') }, onError: () => { @@ -56,12 +66,45 @@ export default function Pantry() { }, }) - const handleAdd = () => { - if (!selectedIngredient) return + async function handleAdd() { + const name = ingredientName.trim() + if (!name) { + showToast.error('Please enter an ingredient name') + return + } + + let ingredientId = matchedIngredient?.id + + if (!ingredientId) { + try { + const res = await mealPlannerApi.recipes.createIngredient({ + name, + aisle: aisle.trim() || undefined, + unit: unit.trim() || undefined, + }) + const created = res.data as any + ingredientId = created?.id + // refresh ingredient list so next add sees it + await queryClient.invalidateQueries({ queryKey: ['ingredients'] }) + } catch (err: any) { + if (err?.response?.status === 409) { + showToast.error('Ingredient name already exists') + } else { + showToast.error('Failed to create ingredient') + } + return + } + } + + if (!ingredientId) { + showToast.error('Could not resolve ingredient') + return + } + addMutation.mutate({ - ingredient_id: selectedIngredient, + ingredient_id: ingredientId, quantity: quantity ? parseFloat(quantity) : undefined, - unit: unit || undefined, + unit: unit.trim() || undefined, }) } @@ -70,11 +113,6 @@ export default function Pantry() { (item.ingredient?.aisle || '').toLowerCase().includes(searchQuery.toLowerCase()) ) - const ingredientOptions = ingredients?.map(ing => ({ - value: ing.id, - label: ing.name + (ing.aisle ? ` (${ing.aisle})` : ''), - })) || [] - if (isLoading) { return (
@@ -132,14 +170,17 @@ export default function Pantry() {

Add Pantry Item

-
+
- setIngredientName(e.target.value)} + placeholder="e.g., Avocado" /> + {matchedIngredient && ( +

Matched existing ingredient ✓

+ )}
setUnit(e.target.value)} placeholder="cans, lbs, etc." /> + setAisle(e.target.value)} + placeholder="e.g., Produce" + />