feat: redesign meal ordering system
This commit is contained in:
@@ -1,4 +1,12 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
root: true,
|
root: true,
|
||||||
extends: ['@payloadcms'],
|
extends: ['next/core-web-vitals', 'next/typescript'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
'@typescript-eslint/no-unused-vars': ['warn', {
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
caughtErrorsIgnorePattern: '^_',
|
||||||
|
}],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ pnpm generate:types # Generate TypeScript types
|
|||||||
|
|
||||||
### Database
|
### Database
|
||||||
|
|
||||||
The application uses SQLite by default (`payload.db`). To migrate to PostgreSQL:
|
The application uses SQLite by default (`meal-planner.db`). To migrate to PostgreSQL:
|
||||||
|
|
||||||
1. Install the PostgreSQL adapter: `pnpm add @payloadcms/db-postgres`
|
1. Install the PostgreSQL adapter: `pnpm add @payloadcms/db-postgres`
|
||||||
2. Update `payload.config.ts`:
|
2. Update `payload.config.ts`:
|
||||||
|
|||||||
@@ -16,17 +16,23 @@
|
|||||||
"start": "cross-env NODE_OPTIONS=--no-deprecation next start"
|
"start": "cross-env NODE_OPTIONS=--no-deprecation next start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@payloadcms/db-postgres": "^3.65.0",
|
||||||
"@payloadcms/db-sqlite": "3.65.0",
|
"@payloadcms/db-sqlite": "3.65.0",
|
||||||
"@payloadcms/next": "3.65.0",
|
"@payloadcms/next": "3.65.0",
|
||||||
|
"@payloadcms/plugin-cloud-storage": "^3.65.0",
|
||||||
"@payloadcms/plugin-multi-tenant": "3.65.0",
|
"@payloadcms/plugin-multi-tenant": "3.65.0",
|
||||||
"@payloadcms/richtext-lexical": "3.65.0",
|
"@payloadcms/richtext-lexical": "3.65.0",
|
||||||
|
"@payloadcms/storage-s3": "^3.65.0",
|
||||||
"@payloadcms/ui": "3.65.0",
|
"@payloadcms/ui": "3.65.0",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/postcss": "^4.1.17",
|
"@tailwindcss/postcss": "^4.1.17",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
1690
pnpm-lock.yaml
generated
1690
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import {
|
import {
|
||||||
@@ -12,10 +12,56 @@ import {
|
|||||||
ClipboardList,
|
ClipboardList,
|
||||||
Users,
|
Users,
|
||||||
Settings,
|
Settings,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Eye,
|
||||||
|
Send,
|
||||||
|
Check,
|
||||||
|
ChefHat,
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Calendar,
|
||||||
|
UserCheck,
|
||||||
|
UserX,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Progress } from '@/components/ui/progress'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number
|
id: number
|
||||||
@@ -27,22 +73,122 @@ interface User {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MealOrder {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
date: string
|
||||||
|
mealType: 'breakfast' | 'lunch' | 'dinner'
|
||||||
|
status: 'draft' | 'submitted' | 'preparing' | 'completed'
|
||||||
|
mealCount: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Resident {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
room: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Meal {
|
||||||
|
id: number
|
||||||
|
resident: number | { id: number; name: string }
|
||||||
|
}
|
||||||
|
|
||||||
interface OrderStats {
|
interface OrderStats {
|
||||||
pending: number
|
draft: number
|
||||||
|
submitted: number
|
||||||
preparing: number
|
preparing: number
|
||||||
prepared: number
|
completed: number
|
||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PaginationInfo {
|
||||||
|
totalDocs: number
|
||||||
|
totalPages: number
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
hasNextPage: boolean
|
||||||
|
hasPrevPage: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 10
|
||||||
|
|
||||||
export default function CaregiverDashboardPage() {
|
export default function CaregiverDashboardPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const [user, setUser] = useState<User | null>(null)
|
||||||
const [stats, setStats] = useState<OrderStats>({ pending: 0, preparing: 0, prepared: 0, total: 0 })
|
const [orders, setOrders] = useState<MealOrder[]>([])
|
||||||
|
const [residents, setResidents] = useState<Resident[]>([])
|
||||||
|
const [stats, setStats] = useState<OrderStats>({
|
||||||
|
draft: 0,
|
||||||
|
submitted: 0,
|
||||||
|
preparing: 0,
|
||||||
|
completed: 0,
|
||||||
|
total: 0,
|
||||||
|
})
|
||||||
|
const [pagination, setPagination] = useState<PaginationInfo>({
|
||||||
|
totalDocs: 0,
|
||||||
|
totalPages: 1,
|
||||||
|
page: 1,
|
||||||
|
limit: ITEMS_PER_PAGE,
|
||||||
|
hasNextPage: false,
|
||||||
|
hasPrevPage: false,
|
||||||
|
})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [ordersLoading, setOrdersLoading] = useState(false)
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [dateFilter, setDateFilter] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||||
|
|
||||||
|
// Coverage dialog
|
||||||
|
const [coverageDialogOpen, setCoverageDialogOpen] = useState(false)
|
||||||
|
const [selectedOrder, setSelectedOrder] = useState<MealOrder | null>(null)
|
||||||
|
const [orderMeals, setOrderMeals] = useState<Meal[]>([])
|
||||||
|
const [coverageLoading, setCoverageLoading] = useState(false)
|
||||||
|
|
||||||
|
// Create order dialog
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||||
|
const [newOrderDate, setNewOrderDate] = useState(() => new Date().toISOString().split('T')[0])
|
||||||
|
const [newOrderMealType, setNewOrderMealType] = useState<'breakfast' | 'lunch' | 'dinner'>('breakfast')
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
|
const fetchOrders = useCallback(async (page: number = 1) => {
|
||||||
|
setOrdersLoading(true)
|
||||||
|
try {
|
||||||
|
let url = `/api/meal-orders?sort=-date,-createdAt&limit=${ITEMS_PER_PAGE}&page=${page}&depth=0`
|
||||||
|
if (dateFilter) {
|
||||||
|
url += `&where[date][equals]=${dateFilter}`
|
||||||
|
}
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
url += `&where[status][equals]=${statusFilter}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, { credentials: 'include' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setOrders(data.docs || [])
|
||||||
|
setPagination({
|
||||||
|
totalDocs: data.totalDocs || 0,
|
||||||
|
totalPages: data.totalPages || 1,
|
||||||
|
page: data.page || 1,
|
||||||
|
limit: data.limit || ITEMS_PER_PAGE,
|
||||||
|
hasNextPage: data.hasNextPage || false,
|
||||||
|
hasPrevPage: data.hasPrevPage || false,
|
||||||
|
})
|
||||||
|
} else if (res.status === 401) {
|
||||||
|
router.push('/caregiver/login')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching orders:', err)
|
||||||
|
} finally {
|
||||||
|
setOrdersLoading(false)
|
||||||
|
}
|
||||||
|
}, [router, dateFilter, statusFilter])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchInitialData = async () => {
|
||||||
try {
|
try {
|
||||||
|
// Fetch current user
|
||||||
const userRes = await fetch('/api/users/me', { credentials: 'include' })
|
const userRes = await fetch('/api/users/me', { credentials: 'include' })
|
||||||
if (!userRes.ok) {
|
if (!userRes.ok) {
|
||||||
router.push('/caregiver/login')
|
router.push('/caregiver/login')
|
||||||
@@ -55,18 +201,31 @@ export default function CaregiverDashboardPage() {
|
|||||||
}
|
}
|
||||||
setUser(userData.user)
|
setUser(userData.user)
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0]
|
// Fetch residents
|
||||||
const ordersRes = await fetch(`/api/meal-orders?where[date][equals]=${today}&limit=1000`, {
|
const residentsRes = await fetch('/api/residents?where[active][equals]=true&limit=500', {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (ordersRes.ok) {
|
if (residentsRes.ok) {
|
||||||
const ordersData = await ordersRes.json()
|
const residentsData = await residentsRes.json()
|
||||||
const orders = ordersData.docs || []
|
setResidents(residentsData.docs || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch stats (all orders from last 7 days)
|
||||||
|
const weekAgo = new Date()
|
||||||
|
weekAgo.setDate(weekAgo.getDate() - 7)
|
||||||
|
const statsRes = await fetch(
|
||||||
|
`/api/meal-orders?where[date][greater_than_equal]=${weekAgo.toISOString().split('T')[0]}&limit=1000&depth=0`,
|
||||||
|
{ credentials: 'include' }
|
||||||
|
)
|
||||||
|
if (statsRes.ok) {
|
||||||
|
const statsData = await statsRes.json()
|
||||||
|
const allOrders = statsData.docs || []
|
||||||
setStats({
|
setStats({
|
||||||
pending: orders.filter((o: { status: string }) => o.status === 'pending').length,
|
draft: allOrders.filter((o: MealOrder) => o.status === 'draft').length,
|
||||||
preparing: orders.filter((o: { status: string }) => o.status === 'preparing').length,
|
submitted: allOrders.filter((o: MealOrder) => o.status === 'submitted').length,
|
||||||
prepared: orders.filter((o: { status: string }) => o.status === 'prepared').length,
|
preparing: allOrders.filter((o: MealOrder) => o.status === 'preparing').length,
|
||||||
total: orders.length,
|
completed: allOrders.filter((o: MealOrder) => o.status === 'completed').length,
|
||||||
|
total: allOrders.length,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -75,9 +234,15 @@ export default function CaregiverDashboardPage() {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchData()
|
fetchInitialData()
|
||||||
}, [router])
|
}, [router])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading) {
|
||||||
|
fetchOrders(1)
|
||||||
|
}
|
||||||
|
}, [loading, fetchOrders])
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await fetch('/api/users/logout', {
|
await fetch('/api/users/logout', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -86,6 +251,137 @@ export default function CaregiverDashboardPage() {
|
|||||||
router.push('/caregiver/login')
|
router.push('/caregiver/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCreateOrder = async () => {
|
||||||
|
setCreating(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/meal-orders', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
date: newOrderDate,
|
||||||
|
mealType: newOrderMealType,
|
||||||
|
status: 'draft',
|
||||||
|
}),
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setCreateDialogOpen(false)
|
||||||
|
router.push(`/caregiver/orders/${data.doc.id}`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating order:', err)
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleViewCoverage = async (order: MealOrder) => {
|
||||||
|
setSelectedOrder(order)
|
||||||
|
setCoverageDialogOpen(true)
|
||||||
|
setCoverageLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/meals?where[order][equals]=${order.id}&depth=1&limit=500`,
|
||||||
|
{ credentials: 'include' }
|
||||||
|
)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setOrderMeals(data.docs || [])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching meals:', err)
|
||||||
|
} finally {
|
||||||
|
setCoverageLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMealTypeLabel = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'breakfast':
|
||||||
|
return 'Breakfast'
|
||||||
|
case 'lunch':
|
||||||
|
return 'Lunch'
|
||||||
|
case 'dinner':
|
||||||
|
return 'Dinner'
|
||||||
|
default:
|
||||||
|
return type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMealTypeIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'breakfast':
|
||||||
|
return <Sunrise className="h-4 w-4 text-orange-500" />
|
||||||
|
case 'lunch':
|
||||||
|
return <Sun className="h-4 w-4 text-yellow-500" />
|
||||||
|
case 'dinner':
|
||||||
|
return <Moon className="h-4 w-4 text-indigo-500" />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'draft':
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-200">
|
||||||
|
<Pencil className="mr-1 h-3 w-3" />
|
||||||
|
Draft
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
case 'submitted':
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
|
||||||
|
<Send className="mr-1 h-3 w-3" />
|
||||||
|
Submitted
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
case 'preparing':
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200">
|
||||||
|
<ChefHat className="mr-1 h-3 w-3" />
|
||||||
|
Preparing
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
case 'completed':
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
|
||||||
|
<Check className="mr-1 h-3 w-3" />
|
||||||
|
Completed
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return <Badge variant="outline">{status}</Badge>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCoverageInfo = (order: MealOrder) => {
|
||||||
|
const totalResidents = residents.length
|
||||||
|
const coveredCount = order.mealCount
|
||||||
|
const percentage = totalResidents > 0 ? Math.round((coveredCount / totalResidents) * 100) : 0
|
||||||
|
return { coveredCount, totalResidents, percentage }
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCoverageColor = (percentage: number) => {
|
||||||
|
if (percentage === 100) return 'bg-green-500'
|
||||||
|
if (percentage >= 75) return 'bg-blue-500'
|
||||||
|
if (percentage >= 50) return 'bg-yellow-500'
|
||||||
|
return 'bg-red-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-muted/50">
|
<div className="min-h-screen flex items-center justify-center bg-muted/50">
|
||||||
@@ -99,6 +395,13 @@ export default function CaregiverDashboardPage() {
|
|||||||
? user.tenants[0].tenant.name
|
? user.tenants[0].tenant.name
|
||||||
: 'Care Home'
|
: 'Care Home'
|
||||||
|
|
||||||
|
// Calculate covered residents for coverage dialog
|
||||||
|
const coveredResidentIds = new Set(
|
||||||
|
orderMeals.map((m) => (typeof m.resident === 'object' ? m.resident.id : m.resident))
|
||||||
|
)
|
||||||
|
const coveredResidents = residents.filter((r) => coveredResidentIds.has(r.id))
|
||||||
|
const uncoveredResidents = residents.filter((r) => !coveredResidentIds.has(r.id))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-muted/50">
|
<div className="min-h-screen bg-muted/50">
|
||||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
@@ -115,12 +418,19 @@ export default function CaregiverDashboardPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="container py-6">
|
<main className="container py-6">
|
||||||
<div className="mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Dashboard</h2>
|
<h2 className="text-2xl font-bold tracking-tight">Dashboard</h2>
|
||||||
<p className="text-muted-foreground">Today's overview</p>
|
<p className="text-muted-foreground">Manage meal orders for your care home</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
New Meal Order
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-8">
|
{/* Stats Cards */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 mb-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Total Orders</CardTitle>
|
<CardTitle className="text-sm font-medium">Total Orders</CardTitle>
|
||||||
@@ -128,76 +438,63 @@ export default function CaregiverDashboardPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{stats.total}</div>
|
<div className="text-2xl font-bold">{stats.total}</div>
|
||||||
<p className="text-xs text-muted-foreground">Today</p>
|
<p className="text-xs text-muted-foreground">Last 7 days</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Pending</CardTitle>
|
<CardTitle className="text-sm font-medium">Draft</CardTitle>
|
||||||
<div className="h-2 w-2 rounded-full bg-yellow-500" />
|
<div className="h-2 w-2 rounded-full bg-gray-400" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{stats.pending}</div>
|
<div className="text-2xl font-bold">{stats.draft}</div>
|
||||||
<p className="text-xs text-muted-foreground">Awaiting preparation</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Preparing</CardTitle>
|
|
||||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{stats.preparing}</div>
|
|
||||||
<p className="text-xs text-muted-foreground">In progress</p>
|
<p className="text-xs text-muted-foreground">In progress</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Prepared</CardTitle>
|
<CardTitle className="text-sm font-medium">Submitted</CardTitle>
|
||||||
|
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.submitted}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Sent to kitchen</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Preparing</CardTitle>
|
||||||
|
<div className="h-2 w-2 rounded-full bg-yellow-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.preparing}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Being prepared</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Completed</CardTitle>
|
||||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{stats.prepared}</div>
|
<div className="text-2xl font-bold">{stats.completed}</div>
|
||||||
<p className="text-xs text-muted-foreground">Ready to serve</p>
|
<p className="text-xs text-muted-foreground">Finished</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
{/* Quick Actions */}
|
||||||
|
<Card className="mb-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Quick Actions</CardTitle>
|
<CardTitle>Quick Actions</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<Link href="/caregiver/orders/new?mealType=breakfast">
|
|
||||||
<Card className="cursor-pointer transition-colors hover:bg-accent">
|
|
||||||
<CardContent className="flex flex-col items-center justify-center p-6">
|
|
||||||
<Sunrise className="h-8 w-8 mb-2 text-orange-500" />
|
|
||||||
<span className="font-medium">New Breakfast</span>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
<Link href="/caregiver/orders/new?mealType=lunch">
|
|
||||||
<Card className="cursor-pointer transition-colors hover:bg-accent">
|
|
||||||
<CardContent className="flex flex-col items-center justify-center p-6">
|
|
||||||
<Sun className="h-8 w-8 mb-2 text-yellow-500" />
|
|
||||||
<span className="font-medium">New Lunch</span>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
<Link href="/caregiver/orders/new?mealType=dinner">
|
|
||||||
<Card className="cursor-pointer transition-colors hover:bg-accent">
|
|
||||||
<CardContent className="flex flex-col items-center justify-center p-6">
|
|
||||||
<Moon className="h-8 w-8 mb-2 text-indigo-500" />
|
|
||||||
<span className="font-medium">New Dinner</span>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
<Link href="/caregiver/orders">
|
<Link href="/caregiver/orders">
|
||||||
<Card className="cursor-pointer transition-colors hover:bg-accent">
|
<Card className="cursor-pointer transition-colors hover:bg-accent">
|
||||||
<CardContent className="flex flex-col items-center justify-center p-6">
|
<CardContent className="flex flex-col items-center justify-center p-6">
|
||||||
<ClipboardList className="h-8 w-8 mb-2 text-muted-foreground" />
|
<ClipboardList className="h-8 w-8 mb-2 text-muted-foreground" />
|
||||||
<span className="font-medium">View Orders</span>
|
<span className="font-medium">All Orders</span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -217,10 +514,432 @@ export default function CaregiverDashboardPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Card
|
||||||
|
className="cursor-pointer transition-colors hover:bg-accent"
|
||||||
|
onClick={() => setCreateDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center p-6">
|
||||||
|
<Plus className="h-8 w-8 mb-2 text-primary" />
|
||||||
|
<span className="font-medium">New Order</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Meal Orders Table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Meal Orders</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
View and manage all meal orders. Click on coverage to see resident details.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor="dateFilter" className="sr-only">
|
||||||
|
Filter by date
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
id="dateFilter"
|
||||||
|
value={dateFilter}
|
||||||
|
onChange={(e) => setDateFilter(e.target.value)}
|
||||||
|
className="w-40"
|
||||||
|
placeholder="Filter by date"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger className="w-36">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Statuses</SelectItem>
|
||||||
|
<SelectItem value="draft">Draft</SelectItem>
|
||||||
|
<SelectItem value="submitted">Submitted</SelectItem>
|
||||||
|
<SelectItem value="preparing">Preparing</SelectItem>
|
||||||
|
<SelectItem value="completed">Completed</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{(dateFilter || statusFilter !== 'all') && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setDateFilter('')
|
||||||
|
setStatusFilter('all')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{ordersLoading ? (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : orders.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||||
|
<ClipboardList className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<p className="text-muted-foreground">No orders found.</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
Create a new order to get started.
|
||||||
|
</p>
|
||||||
|
<Button className="mt-4" onClick={() => setCreateDialogOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create Order
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Meal</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Coverage</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{orders.map((order) => {
|
||||||
|
const coverage = getCoverageInfo(order)
|
||||||
|
return (
|
||||||
|
<TableRow key={order.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getMealTypeIcon(order.mealType)}
|
||||||
|
<span className="font-medium">
|
||||||
|
{getMealTypeLabel(order.mealType)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||||
|
{formatDate(order.date)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="w-full max-w-32 cursor-pointer"
|
||||||
|
onClick={() => handleViewCoverage(order)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Progress
|
||||||
|
value={coverage.percentage}
|
||||||
|
className="h-2 flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{coverage.coveredCount}/{coverage.totalResidents}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{coverage.percentage < 100 && (
|
||||||
|
<div className="flex items-center gap-1 mt-1">
|
||||||
|
<AlertTriangle className="h-3 w-3 text-yellow-500" />
|
||||||
|
<span className="text-xs text-yellow-600">
|
||||||
|
{coverage.totalResidents - coverage.coveredCount} missing
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Click to view coverage details</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getStatusBadge(order.status)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{order.status === 'draft' ? (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link href={`/caregiver/orders/${order.id}`}>
|
||||||
|
<Pencil className="mr-1 h-3 w-3" />
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link href={`/caregiver/orders/${order.id}`}>
|
||||||
|
<Eye className="mr-1 h-3 w-3" />
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{pagination.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-4 border-t">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Showing {(pagination.page - 1) * pagination.limit + 1} to{' '}
|
||||||
|
{Math.min(pagination.page * pagination.limit, pagination.totalDocs)} of{' '}
|
||||||
|
{pagination.totalDocs} orders
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fetchOrders(pagination.page - 1)}
|
||||||
|
disabled={!pagination.hasPrevPage || ordersLoading}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{Array.from({ length: Math.min(5, pagination.totalPages) }, (_, i) => {
|
||||||
|
let pageNum: number
|
||||||
|
if (pagination.totalPages <= 5) {
|
||||||
|
pageNum = i + 1
|
||||||
|
} else if (pagination.page <= 3) {
|
||||||
|
pageNum = i + 1
|
||||||
|
} else if (pagination.page >= pagination.totalPages - 2) {
|
||||||
|
pageNum = pagination.totalPages - 4 + i
|
||||||
|
} else {
|
||||||
|
pageNum = pagination.page - 2 + i
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={pageNum}
|
||||||
|
variant={pagination.page === pageNum ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="w-8 h-8 p-0"
|
||||||
|
onClick={() => fetchOrders(pageNum)}
|
||||||
|
disabled={ordersLoading}
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fetchOrders(pagination.page + 1)}
|
||||||
|
disabled={!pagination.hasNextPage || ordersLoading}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Create Order Dialog */}
|
||||||
|
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Meal Order</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Select a date and meal type to create a new order.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="newOrderDate">Date</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
id="newOrderDate"
|
||||||
|
value={newOrderDate}
|
||||||
|
onChange={(e) => setNewOrderDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Meal Type</Label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{[
|
||||||
|
{ value: 'breakfast', label: 'Breakfast', icon: Sunrise, color: 'text-orange-500' },
|
||||||
|
{ value: 'lunch', label: 'Lunch', icon: Sun, color: 'text-yellow-500' },
|
||||||
|
{ value: 'dinner', label: 'Dinner', icon: Moon, color: 'text-indigo-500' },
|
||||||
|
].map(({ value, label, icon: Icon, color }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center justify-center p-4 rounded-lg border-2 transition-colors',
|
||||||
|
newOrderMealType === value
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border hover:bg-muted'
|
||||||
|
)}
|
||||||
|
onClick={() => setNewOrderMealType(value as 'breakfast' | 'lunch' | 'dinner')}
|
||||||
|
>
|
||||||
|
<Icon className={cn('h-6 w-6 mb-1', color)} />
|
||||||
|
<span className="text-sm font-medium">{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreateOrder} disabled={creating}>
|
||||||
|
{creating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create Order
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Coverage Dialog */}
|
||||||
|
<Dialog open={coverageDialogOpen} onOpenChange={setCoverageDialogOpen}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
Resident Coverage
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{selectedOrder && (
|
||||||
|
<>
|
||||||
|
{getMealTypeLabel(selectedOrder.mealType)} - {formatDate(selectedOrder.date)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{coverageLoading ? (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Coverage Summary */}
|
||||||
|
<div className="flex items-center gap-4 p-4 bg-muted rounded-lg">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-medium">Coverage Progress</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{coveredResidents.length}/{residents.length} residents
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={
|
||||||
|
residents.length > 0
|
||||||
|
? (coveredResidents.length / residents.length) * 100
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
className="h-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center w-16 h-16 rounded-full text-white font-bold',
|
||||||
|
getCoverageColor(
|
||||||
|
residents.length > 0
|
||||||
|
? (coveredResidents.length / residents.length) * 100
|
||||||
|
: 0
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{residents.length > 0
|
||||||
|
? Math.round((coveredResidents.length / residents.length) * 100)
|
||||||
|
: 0}
|
||||||
|
%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Uncovered Residents */}
|
||||||
|
{uncoveredResidents.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<UserX className="h-4 w-4 text-red-500" />
|
||||||
|
<h4 className="font-medium text-red-700">
|
||||||
|
Missing Meals ({uncoveredResidents.length})
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{uncoveredResidents.map((resident) => (
|
||||||
|
<div
|
||||||
|
key={resident.id}
|
||||||
|
className="flex items-center justify-between p-3 bg-red-50 border border-red-200 rounded-lg"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-red-900">{resident.name}</div>
|
||||||
|
<div className="text-sm text-red-600">Room {resident.room}</div>
|
||||||
|
</div>
|
||||||
|
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Covered Residents */}
|
||||||
|
{coveredResidents.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<UserCheck className="h-4 w-4 text-green-500" />
|
||||||
|
<h4 className="font-medium text-green-700">
|
||||||
|
Covered Residents ({coveredResidents.length})
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{coveredResidents.map((resident) => (
|
||||||
|
<div
|
||||||
|
key={resident.id}
|
||||||
|
className="flex items-center justify-between p-3 bg-green-50 border border-green-200 rounded-lg"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-green-900">{resident.name}</div>
|
||||||
|
<div className="text-sm text-green-600">Room {resident.room}</div>
|
||||||
|
</div>
|
||||||
|
<Check className="h-4 w-4 text-green-500" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{selectedOrder?.status === 'draft' && (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/caregiver/orders/${selectedOrder.id}`}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Edit Order
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" onClick={() => setCoverageDialogOpen(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
1128
src/app/(app)/caregiver/orders/[id]/page.tsx
Normal file
1128
src/app/(app)/caregiver/orders/[id]/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { ArrowLeft, Plus, Loader2 } from 'lucide-react'
|
import { ArrowLeft, Plus, Loader2, Send, Eye, Pencil, Check, ChefHat } from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
@@ -26,19 +26,13 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
|
|
||||||
interface Resident {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
room: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MealOrder {
|
interface MealOrder {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
date: string
|
date: string
|
||||||
mealType: 'breakfast' | 'lunch' | 'dinner'
|
mealType: 'breakfast' | 'lunch' | 'dinner'
|
||||||
status: 'pending' | 'preparing' | 'prepared'
|
status: 'draft' | 'submitted' | 'preparing' | 'completed'
|
||||||
resident: Resident | number
|
mealCount: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,18 +41,18 @@ export default function OrdersListPage() {
|
|||||||
const [orders, setOrders] = useState<MealOrder[]>([])
|
const [orders, setOrders] = useState<MealOrder[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [dateFilter, setDateFilter] = useState(() => new Date().toISOString().split('T')[0])
|
const [dateFilter, setDateFilter] = useState(() => new Date().toISOString().split('T')[0])
|
||||||
const [mealTypeFilter, setMealTypeFilter] = useState<string>('all')
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchOrders = async () => {
|
const fetchOrders = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
let url = `/api/meal-orders?sort=-createdAt&limit=100&depth=1`
|
let url = `/api/meal-orders?sort=-date&limit=100&depth=0`
|
||||||
if (dateFilter) {
|
if (dateFilter) {
|
||||||
url += `&where[date][equals]=${dateFilter}`
|
url += `&where[date][equals]=${dateFilter}`
|
||||||
}
|
}
|
||||||
if (mealTypeFilter !== 'all') {
|
if (statusFilter !== 'all') {
|
||||||
url += `&where[mealType][equals]=${mealTypeFilter}`
|
url += `&where[status][equals]=${statusFilter}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(url, { credentials: 'include' })
|
const res = await fetch(url, { credentials: 'include' })
|
||||||
@@ -75,7 +69,7 @@ export default function OrdersListPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchOrders()
|
fetchOrders()
|
||||||
}, [router, dateFilter, mealTypeFilter])
|
}, [router, dateFilter, statusFilter])
|
||||||
|
|
||||||
const getMealTypeLabel = (type: string) => {
|
const getMealTypeLabel = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -92,22 +86,59 @@ export default function OrdersListPage() {
|
|||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'pending':
|
case 'draft':
|
||||||
return <Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200">Pending</Badge>
|
return (
|
||||||
|
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-200">
|
||||||
|
<Pencil className="mr-1 h-3 w-3" />
|
||||||
|
Draft
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
case 'submitted':
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
|
||||||
|
<Send className="mr-1 h-3 w-3" />
|
||||||
|
Submitted
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
case 'preparing':
|
case 'preparing':
|
||||||
return <Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">Preparing</Badge>
|
return (
|
||||||
case 'prepared':
|
<Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200">
|
||||||
return <Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">Prepared</Badge>
|
<ChefHat className="mr-1 h-3 w-3" />
|
||||||
|
Preparing
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
case 'completed':
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
|
||||||
|
<Check className="mr-1 h-3 w-3" />
|
||||||
|
Completed
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
return <Badge variant="outline">{status}</Badge>
|
return <Badge variant="outline">{status}</Badge>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getResidentName = (resident: Resident | number) => {
|
const handleCreateOrder = async (mealType: 'breakfast' | 'lunch' | 'dinner') => {
|
||||||
if (typeof resident === 'object') {
|
try {
|
||||||
return resident.name
|
const res = await fetch('/api/meal-orders', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
date: dateFilter,
|
||||||
|
mealType,
|
||||||
|
status: 'draft',
|
||||||
|
}),
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
router.push(`/caregiver/orders/${data.doc.id}`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating order:', err)
|
||||||
}
|
}
|
||||||
return `Resident #${resident}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -123,22 +154,16 @@ export default function OrdersListPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-xl font-semibold">Meal Orders</h1>
|
<h1 className="text-xl font-semibold">Meal Orders</h1>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild>
|
|
||||||
<Link href="/caregiver/orders/new">
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
New Order
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="container py-6">
|
<main className="container py-6">
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Filter Orders</CardTitle>
|
<CardTitle>Filter & Create Orders</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="date">Date</Label>
|
<Label htmlFor="date">Date</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -149,19 +174,52 @@ export default function OrdersListPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="mealType">Meal Type</Label>
|
<Label htmlFor="status">Status</Label>
|
||||||
<Select value={mealTypeFilter} onValueChange={setMealTypeFilter}>
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select meal type" />
|
<SelectValue placeholder="Select status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Types</SelectItem>
|
<SelectItem value="all">All Statuses</SelectItem>
|
||||||
<SelectItem value="breakfast">Breakfast</SelectItem>
|
<SelectItem value="draft">Draft</SelectItem>
|
||||||
<SelectItem value="lunch">Lunch</SelectItem>
|
<SelectItem value="submitted">Submitted</SelectItem>
|
||||||
<SelectItem value="dinner">Dinner</SelectItem>
|
<SelectItem value="preparing">Preparing</SelectItem>
|
||||||
|
<SelectItem value="completed">Completed</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Create New Order</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleCreateOrder('breakfast')}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
Breakfast
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleCreateOrder('lunch')}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
Lunch
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleCreateOrder('dinner')}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
Dinner
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -174,28 +232,48 @@ export default function OrdersListPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : orders.length === 0 ? (
|
) : orders.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||||
<p className="text-muted-foreground">No orders found for the selected criteria.</p>
|
<p className="text-muted-foreground">No orders found for the selected date.</p>
|
||||||
<Button asChild className="mt-4">
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
<Link href="/caregiver/orders/new">Create New Order</Link>
|
Create a new order using the buttons above.
|
||||||
</Button>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Resident</TableHead>
|
<TableHead>Meal Type</TableHead>
|
||||||
<TableHead>Date</TableHead>
|
<TableHead>Date</TableHead>
|
||||||
<TableHead>Meal</TableHead>
|
<TableHead>Meals</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{orders.map((order) => (
|
{orders.map((order) => (
|
||||||
<TableRow key={order.id}>
|
<TableRow key={order.id}>
|
||||||
<TableCell className="font-medium">{getResidentName(order.resident)}</TableCell>
|
<TableCell className="font-medium">
|
||||||
|
{getMealTypeLabel(order.mealType)}
|
||||||
|
</TableCell>
|
||||||
<TableCell>{order.date}</TableCell>
|
<TableCell>{order.date}</TableCell>
|
||||||
<TableCell>{getMealTypeLabel(order.mealType)}</TableCell>
|
<TableCell>{order.mealCount} residents</TableCell>
|
||||||
<TableCell>{getStatusBadge(order.status)}</TableCell>
|
<TableCell>{getStatusBadge(order.status)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link href={`/caregiver/orders/${order.id}`}>
|
||||||
|
{order.status === 'draft' ? (
|
||||||
|
<>
|
||||||
|
<Pencil className="mr-1 h-3 w-3" />
|
||||||
|
Edit
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Eye className="mr-1 h-3 w-3" />
|
||||||
|
View
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { TenantField as TenantField_1d0591e3cf4f332c83a86da13a0de59a } from '@pa
|
|||||||
import { AssignTenantFieldTrigger as AssignTenantFieldTrigger_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'
|
import { AssignTenantFieldTrigger as AssignTenantFieldTrigger_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'
|
||||||
import { TenantSelector as TenantSelector_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc'
|
import { TenantSelector as TenantSelector_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc'
|
||||||
import { TenantSelectionProvider as TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc'
|
import { TenantSelectionProvider as TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc'
|
||||||
import { KitchenDashboard as KitchenDashboard_466f0c465119ff8e562eb80399daabc0 } from './views/KitchenDashboard'
|
import { KitchenDashboard as KitchenDashboard_466f0c465119ff8e562eb80399daabc0 } from '../../../../app/(payload)/admin/views/KitchenDashboard'
|
||||||
|
|
||||||
export const importMap = {
|
export const importMap = {
|
||||||
"@payloadcms/plugin-multi-tenant/client#WatchTenantCollection": WatchTenantCollection_1d0591e3cf4f332c83a86da13a0de59a,
|
"@payloadcms/plugin-multi-tenant/client#WatchTenantCollection": WatchTenantCollection_1d0591e3cf4f332c83a86da13a0de59a,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import './styles.scss'
|
|||||||
interface KitchenReportResponse {
|
interface KitchenReportResponse {
|
||||||
date: string
|
date: string
|
||||||
mealType: string
|
mealType: string
|
||||||
totalOrders: number
|
totalMeals: number
|
||||||
ingredients: Record<string, number>
|
ingredients: Record<string, number>
|
||||||
labels: Record<string, string>
|
labels: Record<string, string>
|
||||||
portionSizes?: Record<string, number>
|
portionSizes?: Record<string, number>
|
||||||
@@ -31,7 +31,7 @@ export const KitchenDashboard: React.FC = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/meal-orders/kitchen-report?date=${date}&mealType=${mealType}`,
|
`/api/meals/kitchen-report?date=${date}&mealType=${mealType}`,
|
||||||
{
|
{
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
},
|
},
|
||||||
@@ -138,14 +138,14 @@ export const KitchenDashboard: React.FC = () => {
|
|||||||
<strong>Meal:</strong> {getMealTypeLabel(report.mealType)}
|
<strong>Meal:</strong> {getMealTypeLabel(report.mealType)}
|
||||||
</span>
|
</span>
|
||||||
<span className="kitchen-dashboard__meta-item kitchen-dashboard__meta-item--highlight">
|
<span className="kitchen-dashboard__meta-item kitchen-dashboard__meta-item--highlight">
|
||||||
<strong>Total Orders:</strong> {report.totalOrders}
|
<strong>Total Meals:</strong> {report.totalMeals}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{report.totalOrders === 0 ? (
|
{report.totalMeals === 0 ? (
|
||||||
<div className="kitchen-dashboard__empty">
|
<div className="kitchen-dashboard__empty">
|
||||||
<p>No orders found for this date and meal type.</p>
|
<p>No meals found for this date and meal type.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import type { CollectionConfig } from 'payload'
|
import type { CollectionConfig } from 'payload'
|
||||||
import { isSuperAdmin } from '@/access/isSuperAdmin'
|
import { isSuperAdmin } from '@/access/isSuperAdmin'
|
||||||
import { hasTenantRole } from '@/access/roles'
|
import { hasTenantRole } from '@/access/roles'
|
||||||
import { setCreatedBy } from './hooks/setCreatedBy'
|
|
||||||
import { generateTitle } from './hooks/generateTitle'
|
|
||||||
import { kitchenReportEndpoint } from './endpoints/kitchenReport'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Meal Orders Collection
|
* Meal Orders Collection
|
||||||
*
|
*
|
||||||
* Represents a single meal order for a resident, including:
|
* Represents a batch of meals for a specific date and meal type.
|
||||||
* - Date and meal type (breakfast, lunch, dinner)
|
* Caregivers create an order, add individual meals for residents, then submit to kitchen.
|
||||||
* - Status tracking (pending, preparing, prepared)
|
|
||||||
* - Meal-specific options from the paper forms
|
|
||||||
*
|
*
|
||||||
* Multi-tenant: each order belongs to a specific care home.
|
* Workflow:
|
||||||
|
* 1. Caregiver creates an order (draft status)
|
||||||
|
* 2. Caregiver adds individual meals for residents
|
||||||
|
* 3. Caregiver reviews summary and submits (submitted status)
|
||||||
|
* 4. Kitchen processes orders (preparing -> completed statuses)
|
||||||
*/
|
*/
|
||||||
export const MealOrders: CollectionConfig = {
|
export const MealOrders: CollectionConfig = {
|
||||||
slug: 'meal-orders',
|
slug: 'meal-orders',
|
||||||
@@ -23,15 +22,10 @@ export const MealOrders: CollectionConfig = {
|
|||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
useAsTitle: 'title',
|
useAsTitle: 'title',
|
||||||
description: 'Manage meal orders for residents',
|
description: 'Batch meals by date and meal type',
|
||||||
defaultColumns: ['title', 'resident', 'date', 'mealType', 'status'],
|
defaultColumns: ['title', 'date', 'mealType', 'status', 'mealCount'],
|
||||||
group: 'Meal Planning',
|
group: 'Meal Planning',
|
||||||
},
|
},
|
||||||
endpoints: [kitchenReportEndpoint],
|
|
||||||
hooks: {
|
|
||||||
beforeChange: [setCreatedBy],
|
|
||||||
beforeValidate: [generateTitle],
|
|
||||||
},
|
|
||||||
access: {
|
access: {
|
||||||
// Admin and caregiver can create orders
|
// Admin and caregiver can create orders
|
||||||
create: ({ req }) => {
|
create: ({ req }) => {
|
||||||
@@ -39,23 +33,22 @@ export const MealOrders: CollectionConfig = {
|
|||||||
if (isSuperAdmin(req.user)) return true
|
if (isSuperAdmin(req.user)) return true
|
||||||
return hasTenantRole(req.user, 'admin') || hasTenantRole(req.user, 'caregiver')
|
return hasTenantRole(req.user, 'admin') || hasTenantRole(req.user, 'caregiver')
|
||||||
},
|
},
|
||||||
// All authenticated users within the tenant can read orders
|
// All authenticated users within the tenant can read
|
||||||
read: ({ req }) => {
|
read: ({ req }) => {
|
||||||
if (!req.user) return false
|
if (!req.user) return false
|
||||||
return true // Multi-tenant plugin will filter by tenant
|
return true // Multi-tenant plugin will filter by tenant
|
||||||
},
|
},
|
||||||
// Admin can update all, caregiver can update own pending orders, kitchen can update status
|
// Admin, caregiver (if draft), and kitchen can update
|
||||||
update: ({ req }) => {
|
update: ({ req }) => {
|
||||||
if (!req.user) return false
|
if (!req.user) return false
|
||||||
if (isSuperAdmin(req.user)) return true
|
if (isSuperAdmin(req.user)) return true
|
||||||
// All tenant roles can update (with field-level restrictions)
|
|
||||||
return (
|
return (
|
||||||
hasTenantRole(req.user, 'admin') ||
|
hasTenantRole(req.user, 'admin') ||
|
||||||
hasTenantRole(req.user, 'caregiver') ||
|
hasTenantRole(req.user, 'caregiver') ||
|
||||||
hasTenantRole(req.user, 'kitchen')
|
hasTenantRole(req.user, 'kitchen')
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
// Only admin can delete orders
|
// Only admin can delete
|
||||||
delete: ({ req }) => {
|
delete: ({ req }) => {
|
||||||
if (!req.user) return false
|
if (!req.user) return false
|
||||||
if (isSuperAdmin(req.user)) return true
|
if (isSuperAdmin(req.user)) return true
|
||||||
@@ -63,7 +56,6 @@ export const MealOrders: CollectionConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
fields: [
|
fields: [
|
||||||
// Core Fields
|
|
||||||
{
|
{
|
||||||
name: 'title',
|
name: 'title',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -71,15 +63,26 @@ export const MealOrders: CollectionConfig = {
|
|||||||
readOnly: true,
|
readOnly: true,
|
||||||
description: 'Auto-generated title',
|
description: 'Auto-generated title',
|
||||||
},
|
},
|
||||||
|
hooks: {
|
||||||
|
beforeChange: [
|
||||||
|
({ data, operation }) => {
|
||||||
|
if (operation === 'create' || operation === 'update') {
|
||||||
|
const mealLabels: Record<string, string> = {
|
||||||
|
breakfast: 'Breakfast',
|
||||||
|
lunch: 'Lunch',
|
||||||
|
dinner: 'Dinner',
|
||||||
|
}
|
||||||
|
const date = data?.date ? new Date(data.date).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}) : ''
|
||||||
|
const mealType = mealLabels[data?.mealType || ''] || data?.mealType || ''
|
||||||
|
return `${mealType} - ${date}`
|
||||||
|
}
|
||||||
|
return data?.title
|
||||||
},
|
},
|
||||||
{
|
],
|
||||||
name: 'resident',
|
|
||||||
type: 'relationship',
|
|
||||||
relationTo: 'residents',
|
|
||||||
required: true,
|
|
||||||
index: true,
|
|
||||||
admin: {
|
|
||||||
description: 'Select the resident for this meal order',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -118,16 +121,39 @@ export const MealOrders: CollectionConfig = {
|
|||||||
name: 'status',
|
name: 'status',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
required: true,
|
required: true,
|
||||||
defaultValue: 'pending',
|
defaultValue: 'draft',
|
||||||
index: true,
|
index: true,
|
||||||
options: [
|
options: [
|
||||||
{ label: 'Pending', value: 'pending' },
|
{ label: 'Draft (In Progress)', value: 'draft' },
|
||||||
|
{ label: 'Submitted to Kitchen', value: 'submitted' },
|
||||||
{ label: 'Preparing', value: 'preparing' },
|
{ label: 'Preparing', value: 'preparing' },
|
||||||
{ label: 'Prepared', value: 'prepared' },
|
{ label: 'Completed', value: 'completed' },
|
||||||
],
|
],
|
||||||
admin: {
|
admin: {
|
||||||
position: 'sidebar',
|
position: 'sidebar',
|
||||||
description: 'Order status for kitchen tracking',
|
description: 'Order status for workflow tracking',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mealCount',
|
||||||
|
type: 'number',
|
||||||
|
defaultValue: 0,
|
||||||
|
admin: {
|
||||||
|
position: 'sidebar',
|
||||||
|
readOnly: true,
|
||||||
|
description: 'Number of meals in this order',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'submittedAt',
|
||||||
|
type: 'date',
|
||||||
|
admin: {
|
||||||
|
position: 'sidebar',
|
||||||
|
readOnly: true,
|
||||||
|
description: 'When the order was submitted to kitchen',
|
||||||
|
date: {
|
||||||
|
pickerAppearance: 'dayAndTime',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -140,300 +166,27 @@ export const MealOrders: CollectionConfig = {
|
|||||||
description: 'User who created this order',
|
description: 'User who created this order',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Override Fields (optional per-order overrides)
|
|
||||||
{
|
|
||||||
type: 'collapsible',
|
|
||||||
label: 'Order Overrides',
|
|
||||||
admin: {
|
|
||||||
initCollapsed: true,
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'highCaloric',
|
|
||||||
type: 'checkbox',
|
|
||||||
defaultValue: false,
|
|
||||||
admin: {
|
|
||||||
description: 'Override: high-caloric requirement for this order',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'aversions',
|
|
||||||
type: 'textarea',
|
|
||||||
admin: {
|
|
||||||
description: 'Override: specific aversions for this order',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'notes',
|
name: 'notes',
|
||||||
type: 'textarea',
|
type: 'textarea',
|
||||||
admin: {
|
admin: {
|
||||||
description: 'Special notes for this order',
|
description: 'General notes for this batch of meals',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
hooks: {
|
||||||
|
beforeChange: [
|
||||||
// ============================================
|
// Set createdBy on create
|
||||||
// BREAKFAST FIELDS GROUP
|
({ req, operation, data }) => {
|
||||||
// ============================================
|
if (operation === 'create' && req.user) {
|
||||||
{
|
data.createdBy = req.user.id
|
||||||
type: 'group',
|
}
|
||||||
name: 'breakfast',
|
// Set submittedAt when status changes to submitted
|
||||||
label: 'Breakfast Options (Frühstück)',
|
if (data.status === 'submitted' && !data.submittedAt) {
|
||||||
admin: {
|
data.submittedAt = new Date().toISOString()
|
||||||
condition: (data) => data?.mealType === 'breakfast',
|
}
|
||||||
},
|
return data
|
||||||
fields: [
|
},
|
||||||
{
|
],
|
||||||
name: 'accordingToPlan',
|
},
|
||||||
type: 'checkbox',
|
|
||||||
label: 'According to Plan (Frühstück lt. Plan)',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'row',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
type: 'group',
|
|
||||||
name: 'bread',
|
|
||||||
label: 'Bread Selection',
|
|
||||||
fields: [
|
|
||||||
{ name: 'breadRoll', type: 'checkbox', label: 'Bread Roll (Brötchen)' },
|
|
||||||
{
|
|
||||||
name: 'wholeGrainRoll',
|
|
||||||
type: 'checkbox',
|
|
||||||
label: 'Whole Grain Roll (Vollkornbrötchen)',
|
|
||||||
},
|
|
||||||
{ name: 'greyBread', type: 'checkbox', label: 'Grey Bread (Graubrot)' },
|
|
||||||
{
|
|
||||||
name: 'wholeGrainBread',
|
|
||||||
type: 'checkbox',
|
|
||||||
label: 'Whole Grain Bread (Vollkornbrot)',
|
|
||||||
},
|
|
||||||
{ name: 'whiteBread', type: 'checkbox', label: 'White Bread (Weißbrot)' },
|
|
||||||
{ name: 'crispbread', type: 'checkbox', label: 'Crispbread (Knäckebrot)' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'porridge',
|
|
||||||
type: 'checkbox',
|
|
||||||
label: 'Porridge/Puree (Brei)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'row',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
type: 'group',
|
|
||||||
name: 'preparation',
|
|
||||||
label: 'Bread Preparation',
|
|
||||||
fields: [
|
|
||||||
{ name: 'sliced', type: 'checkbox', label: 'Sliced (geschnitten)' },
|
|
||||||
{ name: 'spread', type: 'checkbox', label: 'Spread (geschmiert)' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'row',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
type: 'group',
|
|
||||||
name: 'spreads',
|
|
||||||
label: 'Spreads',
|
|
||||||
fields: [
|
|
||||||
{ name: 'butter', type: 'checkbox', label: 'Butter' },
|
|
||||||
{ name: 'margarine', type: 'checkbox', label: 'Margarine' },
|
|
||||||
{ name: 'jam', type: 'checkbox', label: 'Jam (Konfitüre)' },
|
|
||||||
{ name: 'diabeticJam', type: 'checkbox', label: 'Diabetic Jam (Diab. Konfitüre)' },
|
|
||||||
{ name: 'honey', type: 'checkbox', label: 'Honey (Honig)' },
|
|
||||||
{ name: 'cheese', type: 'checkbox', label: 'Cheese (Käse)' },
|
|
||||||
{ name: 'quark', type: 'checkbox', label: 'Quark' },
|
|
||||||
{ name: 'sausage', type: 'checkbox', label: 'Sausage (Wurst)' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'row',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
type: 'group',
|
|
||||||
name: 'beverages',
|
|
||||||
label: 'Beverages',
|
|
||||||
fields: [
|
|
||||||
{ name: 'coffee', type: 'checkbox', label: 'Coffee (Kaffee)' },
|
|
||||||
{ name: 'tea', type: 'checkbox', label: 'Tea (Tee)' },
|
|
||||||
{ name: 'hotMilk', type: 'checkbox', label: 'Hot Milk (Milch heiß)' },
|
|
||||||
{ name: 'coldMilk', type: 'checkbox', label: 'Cold Milk (Milch kalt)' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'row',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
type: 'group',
|
|
||||||
name: 'additions',
|
|
||||||
label: 'Additions',
|
|
||||||
fields: [
|
|
||||||
{ name: 'sugar', type: 'checkbox', label: 'Sugar (Zucker)' },
|
|
||||||
{ name: 'sweetener', type: 'checkbox', label: 'Sweetener (Süßstoff)' },
|
|
||||||
{ name: 'coffeeCreamer', type: 'checkbox', label: 'Coffee Creamer (Kaffeesahne)' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// LUNCH FIELDS GROUP
|
|
||||||
// ============================================
|
|
||||||
{
|
|
||||||
type: 'group',
|
|
||||||
name: 'lunch',
|
|
||||||
label: 'Lunch Options (Mittagessen)',
|
|
||||||
admin: {
|
|
||||||
condition: (data) => data?.mealType === 'lunch',
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'portionSize',
|
|
||||||
type: 'select',
|
|
||||||
label: 'Portion Size',
|
|
||||||
options: [
|
|
||||||
{ label: 'Small Portion (Kleine Portion)', value: 'small' },
|
|
||||||
{ label: 'Large Portion (Große Portion)', value: 'large' },
|
|
||||||
{
|
|
||||||
label: 'Vegetarian Whole-Food (Vollwertkost vegetarisch)',
|
|
||||||
value: 'vegetarian',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'row',
|
|
||||||
fields: [
|
|
||||||
{ name: 'soup', type: 'checkbox', label: 'Soup (Suppe)', admin: { width: '50%' } },
|
|
||||||
{ name: 'dessert', type: 'checkbox', label: 'Dessert', admin: { width: '50%' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'group',
|
|
||||||
name: 'specialPreparations',
|
|
||||||
label: 'Special Preparations',
|
|
||||||
fields: [
|
|
||||||
{ name: 'pureedFood', type: 'checkbox', label: 'Pureed Food (passierte Kost)' },
|
|
||||||
{ name: 'pureedMeat', type: 'checkbox', label: 'Pureed Meat (passiertes Fleisch)' },
|
|
||||||
{ name: 'slicedMeat', type: 'checkbox', label: 'Sliced Meat (geschnittenes Fleisch)' },
|
|
||||||
{ name: 'mashedPotatoes', type: 'checkbox', label: 'Mashed Potatoes (Kartoffelbrei)' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'group',
|
|
||||||
name: 'restrictions',
|
|
||||||
label: 'Restrictions',
|
|
||||||
fields: [
|
|
||||||
{ name: 'noFish', type: 'checkbox', label: 'No Fish (ohne Fisch)' },
|
|
||||||
{ name: 'fingerFood', type: 'checkbox', label: 'Finger Food' },
|
|
||||||
{ name: 'onlySweet', type: 'checkbox', label: 'Only Sweet (nur süß)' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// DINNER FIELDS GROUP
|
|
||||||
// ============================================
|
|
||||||
{
|
|
||||||
type: 'group',
|
|
||||||
name: 'dinner',
|
|
||||||
label: 'Dinner Options (Abendessen)',
|
|
||||||
admin: {
|
|
||||||
condition: (data) => data?.mealType === 'dinner',
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: 'accordingToPlan',
|
|
||||||
type: 'checkbox',
|
|
||||||
label: 'According to Plan (Abendessen lt. Plan)',
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'group',
|
|
||||||
name: 'bread',
|
|
||||||
label: 'Bread Selection',
|
|
||||||
fields: [
|
|
||||||
{ name: 'greyBread', type: 'checkbox', label: 'Grey Bread (Graubrot)' },
|
|
||||||
{
|
|
||||||
name: 'wholeGrainBread',
|
|
||||||
type: 'checkbox',
|
|
||||||
label: 'Whole Grain Bread (Vollkornbrot)',
|
|
||||||
},
|
|
||||||
{ name: 'whiteBread', type: 'checkbox', label: 'White Bread (Weißbrot)' },
|
|
||||||
{ name: 'crispbread', type: 'checkbox', label: 'Crispbread (Knäckebrot)' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'group',
|
|
||||||
name: 'preparation',
|
|
||||||
label: 'Bread Preparation',
|
|
||||||
fields: [
|
|
||||||
{ name: 'spread', type: 'checkbox', label: 'Spread (geschmiert)' },
|
|
||||||
{ name: 'sliced', type: 'checkbox', label: 'Sliced (geschnitten)' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'group',
|
|
||||||
name: 'spreads',
|
|
||||||
label: 'Spreads',
|
|
||||||
fields: [
|
|
||||||
{ name: 'butter', type: 'checkbox', label: 'Butter' },
|
|
||||||
{ name: 'margarine', type: 'checkbox', label: 'Margarine' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'row',
|
|
||||||
fields: [
|
|
||||||
{ name: 'soup', type: 'checkbox', label: 'Soup (Suppe)', admin: { width: '33%' } },
|
|
||||||
{
|
|
||||||
name: 'porridge',
|
|
||||||
type: 'checkbox',
|
|
||||||
label: 'Porridge (Brei)',
|
|
||||||
admin: { width: '33%' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'noFish',
|
|
||||||
type: 'checkbox',
|
|
||||||
label: 'No Fish (ohne Fisch)',
|
|
||||||
admin: { width: '33%' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'group',
|
|
||||||
name: 'beverages',
|
|
||||||
label: 'Beverages',
|
|
||||||
fields: [
|
|
||||||
{ name: 'tea', type: 'checkbox', label: 'Tea (Tee)' },
|
|
||||||
{ name: 'cocoa', type: 'checkbox', label: 'Cocoa (Kakao)' },
|
|
||||||
{ name: 'hotMilk', type: 'checkbox', label: 'Hot Milk (Milch heiß)' },
|
|
||||||
{ name: 'coldMilk', type: 'checkbox', label: 'Cold Milk (Milch kalt)' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'group',
|
|
||||||
name: 'additions',
|
|
||||||
label: 'Additions',
|
|
||||||
fields: [
|
|
||||||
{ name: 'sugar', type: 'checkbox', label: 'Sugar (Zucker)' },
|
|
||||||
{ name: 'sweetener', type: 'checkbox', label: 'Sweetener (Süßstoff)' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,11 +82,12 @@ function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
|||||||
/**
|
/**
|
||||||
* Kitchen Report API Endpoint
|
* Kitchen Report API Endpoint
|
||||||
*
|
*
|
||||||
* GET /api/meal-orders/kitchen-report
|
* GET /api/meals/kitchen-report
|
||||||
*
|
*
|
||||||
* Query Parameters:
|
* Query Parameters:
|
||||||
* - date (required): YYYY-MM-DD format
|
* - date (required): YYYY-MM-DD format
|
||||||
* - mealType (required): breakfast | lunch | dinner
|
* - mealType (required): breakfast | lunch | dinner
|
||||||
|
* - order (optional): filter by meal order ID
|
||||||
*
|
*
|
||||||
* Returns aggregated ingredient counts for the specified date and meal type.
|
* Returns aggregated ingredient counts for the specified date and meal type.
|
||||||
* Only accessible by users with admin or kitchen role.
|
* Only accessible by users with admin or kitchen role.
|
||||||
@@ -114,6 +115,7 @@ export const kitchenReportEndpoint: Endpoint = {
|
|||||||
const url = new URL(req.url || '', 'http://localhost')
|
const url = new URL(req.url || '', 'http://localhost')
|
||||||
const date = url.searchParams.get('date')
|
const date = url.searchParams.get('date')
|
||||||
const mealType = url.searchParams.get('mealType')
|
const mealType = url.searchParams.get('mealType')
|
||||||
|
const orderId = url.searchParams.get('order')
|
||||||
|
|
||||||
// Validate parameters
|
// Validate parameters
|
||||||
if (!date) {
|
if (!date) {
|
||||||
@@ -134,24 +136,26 @@ export const kitchenReportEndpoint: Endpoint = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Query meal orders for the specified date and meal type
|
// Build the where clause
|
||||||
const orders = await payload.find({
|
const whereClause: {
|
||||||
collection: 'meal-orders',
|
and: Array<{ date?: { equals: string }; mealType?: { equals: string }; order?: { equals: number } }>
|
||||||
where: {
|
} = {
|
||||||
and: [
|
and: [
|
||||||
{
|
{ date: { equals: date } },
|
||||||
date: {
|
{ mealType: { equals: mealType } },
|
||||||
equals: date,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mealType: {
|
|
||||||
equals: mealType,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
}
|
||||||
limit: 1000, // Get all orders for the day
|
|
||||||
|
// Optionally filter by meal order
|
||||||
|
if (orderId) {
|
||||||
|
whereClause.and.push({ order: { equals: Number(orderId) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query meals for the specified date and meal type
|
||||||
|
const meals = await payload.find({
|
||||||
|
collection: 'meals',
|
||||||
|
where: whereClause,
|
||||||
|
limit: 1000, // Get all meals for the day
|
||||||
depth: 0,
|
depth: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -179,18 +183,18 @@ export const kitchenReportEndpoint: Endpoint = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Count occurrences
|
// Count occurrences
|
||||||
for (const order of orders.docs) {
|
for (const meal of meals.docs) {
|
||||||
// Count boolean fields
|
// Count boolean fields
|
||||||
for (const fieldPath of Object.keys(fieldMapping)) {
|
for (const fieldPath of Object.keys(fieldMapping)) {
|
||||||
const value = getNestedValue(order as unknown as Record<string, unknown>, fieldPath)
|
const value = getNestedValue(meal as unknown as Record<string, unknown>, fieldPath)
|
||||||
if (value === true) {
|
if (value === true) {
|
||||||
ingredients[fieldPath].count++
|
ingredients[fieldPath].count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count lunch portion sizes
|
// Count lunch portion sizes
|
||||||
if (mealType === 'lunch' && order.lunch?.portionSize) {
|
if (mealType === 'lunch' && meal.lunch?.portionSize) {
|
||||||
const size = order.lunch.portionSize as string
|
const size = meal.lunch.portionSize as string
|
||||||
if (size in portionSizes) {
|
if (size in portionSizes) {
|
||||||
portionSizes[size]++
|
portionSizes[size]++
|
||||||
}
|
}
|
||||||
@@ -214,7 +218,7 @@ export const kitchenReportEndpoint: Endpoint = {
|
|||||||
const response: Record<string, unknown> = {
|
const response: Record<string, unknown> = {
|
||||||
date,
|
date,
|
||||||
mealType,
|
mealType,
|
||||||
totalOrders: orders.totalDocs,
|
totalMeals: meals.totalDocs,
|
||||||
ingredients: ingredientCounts,
|
ingredients: ingredientCounts,
|
||||||
labels: ingredientLabels,
|
labels: ingredientLabels,
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ import type { CollectionBeforeValidateHook } from 'payload'
|
|||||||
* Hook to auto-generate the title field from date, meal type, and resident name
|
* Hook to auto-generate the title field from date, meal type, and resident name
|
||||||
* Format: "Breakfast - 2024-01-15 - John Doe"
|
* Format: "Breakfast - 2024-01-15 - John Doe"
|
||||||
*/
|
*/
|
||||||
export const generateTitle: CollectionBeforeValidateHook = async ({ data, req, operation }) => {
|
export const generateTitle: CollectionBeforeValidateHook = async ({ data, req, operation: _operation }) => {
|
||||||
if (!data) return data
|
if (!data) return data
|
||||||
|
|
||||||
const mealType = data.mealType
|
const mealType = data.mealType
|
||||||
448
src/collections/Meals/index.ts
Normal file
448
src/collections/Meals/index.ts
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
import type { CollectionConfig } from 'payload'
|
||||||
|
import { isSuperAdmin } from '@/access/isSuperAdmin'
|
||||||
|
import { hasTenantRole } from '@/access/roles'
|
||||||
|
import { setCreatedBy } from './hooks/setCreatedBy'
|
||||||
|
import { generateTitle } from './hooks/generateTitle'
|
||||||
|
import { kitchenReportEndpoint } from './endpoints/kitchenReport'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meals Collection
|
||||||
|
*
|
||||||
|
* Represents a single meal for a resident, including:
|
||||||
|
* - Date and meal type (breakfast, lunch, dinner)
|
||||||
|
* - Status tracking (pending, preparing, prepared)
|
||||||
|
* - Meal-specific options from the paper forms
|
||||||
|
*
|
||||||
|
* Multi-tenant: each meal belongs to a specific care home.
|
||||||
|
*/
|
||||||
|
export const Meals: CollectionConfig = {
|
||||||
|
slug: 'meals',
|
||||||
|
labels: {
|
||||||
|
singular: 'Meal',
|
||||||
|
plural: 'Meals',
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
useAsTitle: 'title',
|
||||||
|
description: 'Manage meals for residents',
|
||||||
|
defaultColumns: ['title', 'resident', 'date', 'mealType', 'status'],
|
||||||
|
group: 'Meal Planning',
|
||||||
|
},
|
||||||
|
endpoints: [kitchenReportEndpoint],
|
||||||
|
hooks: {
|
||||||
|
beforeChange: [setCreatedBy],
|
||||||
|
beforeValidate: [generateTitle],
|
||||||
|
},
|
||||||
|
access: {
|
||||||
|
// Admin and caregiver can create meals
|
||||||
|
create: ({ req }) => {
|
||||||
|
if (!req.user) return false
|
||||||
|
if (isSuperAdmin(req.user)) return true
|
||||||
|
return hasTenantRole(req.user, 'admin') || hasTenantRole(req.user, 'caregiver')
|
||||||
|
},
|
||||||
|
// All authenticated users within the tenant can read meals
|
||||||
|
read: ({ req }) => {
|
||||||
|
if (!req.user) return false
|
||||||
|
return true // Multi-tenant plugin will filter by tenant
|
||||||
|
},
|
||||||
|
// Admin can update all, caregiver can update own pending meals, kitchen can update status
|
||||||
|
update: ({ req }) => {
|
||||||
|
if (!req.user) return false
|
||||||
|
if (isSuperAdmin(req.user)) return true
|
||||||
|
// All tenant roles can update (with field-level restrictions)
|
||||||
|
return (
|
||||||
|
hasTenantRole(req.user, 'admin') ||
|
||||||
|
hasTenantRole(req.user, 'caregiver') ||
|
||||||
|
hasTenantRole(req.user, 'kitchen')
|
||||||
|
)
|
||||||
|
},
|
||||||
|
// Only admin can delete meals
|
||||||
|
delete: ({ req }) => {
|
||||||
|
if (!req.user) return false
|
||||||
|
if (isSuperAdmin(req.user)) return true
|
||||||
|
return hasTenantRole(req.user, 'admin')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
// Core Fields
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
description: 'Auto-generated title',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'order',
|
||||||
|
type: 'relationship',
|
||||||
|
relationTo: 'meal-orders',
|
||||||
|
index: true,
|
||||||
|
admin: {
|
||||||
|
description: 'The meal order this meal belongs to',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'resident',
|
||||||
|
type: 'relationship',
|
||||||
|
relationTo: 'residents',
|
||||||
|
required: true,
|
||||||
|
index: true,
|
||||||
|
admin: {
|
||||||
|
description: 'Select the resident for this meal',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'date',
|
||||||
|
type: 'date',
|
||||||
|
required: true,
|
||||||
|
index: true,
|
||||||
|
admin: {
|
||||||
|
date: {
|
||||||
|
pickerAppearance: 'dayOnly',
|
||||||
|
displayFormat: 'yyyy-MM-dd',
|
||||||
|
},
|
||||||
|
width: '50%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mealType',
|
||||||
|
type: 'select',
|
||||||
|
required: true,
|
||||||
|
index: true,
|
||||||
|
options: [
|
||||||
|
{ label: 'Breakfast (Frühstück)', value: 'breakfast' },
|
||||||
|
{ label: 'Lunch (Mittagessen)', value: 'lunch' },
|
||||||
|
{ label: 'Dinner (Abendessen)', value: 'dinner' },
|
||||||
|
],
|
||||||
|
admin: {
|
||||||
|
width: '50%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'status',
|
||||||
|
type: 'select',
|
||||||
|
required: true,
|
||||||
|
defaultValue: 'pending',
|
||||||
|
index: true,
|
||||||
|
options: [
|
||||||
|
{ label: 'Pending', value: 'pending' },
|
||||||
|
{ label: 'Preparing', value: 'preparing' },
|
||||||
|
{ label: 'Prepared', value: 'prepared' },
|
||||||
|
],
|
||||||
|
admin: {
|
||||||
|
position: 'sidebar',
|
||||||
|
description: 'Meal status for kitchen tracking',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'createdBy',
|
||||||
|
type: 'relationship',
|
||||||
|
relationTo: 'users',
|
||||||
|
admin: {
|
||||||
|
position: 'sidebar',
|
||||||
|
readOnly: true,
|
||||||
|
description: 'User who created this meal',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Override Fields (optional per-meal overrides)
|
||||||
|
{
|
||||||
|
type: 'collapsible',
|
||||||
|
label: 'Meal Overrides',
|
||||||
|
admin: {
|
||||||
|
initCollapsed: true,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'highCaloric',
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: false,
|
||||||
|
admin: {
|
||||||
|
description: 'Override: high-caloric requirement for this meal',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'aversions',
|
||||||
|
type: 'textarea',
|
||||||
|
admin: {
|
||||||
|
description: 'Override: specific aversions for this meal',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'notes',
|
||||||
|
type: 'textarea',
|
||||||
|
admin: {
|
||||||
|
description: 'Special notes for this meal',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// BREAKFAST FIELDS GROUP
|
||||||
|
// ============================================
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
name: 'breakfast',
|
||||||
|
label: 'Breakfast Options (Frühstück)',
|
||||||
|
admin: {
|
||||||
|
condition: (data) => data?.mealType === 'breakfast',
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'accordingToPlan',
|
||||||
|
type: 'checkbox',
|
||||||
|
label: 'According to Plan (Frühstück lt. Plan)',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
name: 'bread',
|
||||||
|
label: 'Bread Selection',
|
||||||
|
fields: [
|
||||||
|
{ name: 'breadRoll', type: 'checkbox', label: 'Bread Roll (Brötchen)' },
|
||||||
|
{
|
||||||
|
name: 'wholeGrainRoll',
|
||||||
|
type: 'checkbox',
|
||||||
|
label: 'Whole Grain Roll (Vollkornbrötchen)',
|
||||||
|
},
|
||||||
|
{ name: 'greyBread', type: 'checkbox', label: 'Grey Bread (Graubrot)' },
|
||||||
|
{
|
||||||
|
name: 'wholeGrainBread',
|
||||||
|
type: 'checkbox',
|
||||||
|
label: 'Whole Grain Bread (Vollkornbrot)',
|
||||||
|
},
|
||||||
|
{ name: 'whiteBread', type: 'checkbox', label: 'White Bread (Weißbrot)' },
|
||||||
|
{ name: 'crispbread', type: 'checkbox', label: 'Crispbread (Knäckebrot)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'porridge',
|
||||||
|
type: 'checkbox',
|
||||||
|
label: 'Porridge/Puree (Brei)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
name: 'preparation',
|
||||||
|
label: 'Bread Preparation',
|
||||||
|
fields: [
|
||||||
|
{ name: 'sliced', type: 'checkbox', label: 'Sliced (geschnitten)' },
|
||||||
|
{ name: 'spread', type: 'checkbox', label: 'Spread (geschmiert)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
name: 'spreads',
|
||||||
|
label: 'Spreads',
|
||||||
|
fields: [
|
||||||
|
{ name: 'butter', type: 'checkbox', label: 'Butter' },
|
||||||
|
{ name: 'margarine', type: 'checkbox', label: 'Margarine' },
|
||||||
|
{ name: 'jam', type: 'checkbox', label: 'Jam (Konfitüre)' },
|
||||||
|
{ name: 'diabeticJam', type: 'checkbox', label: 'Diabetic Jam (Diab. Konfitüre)' },
|
||||||
|
{ name: 'honey', type: 'checkbox', label: 'Honey (Honig)' },
|
||||||
|
{ name: 'cheese', type: 'checkbox', label: 'Cheese (Käse)' },
|
||||||
|
{ name: 'quark', type: 'checkbox', label: 'Quark' },
|
||||||
|
{ name: 'sausage', type: 'checkbox', label: 'Sausage (Wurst)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
name: 'beverages',
|
||||||
|
label: 'Beverages',
|
||||||
|
fields: [
|
||||||
|
{ name: 'coffee', type: 'checkbox', label: 'Coffee (Kaffee)' },
|
||||||
|
{ name: 'tea', type: 'checkbox', label: 'Tea (Tee)' },
|
||||||
|
{ name: 'hotMilk', type: 'checkbox', label: 'Hot Milk (Milch heiß)' },
|
||||||
|
{ name: 'coldMilk', type: 'checkbox', label: 'Cold Milk (Milch kalt)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
name: 'additions',
|
||||||
|
label: 'Additions',
|
||||||
|
fields: [
|
||||||
|
{ name: 'sugar', type: 'checkbox', label: 'Sugar (Zucker)' },
|
||||||
|
{ name: 'sweetener', type: 'checkbox', label: 'Sweetener (Süßstoff)' },
|
||||||
|
{ name: 'coffeeCreamer', type: 'checkbox', label: 'Coffee Creamer (Kaffeesahne)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// LUNCH FIELDS GROUP
|
||||||
|
// ============================================
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
name: 'lunch',
|
||||||
|
label: 'Lunch Options (Mittagessen)',
|
||||||
|
admin: {
|
||||||
|
condition: (data) => data?.mealType === 'lunch',
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'portionSize',
|
||||||
|
type: 'select',
|
||||||
|
label: 'Portion Size',
|
||||||
|
options: [
|
||||||
|
{ label: 'Small Portion (Kleine Portion)', value: 'small' },
|
||||||
|
{ label: 'Large Portion (Große Portion)', value: 'large' },
|
||||||
|
{
|
||||||
|
label: 'Vegetarian Whole-Food (Vollwertkost vegetarisch)',
|
||||||
|
value: 'vegetarian',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
fields: [
|
||||||
|
{ name: 'soup', type: 'checkbox', label: 'Soup (Suppe)', admin: { width: '50%' } },
|
||||||
|
{ name: 'dessert', type: 'checkbox', label: 'Dessert', admin: { width: '50%' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
name: 'specialPreparations',
|
||||||
|
label: 'Special Preparations',
|
||||||
|
fields: [
|
||||||
|
{ name: 'pureedFood', type: 'checkbox', label: 'Pureed Food (passierte Kost)' },
|
||||||
|
{ name: 'pureedMeat', type: 'checkbox', label: 'Pureed Meat (passiertes Fleisch)' },
|
||||||
|
{ name: 'slicedMeat', type: 'checkbox', label: 'Sliced Meat (geschnittenes Fleisch)' },
|
||||||
|
{ name: 'mashedPotatoes', type: 'checkbox', label: 'Mashed Potatoes (Kartoffelbrei)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
name: 'restrictions',
|
||||||
|
label: 'Restrictions',
|
||||||
|
fields: [
|
||||||
|
{ name: 'noFish', type: 'checkbox', label: 'No Fish (ohne Fisch)' },
|
||||||
|
{ name: 'fingerFood', type: 'checkbox', label: 'Finger Food' },
|
||||||
|
{ name: 'onlySweet', type: 'checkbox', label: 'Only Sweet (nur süß)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// DINNER FIELDS GROUP
|
||||||
|
// ============================================
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
name: 'dinner',
|
||||||
|
label: 'Dinner Options (Abendessen)',
|
||||||
|
admin: {
|
||||||
|
condition: (data) => data?.mealType === 'dinner',
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'accordingToPlan',
|
||||||
|
type: 'checkbox',
|
||||||
|
label: 'According to Plan (Abendessen lt. Plan)',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
name: 'bread',
|
||||||
|
label: 'Bread Selection',
|
||||||
|
fields: [
|
||||||
|
{ name: 'greyBread', type: 'checkbox', label: 'Grey Bread (Graubrot)' },
|
||||||
|
{
|
||||||
|
name: 'wholeGrainBread',
|
||||||
|
type: 'checkbox',
|
||||||
|
label: 'Whole Grain Bread (Vollkornbrot)',
|
||||||
|
},
|
||||||
|
{ name: 'whiteBread', type: 'checkbox', label: 'White Bread (Weißbrot)' },
|
||||||
|
{ name: 'crispbread', type: 'checkbox', label: 'Crispbread (Knäckebrot)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
name: 'preparation',
|
||||||
|
label: 'Bread Preparation',
|
||||||
|
fields: [
|
||||||
|
{ name: 'spread', type: 'checkbox', label: 'Spread (geschmiert)' },
|
||||||
|
{ name: 'sliced', type: 'checkbox', label: 'Sliced (geschnitten)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
name: 'spreads',
|
||||||
|
label: 'Spreads',
|
||||||
|
fields: [
|
||||||
|
{ name: 'butter', type: 'checkbox', label: 'Butter' },
|
||||||
|
{ name: 'margarine', type: 'checkbox', label: 'Margarine' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
fields: [
|
||||||
|
{ name: 'soup', type: 'checkbox', label: 'Soup (Suppe)', admin: { width: '33%' } },
|
||||||
|
{
|
||||||
|
name: 'porridge',
|
||||||
|
type: 'checkbox',
|
||||||
|
label: 'Porridge (Brei)',
|
||||||
|
admin: { width: '33%' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'noFish',
|
||||||
|
type: 'checkbox',
|
||||||
|
label: 'No Fish (ohne Fisch)',
|
||||||
|
admin: { width: '33%' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
name: 'beverages',
|
||||||
|
label: 'Beverages',
|
||||||
|
fields: [
|
||||||
|
{ name: 'tea', type: 'checkbox', label: 'Tea (Tee)' },
|
||||||
|
{ name: 'cocoa', type: 'checkbox', label: 'Cocoa (Kakao)' },
|
||||||
|
{ name: 'hotMilk', type: 'checkbox', label: 'Hot Milk (Milch heiß)' },
|
||||||
|
{ name: 'coldMilk', type: 'checkbox', label: 'Cold Milk (Milch kalt)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
name: 'additions',
|
||||||
|
label: 'Additions',
|
||||||
|
fields: [
|
||||||
|
{ name: 'sugar', type: 'checkbox', label: 'Sugar (Zucker)' },
|
||||||
|
{ name: 'sweetener', type: 'checkbox', label: 'Sweetener (Süßstoff)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CollectionConfig } from 'payload'
|
import type { CollectionConfig } from 'payload'
|
||||||
import { isSuperAdmin, isSuperAdminAccess } from '@/access/isSuperAdmin'
|
import { isSuperAdmin } from '@/access/isSuperAdmin'
|
||||||
import { hasTenantRole } from '@/access/roles'
|
import { hasTenantRole } from '@/access/roles'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const externalUsersLogin: Endpoint = {
|
|||||||
if (typeof req.json === 'function') {
|
if (typeof req.json === 'function') {
|
||||||
data = await req.json()
|
data = await req.json()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
// swallow error, data is already empty object
|
// swallow error, data is already empty object
|
||||||
}
|
}
|
||||||
const { password, tenantSlug, tenantDomain, username } = data
|
const { password, tenantSlug, tenantDomain, username } = data
|
||||||
@@ -113,7 +113,7 @@ export const externalUsersLogin: Endpoint = {
|
|||||||
null,
|
null,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
throw new APIError(
|
throw new APIError(
|
||||||
'Unable to login with the provided username and password.',
|
'Unable to login with the provided username and password.',
|
||||||
400,
|
400,
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ import type { FieldHook, Where } from 'payload'
|
|||||||
import { ValidationError } from 'payload'
|
import { ValidationError } from 'payload'
|
||||||
|
|
||||||
import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs'
|
import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs'
|
||||||
import { extractID } from '@/utilities/extractID'
|
|
||||||
import { getTenantFromCookie } from '@payloadcms/plugin-multi-tenant/utilities'
|
import { getTenantFromCookie } from '@payloadcms/plugin-multi-tenant/utilities'
|
||||||
import { getCollectionIDType } from '@/utilities/getCollectionIDType'
|
import { getCollectionIDType } from '@/utilities/getCollectionIDType'
|
||||||
|
|
||||||
export const ensureUniqueUsername: FieldHook = async ({ data, originalDoc, req, value }) => {
|
export const ensureUniqueUsername: FieldHook = async ({ data: _data, originalDoc, req, value }) => {
|
||||||
// if value is unchanged, skip validation
|
// if value is unchanged, skip validation
|
||||||
if (originalDoc.username === value) {
|
if (originalDoc.username === value) {
|
||||||
return value
|
return value
|
||||||
@@ -47,7 +46,7 @@ export const ensureUniqueUsername: FieldHook = async ({ data, originalDoc, req,
|
|||||||
// provide a more specific error message
|
// provide a more specific error message
|
||||||
if (req.user.roles?.includes('super-admin') || tenantIDs.length > 1) {
|
if (req.user.roles?.includes('super-admin') || tenantIDs.length > 1) {
|
||||||
const attemptedTenantChange = await req.payload.findByID({
|
const attemptedTenantChange = await req.payload.findByID({
|
||||||
// @ts-ignore - selectedTenant will match DB ID type
|
// @ts-expect-error - selectedTenant will match DB ID type
|
||||||
id: selectedTenant,
|
id: selectedTenant,
|
||||||
collection: 'tenants',
|
collection: 'tenants',
|
||||||
})
|
})
|
||||||
|
|||||||
143
src/components/ui/dialog.tsx
Normal file
143
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
31
src/components/ui/progress.tsx
Normal file
31
src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className="bg-primary h-full w-full flex-1 transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
139
src/components/ui/sheet.tsx
Normal file
139
src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
|
side === "right" &&
|
||||||
|
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||||
|
side === "left" &&
|
||||||
|
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||||
|
side === "top" &&
|
||||||
|
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||||
|
side === "bottom" &&
|
||||||
|
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
61
src/components/ui/tooltip.tsx
Normal file
61
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delayDuration = 0,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
@@ -71,6 +71,7 @@ export interface Config {
|
|||||||
tenants: Tenant;
|
tenants: Tenant;
|
||||||
residents: Resident;
|
residents: Resident;
|
||||||
'meal-orders': MealOrder;
|
'meal-orders': MealOrder;
|
||||||
|
meals: Meal;
|
||||||
'payload-kv': PayloadKv;
|
'payload-kv': PayloadKv;
|
||||||
'payload-locked-documents': PayloadLockedDocument;
|
'payload-locked-documents': PayloadLockedDocument;
|
||||||
'payload-preferences': PayloadPreference;
|
'payload-preferences': PayloadPreference;
|
||||||
@@ -82,6 +83,7 @@ export interface Config {
|
|||||||
tenants: TenantsSelect<false> | TenantsSelect<true>;
|
tenants: TenantsSelect<false> | TenantsSelect<true>;
|
||||||
residents: ResidentsSelect<false> | ResidentsSelect<true>;
|
residents: ResidentsSelect<false> | ResidentsSelect<true>;
|
||||||
'meal-orders': MealOrdersSelect<false> | MealOrdersSelect<true>;
|
'meal-orders': MealOrdersSelect<false> | MealOrdersSelect<true>;
|
||||||
|
meals: MealsSelect<false> | MealsSelect<true>;
|
||||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||||
@@ -242,7 +244,7 @@ export interface Resident {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Manage meal orders for residents
|
* Batch meals by date and meal type
|
||||||
*
|
*
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "meal-orders".
|
* via the `definition` "meal-orders".
|
||||||
@@ -254,30 +256,72 @@ export interface MealOrder {
|
|||||||
* Auto-generated title
|
* Auto-generated title
|
||||||
*/
|
*/
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
/**
|
|
||||||
* Select the resident for this meal order
|
|
||||||
*/
|
|
||||||
resident: number | Resident;
|
|
||||||
date: string;
|
date: string;
|
||||||
mealType: 'breakfast' | 'lunch' | 'dinner';
|
mealType: 'breakfast' | 'lunch' | 'dinner';
|
||||||
/**
|
/**
|
||||||
* Order status for kitchen tracking
|
* Order status for workflow tracking
|
||||||
*/
|
*/
|
||||||
status: 'pending' | 'preparing' | 'prepared';
|
status: 'draft' | 'submitted' | 'preparing' | 'completed';
|
||||||
|
/**
|
||||||
|
* Number of meals in this order
|
||||||
|
*/
|
||||||
|
mealCount?: number | null;
|
||||||
|
/**
|
||||||
|
* When the order was submitted to kitchen
|
||||||
|
*/
|
||||||
|
submittedAt?: string | null;
|
||||||
/**
|
/**
|
||||||
* User who created this order
|
* User who created this order
|
||||||
*/
|
*/
|
||||||
createdBy?: (number | null) | User;
|
createdBy?: (number | null) | User;
|
||||||
/**
|
/**
|
||||||
* Override: high-caloric requirement for this order
|
* General notes for this batch of meals
|
||||||
|
*/
|
||||||
|
notes?: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Manage meals for residents
|
||||||
|
*
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "meals".
|
||||||
|
*/
|
||||||
|
export interface Meal {
|
||||||
|
id: number;
|
||||||
|
tenant?: (number | null) | Tenant;
|
||||||
|
/**
|
||||||
|
* Auto-generated title
|
||||||
|
*/
|
||||||
|
title?: string | null;
|
||||||
|
/**
|
||||||
|
* The meal order this meal belongs to
|
||||||
|
*/
|
||||||
|
order?: (number | null) | MealOrder;
|
||||||
|
/**
|
||||||
|
* Select the resident for this meal
|
||||||
|
*/
|
||||||
|
resident: number | Resident;
|
||||||
|
date: string;
|
||||||
|
mealType: 'breakfast' | 'lunch' | 'dinner';
|
||||||
|
/**
|
||||||
|
* Meal status for kitchen tracking
|
||||||
|
*/
|
||||||
|
status: 'pending' | 'preparing' | 'prepared';
|
||||||
|
/**
|
||||||
|
* User who created this meal
|
||||||
|
*/
|
||||||
|
createdBy?: (number | null) | User;
|
||||||
|
/**
|
||||||
|
* Override: high-caloric requirement for this meal
|
||||||
*/
|
*/
|
||||||
highCaloric?: boolean | null;
|
highCaloric?: boolean | null;
|
||||||
/**
|
/**
|
||||||
* Override: specific aversions for this order
|
* Override: specific aversions for this meal
|
||||||
*/
|
*/
|
||||||
aversions?: string | null;
|
aversions?: string | null;
|
||||||
/**
|
/**
|
||||||
* Special notes for this order
|
* Special notes for this meal
|
||||||
*/
|
*/
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
breakfast?: {
|
breakfast?: {
|
||||||
@@ -405,6 +449,10 @@ export interface PayloadLockedDocument {
|
|||||||
| ({
|
| ({
|
||||||
relationTo: 'meal-orders';
|
relationTo: 'meal-orders';
|
||||||
value: number | MealOrder;
|
value: number | MealOrder;
|
||||||
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'meals';
|
||||||
|
value: number | Meal;
|
||||||
} | null);
|
} | null);
|
||||||
globalSlug?: string | null;
|
globalSlug?: string | null;
|
||||||
user: {
|
user: {
|
||||||
@@ -518,6 +566,24 @@ export interface ResidentsSelect<T extends boolean = true> {
|
|||||||
export interface MealOrdersSelect<T extends boolean = true> {
|
export interface MealOrdersSelect<T extends boolean = true> {
|
||||||
tenant?: T;
|
tenant?: T;
|
||||||
title?: T;
|
title?: T;
|
||||||
|
date?: T;
|
||||||
|
mealType?: T;
|
||||||
|
status?: T;
|
||||||
|
mealCount?: T;
|
||||||
|
submittedAt?: T;
|
||||||
|
createdBy?: T;
|
||||||
|
notes?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "meals_select".
|
||||||
|
*/
|
||||||
|
export interface MealsSelect<T extends boolean = true> {
|
||||||
|
tenant?: T;
|
||||||
|
title?: T;
|
||||||
|
order?: T;
|
||||||
resident?: T;
|
resident?: T;
|
||||||
date?: T;
|
date?: T;
|
||||||
mealType?: T;
|
mealType?: T;
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { sqliteAdapter } from '@payloadcms/db-sqlite'
|
import { sqliteAdapter } from '@payloadcms/db-sqlite'
|
||||||
|
import { postgresAdapter } from '@payloadcms/db-postgres'
|
||||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { buildConfig } from 'payload'
|
import { buildConfig } from 'payload'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
import { s3Storage } from '@payloadcms/storage-s3'
|
||||||
|
|
||||||
import { Tenants } from './collections/Tenants'
|
import { Tenants } from './collections/Tenants'
|
||||||
import Users from './collections/Users'
|
import Users from './collections/Users'
|
||||||
import { Residents } from './collections/Residents'
|
import { Residents } from './collections/Residents'
|
||||||
import { MealOrders } from './collections/MealOrders'
|
import { MealOrders } from './collections/MealOrders'
|
||||||
|
import { Meals } from './collections/Meals'
|
||||||
import { multiTenantPlugin } from '@payloadcms/plugin-multi-tenant'
|
import { multiTenantPlugin } from '@payloadcms/plugin-multi-tenant'
|
||||||
import { isSuperAdmin } from './access/isSuperAdmin'
|
import { isSuperAdmin } from './access/isSuperAdmin'
|
||||||
import type { Config } from './payload-types'
|
import type { Config } from './payload-types'
|
||||||
@@ -33,10 +36,16 @@ export default buildConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
collections: [Users, Tenants, Residents, MealOrders],
|
collections: [Users, Tenants, Residents, MealOrders, Meals],
|
||||||
db: sqliteAdapter({
|
db: process.env.DATABASE_URI
|
||||||
|
? postgresAdapter({
|
||||||
|
pool: {
|
||||||
|
connectionString: process.env.DATABASE_URI,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: sqliteAdapter({
|
||||||
client: {
|
client: {
|
||||||
url: 'file:./payload.db',
|
url: 'file:./meal-planner.db',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
onInit: async (args) => {
|
onInit: async (args) => {
|
||||||
@@ -53,11 +62,33 @@ export default buildConfig({
|
|||||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
// Conditionally add S3 storage when MINIO_ENDPOINT is set (Docker environment)
|
||||||
|
...(process.env.MINIO_ENDPOINT
|
||||||
|
? [
|
||||||
|
s3Storage({
|
||||||
|
collections: {
|
||||||
|
// You can add specific collections here if needed
|
||||||
|
// For now, it applies to all collections with upload fields
|
||||||
|
},
|
||||||
|
bucket: process.env.S3_BUCKET || 'meal-planner',
|
||||||
|
config: {
|
||||||
|
endpoint: process.env.MINIO_ENDPOINT,
|
||||||
|
region: process.env.S3_REGION || 'us-east-1',
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.S3_ACCESS_KEY_ID || '',
|
||||||
|
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || '',
|
||||||
|
},
|
||||||
|
forcePathStyle: true, // Required for MinIO
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
multiTenantPlugin<Config>({
|
multiTenantPlugin<Config>({
|
||||||
collections: {
|
collections: {
|
||||||
// Enable multi-tenancy for residents and meal orders
|
// Enable multi-tenancy for residents, meal orders, and meals
|
||||||
residents: {},
|
residents: {},
|
||||||
'meal-orders': {},
|
'meal-orders': {},
|
||||||
|
meals: {},
|
||||||
},
|
},
|
||||||
tenantField: {
|
tenantField: {
|
||||||
access: {
|
access: {
|
||||||
|
|||||||
178
src/seed.ts
178
src/seed.ts
@@ -7,7 +7,7 @@ import type { Payload } from 'payload'
|
|||||||
* - 1 care home (tenant)
|
* - 1 care home (tenant)
|
||||||
* - 3 users (admin, caregiver, kitchen)
|
* - 3 users (admin, caregiver, kitchen)
|
||||||
* - 8 residents with varied data
|
* - 8 residents with varied data
|
||||||
* - 20+ meal orders covering multiple dates and meal types
|
* - Meal orders with individual meals
|
||||||
*/
|
*/
|
||||||
export const seed = async (payload: Payload): Promise<void> => {
|
export const seed = async (payload: Payload): Promise<void> => {
|
||||||
// Check if already seeded
|
// Check if already seeded
|
||||||
@@ -185,7 +185,7 @@ export const seed = async (payload: Payload): Promise<void> => {
|
|||||||
payload.logger.info(`Created ${residents.length} residents`)
|
payload.logger.info(`Created ${residents.length} residents`)
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// CREATE MEAL ORDERS
|
// CREATE MEAL ORDERS AND MEALS
|
||||||
// ============================================
|
// ============================================
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
const yesterday = new Date(today)
|
const yesterday = new Date(today)
|
||||||
@@ -195,32 +195,75 @@ export const seed = async (payload: Payload): Promise<void> => {
|
|||||||
|
|
||||||
const formatDate = (date: Date) => date.toISOString().split('T')[0]
|
const formatDate = (date: Date) => date.toISOString().split('T')[0]
|
||||||
|
|
||||||
const dates = [formatDate(yesterday), formatDate(today), formatDate(tomorrow)]
|
type MealType = 'breakfast' | 'lunch' | 'dinner'
|
||||||
|
type OrderStatus = 'draft' | 'submitted' | 'preparing' | 'completed'
|
||||||
|
|
||||||
const statuses: Array<'pending' | 'preparing' | 'prepared'> = [
|
const mealOrders: Array<{
|
||||||
'pending',
|
date: string
|
||||||
'preparing',
|
mealType: MealType
|
||||||
'prepared',
|
status: OrderStatus
|
||||||
|
}> = [
|
||||||
|
// Yesterday - all completed
|
||||||
|
{ date: formatDate(yesterday), mealType: 'breakfast', status: 'completed' },
|
||||||
|
{ date: formatDate(yesterday), mealType: 'lunch', status: 'completed' },
|
||||||
|
{ date: formatDate(yesterday), mealType: 'dinner', status: 'completed' },
|
||||||
|
// Today - mixed statuses
|
||||||
|
{ date: formatDate(today), mealType: 'breakfast', status: 'completed' },
|
||||||
|
{ date: formatDate(today), mealType: 'lunch', status: 'preparing' },
|
||||||
|
{ date: formatDate(today), mealType: 'dinner', status: 'submitted' },
|
||||||
|
// Tomorrow - draft (in progress)
|
||||||
|
{ date: formatDate(tomorrow), mealType: 'breakfast', status: 'draft' },
|
||||||
|
{ date: formatDate(tomorrow), mealType: 'lunch', status: 'draft' },
|
||||||
]
|
]
|
||||||
|
|
||||||
let orderCount = 0
|
let totalMeals = 0
|
||||||
|
|
||||||
// Create varied breakfast orders
|
for (const orderData of mealOrders) {
|
||||||
for (let i = 0; i < residents.length; i++) {
|
// Create the meal order
|
||||||
const resident = residents[i]
|
const order = await payload.create({
|
||||||
const dateIndex = i % dates.length
|
|
||||||
const statusIndex = i % statuses.length
|
|
||||||
|
|
||||||
await payload.create({
|
|
||||||
collection: 'meal-orders',
|
collection: 'meal-orders',
|
||||||
data: {
|
data: {
|
||||||
resident: resident.id,
|
date: orderData.date,
|
||||||
date: dates[dateIndex],
|
mealType: orderData.mealType,
|
||||||
mealType: 'breakfast',
|
status: orderData.status,
|
||||||
status: statuses[statusIndex],
|
|
||||||
createdBy: caregiver.id,
|
createdBy: caregiver.id,
|
||||||
tenant: careHome.id,
|
tenant: careHome.id,
|
||||||
breakfast: {
|
submittedAt: orderData.status !== 'draft' ? new Date().toISOString() : undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Determine meal status based on order status
|
||||||
|
const mealStatus =
|
||||||
|
orderData.status === 'completed'
|
||||||
|
? 'prepared'
|
||||||
|
: orderData.status === 'preparing'
|
||||||
|
? 'preparing'
|
||||||
|
: 'pending'
|
||||||
|
|
||||||
|
// Create meals for each resident in the order
|
||||||
|
// For draft orders, only add some residents to demonstrate partial completion
|
||||||
|
const residentsToUse =
|
||||||
|
orderData.status === 'draft'
|
||||||
|
? residents.slice(0, Math.floor(residents.length / 2))
|
||||||
|
: residents
|
||||||
|
|
||||||
|
for (let i = 0; i < residentsToUse.length; i++) {
|
||||||
|
const resident = residentsToUse[i]
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const mealData: any = {
|
||||||
|
order: order.id,
|
||||||
|
resident: resident.id,
|
||||||
|
date: orderData.date,
|
||||||
|
mealType: orderData.mealType,
|
||||||
|
status: mealStatus,
|
||||||
|
createdBy: caregiver.id,
|
||||||
|
tenant: careHome.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add meal-specific options based on meal type
|
||||||
|
if (orderData.mealType === 'breakfast') {
|
||||||
|
mealData.breakfast = {
|
||||||
accordingToPlan: i % 3 === 0,
|
accordingToPlan: i % 3 === 0,
|
||||||
bread: {
|
bread: {
|
||||||
breadRoll: i % 2 === 0,
|
breadRoll: i % 2 === 0,
|
||||||
@@ -239,7 +282,7 @@ export const seed = async (payload: Payload): Promise<void> => {
|
|||||||
butter: true,
|
butter: true,
|
||||||
margarine: false,
|
margarine: false,
|
||||||
jam: i % 2 === 0,
|
jam: i % 2 === 0,
|
||||||
diabeticJam: i === 3, // For diabetic resident
|
diabeticJam: i === 3,
|
||||||
honey: i % 4 === 0,
|
honey: i % 4 === 0,
|
||||||
cheese: i % 3 === 0,
|
cheese: i % 3 === 0,
|
||||||
quark: i % 5 === 0,
|
quark: i % 5 === 0,
|
||||||
@@ -252,70 +295,35 @@ export const seed = async (payload: Payload): Promise<void> => {
|
|||||||
coldMilk: i % 5 === 0,
|
coldMilk: i % 5 === 0,
|
||||||
},
|
},
|
||||||
additions: {
|
additions: {
|
||||||
sugar: i !== 3, // Not for diabetic
|
sugar: i !== 3,
|
||||||
sweetener: i === 3, // For diabetic
|
sweetener: i === 3,
|
||||||
coffeeCreamer: i % 3 === 0,
|
coffeeCreamer: i % 3 === 0,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
orderCount++
|
|
||||||
}
|
}
|
||||||
|
} else if (orderData.mealType === 'lunch') {
|
||||||
// Create varied lunch orders
|
const portionOptions: Array<'small' | 'large' | 'vegetarian'> = [
|
||||||
for (let i = 0; i < residents.length; i++) {
|
'small',
|
||||||
const resident = residents[i]
|
'large',
|
||||||
const dateIndex = (i + 1) % dates.length
|
'vegetarian',
|
||||||
const statusIndex = (i + 1) % statuses.length
|
]
|
||||||
|
mealData.lunch = {
|
||||||
const portionOptions: Array<'small' | 'large' | 'vegetarian'> = ['small', 'large', 'vegetarian']
|
|
||||||
|
|
||||||
await payload.create({
|
|
||||||
collection: 'meal-orders',
|
|
||||||
data: {
|
|
||||||
resident: resident.id,
|
|
||||||
date: dates[dateIndex],
|
|
||||||
mealType: 'lunch',
|
|
||||||
status: statuses[statusIndex],
|
|
||||||
createdBy: caregiver.id,
|
|
||||||
tenant: careHome.id,
|
|
||||||
lunch: {
|
|
||||||
portionSize: portionOptions[i % 3],
|
portionSize: portionOptions[i % 3],
|
||||||
soup: i % 2 === 0,
|
soup: i % 2 === 0,
|
||||||
dessert: true,
|
dessert: true,
|
||||||
specialPreparations: {
|
specialPreparations: {
|
||||||
pureedFood: i === 7, // For resident who needs pureed food
|
pureedFood: i === 7,
|
||||||
pureedMeat: i === 7,
|
pureedMeat: i === 7,
|
||||||
slicedMeat: i % 3 === 0 && i !== 7,
|
slicedMeat: i % 3 === 0 && i !== 7,
|
||||||
mashedPotatoes: i % 4 === 0,
|
mashedPotatoes: i % 4 === 0,
|
||||||
},
|
},
|
||||||
restrictions: {
|
restrictions: {
|
||||||
noFish: i === 2, // For resident with fish aversion
|
noFish: i === 2,
|
||||||
fingerFood: i % 6 === 0,
|
fingerFood: i % 6 === 0,
|
||||||
onlySweet: false,
|
onlySweet: false,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
orderCount++
|
|
||||||
}
|
}
|
||||||
|
} else if (orderData.mealType === 'dinner') {
|
||||||
// Create varied dinner orders
|
mealData.dinner = {
|
||||||
for (let i = 0; i < residents.length; i++) {
|
|
||||||
const resident = residents[i]
|
|
||||||
const dateIndex = (i + 2) % dates.length
|
|
||||||
const statusIndex = (i + 2) % statuses.length
|
|
||||||
|
|
||||||
await payload.create({
|
|
||||||
collection: 'meal-orders',
|
|
||||||
data: {
|
|
||||||
resident: resident.id,
|
|
||||||
date: dates[dateIndex],
|
|
||||||
mealType: 'dinner',
|
|
||||||
status: statuses[statusIndex],
|
|
||||||
createdBy: caregiver.id,
|
|
||||||
tenant: careHome.id,
|
|
||||||
dinner: {
|
|
||||||
accordingToPlan: i % 2 === 0,
|
accordingToPlan: i % 2 === 0,
|
||||||
bread: {
|
bread: {
|
||||||
greyBread: i % 2 === 0,
|
greyBread: i % 2 === 0,
|
||||||
@@ -332,8 +340,8 @@ export const seed = async (payload: Payload): Promise<void> => {
|
|||||||
margarine: i % 3 === 0,
|
margarine: i % 3 === 0,
|
||||||
},
|
},
|
||||||
soup: i % 2 === 0,
|
soup: i % 2 === 0,
|
||||||
porridge: i === 7, // For resident who needs pureed food
|
porridge: i === 7,
|
||||||
noFish: i === 2, // For resident with fish aversion
|
noFish: i === 2,
|
||||||
beverages: {
|
beverages: {
|
||||||
tea: i % 2 === 0,
|
tea: i % 2 === 0,
|
||||||
cocoa: i % 4 === 0,
|
cocoa: i % 4 === 0,
|
||||||
@@ -341,16 +349,30 @@ export const seed = async (payload: Payload): Promise<void> => {
|
|||||||
coldMilk: i % 5 === 0,
|
coldMilk: i % 5 === 0,
|
||||||
},
|
},
|
||||||
additions: {
|
additions: {
|
||||||
sugar: i !== 3, // Not for diabetic
|
sugar: i !== 3,
|
||||||
sweetener: i === 3, // For diabetic
|
sweetener: i === 3,
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
},
|
|
||||||
})
|
|
||||||
orderCount++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
payload.logger.info(`Created ${orderCount} meal orders`)
|
await payload.create({
|
||||||
|
collection: 'meals',
|
||||||
|
data: mealData,
|
||||||
|
})
|
||||||
|
totalMeals++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update order meal count
|
||||||
|
await payload.update({
|
||||||
|
collection: 'meal-orders',
|
||||||
|
id: order.id,
|
||||||
|
data: {
|
||||||
|
mealCount: residentsToUse.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.logger.info(`Created ${mealOrders.length} meal orders with ${totalMeals} total meals`)
|
||||||
payload.logger.info('Database seeding complete!')
|
payload.logger.info('Database seeding complete!')
|
||||||
payload.logger.info('')
|
payload.logger.info('')
|
||||||
payload.logger.info('Login credentials:')
|
payload.logger.info('Login credentials:')
|
||||||
|
|||||||
Reference in New Issue
Block a user