Public Access
feat: drag-and-drop meal scheduling + 7-day grid
This commit is contained in:
@@ -320,3 +320,44 @@ def swap_meal_item(item_id: UUID, new_recipe_id: UUID, db: Session = Depends(get
|
|||||||
|
|
||||||
db.commit()
|
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",
|
"name": "mealplanner-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@tanstack/react-query": "^5.17.0",
|
"@tanstack/react-query": "^5.17.0",
|
||||||
"axios": "^1.6.5",
|
"axios": "^1.6.5",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -278,6 +279,15 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@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": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
"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": "^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": {
|
"node_modules/@humanwhocodes/config-array": {
|
||||||
"version": "0.13.0",
|
"version": "0.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||||
@@ -1354,14 +1381,14 @@
|
|||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.28",
|
"version": "18.3.28",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
||||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
@@ -1378,6 +1405,12 @@
|
|||||||
"@types/react": "^18.0.0"
|
"@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": {
|
"node_modules/@ungap/structured-clone": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
|
||||||
@@ -1841,6 +1874,15 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
@@ -3445,6 +3487,12 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
@@ -3487,6 +3535,29 @@
|
|||||||
"react-dom": ">=16"
|
"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": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
@@ -3552,6 +3623,12 @@
|
|||||||
"node": ">=8.10.0"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.12",
|
"version": "1.22.12",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||||
@@ -3886,6 +3963,12 @@
|
|||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||||
@@ -4041,6 +4124,15 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"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"
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@tanstack/react-query": "^5.17.0",
|
"@tanstack/react-query": "^5.17.0",
|
||||||
"axios": "^1.6.5",
|
"axios": "^1.6.5",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export const mealPlannerApi = {
|
|||||||
getVotePage: (itemId: string, token: string) => api.get(`/meals/items/${itemId}/vote/${token}`),
|
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),
|
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}`),
|
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: {
|
pantry: {
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
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 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 { mealPlannerApi } from '../api'
|
||||||
import type { MealPlan, MealPlanItem } from '../types'
|
import type { MealPlan, MealPlanItem } from '../types'
|
||||||
import { Badge } from '../components/ui/Badge'
|
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 FULL_DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
||||||
const MEAL_TYPES = ['breakfast', 'lunch', 'dinner'] as const
|
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 ??
|
const totalTime = item.recipe?.total_time_minutes ??
|
||||||
(item.recipe?.prep_time_minutes || 0) + (item.recipe?.cook_time_minutes || 0)
|
(item.recipe?.prep_time_minutes || 0) + (item.recipe?.cook_time_minutes || 0)
|
||||||
|
|
||||||
@@ -25,76 +46,151 @@ function MealCard({ item }: { item: MealPlanItem }) {
|
|||||||
'neutral'
|
'neutral'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<div
|
||||||
to={`/meals/${item.id}`}
|
className={`
|
||||||
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"
|
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 ? (
|
{item.recipe?.image_url ? (
|
||||||
<img
|
<img
|
||||||
src={item.recipe.image_url}
|
src={item.recipe.image_url}
|
||||||
alt={item.recipe.name}
|
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">
|
<div className="w-14 h-14 rounded-lg bg-surface-100 flex items-center justify-center flex-shrink-0">
|
||||||
<CookingPot className="w-8 h-8 text-surface-400" />
|
<CookingPot className="w-6 h-6 text-surface-400" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
<Link to={`/meals/${item.id}`} className="block">
|
||||||
<h4 className="font-semibold text-sm text-surface-900 truncate group-hover:text-primary-700 transition-colors">
|
<h4 className="font-semibold text-sm text-surface-900 truncate group-hover:text-primary-700 transition-colors">
|
||||||
{item.recipe?.name || 'Unknown Recipe'}
|
{item.recipe?.name || 'Unknown Recipe'}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-xs text-surface-500 mt-0.5">
|
</Link>
|
||||||
|
<p className="text-[11px] text-surface-500 mt-0.5">
|
||||||
{totalTime > 0 && `${totalTime} min · `}
|
{totalTime > 0 && `${totalTime} min · `}
|
||||||
{item.recipe?.servings} servings
|
{item.recipe?.servings} servings
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
<Badge variant={statusVariant}>
|
<Badge variant={statusVariant} className="text-[10px] px-1.5 py-0.5">
|
||||||
{item.approval_status}
|
{item.approval_status}
|
||||||
</Badge>
|
</Badge>
|
||||||
{item.estimated_cost && (
|
{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)}
|
${item.estimated_cost.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 today = new Date().getDay()
|
||||||
const isToday = today === (dayIndex + 1) % 7
|
const isToday = today === (dayIndex + 1) % 7
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-shrink-0 w-[260px]">
|
<div className="flex flex-col gap-1">
|
||||||
<div className={`px-3 py-2 rounded-lg mb-2 ${isToday ? 'bg-primary-50 border border-primary-200' : 'bg-surface-100'}`}>
|
<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">
|
<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]}
|
{FULL_DAY_NAMES[dayIndex]}
|
||||||
</span>
|
</span>
|
||||||
{isToday && <Badge variant="primary">Today</Badge>}
|
{isToday && <Badge variant="primary" className="text-[10px]">Today</Badge>}
|
||||||
</div>
|
</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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
{MEAL_TYPES.map(mealType => {
|
{MEAL_TYPES.map(mealType => {
|
||||||
const item = items.find(i => i.meal_type === mealType)
|
const item = items.find(i => i.meal_type === mealType)
|
||||||
return (
|
return (
|
||||||
<div key={mealType}>
|
<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}
|
{mealType}
|
||||||
</span>
|
</span>
|
||||||
{item ? (
|
<MealSlot
|
||||||
<MealCard item={item} />
|
dayIndex={dayIndex}
|
||||||
) : (
|
mealType={mealType}
|
||||||
<div className="h-20 rounded-xl border-2 border-dashed border-surface-200 flex items-center justify-center">
|
item={item}
|
||||||
<span className="text-xs text-surface-400">—</span>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -103,6 +199,9 @@ function DayColumn({ dayIndex, items }: { dayIndex: number; items: MealPlanItem[
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Skeleton */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
function DashboardSkeleton() {
|
function DashboardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
@@ -113,10 +212,10 @@ function DashboardSkeleton() {
|
|||||||
<Skeleton className="h-7 w-20" />
|
<Skeleton className="h-7 w-20" />
|
||||||
</div>
|
</div>
|
||||||
</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) => (
|
{Array.from({ length: 7 }).map((_, i) => (
|
||||||
<div key={i} className="flex-shrink-0 w-[260px] space-y-2">
|
<div key={i} className="space-y-2">
|
||||||
<Skeleton className="h-10 w-full" />
|
<Skeleton className="h-8 w-full" />
|
||||||
<SkeletonCard />
|
<SkeletonCard />
|
||||||
<SkeletonCard />
|
<SkeletonCard />
|
||||||
<SkeletonCard />
|
<SkeletonCard />
|
||||||
@@ -131,6 +230,9 @@ function DashboardSkeleton() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* VoteEmailButton */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
function VoteEmailButton() {
|
function VoteEmailButton() {
|
||||||
const [sendingVoteEmail, setSendingVoteEmail] = useState(false)
|
const [sendingVoteEmail, setSendingVoteEmail] = useState(false)
|
||||||
|
|
||||||
@@ -159,15 +261,35 @@ function VoteEmailButton() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Dashboard */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const { data: mealPlan, isLoading } = useQuery<MealPlan | null>({
|
const { data: mealPlan, isLoading } = useQuery<MealPlan | null>({
|
||||||
queryKey: ['mealPlan'],
|
queryKey: ['mealPlan'],
|
||||||
queryFn: () => mealPlannerApi.meals.getPlanned().then(r => r.data),
|
queryFn: () => mealPlannerApi.meals.getPlanned().then(r => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isLoading) {
|
async function handleDrop(itemId: string, newDay: number, newType: string) {
|
||||||
return <DashboardSkeleton />
|
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) {
|
if (!mealPlan) {
|
||||||
return (
|
return (
|
||||||
@@ -228,17 +350,23 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Weekly Grid */}
|
{/* Weekly Grid — all 7 days visible */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h2 className="text-lg font-semibold text-surface-900">Weekly Overview</h2>
|
<h2 className="text-lg font-semibold text-surface-900">Weekly Overview</h2>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-hide">
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
|
<div className="grid grid-cols-7 gap-2">
|
||||||
{itemsByDay.map((items, index) => (
|
{itemsByDay.map((items, index) => (
|
||||||
<DayColumn key={index} dayIndex={index} items={items} />
|
<DayColumn
|
||||||
|
key={index}
|
||||||
|
dayIndex={index}
|
||||||
|
items={items}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</DragDropContext>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user