Public Access
Hand-rolled 4-step tour (no react-joyride) anchors to existing [data-tour="<id>"] attributes. localStorage key mealplanner:onboarding-complete is the source of truth; ?reset-tour=1 clears the key and re-shows. Steps: Dashboard / Pantry / Recipes / Shopping List. Keyboard: 1-4 jump, ←/→ step, Esc dismiss. Off-route fallback renders a centered card with an 'Open <page>' CTA. A11y: role=dialog, aria-modal=true, focus captured on open and restored on close. 5 lines of code across 4 pages; 1 new component (~420 lines). No new dependencies. No backend changes. No migration. Frontend-only deploy. Tracking: Review/sprint9-verification.md (8-step browser smoke + a11y check + reset-link test).
674 lines
27 KiB
TypeScript
674 lines
27 KiB
TypeScript
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { useState } from 'react'
|
|
import { Link, useSearchParams } from 'react-router-dom'
|
|
import {
|
|
CookingPot, CalendarDays, ShoppingCart, ChevronRight, Sparkles, Loader2,
|
|
GripVertical, X
|
|
} from 'lucide-react'
|
|
import toast from 'react-hot-toast'
|
|
import { showToast, showApiError } from '../lib/toast'
|
|
import { upcomingMonday, parseIsoDate, shiftIsoDate, formatIsoDate } from '../lib/utils'
|
|
import { ChevronDown } from 'lucide-react'
|
|
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'
|
|
import { WeekRangeNav } from '../components/WeekRangeNav'
|
|
|
|
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, onDelete }: {
|
|
item: MealPlanItem
|
|
dragHandleProps?: DraggableProvidedDragHandleProps | null
|
|
isDragging?: boolean
|
|
onApprove?: (itemId: string) => void
|
|
// Sprint 8: optional scope. When provided, the second arg is the
|
|
// deny-scope ('this_week' | 'never_again'); when omitted, defaults
|
|
// to 'this_week' at the handler level.
|
|
onDeny?: (itemId: string, scope?: 'this_week' | 'never_again') => 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 (
|
|
<div className={`relative group block bg-surface-0 rounded-xl border overflow-hidden hover:shadow-md hover:border-primary-200 transition-all duration-200 ${isDragging ? 'shadow-lg border-primary-400 ring-2 ring-primary-200' : 'border-surface-200'}`}>
|
|
{/* Keep delete visible on touch devices where hover is unavailable. */}
|
|
{onDelete && (
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onDelete(item.id) }}
|
|
className="absolute top-1 right-1 z-10 rounded-full bg-danger-100 p-1 text-danger-600 shadow-sm transition-colors hover:bg-danger-200 hover:text-danger-800 focus:outline-none focus:ring-2 focus:ring-danger-400 focus:ring-offset-1"
|
|
aria-label="Delete meal"
|
|
title="Delete meal"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
<div className="flex gap-2 p-2">
|
|
{/* drag handle */}
|
|
<div
|
|
{...dragHandleProps}
|
|
className="flex-shrink-0 self-center cursor-grab active:cursor-grabbing text-surface-400 hover:text-surface-600"
|
|
aria-label="Drag to move meal"
|
|
>
|
|
<GripVertical className="w-4 h-4" />
|
|
</div>
|
|
|
|
{item.recipe?.image_url ? (
|
|
<img
|
|
src={item.recipe.image_url}
|
|
alt={item.recipe.name}
|
|
className="w-10 h-10 md:w-14 md:h-14 object-cover rounded-lg flex-shrink-0"
|
|
/>
|
|
) : (
|
|
<div className="w-10 h-10 md:w-14 md:h-14 rounded-lg bg-surface-100 flex items-center justify-center flex-shrink-0">
|
|
<CookingPot className="w-5 h-5 md:w-6 md:h-6 text-surface-400" />
|
|
</div>
|
|
)}
|
|
<div className="flex-1 min-w-0 pr-7">
|
|
<Link to={`/meals/${item.id}`} className="block">
|
|
<h4 className="font-semibold text-sm text-surface-900 leading-tight line-clamp-2 group-hover:text-primary-700 transition-colors">
|
|
{item.recipe?.name || 'Unknown Recipe'}
|
|
</h4>
|
|
</Link>
|
|
<p className="text-[11px] text-surface-500 mt-0.5">
|
|
{totalTime > 0 && `${totalTime} min · `}
|
|
{item.recipe?.servings} servings
|
|
</p>
|
|
<div className="flex items-center gap-1.5 mt-1">
|
|
<Badge
|
|
variant={statusVariant}
|
|
className="text-[10px] px-1.5 py-0.5"
|
|
aria-label={`Approval status: ${item.approval_status}`}
|
|
>
|
|
{item.approval_status}
|
|
</Badge>
|
|
{item.estimated_cost && (
|
|
<span className="text-[11px] font-medium text-surface-600">
|
|
${item.estimated_cost.toFixed(2)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{/* Sprint 8: 3-button voting row. Only shown for pending items
|
|
(approved/denied items are terminal). Compact on mobile. */}
|
|
{onDeny && item.approval_status === 'pending' && (
|
|
<div className="flex items-center gap-1 mt-1.5">
|
|
{_onApprove && (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => { e.stopPropagation(); _onApprove(item.id) }}
|
|
aria-label="Approve this meal"
|
|
className="flex-1 text-[10px] font-medium px-1.5 py-1 rounded bg-success-50 text-success-700 hover:bg-success-100 focus:outline-none focus:ring-2 focus:ring-success-400 min-h-11"
|
|
>
|
|
Approve
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={(e) => { e.stopPropagation(); onDeny(item.id, 'this_week') }}
|
|
aria-label="Deny this meal for this week (will not reappear for 90 days)"
|
|
className="flex-1 text-[10px] font-medium px-1.5 py-1 rounded bg-danger-50 text-danger-700 hover:bg-danger-100 focus:outline-none focus:ring-2 focus:ring-danger-400 min-h-11"
|
|
>
|
|
Deny this week
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
if (window.confirm(`Never suggest "${item.recipe?.name || 'this recipe'}" again? This permanently blocks the recipe for your family.`)) {
|
|
onDeny(item.id, 'never_again')
|
|
}
|
|
}}
|
|
aria-label="Never suggest this recipe again"
|
|
title="Never suggest this recipe again"
|
|
className="text-[10px] font-medium px-1.5 py-1 rounded border border-dashed border-danger-300 text-danger-700 hover:bg-danger-100 focus:outline-none focus:ring-2 focus:ring-danger-400 min-h-11"
|
|
>
|
|
Never again
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 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 (
|
|
<Droppable droppableId={droppableId}>
|
|
{(provided: DroppableProvided, snapshot: DroppableStateSnapshot) => (
|
|
<div
|
|
ref={provided.innerRef}
|
|
{...provided.droppableProps}
|
|
className={`
|
|
rounded-lg p-1 min-h-[20px] md:min-h-[80px] transition-colors
|
|
${snapshot.isDraggingOver ? 'bg-primary-50 ring-2 ring-primary-300' : 'bg-transparent'}
|
|
`}
|
|
>
|
|
{item ? (
|
|
<Draggable draggableId={`item-${item.id}`} index={0}>
|
|
{(dragProvided: DraggableProvided, dragSnapshot: DraggableStateSnapshot) => (
|
|
<div
|
|
ref={dragProvided.innerRef}
|
|
{...dragProvided.draggableProps}
|
|
style={dragProvided.draggableProps.style}
|
|
>
|
|
<MealCard
|
|
item={item}
|
|
dragHandleProps={dragProvided.dragHandleProps}
|
|
isDragging={dragSnapshot.isDragging}
|
|
onApprove={onApprove}
|
|
onDeny={onDeny}
|
|
onDelete={onDelete}
|
|
/>
|
|
</div>
|
|
)}
|
|
</Draggable>
|
|
) : (
|
|
<div className="flex h-16 rounded-xl border-2 border-dashed border-surface-200 flex-col items-center justify-center gap-1">
|
|
<span className="text-xs text-surface-300">Empty</span>
|
|
{onGenerate && (
|
|
<button
|
|
onClick={() => onGenerate(dayIndex, mealType)}
|
|
className="text-[10px] px-2 py-0.5 rounded bg-primary-50 text-primary-700 hover:bg-primary-100 font-medium transition-colors min-h-11"
|
|
>
|
|
Generate
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{provided.placeholder}
|
|
</div>
|
|
)}
|
|
</Droppable>
|
|
)
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 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 (
|
|
<div className="flex flex-col gap-1">
|
|
<div className={`px-2 py-1.5 rounded-lg ${isToday ? 'bg-primary-50 border border-primary-200' : 'bg-surface-100'}`}>
|
|
<div className="flex items-center justify-between">
|
|
<span className={`text-xs font-semibold ${isToday ? 'text-primary-700' : 'text-surface-700'}`}>
|
|
{FULL_DAY_NAMES[dayIndex]}
|
|
</span>
|
|
{isToday && <Badge variant="primary" className="text-[10px]">Today</Badge>}
|
|
</div>
|
|
<span className="text-[10px] text-surface-500">{DAY_NAMES[dayIndex]}</span>
|
|
</div>
|
|
<div className="space-y-1">
|
|
{MEAL_TYPES.map(mealType => {
|
|
const item = items.find(i => i.meal_type === mealType)
|
|
return (
|
|
<div key={mealType}>
|
|
<span className="text-[9px] font-medium text-surface-400 uppercase tracking-wider px-1 block">
|
|
{mealType}
|
|
</span>
|
|
<MealSlot
|
|
dayIndex={dayIndex}
|
|
mealType={mealType}
|
|
item={item}
|
|
onApprove={onApprove}
|
|
onDeny={onDeny}
|
|
onDelete={onDelete}
|
|
onGenerate={onGenerate}
|
|
/>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function DashboardSkeleton() {
|
|
return (
|
|
<div className="space-y-6 animate-fade-in">
|
|
<div className="flex justify-between items-center">
|
|
<Skeleton className="h-8 w-48" />
|
|
<div className="flex gap-2">
|
|
<Skeleton className="h-7 w-24" />
|
|
<Skeleton className="h-7 w-20" />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-2">
|
|
{Array.from({ length: 7 }).map((_, i) => (
|
|
<div key={i} className="space-y-2">
|
|
<Skeleton className="h-8 w-full" />
|
|
<div className="hidden sm:block"><SkeletonCard /></div>
|
|
<div className="hidden sm:block"><SkeletonCard /></div>
|
|
<div className="hidden sm:block"><SkeletonCard /></div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<SkeletonCard />
|
|
<SkeletonCard />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 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) {
|
|
showApiError(err, 'Failed to send vote emails')
|
|
} finally {
|
|
setSendingVoteEmail(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<button
|
|
onClick={handleSend}
|
|
disabled={sendingVoteEmail}
|
|
className="inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-700 transition-colors text-left disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{sendingVoteEmail ? <Loader2 className="w-4 h-4 animate-spin" /> : <ChevronRight className="w-4 h-4" />}
|
|
{sendingVoteEmail ? 'Sending...' : 'Send Vote Email'}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Dashboard */
|
|
/* ------------------------------------------------------------------ */
|
|
export default function Dashboard() {
|
|
const queryClient = useQueryClient()
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
const weekParam = searchParams.get('week')
|
|
const parsedWeek = weekParam ? parseIsoDate(weekParam) : null
|
|
const weekStart = weekParam && parsedWeek
|
|
? weekParam
|
|
: upcomingMonday()
|
|
const isCurrentWeek = weekStart === upcomingMonday()
|
|
const navigateWeek = (next: string) => {
|
|
setSearchParams(next === upcomingMonday() ? {} : { 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),
|
|
})
|
|
|
|
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', weekStart] })
|
|
} catch {
|
|
// Error toast fires from the global MutationCache handler.
|
|
}
|
|
}
|
|
|
|
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', weekStart] })
|
|
} catch {
|
|
// Error toast fires from the global MutationCache handler.
|
|
}
|
|
}
|
|
|
|
// Sprint 8: scope-aware deny. 'this_week' (default) is a soft denial
|
|
// that decays in 90 days. 'never_again' writes a permanent
|
|
// NeverSuggest block. The server auto-promotes 'this_week' to
|
|
// permanent on the 2nd denial in the window; the response's
|
|
// `promoted_to_permanent` flag drives the toast text.
|
|
async function handleDeny(
|
|
itemId: string,
|
|
scope: 'this_week' | 'never_again' = 'this_week',
|
|
) {
|
|
try {
|
|
const res = await mealPlannerApi.meals.denyItem(itemId, { scope })
|
|
const promoted = res.data?.promoted_to_permanent === true
|
|
if (scope === 'never_again') {
|
|
toast.success('Denied — will never be suggested again')
|
|
} else if (promoted) {
|
|
toast.success("Denied — won't suggest again (denied twice recently)")
|
|
} else {
|
|
toast.success('Denied this week — will not reappear for 90 days')
|
|
}
|
|
queryClient.invalidateQueries({ queryKey: ['mealPlan', weekStart] })
|
|
} catch {
|
|
// Error toast fires from the global MutationCache handler.
|
|
}
|
|
}
|
|
|
|
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', weekStart] })
|
|
showToast.undo(
|
|
'Meal deleted',
|
|
async () => {
|
|
try {
|
|
await mealPlannerApi.meals.generateItem(
|
|
mealPlan.id,
|
|
item.day_of_week,
|
|
item.meal_type
|
|
)
|
|
queryClient.invalidateQueries({ queryKey: ['mealPlan', weekStart] })
|
|
toast.success('Slot filled with a new meal')
|
|
} catch (err) {
|
|
showApiError(err, 'Failed to refill slot')
|
|
}
|
|
}
|
|
)
|
|
} catch {
|
|
// Error toast fires from the global MutationCache handler.
|
|
}
|
|
}
|
|
|
|
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', weekStart] })
|
|
} catch {
|
|
// Error toast fires from the global MutationCache handler.
|
|
}
|
|
}
|
|
|
|
if (isLoading) return <DashboardSkeleton />
|
|
|
|
if (!mealPlan) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center gap-3">
|
|
<CalendarDays className="w-6 h-6 text-primary-600" />
|
|
<h1 className="text-2xl font-bold text-surface-900">This Week's Meal Plan</h1>
|
|
</div>
|
|
<EmptyState
|
|
icon={Sparkles}
|
|
title="No meal plan yet"
|
|
description="Generate your first weekly meal plan to get started with smart shopping lists and vote emails."
|
|
action={{ label: 'Generate Meal Plan', onClick: () => {} }}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center">
|
|
<CalendarDays className="w-5 h-5 text-primary-600" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-surface-900">
|
|
Week of {formatIsoDate(mealPlan.week_start_date)}
|
|
</h1>
|
|
<p className="text-sm text-surface-500">
|
|
{mealPlan.items.length} meals planned · {mealPlan.items.filter(i => i.approval_status === 'approved').length} approved
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<WeekRangeNav
|
|
weekStart={weekStart}
|
|
isCurrentWeek={isCurrentWeek}
|
|
onPrev={() => navigateWeek(shiftIsoDate(weekStart, -7))}
|
|
onNext={() => navigateWeek(shiftIsoDate(weekStart, 7))}
|
|
onJumpHome={() => navigateWeek(upcomingMonday())}
|
|
/>
|
|
<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, ' ')}`}
|
|
>
|
|
{mealPlan.status.replace(/_/g, ' ')}
|
|
</Badge>
|
|
{mealPlan.total_estimated_cost !== undefined && (
|
|
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-surface-100 rounded-lg">
|
|
<span className="text-sm font-semibold text-surface-900">
|
|
${mealPlan.total_estimated_cost.toFixed(2)}
|
|
</span>
|
|
<span className="text-xs text-surface-500">total</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Weekly Grid — all 7 days visible */}
|
|
<Card data-tour="dashboard">
|
|
<CardHeader>
|
|
<h2 className="text-lg font-semibold text-surface-900">Weekly Overview</h2>
|
|
</CardHeader>
|
|
<CardBody>
|
|
<DragDropContext onDragEnd={onDragEnd}>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-2">
|
|
{itemsByDay.map((items, index) => (
|
|
<DayColumn
|
|
key={index}
|
|
dayIndex={index}
|
|
items={items}
|
|
onApprove={handleApprove}
|
|
onDeny={handleDeny}
|
|
onDelete={handleDelete}
|
|
onGenerate={handleGenerate}
|
|
/>
|
|
))}
|
|
</div>
|
|
</DragDropContext>
|
|
</CardBody>
|
|
</Card>
|
|
|
|
{/* Quick Actions */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<Card className="hover:shadow-md transition-shadow">
|
|
<CardBody>
|
|
<div className="flex items-start gap-4">
|
|
<div className="w-10 h-10 rounded-xl bg-success-50 flex items-center justify-center flex-shrink-0">
|
|
<ShoppingCart className="w-5 h-5 text-success-600" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<h3 className="text-lg font-semibold text-surface-900">Shopping List</h3>
|
|
<p className="text-sm text-surface-500 mt-1">
|
|
View your aisle-organized shopping list with sale tracking.
|
|
</p>
|
|
<Link
|
|
to="/shopping-list"
|
|
className="inline-flex items-center gap-1 mt-3 text-sm font-medium text-primary-600 hover:text-primary-700 transition-colors"
|
|
>
|
|
View shopping list <ChevronRight className="w-4 h-4" />
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</CardBody>
|
|
</Card>
|
|
|
|
<Card className="hover:shadow-md transition-shadow">
|
|
<CardBody>
|
|
<div className="flex items-start gap-4">
|
|
<div className="w-10 h-10 rounded-xl bg-warning-50 flex items-center justify-center flex-shrink-0">
|
|
<Sparkles className="w-5 h-5 text-warning-600" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<h3 className="text-lg font-semibold text-surface-900">Quick Actions</h3>
|
|
<div className="flex flex-col gap-2 mt-2">
|
|
<Link
|
|
to="/pantry"
|
|
className="inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-700 transition-colors"
|
|
>
|
|
Manage Pantry <ChevronRight className="w-4 h-4" />
|
|
</Link>
|
|
<VoteEmailButton />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardBody>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|