import { useQuery, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { Link } from 'react-router-dom' import { CookingPot, CalendarDays, ShoppingCart, ChevronRight, Sparkles, Loader2, GripVertical, X } from 'lucide-react' import toast from 'react-hot-toast' import { showToast } from '../lib/toast' import { DragDropContext, Droppable, Draggable, type DropResult, type DroppableProvided, type DraggableProvided, type DraggableProvidedDragHandleProps, type DraggableStateSnapshot, type DroppableStateSnapshot, } from '@hello-pangea/dnd' import { mealPlannerApi } from '../api' import type { MealPlan, MealPlanItem } from '../types' import { Badge } from '../components/ui/Badge' import { Card, CardBody, CardHeader } from '../components/ui/Card' import { SkeletonCard, Skeleton } from '../components/ui/Skeleton' import { EmptyState } from '../components/ui/EmptyState' const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] const FULL_DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] const MEAL_TYPES = ['breakfast', 'lunch', 'dinner'] as const /* ------------------------------------------------------------------ */ /* MealCard (draggable) */ /* ------------------------------------------------------------------ */ function MealCard({ item, dragHandleProps, isDragging, onApprove: _onApprove, onDeny: _onDeny, onDelete }: { item: MealPlanItem dragHandleProps?: DraggableProvidedDragHandleProps | null isDragging?: boolean onApprove?: (itemId: string) => void onDeny?: (itemId: string) => void onDelete?: (itemId: string) => void }) { const totalTime = item.recipe?.total_time_minutes ?? (item.recipe?.prep_time_minutes || 0) + (item.recipe?.cook_time_minutes || 0) const statusVariant = item.approval_status === 'approved' ? 'success' : item.approval_status === 'denied' ? 'danger' : item.approval_status === 'swapped' ? 'warning' : 'neutral' return (
{/* Keep delete visible on touch devices where hover is unavailable. */} {onDelete && ( )}
{/* drag handle */}
{item.recipe?.image_url ? ( {item.recipe.name} ) : (
)}

{item.recipe?.name || 'Unknown Recipe'}

{totalTime > 0 && `${totalTime} min · `} {item.recipe?.servings} servings

{item.approval_status} {item.estimated_cost && ( ${item.estimated_cost.toFixed(2)} )}
) } /* ------------------------------------------------------------------ */ /* MealSlot — droppable area for one day + meal_type */ /* ------------------------------------------------------------------ */ function MealSlot({ dayIndex, mealType, item, onApprove, onDeny, onDelete, onGenerate, }: { dayIndex: number mealType: string item?: MealPlanItem onApprove?: (itemId: string) => void onDeny?: (itemId: string) => void onDelete?: (itemId: string) => void onGenerate?: (dayIndex: number, mealType: string) => void }) { const droppableId = `slot-${dayIndex}-${mealType}` return ( {(provided: DroppableProvided, snapshot: DroppableStateSnapshot) => (
{item ? ( {(dragProvided: DraggableProvided, dragSnapshot: DraggableStateSnapshot) => (
)}
) : (
Empty {onGenerate && ( )}
)} {provided.placeholder}
)}
) } /* ------------------------------------------------------------------ */ /* DayColumn */ /* ------------------------------------------------------------------ */ function DayColumn({ dayIndex, items, onApprove, onDeny, onDelete, onGenerate, }: { dayIndex: number items: MealPlanItem[] onApprove?: (itemId: string) => void onDeny?: (itemId: string) => void onDelete?: (itemId: string) => void onGenerate?: (dayIndex: number, mealType: string) => void }) { const today = new Date().getDay() const isToday = today === (dayIndex + 1) % 7 return (
{FULL_DAY_NAMES[dayIndex]} {isToday && Today}
{DAY_NAMES[dayIndex]}
{MEAL_TYPES.map(mealType => { const item = items.find(i => i.meal_type === mealType) return (
{mealType}
) })}
) } function DashboardSkeleton() { return (
{Array.from({ length: 7 }).map((_, i) => (
))}
) } /* ------------------------------------------------------------------ */ /* VoteEmailButton */ /* ------------------------------------------------------------------ */ function VoteEmailButton() { const [sendingVoteEmail, setSendingVoteEmail] = useState(false) async function handleSend() { setSendingVoteEmail(true) try { const res = await mealPlannerApi.admin.triggerOrchestrate('email') const status = res.data?.status || res.data?.message || 'sent' toast.success(status) } catch (err: any) { toast.error(err?.response?.data?.detail || 'Failed to send vote emails') } finally { setSendingVoteEmail(false) } } return ( ) } /* ------------------------------------------------------------------ */ /* Dashboard */ /* ------------------------------------------------------------------ */ export default function Dashboard() { const queryClient = useQueryClient() const { data: mealPlan, isLoading } = useQuery({ queryKey: ['mealPlan'], queryFn: () => mealPlannerApi.meals.getPlanned().then(r => r.data), }) async function handleDrop(itemId: string, newDay: number, newType: string) { try { await mealPlannerApi.meals.moveItem(itemId, newDay, newType) toast.success('Meal moved') queryClient.invalidateQueries({ queryKey: ['mealPlan'] }) } catch (err: any) { toast.error(err?.response?.data?.detail || 'Failed to move meal') } } function onDragEnd(result: DropResult) { if (!result.destination) return const { draggableId, destination } = result const itemId = draggableId.replace('item-', '') const [, dayStr, typeStr] = destination.droppableId.split('-') handleDrop(itemId, parseInt(dayStr, 10) + 1, typeStr) } async function handleApprove(itemId: string) { try { await mealPlannerApi.meals.approveItem(itemId) toast.success('Meal approved') queryClient.invalidateQueries({ queryKey: ['mealPlan'] }) } catch (err: any) { toast.error(err?.response?.data?.detail || 'Failed to approve meal') } } async function handleDeny(itemId: string) { try { await mealPlannerApi.meals.denyItem(itemId) toast.success('Meal denied') queryClient.invalidateQueries({ queryKey: ['mealPlan'] }) } catch (err: any) { toast.error(err?.response?.data?.detail || 'Failed to deny meal') } } async function handleDelete(itemId: string) { if (!mealPlan) return const item = mealPlan.items.find(i => i.id === itemId) if (!item) return try { await mealPlannerApi.meals.deleteItem(itemId) queryClient.invalidateQueries({ queryKey: ['mealPlan'] }) showToast.undo( 'Meal deleted', async () => { try { await mealPlannerApi.meals.generateItem( mealPlan.id, item.day_of_week, item.meal_type ) queryClient.invalidateQueries({ queryKey: ['mealPlan'] }) toast.success('Slot filled with a new meal') } catch (err: any) { toast.error(err?.response?.data?.detail || 'Failed to refill slot') } } ) } catch (err: any) { toast.error(err?.response?.data?.detail || 'Failed to delete meal') } } async function handleGenerate(dayIndex: number, mealType: string) { if (!mealPlan) return try { await mealPlannerApi.meals.generateItem(mealPlan.id, dayIndex + 1, mealType) toast.success('Meal generated') queryClient.invalidateQueries({ queryKey: ['mealPlan'] }) } catch (err: any) { toast.error(err?.response?.data?.detail || 'Failed to generate meal') } } if (isLoading) return if (!mealPlan) { return (

This Week's Meal Plan

{} }} />
) } const itemsByDay = Array.from({ length: 7 }, (_, i) => mealPlan.items.filter(item => item.day_of_week === i + 1) ) const statusVariant = mealPlan.status === 'approved' ? 'success' : mealPlan.status === 'locked' ? 'primary' : mealPlan.status === 'pending_approval' ? 'warning' : 'neutral' return (
{/* Header */}

Week of {new Date(mealPlan.week_start_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}

{mealPlan.items.length} meals planned · {mealPlan.items.filter(i => i.approval_status === 'approved').length} approved

{mealPlan.status.replace(/_/g, ' ')} {mealPlan.total_estimated_cost !== undefined && (
${mealPlan.total_estimated_cost.toFixed(2)} total
)}
{/* Weekly Grid — all 7 days visible */}

Weekly Overview

{itemsByDay.map((items, index) => ( ))}
{/* Quick Actions */}

Shopping List

View your aisle-organized shopping list with sale tracking.

View shopping list

Quick Actions

Manage Pantry
) }