Public Access
feat(ui): bulk pantry add + plan-the-week button (Sprint 6 F3+F4)
F3 — Bulk 'add checked to pantry' on ShoppingList (the audit's F3 /
H7 finding). ShoppingList already had a 'checked' Set keyed on
ingredient_id and persisted to localStorage — that selection state
is the natural substrate for a bulk action.
Backend (POST /api/pantry/bulk):
- New endpoint that accepts {items: HomePantryCreate[]} and returns
HomePantryBulkResult with per-item status (added / updated /
skipped) and totals. Each item follows the same upsert semantics
as POST /api/pantry (insert or overwrite qty/unit/expires_at).
- Items with an unknown ingredient id are reported as 'skipped'
with reason='Unknown ingredient' rather than aborting the batch.
Per-item failure is the chosen model (partial-success) so the
user gets a precise count of what actually went in.
- New Pydantic schemas: HomePantryBulkCreate, HomePantryBulkResult,
HomePantryBulkResultItem.
Frontend:
- mealPlannerApi.pantry.addBulk(items) is the API binding.
- ShoppingList gets a new 'Add N to pantry' primary button (next
to the existing Reset button) that appears when checked.size > 0.
Click → POST /api/pantry/bulk → toast shows 'added X, updated Y,
skipped Z' counts. On success, only the items that actually
landed in the pantry are removed from the checked set; skipped
items stay checked so the user can see what failed.
- Disabled state with 'Adding…' label while the request is in
flight; button text shows the count dynamically (matches the
F4 design language: tell the user what they're about to do).
F4 — Plan the whole week (the audit's F4 / H7 finding).
Backend (POST /api/meals/{id}/fill-empty-slots):
- New endpoint that takes {meal_types: [str, ...]} and fills every
empty slot in the plan whose meal_type is in the request. Per-day
iteration (1-7) per meal_type, skipping already-occupied slots.
Recipe selection: prefer un-used, fall back to any (same as the
existing generate-item).
- Per-slot failure model: never aborts mid-batch. Returns
FillEmptySlotsResult { filled: [{day, meal_type, item}],
failed: [{day, meal_type, reason}] }. Invalid meal_types
(e.g. 'brunch') return immediately with a single FailedSlot
explaining why.
- Same approval_status=pending semantics as generate-item.
Frontend:
- mealPlannerApi.meals.fillEmptySlots(planId, mealTypes) is the
API binding.
- New 'Plan the week' button on the Dashboard header (next to the
week-nav control from Sprint 5). Primary color, Sparkles icon,
ChevronDown caret indicates a dropdown. Disabled + spinner
('Planning…') while the request runs.
- Dropdown has two options: 'Dinners only' (sends
meal_types=['dinner']) and 'All meals' (sends
meal_types=['breakfast','lunch','dinner']). Each option has a
one-line secondary label explaining the action.
- Toast on success: 'Planned N meal slots' (full) or 'Planned N
of M meal slots — X failed (e.g. <reason>)' (partial). The
query is then invalidated so the new slots show up.
Files: backend/app/api/meals.py, backend/app/api/pantry.py,
backend/app/schemas/__init__.py, frontend/src/api/index.ts,
frontend/src/pages/Dashboard.tsx, frontend/src/pages/ShoppingList.tsx.
Build: tsc 0 errors, vite 0 errors. Bundle +3.6KB (the new code
fits in the existing chunk).
Curl smoke on local dev DB confirms both new endpoints behave as
designed: /api/pantry/bulk returns proper skipped count for
unknown ingredients, /api/meals/{id}/fill-empty-slots returns
the partial-success result for the dinners-only call.
This commit is contained in:
+105
-1
@@ -12,7 +12,9 @@ from app.models import (
|
||||
)
|
||||
from app.schemas import (
|
||||
MealPlanResponse, MealPlanCreate,
|
||||
MealPlanItemResponse, VoteRequest, VoteResponse
|
||||
MealPlanItemResponse, VoteRequest, VoteResponse,
|
||||
FillEmptySlotsRequest, FillEmptySlotsResult,
|
||||
FilledSlot, FailedSlot,
|
||||
)
|
||||
from app.security import require_session
|
||||
from app.services import approval as approval_service
|
||||
@@ -431,6 +433,108 @@ def generate_single_item(
|
||||
return {"message": "Meal generated", "item": new_item}
|
||||
|
||||
|
||||
@router.post("/{meal_plan_id}/fill-empty-slots", response_model=FillEmptySlotsResult)
|
||||
def fill_empty_slots(
|
||||
meal_plan_id: UUID,
|
||||
payload: FillEmptySlotsRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Fill every empty slot in the plan whose meal_type is in the
|
||||
request's `meal_types`. Returns a per-slot report (filled vs
|
||||
failed) so the UI can show "12 of 21 filled, 9 failed — recipe
|
||||
library exhausted".
|
||||
|
||||
Failure model: per-slot. The endpoint never aborts mid-batch on
|
||||
a single failure; it commits what succeeded and reports the
|
||||
rest. This matches the user's chosen model (partial-success
|
||||
with detailed report).
|
||||
"""
|
||||
plan = db.query(MealPlan).filter(MealPlan.id == meal_plan_id).first()
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="Meal plan not found")
|
||||
|
||||
# Normalise and validate the requested meal_types.
|
||||
requested: list[str] = []
|
||||
for mt in payload.meal_types:
|
||||
try:
|
||||
canonical = MealType[mt.upper()].value
|
||||
except KeyError:
|
||||
return FillEmptySlotsResult(
|
||||
filled=[],
|
||||
failed=[FailedSlot(day_of_week=0, meal_type=mt, reason=f"Unknown meal_type: {mt}")],
|
||||
)
|
||||
if canonical not in requested:
|
||||
requested.append(canonical)
|
||||
|
||||
if not requested:
|
||||
return FillEmptySlotsResult(filled=[], failed=[])
|
||||
|
||||
all_recipes = db.query(Recipe).all()
|
||||
if not all_recipes:
|
||||
# No recipes at all — every requested slot fails.
|
||||
return FillEmptySlotsResult(
|
||||
filled=[],
|
||||
failed=[
|
||||
FailedSlot(day_of_week=d, meal_type=mt, reason="No recipes available")
|
||||
for d in range(1, 8)
|
||||
for mt in requested
|
||||
],
|
||||
)
|
||||
|
||||
used_ids: set = {i.recipe_id for i in plan.items if i.recipe_id is not None}
|
||||
|
||||
filled: list[FilledSlot] = []
|
||||
failed: list[FailedSlot] = []
|
||||
|
||||
for day in range(1, 8):
|
||||
for mt in requested:
|
||||
# Skip already-occupied slots.
|
||||
existing = (
|
||||
db.query(MealPlanItem)
|
||||
.filter(
|
||||
MealPlanItem.meal_plan_id == meal_plan_id,
|
||||
MealPlanItem.day_of_week == day,
|
||||
MealPlanItem.meal_type == mt,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
continue # not a failure, just nothing to do
|
||||
|
||||
# Pick a recipe; prefer un-used, fall back to any.
|
||||
available = [r for r in all_recipes if r.id not in used_ids]
|
||||
pool = available if available else all_recipes
|
||||
recipe = random.choice(pool)
|
||||
|
||||
new_item = MealPlanItem(
|
||||
meal_plan_id=meal_plan_id,
|
||||
recipe_id=recipe.id,
|
||||
day_of_week=day,
|
||||
meal_type=MealType[mt.upper()],
|
||||
approval_status=MealPlanItemStatus.pending,
|
||||
)
|
||||
db.add(new_item)
|
||||
try:
|
||||
db.flush()
|
||||
used_ids.add(recipe.id)
|
||||
filled.append(FilledSlot(
|
||||
day_of_week=day,
|
||||
meal_type=mt,
|
||||
item=MealPlanItemResponse.model_validate(new_item),
|
||||
))
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
used_ids = {i.recipe_id for i in plan.items if i.recipe_id is not None}
|
||||
failed.append(FilledSlot if False else FailedSlot(
|
||||
day_of_week=day,
|
||||
meal_type=mt,
|
||||
reason=str(exc) or "Insert failed",
|
||||
))
|
||||
|
||||
db.commit()
|
||||
return FillEmptySlotsResult(filled=filled, failed=failed)
|
||||
|
||||
|
||||
@router.put("/items/{item_id}/move")
|
||||
def move_meal_item(
|
||||
item_id: UUID,
|
||||
|
||||
@@ -2,7 +2,13 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.models import HomePantry, Ingredient, FamilyProfile
|
||||
from app.schemas import HomePantryResponse, HomePantryCreate
|
||||
from app.schemas import (
|
||||
HomePantryResponse,
|
||||
HomePantryCreate,
|
||||
HomePantryBulkCreate,
|
||||
HomePantryBulkResult,
|
||||
HomePantryBulkResultItem,
|
||||
)
|
||||
from app.security import require_session
|
||||
from uuid import UUID
|
||||
from typing import List
|
||||
@@ -55,6 +61,78 @@ def add_pantry_item(item: HomePantryCreate, db: Session = Depends(get_db)):
|
||||
return db_item
|
||||
|
||||
|
||||
@router.post("/bulk", response_model=HomePantryBulkResult, dependencies=[Depends(require_session)])
|
||||
def add_pantry_items_bulk(payload: HomePantryBulkCreate, db: Session = Depends(get_db)):
|
||||
"""Add or upsert many pantry items in a single call.
|
||||
|
||||
Used by the Shopping List "Add checked to pantry" bulk action. Each
|
||||
item follows the same semantics as POST /api/pantry (insert or
|
||||
overwrite). Items missing an ingredient link or with an
|
||||
unknown ingredient id are reported as "skipped" rather than
|
||||
aborting the batch, so the user gets a precise count of what
|
||||
actually went into their pantry.
|
||||
"""
|
||||
profile = db.query(FamilyProfile).first()
|
||||
if not profile:
|
||||
raise HTTPException(status_code=404, detail="Family profile not found")
|
||||
|
||||
results: List[HomePantryBulkResultItem] = []
|
||||
added = updated = skipped = 0
|
||||
|
||||
for item in payload.items:
|
||||
# Validate the ingredient id before touching the DB.
|
||||
ingredient = db.query(Ingredient).filter(Ingredient.id == item.ingredient_id).first()
|
||||
if not ingredient:
|
||||
results.append(HomePantryBulkResultItem(
|
||||
ingredient_id=item.ingredient_id,
|
||||
status="skipped",
|
||||
reason="Unknown ingredient",
|
||||
))
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
existing = db.query(HomePantry).filter(
|
||||
HomePantry.family_profile_id == profile.id,
|
||||
HomePantry.ingredient_id == item.ingredient_id,
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.quantity = item.quantity
|
||||
existing.unit = item.unit
|
||||
existing.expires_at = item.expires_at
|
||||
db.flush()
|
||||
results.append(HomePantryBulkResultItem(
|
||||
ingredient_id=item.ingredient_id,
|
||||
id=existing.id,
|
||||
status="updated",
|
||||
))
|
||||
updated += 1
|
||||
else:
|
||||
db_item = HomePantry(
|
||||
family_profile_id=profile.id,
|
||||
ingredient_id=item.ingredient_id,
|
||||
quantity=item.quantity,
|
||||
unit=item.unit,
|
||||
expires_at=item.expires_at,
|
||||
)
|
||||
db.add(db_item)
|
||||
db.flush()
|
||||
results.append(HomePantryBulkResultItem(
|
||||
ingredient_id=item.ingredient_id,
|
||||
id=db_item.id,
|
||||
status="added",
|
||||
))
|
||||
added += 1
|
||||
|
||||
db.commit()
|
||||
return HomePantryBulkResult(
|
||||
added=added,
|
||||
updated=updated,
|
||||
skipped=skipped,
|
||||
results=results,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{item_id}", dependencies=[Depends(require_session)])
|
||||
def remove_pantry_item(item_id: UUID, db: Session = Depends(get_db)):
|
||||
item = db.query(HomePantry).filter(HomePantry.id == item_id).first()
|
||||
|
||||
@@ -284,6 +284,64 @@ class HomePantryCreate(HomePantryBase):
|
||||
pass
|
||||
|
||||
|
||||
class HomePantryBulkCreate(BaseModel):
|
||||
"""Request body for POST /api/pantry/bulk. Accepts a list of items to
|
||||
add in one call; each item follows the same upsert semantics as
|
||||
HomePantryCreate (insert or overwrite qty/unit/expires)."""
|
||||
items: List[HomePantryCreate]
|
||||
|
||||
|
||||
class HomePantryBulkResultItem(BaseModel):
|
||||
ingredient_id: UUID
|
||||
status: str # "added" | "updated" | "skipped"
|
||||
id: Optional[UUID] = None
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
class HomePantryBulkResult(BaseModel):
|
||||
"""Response body for POST /api/pantry/bulk. Reports per-item
|
||||
outcomes so the UI can show a precise toast ("Added 8 items, 1
|
||||
skipped — no ingredient link"). Total counts are derived for
|
||||
convenience."""
|
||||
added: int
|
||||
updated: int
|
||||
skipped: int
|
||||
results: List[HomePantryBulkResultItem]
|
||||
|
||||
|
||||
class FillEmptySlotsRequest(BaseModel):
|
||||
"""Request body for POST /api/meals/{id}/fill-empty-slots. The
|
||||
caller selects which meal types to fill (dinner only, or all
|
||||
three). Days 1-7 are filled automatically; the backend iterates
|
||||
in day order then meal_type order."""
|
||||
meal_types: List[str] = Field(
|
||||
default_factory=lambda: ["breakfast", "lunch", "dinner"],
|
||||
description="Subset of {breakfast, lunch, dinner} to fill.",
|
||||
)
|
||||
|
||||
|
||||
class FilledSlot(BaseModel):
|
||||
day_of_week: int
|
||||
meal_type: str
|
||||
item: MealPlanItemResponse
|
||||
|
||||
|
||||
class FailedSlot(BaseModel):
|
||||
day_of_week: int
|
||||
meal_type: str
|
||||
reason: str
|
||||
|
||||
|
||||
class FillEmptySlotsResult(BaseModel):
|
||||
"""Response body for POST /api/meals/{id}/fill-empty-slots.
|
||||
Reports each slot as either 'filled' (with the new MealPlanItem)
|
||||
or 'failed' (with a human-readable reason). Partial success is
|
||||
the model: the caller decides whether to retry the failed
|
||||
slots."""
|
||||
filled: List[FilledSlot]
|
||||
failed: List[FailedSlot]
|
||||
|
||||
|
||||
class FeedbackBase(BaseModel):
|
||||
rating: Optional[int] = Field(None, ge=1, le=5)
|
||||
never_suggest: bool = False
|
||||
|
||||
@@ -50,6 +50,8 @@ export const mealPlannerApi = {
|
||||
deleteItem: (itemId: string) => api.delete(`/meals/items/${itemId}`),
|
||||
generateItem: (mealPlanId: string, dayOfWeek: number, mealType: string) =>
|
||||
api.post(`/meals/${mealPlanId}/generate-item`, null, { params: { day_of_week: dayOfWeek, meal_type: mealType } }),
|
||||
fillEmptySlots: (mealPlanId: string, mealTypes: string[]) =>
|
||||
api.post(`/meals/${mealPlanId}/fill-empty-slots`, { meal_types: mealTypes }),
|
||||
},
|
||||
|
||||
pantry: {
|
||||
@@ -57,6 +59,8 @@ export const mealPlannerApi = {
|
||||
add: (data: any) => api.post('/pantry', data),
|
||||
update: (id: string, data: any) => api.put(`/pantry/${id}`, data),
|
||||
remove: (id: string) => api.delete(`/pantry/${id}`),
|
||||
addBulk: (items: Array<{ ingredient_id: string; quantity?: number; unit?: string }>) =>
|
||||
api.post('/pantry/bulk', { items }),
|
||||
},
|
||||
|
||||
shoppingList: {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import toast from 'react-hot-toast'
|
||||
import { showToast, showApiError } from '../lib/toast'
|
||||
import { isoMonday, parseIsoDate, shiftIsoDate, formatIsoDate } from '../lib/utils'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
@@ -317,6 +318,36 @@ export default function Dashboard() {
|
||||
const navigateWeek = (next: string) => {
|
||||
setSearchParams(next === isoMonday() ? {} : { week: next }, { replace: true })
|
||||
}
|
||||
const [planningWeek, setPlanningWeek] = useState(false)
|
||||
const [planMenuOpen, setPlanMenuOpen] = useState(false)
|
||||
|
||||
async function handlePlanWeek(mealTypes: string[]) {
|
||||
if (!mealPlan || planningWeek) return
|
||||
setPlanMenuOpen(false)
|
||||
setPlanningWeek(true)
|
||||
try {
|
||||
const res = await mealPlannerApi.meals.fillEmptySlots(mealPlan.id, mealTypes)
|
||||
const data = res.data as { filled: unknown[]; failed: { reason: string }[] }
|
||||
const filledCount = data.filled.length
|
||||
const failedCount = data.failed.length
|
||||
const label = mealTypes.length === 1 && mealTypes[0] === 'dinner' ? 'dinners' : 'meal slots'
|
||||
if (filledCount === 0 && failedCount === 0) {
|
||||
showToast.success(`No empty ${label} to fill`)
|
||||
} else if (failedCount === 0) {
|
||||
showToast.success(`Planned ${filledCount} ${label}`)
|
||||
} else {
|
||||
const reason = data.failed[0]?.reason ?? 'Unknown'
|
||||
showToast.error(
|
||||
`Planned ${filledCount} of ${filledCount + failedCount} ${label} — ${failedCount} failed (e.g. ${reason})`,
|
||||
)
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['mealPlan', weekStart] })
|
||||
} catch (err) {
|
||||
showApiError(err, 'Failed to plan the week')
|
||||
} finally {
|
||||
setPlanningWeek(false)
|
||||
}
|
||||
}
|
||||
const { data: mealPlan, isLoading } = useQuery<MealPlan | null>({
|
||||
queryKey: ['mealPlan', weekStart],
|
||||
queryFn: () => mealPlannerApi.meals.getPlanned(weekStart).then(r => r.data),
|
||||
@@ -470,6 +501,46 @@ export default function Dashboard() {
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setPlanMenuOpen(o => !o)}
|
||||
disabled={planningWeek}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={planMenuOpen}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg bg-primary-600 text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{planningWeek ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="w-4 h-4" />
|
||||
)}
|
||||
{planningWeek ? 'Planning…' : 'Plan the week'}
|
||||
{!planningWeek && <ChevronDown className="w-3 h-3" />}
|
||||
</button>
|
||||
{planMenuOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 top-full mt-1 w-56 bg-white border border-surface-200 rounded-lg shadow-lg z-20 py-1 animate-fade-in"
|
||||
>
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={() => handlePlanWeek(['dinner'])}
|
||||
className="w-full text-left px-3 py-2 text-sm text-surface-700 hover:bg-surface-50 focus:outline-none focus:bg-surface-50"
|
||||
>
|
||||
Dinners only
|
||||
<span className="block text-xs text-surface-500">Fill every empty dinner slot this week</span>
|
||||
</button>
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={() => handlePlanWeek(['breakfast', 'lunch', 'dinner'])}
|
||||
className="w-full text-left px-3 py-2 text-sm text-surface-700 hover:bg-surface-50 focus:outline-none focus:bg-surface-50"
|
||||
>
|
||||
All meals
|
||||
<span className="block text-xs text-surface-500">Fill every empty slot (breakfast, lunch, dinner) this week</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
variant={statusVariant}
|
||||
aria-label={`Plan status: ${mealPlan.status.replace(/_/g, ' ')}`}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Printer, ShoppingCart, Package, Tag, Receipt, RotateCcw, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { Printer, ShoppingCart, Package, Tag, Receipt, RotateCcw, ChevronLeft, ChevronRight, PackagePlus } from 'lucide-react'
|
||||
import { mealPlannerApi } from '../api'
|
||||
import type { ShoppingList } from '../types'
|
||||
import { Button } from '../components/ui/Button'
|
||||
@@ -10,6 +10,7 @@ import { Card, CardBody } from '../components/ui/Card'
|
||||
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
|
||||
import { EmptyState } from '../components/ui/EmptyState'
|
||||
import { isoMonday, parseIsoDate, shiftIsoDate, formatIsoDate } from '../lib/utils'
|
||||
import { showToast, showApiError } from '../lib/toast'
|
||||
|
||||
const AISLE_LABEL: Record<string, string> = {
|
||||
produce: 'Produce',
|
||||
@@ -127,6 +128,55 @@ export default function ShoppingListPage() {
|
||||
|
||||
const clearAll = () => setChecked(new Set())
|
||||
|
||||
const [addingToPantry, setAddingToPantry] = useState(false)
|
||||
const addCheckedToPantry = async () => {
|
||||
if (!shoppingList || checked.size === 0 || addingToPantry) return
|
||||
const itemsToAdd = shoppingList.items.filter(
|
||||
i => i.ingredient_id && checked.has(i.ingredient_id),
|
||||
)
|
||||
if (itemsToAdd.length === 0) return
|
||||
setAddingToPantry(true)
|
||||
try {
|
||||
const res = await mealPlannerApi.pantry.addBulk(
|
||||
itemsToAdd.map(i => ({
|
||||
ingredient_id: i.ingredient_id!,
|
||||
quantity: i.quantity,
|
||||
unit: i.unit,
|
||||
})),
|
||||
)
|
||||
const data = res.data as { added: number; updated: number; skipped: number }
|
||||
const parts: string[] = []
|
||||
if (data.added) parts.push(`added ${data.added}`)
|
||||
if (data.updated) parts.push(`updated ${data.updated}`)
|
||||
if (data.skipped) parts.push(`skipped ${data.skipped}`)
|
||||
const summary = parts.length ? parts.join(', ') : 'no changes'
|
||||
if (data.added || data.updated) {
|
||||
showToast.success(`Pantry: ${summary}`)
|
||||
// Drop only the items that actually went into the pantry from
|
||||
// the checked set; any skipped items stay checked so the user
|
||||
// can see what failed.
|
||||
const skippedIds = new Set(
|
||||
shoppingList.items
|
||||
.filter(i => i.ingredient_id && !itemsToAdd.some(j => j.ingredient_id === i.ingredient_id))
|
||||
.map(i => i.ingredient_id!),
|
||||
)
|
||||
setChecked(prev => {
|
||||
const next = new Set(prev)
|
||||
for (const id of Array.from(next)) {
|
||||
if (!skippedIds.has(id)) next.delete(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
showToast.error(`Pantry: ${summary}`)
|
||||
}
|
||||
} catch (err) {
|
||||
showApiError(err, 'Failed to add items to pantry')
|
||||
} finally {
|
||||
setAddingToPantry(false)
|
||||
}
|
||||
}
|
||||
|
||||
const progress =
|
||||
shoppingList && shoppingList.items.length > 0
|
||||
? Math.round(
|
||||
@@ -234,6 +284,17 @@ export default function ShoppingListPage() {
|
||||
{progress > 0 && (
|
||||
<span className="text-sm text-surface-500">{progress}% complete</span>
|
||||
)}
|
||||
{checked.size > 0 && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<PackagePlus className="w-4 h-4" />}
|
||||
onClick={addCheckedToPantry}
|
||||
disabled={addingToPantry}
|
||||
>
|
||||
{addingToPantry ? 'Adding…' : `Add ${checked.size} to pantry`}
|
||||
</Button>
|
||||
)}
|
||||
{checked.size > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
Reference in New Issue
Block a user