Public Access
feat: drag-and-drop meal scheduling + 7-day grid
This commit is contained in:
@@ -319,4 +319,45 @@ def swap_meal_item(item_id: UUID, new_recipe_id: UUID, db: Session = Depends(get
|
||||
item.denial_details = None
|
||||
|
||||
db.commit()
|
||||
return {"message": "Meal swapped", "item": item}
|
||||
return {"message": "Meal swapped", "item": item}
|
||||
|
||||
|
||||
@router.put("/items/{item_id}/move")
|
||||
def move_meal_item(
|
||||
item_id: UUID,
|
||||
new_day_of_week: int,
|
||||
new_meal_type: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Move a meal to a new day/type slot.
|
||||
|
||||
If the target slot is occupied by another item in the same meal plan,
|
||||
swap the two items.
|
||||
"""
|
||||
item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Meal plan item not found")
|
||||
|
||||
plan_id = item.meal_plan_id
|
||||
conflict = (
|
||||
db.query(MealPlanItem)
|
||||
.filter(
|
||||
MealPlanItem.meal_plan_id == plan_id,
|
||||
MealPlanItem.day_of_week == new_day_of_week,
|
||||
MealPlanItem.meal_type == new_meal_type,
|
||||
MealPlanItem.id != item_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if conflict:
|
||||
# swap day/type
|
||||
conflict.day_of_week = item.day_of_week
|
||||
conflict.meal_type = item.meal_type
|
||||
|
||||
item.day_of_week = new_day_of_week
|
||||
item.meal_type = new_meal_type
|
||||
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return {"message": "Meal moved", "item": item}
|
||||
Generated
+94
-2
@@ -8,6 +8,7 @@
|
||||
"name": "mealplanner-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"axios": "^1.6.5",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -278,6 +279,15 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
@@ -780,6 +790,23 @@
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hello-pangea/dnd": {
|
||||
"version": "18.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@hello-pangea/dnd/-/dnd-18.0.1.tgz",
|
||||
"integrity": "sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.26.7",
|
||||
"css-box-model": "^1.2.1",
|
||||
"raf-schd": "^4.0.3",
|
||||
"react-redux": "^9.2.0",
|
||||
"redux": "^5.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||
@@ -1354,14 +1381,14 @@
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.28",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
@@ -1378,6 +1405,12 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ungap/structured-clone": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
|
||||
@@ -1841,6 +1874,15 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-box-model": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
|
||||
"integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tiny-invariant": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -3445,6 +3487,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/raf-schd": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
|
||||
"integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
@@ -3487,6 +3535,29 @@
|
||||
"react-dom": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -3552,6 +3623,12 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -3886,6 +3963,12 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
@@ -4041,6 +4124,15 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"axios": "^1.6.5",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -42,6 +42,7 @@ export const mealPlannerApi = {
|
||||
getVotePage: (itemId: string, token: string) => api.get(`/meals/items/${itemId}/vote/${token}`),
|
||||
submitVote: (itemId: string, token: string, data: any) => api.post(`/meals/items/${itemId}/vote/${token}`, data),
|
||||
swapItem: (itemId: string, newRecipeId: string) => api.post(`/meals/items/${itemId}/swap?new_recipe_id=${newRecipeId}`),
|
||||
moveItem: (itemId: string, newDayOfWeek: number, newMealType: string) => api.put(`/meals/items/${itemId}/move`, null, { params: { new_day_of_week: newDayOfWeek, new_meal_type: newMealType } }),
|
||||
},
|
||||
|
||||
pantry: {
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { CookingPot, CalendarDays, ShoppingCart, ChevronRight, Sparkles, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
CookingPot, CalendarDays, ShoppingCart, ChevronRight, Sparkles, Loader2,
|
||||
GripVertical
|
||||
} from 'lucide-react'
|
||||
import toast from 'react-hot-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'
|
||||
@@ -14,7 +28,14 @@ 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
|
||||
|
||||
function MealCard({ item }: { item: MealPlanItem }) {
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* MealCard (draggable) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function MealCard({ item, dragHandleProps, isDragging }: {
|
||||
item: MealPlanItem
|
||||
dragHandleProps?: DraggableProvidedDragHandleProps | null
|
||||
isDragging?: boolean
|
||||
}) {
|
||||
const totalTime = item.recipe?.total_time_minutes ??
|
||||
(item.recipe?.prep_time_minutes || 0) + (item.recipe?.cook_time_minutes || 0)
|
||||
|
||||
@@ -25,76 +46,151 @@ function MealCard({ item }: { item: MealPlanItem }) {
|
||||
'neutral'
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/meals/${item.id}`}
|
||||
className="group block bg-surface-0 rounded-xl border border-surface-200 overflow-hidden hover:shadow-md hover:border-primary-200 transition-all duration-200"
|
||||
<div
|
||||
className={`
|
||||
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'}
|
||||
`}
|
||||
>
|
||||
<div className="flex gap-3 p-3">
|
||||
<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-20 h-20 object-cover rounded-lg flex-shrink-0"
|
||||
className="w-14 h-14 object-cover rounded-lg flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 rounded-lg bg-surface-100 flex items-center justify-center flex-shrink-0">
|
||||
<CookingPot className="w-8 h-8 text-surface-400" />
|
||||
<div className="w-14 h-14 rounded-lg bg-surface-100 flex items-center justify-center flex-shrink-0">
|
||||
<CookingPot className="w-6 h-6 text-surface-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-semibold text-sm text-surface-900 truncate group-hover:text-primary-700 transition-colors">
|
||||
{item.recipe?.name || 'Unknown Recipe'}
|
||||
</h4>
|
||||
<p className="text-xs text-surface-500 mt-0.5">
|
||||
<Link to={`/meals/${item.id}`} className="block">
|
||||
<h4 className="font-semibold text-sm text-surface-900 truncate 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-2 mt-2">
|
||||
<Badge variant={statusVariant}>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<Badge variant={statusVariant} className="text-[10px] px-1.5 py-0.5">
|
||||
{item.approval_status}
|
||||
</Badge>
|
||||
{item.estimated_cost && (
|
||||
<span className="text-xs font-medium text-surface-600">
|
||||
<span className="text-[11px] font-medium text-surface-600">
|
||||
${item.estimated_cost.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DayColumn({ dayIndex, items }: { dayIndex: number; items: MealPlanItem[] }) {
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* MealSlot — droppable area for one day + meal_type */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function MealSlot({
|
||||
dayIndex,
|
||||
mealType,
|
||||
item,
|
||||
}: {
|
||||
dayIndex: number
|
||||
mealType: string
|
||||
item?: MealPlanItem
|
||||
}) {
|
||||
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-[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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
) : (
|
||||
/* empty slot placeholder — invisible but droppable */
|
||||
<div className="h-16 rounded-xl border-2 border-dashed border-surface-200 flex items-center justify-center">
|
||||
<span className="text-xs text-surface-300">Drop here</span>
|
||||
</div>
|
||||
)}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* DayColumn */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function DayColumn({
|
||||
dayIndex,
|
||||
items,
|
||||
}: {
|
||||
dayIndex: number
|
||||
items: MealPlanItem[]
|
||||
}) {
|
||||
const today = new Date().getDay()
|
||||
const isToday = today === (dayIndex + 1) % 7
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0 w-[260px]">
|
||||
<div className={`px-3 py-2 rounded-lg mb-2 ${isToday ? 'bg-primary-50 border border-primary-200' : 'bg-surface-100'}`}>
|
||||
<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-sm font-semibold ${isToday ? 'text-primary-700' : 'text-surface-700'}`}>
|
||||
<span className={`text-xs font-semibold ${isToday ? 'text-primary-700' : 'text-surface-700'}`}>
|
||||
{FULL_DAY_NAMES[dayIndex]}
|
||||
</span>
|
||||
{isToday && <Badge variant="primary">Today</Badge>}
|
||||
{isToday && <Badge variant="primary" className="text-[10px]">Today</Badge>}
|
||||
</div>
|
||||
<span className="text-xs text-surface-500">{DAY_NAMES[dayIndex]}</span>
|
||||
<span className="text-[10px] text-surface-500">{DAY_NAMES[dayIndex]}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<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-[10px] font-medium text-surface-400 uppercase tracking-wider px-1">
|
||||
<span className="text-[9px] font-medium text-surface-400 uppercase tracking-wider px-1 block">
|
||||
{mealType}
|
||||
</span>
|
||||
{item ? (
|
||||
<MealCard item={item} />
|
||||
) : (
|
||||
<div className="h-20 rounded-xl border-2 border-dashed border-surface-200 flex items-center justify-center">
|
||||
<span className="text-xs text-surface-400">—</span>
|
||||
</div>
|
||||
)}
|
||||
<MealSlot
|
||||
dayIndex={dayIndex}
|
||||
mealType={mealType}
|
||||
item={item}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -103,6 +199,9 @@ function DayColumn({ dayIndex, items }: { dayIndex: number; items: MealPlanItem[
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Skeleton */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
@@ -113,10 +212,10 @@ function DashboardSkeleton() {
|
||||
<Skeleton className="h-7 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{Array.from({ length: 7 }).map((_, i) => (
|
||||
<div key={i} className="flex-shrink-0 w-[260px] space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
@@ -131,6 +230,9 @@ function DashboardSkeleton() {
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* VoteEmailButton */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function VoteEmailButton() {
|
||||
const [sendingVoteEmail, setSendingVoteEmail] = useState(false)
|
||||
|
||||
@@ -159,16 +261,36 @@ function VoteEmailButton() {
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Dashboard */
|
||||
/* ------------------------------------------------------------------ */
|
||||
export default function Dashboard() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: mealPlan, isLoading } = useQuery<MealPlan | null>({
|
||||
queryKey: ['mealPlan'],
|
||||
queryFn: () => mealPlannerApi.meals.getPlanned().then(r => r.data),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <DashboardSkeleton />
|
||||
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), typeStr)
|
||||
}
|
||||
|
||||
if (isLoading) return <DashboardSkeleton />
|
||||
|
||||
if (!mealPlan) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -228,17 +350,23 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekly Grid */}
|
||||
{/* Weekly Grid — all 7 days visible */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-surface-900">Weekly Overview</h2>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-hide">
|
||||
{itemsByDay.map((items, index) => (
|
||||
<DayColumn key={index} dayIndex={index} items={items} />
|
||||
))}
|
||||
</div>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{itemsByDay.map((items, index) => (
|
||||
<DayColumn
|
||||
key={index}
|
||||
dayIndex={index}
|
||||
items={items}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DragDropContext>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user