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 || '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
)
}