feat: initial setup, collections, caregiver frontend
This commit is contained in:
89
src/access/roles.ts
Normal file
89
src/access/roles.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { User, Tenant } from '../payload-types'
|
||||
import { extractID } from '../utilities/extractID'
|
||||
|
||||
/**
|
||||
* Tenant role types for care home staff
|
||||
*/
|
||||
export type TenantRole = 'admin' | 'caregiver' | 'kitchen'
|
||||
|
||||
/**
|
||||
* Check if user has a specific tenant role in any tenant
|
||||
*/
|
||||
export const hasTenantRole = (user: User | null | undefined, role: TenantRole): boolean => {
|
||||
if (!user?.tenants) return false
|
||||
return user.tenants.some((t) => t.roles?.includes(role))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has a specific tenant role in a specific tenant
|
||||
*/
|
||||
export const hasTenantRoleInTenant = (
|
||||
user: User | null | undefined,
|
||||
role: TenantRole,
|
||||
tenantId: number | string,
|
||||
): boolean => {
|
||||
if (!user?.tenants) return false
|
||||
const targetId = String(tenantId)
|
||||
return user.tenants.some(
|
||||
(t) => String(extractID(t.tenant)) === targetId && t.roles?.includes(role),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tenant IDs where user has a specific role
|
||||
*/
|
||||
export const getTenantIDsWithRole = (
|
||||
user: User | null | undefined,
|
||||
role: TenantRole,
|
||||
): Tenant['id'][] => {
|
||||
if (!user?.tenants) return []
|
||||
return user.tenants
|
||||
.filter((t) => t.roles?.includes(role))
|
||||
.map((t) => extractID(t.tenant))
|
||||
.filter((id): id is Tenant['id'] => id !== null && id !== undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tenant IDs for a user (regardless of role)
|
||||
*/
|
||||
export const getAllUserTenantIDs = (user: User | null | undefined): Tenant['id'][] => {
|
||||
if (!user?.tenants) return []
|
||||
return user.tenants
|
||||
.map((t) => extractID(t.tenant))
|
||||
.filter((id): id is Tenant['id'] => id !== null && id !== undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is a tenant admin in any tenant
|
||||
*/
|
||||
export const isTenantAdmin = (user: User | null | undefined): boolean => {
|
||||
return hasTenantRole(user, 'admin')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is a caregiver in any tenant
|
||||
*/
|
||||
export const isCaregiver = (user: User | null | undefined): boolean => {
|
||||
return hasTenantRole(user, 'caregiver')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is kitchen staff in any tenant
|
||||
*/
|
||||
export const isKitchenStaff = (user: User | null | undefined): boolean => {
|
||||
return hasTenantRole(user, 'kitchen')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can access kitchen features (admin or kitchen role)
|
||||
*/
|
||||
export const canAccessKitchen = (user: User | null | undefined): boolean => {
|
||||
return hasTenantRole(user, 'admin') || hasTenantRole(user, 'kitchen')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can create meal orders (admin or caregiver role)
|
||||
*/
|
||||
export const canCreateOrders = (user: User | null | undefined): boolean => {
|
||||
return hasTenantRole(user, 'admin') || hasTenantRole(user, 'caregiver')
|
||||
}
|
||||
166
src/app/(app)/caregiver/dashboard/page.tsx
Normal file
166
src/app/(app)/caregiver/dashboard/page.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
name?: string
|
||||
email: string
|
||||
tenants?: Array<{
|
||||
tenant: { id: number; name: string } | number
|
||||
roles?: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
interface OrderStats {
|
||||
pending: number
|
||||
preparing: number
|
||||
prepared: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export default function CaregiverDashboardPage() {
|
||||
const router = useRouter()
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [stats, setStats] = useState<OrderStats>({ pending: 0, preparing: 0, prepared: 0, total: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Check auth
|
||||
const userRes = await fetch('/api/users/me', { credentials: 'include' })
|
||||
if (!userRes.ok) {
|
||||
router.push('/caregiver/login')
|
||||
return
|
||||
}
|
||||
const userData = await userRes.json()
|
||||
if (!userData.user) {
|
||||
router.push('/caregiver/login')
|
||||
return
|
||||
}
|
||||
setUser(userData.user)
|
||||
|
||||
// Fetch today's orders stats
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const ordersRes = await fetch(`/api/meal-orders?where[date][equals]=${today}&limit=1000`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
if (ordersRes.ok) {
|
||||
const ordersData = await ordersRes.json()
|
||||
const orders = ordersData.docs || []
|
||||
setStats({
|
||||
pending: orders.filter((o: { status: string }) => o.status === 'pending').length,
|
||||
preparing: orders.filter((o: { status: string }) => o.status === 'preparing').length,
|
||||
prepared: orders.filter((o: { status: string }) => o.status === 'prepared').length,
|
||||
total: orders.length,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [router])
|
||||
|
||||
const handleLogout = async () => {
|
||||
await fetch('/api/users/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
router.push('/caregiver/login')
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tenantName =
|
||||
user?.tenants?.[0]?.tenant && typeof user.tenants[0].tenant === 'object'
|
||||
? user.tenants[0].tenant.name
|
||||
: 'Care Home'
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="header">
|
||||
<div className="header__content">
|
||||
<h1 className="header__title">{tenantName}</h1>
|
||||
<div className="header__user">
|
||||
<span className="header__user-name">{user?.name || user?.email}</span>
|
||||
<button onClick={handleLogout} className="btn btn--secondary">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container">
|
||||
<div className="page-title">
|
||||
<h1>Dashboard</h1>
|
||||
<p>Today's overview</p>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-card__value">{stats.total}</div>
|
||||
<div className="stat-card__label">Total Orders Today</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-card__value">{stats.pending}</div>
|
||||
<div className="stat-card__label">Pending</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-card__value">{stats.preparing}</div>
|
||||
<div className="stat-card__label">Preparing</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-card__value">{stats.prepared}</div>
|
||||
<div className="stat-card__label">Prepared</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card__header">
|
||||
<h2>Quick Actions</h2>
|
||||
</div>
|
||||
<div className="card__body">
|
||||
<div className="quick-actions">
|
||||
<Link href="/caregiver/orders/new?mealType=breakfast" className="quick-action">
|
||||
<div className="quick-action__icon">🌅</div>
|
||||
<div className="quick-action__label">New Breakfast</div>
|
||||
</Link>
|
||||
<Link href="/caregiver/orders/new?mealType=lunch" className="quick-action">
|
||||
<div className="quick-action__icon">☀️</div>
|
||||
<div className="quick-action__label">New Lunch</div>
|
||||
</Link>
|
||||
<Link href="/caregiver/orders/new?mealType=dinner" className="quick-action">
|
||||
<div className="quick-action__icon">🌙</div>
|
||||
<div className="quick-action__label">New Dinner</div>
|
||||
</Link>
|
||||
<Link href="/caregiver/orders" className="quick-action">
|
||||
<div className="quick-action__icon">📋</div>
|
||||
<div className="quick-action__label">View Orders</div>
|
||||
</Link>
|
||||
<Link href="/caregiver/residents" className="quick-action">
|
||||
<div className="quick-action__icon">👥</div>
|
||||
<div className="quick-action__label">Residents</div>
|
||||
</Link>
|
||||
<Link href="/admin" className="quick-action">
|
||||
<div className="quick-action__icon">⚙️</div>
|
||||
<div className="quick-action__label">Admin Panel</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
141
src/app/(app)/caregiver/login/page.tsx
Normal file
141
src/app/(app)/caregiver/login/page.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export default function CaregiverLoginPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [checking, setChecking] = useState(true)
|
||||
|
||||
// Check if already logged in
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/users/me', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.user) {
|
||||
router.push('/caregiver/dashboard')
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not logged in
|
||||
}
|
||||
setChecking(false)
|
||||
}
|
||||
checkAuth()
|
||||
}, [router])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/users/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.errors?.[0]?.message || 'Login failed')
|
||||
}
|
||||
|
||||
// Check if user has caregiver or admin role
|
||||
const user = data.user
|
||||
const hasCaregiverRole =
|
||||
user?.roles?.includes('super-admin') ||
|
||||
user?.tenants?.some(
|
||||
(t: { roles?: string[] }) =>
|
||||
t.roles?.includes('caregiver') || t.roles?.includes('admin'),
|
||||
)
|
||||
|
||||
if (!hasCaregiverRole) {
|
||||
// Logout if not a caregiver
|
||||
await fetch('/api/users/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
throw new Error('You do not have caregiver access')
|
||||
}
|
||||
|
||||
router.push('/caregiver/dashboard')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-page__card">
|
||||
<div className="login-page__logo">
|
||||
<h1>Meal Planner</h1>
|
||||
<p>Caregiver Portal</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card__body">
|
||||
{error && <div className="message message--error">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
className="input"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
className="input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn--primary btn--block btn--large"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
768
src/app/(app)/caregiver/orders/new/page.tsx
Normal file
768
src/app/(app)/caregiver/orders/new/page.tsx
Normal file
@@ -0,0 +1,768 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, Suspense } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Resident {
|
||||
id: number
|
||||
name: string
|
||||
room: string
|
||||
table?: string
|
||||
station?: string
|
||||
highCaloric?: boolean
|
||||
aversions?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
type MealType = 'breakfast' | 'lunch' | 'dinner'
|
||||
|
||||
interface BreakfastOptions {
|
||||
accordingToPlan: boolean
|
||||
bread: {
|
||||
breadRoll: boolean
|
||||
wholeGrainRoll: boolean
|
||||
greyBread: boolean
|
||||
wholeGrainBread: boolean
|
||||
whiteBread: boolean
|
||||
crispbread: boolean
|
||||
}
|
||||
porridge: boolean
|
||||
preparation: { sliced: boolean; spread: boolean }
|
||||
spreads: {
|
||||
butter: boolean
|
||||
margarine: boolean
|
||||
jam: boolean
|
||||
diabeticJam: boolean
|
||||
honey: boolean
|
||||
cheese: boolean
|
||||
quark: boolean
|
||||
sausage: boolean
|
||||
}
|
||||
beverages: { coffee: boolean; tea: boolean; hotMilk: boolean; coldMilk: boolean }
|
||||
additions: { sugar: boolean; sweetener: boolean; coffeeCreamer: boolean }
|
||||
}
|
||||
|
||||
interface LunchOptions {
|
||||
portionSize: 'small' | 'large' | 'vegetarian'
|
||||
soup: boolean
|
||||
dessert: boolean
|
||||
specialPreparations: {
|
||||
pureedFood: boolean
|
||||
pureedMeat: boolean
|
||||
slicedMeat: boolean
|
||||
mashedPotatoes: boolean
|
||||
}
|
||||
restrictions: { noFish: boolean; fingerFood: boolean; onlySweet: boolean }
|
||||
}
|
||||
|
||||
interface DinnerOptions {
|
||||
accordingToPlan: boolean
|
||||
bread: { greyBread: boolean; wholeGrainBread: boolean; whiteBread: boolean; crispbread: boolean }
|
||||
preparation: { spread: boolean; sliced: boolean }
|
||||
spreads: { butter: boolean; margarine: boolean }
|
||||
soup: boolean
|
||||
porridge: boolean
|
||||
noFish: boolean
|
||||
beverages: { tea: boolean; cocoa: boolean; hotMilk: boolean; coldMilk: boolean }
|
||||
additions: { sugar: boolean; sweetener: boolean }
|
||||
}
|
||||
|
||||
const defaultBreakfast: BreakfastOptions = {
|
||||
accordingToPlan: false,
|
||||
bread: {
|
||||
breadRoll: false,
|
||||
wholeGrainRoll: false,
|
||||
greyBread: false,
|
||||
wholeGrainBread: false,
|
||||
whiteBread: false,
|
||||
crispbread: false,
|
||||
},
|
||||
porridge: false,
|
||||
preparation: { sliced: false, spread: false },
|
||||
spreads: {
|
||||
butter: false,
|
||||
margarine: false,
|
||||
jam: false,
|
||||
diabeticJam: false,
|
||||
honey: false,
|
||||
cheese: false,
|
||||
quark: false,
|
||||
sausage: false,
|
||||
},
|
||||
beverages: { coffee: false, tea: false, hotMilk: false, coldMilk: false },
|
||||
additions: { sugar: false, sweetener: false, coffeeCreamer: false },
|
||||
}
|
||||
|
||||
const defaultLunch: LunchOptions = {
|
||||
portionSize: 'large',
|
||||
soup: false,
|
||||
dessert: true,
|
||||
specialPreparations: {
|
||||
pureedFood: false,
|
||||
pureedMeat: false,
|
||||
slicedMeat: false,
|
||||
mashedPotatoes: false,
|
||||
},
|
||||
restrictions: { noFish: false, fingerFood: false, onlySweet: false },
|
||||
}
|
||||
|
||||
const defaultDinner: DinnerOptions = {
|
||||
accordingToPlan: false,
|
||||
bread: { greyBread: false, wholeGrainBread: false, whiteBread: false, crispbread: false },
|
||||
preparation: { spread: false, sliced: false },
|
||||
spreads: { butter: false, margarine: false },
|
||||
soup: false,
|
||||
porridge: false,
|
||||
noFish: false,
|
||||
beverages: { tea: false, cocoa: false, hotMilk: false, coldMilk: false },
|
||||
additions: { sugar: false, sweetener: false },
|
||||
}
|
||||
|
||||
function NewOrderContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const initialMealType = (searchParams.get('mealType') as MealType) || null
|
||||
|
||||
const [step, setStep] = useState(initialMealType ? 2 : 1)
|
||||
const [residents, setResidents] = useState<Resident[]>([])
|
||||
const [selectedResident, setSelectedResident] = useState<Resident | null>(null)
|
||||
const [mealType, setMealType] = useState<MealType | null>(initialMealType)
|
||||
const [date, setDate] = useState(() => new Date().toISOString().split('T')[0])
|
||||
const [breakfast, setBreakfast] = useState<BreakfastOptions>(defaultBreakfast)
|
||||
const [lunch, setLunch] = useState<LunchOptions>(defaultLunch)
|
||||
const [dinner, setDinner] = useState<DinnerOptions>(defaultDinner)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const fetchResidents = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/residents?where[active][equals]=true&limit=100&sort=name', {
|
||||
credentials: 'include',
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setResidents(data.docs || [])
|
||||
} else if (res.status === 401) {
|
||||
router.push('/caregiver/login')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching residents:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchResidents()
|
||||
}, [router])
|
||||
|
||||
const filteredResidents = residents.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
r.room.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedResident || !mealType || !date) return
|
||||
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const orderData: Record<string, unknown> = {
|
||||
resident: selectedResident.id,
|
||||
date,
|
||||
mealType,
|
||||
status: 'pending',
|
||||
}
|
||||
|
||||
if (mealType === 'breakfast') {
|
||||
orderData.breakfast = breakfast
|
||||
} else if (mealType === 'lunch') {
|
||||
orderData.lunch = lunch
|
||||
} else if (mealType === 'dinner') {
|
||||
orderData.dinner = dinner
|
||||
}
|
||||
|
||||
const res = await fetch('/api/meal-orders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(orderData),
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.errors?.[0]?.message || 'Failed to create order')
|
||||
}
|
||||
|
||||
router.push('/caregiver/dashboard')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getMealTypeLabel = (type: MealType) => {
|
||||
switch (type) {
|
||||
case 'breakfast':
|
||||
return 'Breakfast (Frühstück)'
|
||||
case 'lunch':
|
||||
return 'Lunch (Mittagessen)'
|
||||
case 'dinner':
|
||||
return 'Dinner (Abendessen)'
|
||||
}
|
||||
}
|
||||
|
||||
const renderCheckbox = (
|
||||
label: string,
|
||||
checked: boolean,
|
||||
onChange: (checked: boolean) => void,
|
||||
) => (
|
||||
<label className={`checkbox-item ${checked ? 'checkbox-item--checked' : ''}`}>
|
||||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="header">
|
||||
<div className="header__content">
|
||||
<Link href="/caregiver/dashboard" className="btn btn--secondary">
|
||||
← Back
|
||||
</Link>
|
||||
<h1 className="header__title">New Meal Order</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container">
|
||||
{/* Progress Steps */}
|
||||
<div className="steps">
|
||||
<div className={`steps__step ${step >= 1 ? 'steps__step--active' : ''} ${step > 1 ? 'steps__step--completed' : ''}`} />
|
||||
<div className={`steps__step ${step >= 2 ? 'steps__step--active' : ''} ${step > 2 ? 'steps__step--completed' : ''}`} />
|
||||
<div className={`steps__step ${step >= 3 ? 'steps__step--active' : ''} ${step > 3 ? 'steps__step--completed' : ''}`} />
|
||||
<div className={`steps__step ${step >= 4 ? 'steps__step--active' : ''}`} />
|
||||
</div>
|
||||
|
||||
{error && <div className="message message--error">{error}</div>}
|
||||
|
||||
{/* Step 1: Select Meal Type */}
|
||||
{step === 1 && (
|
||||
<div className="card">
|
||||
<div className="card__header">
|
||||
<h2>Step 1: Select Meal Type</h2>
|
||||
</div>
|
||||
<div className="card__body">
|
||||
<div className="form-group">
|
||||
<label htmlFor="date">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
className="input"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="meal-type-grid">
|
||||
<button
|
||||
type="button"
|
||||
className={`meal-type-btn ${mealType === 'breakfast' ? 'meal-type-btn--selected' : ''}`}
|
||||
onClick={() => setMealType('breakfast')}
|
||||
>
|
||||
<div className="meal-type-btn__icon">🌅</div>
|
||||
<div className="meal-type-btn__label">Breakfast</div>
|
||||
<div className="meal-type-btn__sublabel">Frühstück</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`meal-type-btn ${mealType === 'lunch' ? 'meal-type-btn--selected' : ''}`}
|
||||
onClick={() => setMealType('lunch')}
|
||||
>
|
||||
<div className="meal-type-btn__icon">☀️</div>
|
||||
<div className="meal-type-btn__label">Lunch</div>
|
||||
<div className="meal-type-btn__sublabel">Mittagessen</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`meal-type-btn ${mealType === 'dinner' ? 'meal-type-btn--selected' : ''}`}
|
||||
onClick={() => setMealType('dinner')}
|
||||
>
|
||||
<div className="meal-type-btn__icon">🌙</div>
|
||||
<div className="meal-type-btn__label">Dinner</div>
|
||||
<div className="meal-type-btn__sublabel">Abendessen</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '1.5rem' }}>
|
||||
<button
|
||||
className="btn btn--primary btn--block btn--large"
|
||||
disabled={!mealType}
|
||||
onClick={() => setStep(2)}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Select Resident */}
|
||||
{step === 2 && (
|
||||
<div className="card">
|
||||
<div className="card__header">
|
||||
<h2>Step 2: Select Resident</h2>
|
||||
</div>
|
||||
<div className="card__body">
|
||||
<div className="form-group">
|
||||
<div className="search-box">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or room..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="resident-list">
|
||||
{filteredResidents.map((resident) => (
|
||||
<div
|
||||
key={resident.id}
|
||||
className={`resident-card ${selectedResident?.id === resident.id ? 'resident-card--selected' : ''}`}
|
||||
onClick={() => setSelectedResident(resident)}
|
||||
>
|
||||
<div className="resident-card__name">{resident.name}</div>
|
||||
<div className="resident-card__details">
|
||||
<span>Room {resident.room}</span>
|
||||
{resident.table && <span>Table {resident.table}</span>}
|
||||
{resident.station && <span>{resident.station}</span>}
|
||||
</div>
|
||||
{resident.highCaloric && (
|
||||
<div className="resident-card__badge">High Caloric</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '1.5rem', display: 'flex', gap: '1rem' }}>
|
||||
<button className="btn btn--secondary" onClick={() => setStep(1)}>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--primary btn--block btn--large"
|
||||
disabled={!selectedResident}
|
||||
onClick={() => setStep(3)}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Meal Options */}
|
||||
{step === 3 && (
|
||||
<div className="card">
|
||||
<div className="card__header">
|
||||
<h2>Step 3: {mealType && getMealTypeLabel(mealType)} Options</h2>
|
||||
</div>
|
||||
<div className="card__body">
|
||||
{/* Show resident notes if any */}
|
||||
{(selectedResident?.aversions || selectedResident?.notes) && (
|
||||
<div className="message message--warning">
|
||||
<strong>Notes for {selectedResident?.name}:</strong>
|
||||
{selectedResident?.aversions && <div>Aversions: {selectedResident.aversions}</div>}
|
||||
{selectedResident?.notes && <div>{selectedResident.notes}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BREAKFAST OPTIONS */}
|
||||
{mealType === 'breakfast' && (
|
||||
<>
|
||||
<div className="section">
|
||||
<h3 className="section__title">General</h3>
|
||||
<div className="checkbox-group">
|
||||
{renderCheckbox('According to Plan (lt. Plan)', breakfast.accordingToPlan, (v) =>
|
||||
setBreakfast({ ...breakfast, accordingToPlan: v }),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3 className="section__title">Bread (Brot)</h3>
|
||||
<div className="checkbox-group checkbox-group--cols-2">
|
||||
{renderCheckbox('Bread Roll (Brötchen)', breakfast.bread.breadRoll, (v) =>
|
||||
setBreakfast({ ...breakfast, bread: { ...breakfast.bread, breadRoll: v } }),
|
||||
)}
|
||||
{renderCheckbox('Whole Grain Roll (Vollkornbrötchen)', breakfast.bread.wholeGrainRoll, (v) =>
|
||||
setBreakfast({ ...breakfast, bread: { ...breakfast.bread, wholeGrainRoll: v } }),
|
||||
)}
|
||||
{renderCheckbox('Grey Bread (Graubrot)', breakfast.bread.greyBread, (v) =>
|
||||
setBreakfast({ ...breakfast, bread: { ...breakfast.bread, greyBread: v } }),
|
||||
)}
|
||||
{renderCheckbox('Whole Grain Bread (Vollkornbrot)', breakfast.bread.wholeGrainBread, (v) =>
|
||||
setBreakfast({ ...breakfast, bread: { ...breakfast.bread, wholeGrainBread: v } }),
|
||||
)}
|
||||
{renderCheckbox('White Bread (Weißbrot)', breakfast.bread.whiteBread, (v) =>
|
||||
setBreakfast({ ...breakfast, bread: { ...breakfast.bread, whiteBread: v } }),
|
||||
)}
|
||||
{renderCheckbox('Crispbread (Knäckebrot)', breakfast.bread.crispbread, (v) =>
|
||||
setBreakfast({ ...breakfast, bread: { ...breakfast.bread, crispbread: v } }),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3 className="section__title">Preparation</h3>
|
||||
<div className="checkbox-group checkbox-group--cols-2">
|
||||
{renderCheckbox('Porridge (Brei)', breakfast.porridge, (v) =>
|
||||
setBreakfast({ ...breakfast, porridge: v }),
|
||||
)}
|
||||
{renderCheckbox('Sliced (geschnitten)', breakfast.preparation.sliced, (v) =>
|
||||
setBreakfast({ ...breakfast, preparation: { ...breakfast.preparation, sliced: v } }),
|
||||
)}
|
||||
{renderCheckbox('Spread (geschmiert)', breakfast.preparation.spread, (v) =>
|
||||
setBreakfast({ ...breakfast, preparation: { ...breakfast.preparation, spread: v } }),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3 className="section__title">Spreads (Aufstrich)</h3>
|
||||
<div className="checkbox-group checkbox-group--cols-2">
|
||||
{renderCheckbox('Butter', breakfast.spreads.butter, (v) =>
|
||||
setBreakfast({ ...breakfast, spreads: { ...breakfast.spreads, butter: v } }),
|
||||
)}
|
||||
{renderCheckbox('Margarine', breakfast.spreads.margarine, (v) =>
|
||||
setBreakfast({ ...breakfast, spreads: { ...breakfast.spreads, margarine: v } }),
|
||||
)}
|
||||
{renderCheckbox('Jam (Konfitüre)', breakfast.spreads.jam, (v) =>
|
||||
setBreakfast({ ...breakfast, spreads: { ...breakfast.spreads, jam: v } }),
|
||||
)}
|
||||
{renderCheckbox('Diabetic Jam (Diab. Konfitüre)', breakfast.spreads.diabeticJam, (v) =>
|
||||
setBreakfast({ ...breakfast, spreads: { ...breakfast.spreads, diabeticJam: v } }),
|
||||
)}
|
||||
{renderCheckbox('Honey (Honig)', breakfast.spreads.honey, (v) =>
|
||||
setBreakfast({ ...breakfast, spreads: { ...breakfast.spreads, honey: v } }),
|
||||
)}
|
||||
{renderCheckbox('Cheese (Käse)', breakfast.spreads.cheese, (v) =>
|
||||
setBreakfast({ ...breakfast, spreads: { ...breakfast.spreads, cheese: v } }),
|
||||
)}
|
||||
{renderCheckbox('Quark', breakfast.spreads.quark, (v) =>
|
||||
setBreakfast({ ...breakfast, spreads: { ...breakfast.spreads, quark: v } }),
|
||||
)}
|
||||
{renderCheckbox('Sausage (Wurst)', breakfast.spreads.sausage, (v) =>
|
||||
setBreakfast({ ...breakfast, spreads: { ...breakfast.spreads, sausage: v } }),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3 className="section__title">Beverages (Getränke)</h3>
|
||||
<div className="checkbox-group checkbox-group--cols-2">
|
||||
{renderCheckbox('Coffee (Kaffee)', breakfast.beverages.coffee, (v) =>
|
||||
setBreakfast({ ...breakfast, beverages: { ...breakfast.beverages, coffee: v } }),
|
||||
)}
|
||||
{renderCheckbox('Tea (Tee)', breakfast.beverages.tea, (v) =>
|
||||
setBreakfast({ ...breakfast, beverages: { ...breakfast.beverages, tea: v } }),
|
||||
)}
|
||||
{renderCheckbox('Hot Milk (Milch heiß)', breakfast.beverages.hotMilk, (v) =>
|
||||
setBreakfast({ ...breakfast, beverages: { ...breakfast.beverages, hotMilk: v } }),
|
||||
)}
|
||||
{renderCheckbox('Cold Milk (Milch kalt)', breakfast.beverages.coldMilk, (v) =>
|
||||
setBreakfast({ ...breakfast, beverages: { ...breakfast.beverages, coldMilk: v } }),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3 className="section__title">Additions (Zusätze)</h3>
|
||||
<div className="checkbox-group checkbox-group--cols-3">
|
||||
{renderCheckbox('Sugar (Zucker)', breakfast.additions.sugar, (v) =>
|
||||
setBreakfast({ ...breakfast, additions: { ...breakfast.additions, sugar: v } }),
|
||||
)}
|
||||
{renderCheckbox('Sweetener (Süßstoff)', breakfast.additions.sweetener, (v) =>
|
||||
setBreakfast({ ...breakfast, additions: { ...breakfast.additions, sweetener: v } }),
|
||||
)}
|
||||
{renderCheckbox('Coffee Creamer (Kaffeesahne)', breakfast.additions.coffeeCreamer, (v) =>
|
||||
setBreakfast({ ...breakfast, additions: { ...breakfast.additions, coffeeCreamer: v } }),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* LUNCH OPTIONS */}
|
||||
{mealType === 'lunch' && (
|
||||
<>
|
||||
<div className="section">
|
||||
<h3 className="section__title">Portion Size</h3>
|
||||
<div className="checkbox-group checkbox-group--cols-3">
|
||||
<label
|
||||
className={`checkbox-item ${lunch.portionSize === 'small' ? 'checkbox-item--checked' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="portionSize"
|
||||
checked={lunch.portionSize === 'small'}
|
||||
onChange={() => setLunch({ ...lunch, portionSize: 'small' })}
|
||||
/>
|
||||
<span>Small (Kleine)</span>
|
||||
</label>
|
||||
<label
|
||||
className={`checkbox-item ${lunch.portionSize === 'large' ? 'checkbox-item--checked' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="portionSize"
|
||||
checked={lunch.portionSize === 'large'}
|
||||
onChange={() => setLunch({ ...lunch, portionSize: 'large' })}
|
||||
/>
|
||||
<span>Large (Große)</span>
|
||||
</label>
|
||||
<label
|
||||
className={`checkbox-item ${lunch.portionSize === 'vegetarian' ? 'checkbox-item--checked' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="portionSize"
|
||||
checked={lunch.portionSize === 'vegetarian'}
|
||||
onChange={() => setLunch({ ...lunch, portionSize: 'vegetarian' })}
|
||||
/>
|
||||
<span>Vegetarian</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3 className="section__title">Meal Options</h3>
|
||||
<div className="checkbox-group checkbox-group--cols-2">
|
||||
{renderCheckbox('Soup (Suppe)', lunch.soup, (v) => setLunch({ ...lunch, soup: v }))}
|
||||
{renderCheckbox('Dessert', lunch.dessert, (v) => setLunch({ ...lunch, dessert: v }))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3 className="section__title">Special Preparations</h3>
|
||||
<div className="checkbox-group checkbox-group--cols-2">
|
||||
{renderCheckbox('Pureed Food (passierte Kost)', lunch.specialPreparations.pureedFood, (v) =>
|
||||
setLunch({ ...lunch, specialPreparations: { ...lunch.specialPreparations, pureedFood: v } }),
|
||||
)}
|
||||
{renderCheckbox('Pureed Meat (passiertes Fleisch)', lunch.specialPreparations.pureedMeat, (v) =>
|
||||
setLunch({ ...lunch, specialPreparations: { ...lunch.specialPreparations, pureedMeat: v } }),
|
||||
)}
|
||||
{renderCheckbox('Sliced Meat (geschnittenes Fleisch)', lunch.specialPreparations.slicedMeat, (v) =>
|
||||
setLunch({ ...lunch, specialPreparations: { ...lunch.specialPreparations, slicedMeat: v } }),
|
||||
)}
|
||||
{renderCheckbox('Mashed Potatoes (Kartoffelbrei)', lunch.specialPreparations.mashedPotatoes, (v) =>
|
||||
setLunch({ ...lunch, specialPreparations: { ...lunch.specialPreparations, mashedPotatoes: v } }),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3 className="section__title">Restrictions</h3>
|
||||
<div className="checkbox-group checkbox-group--cols-3">
|
||||
{renderCheckbox('No Fish (ohne Fisch)', lunch.restrictions.noFish, (v) =>
|
||||
setLunch({ ...lunch, restrictions: { ...lunch.restrictions, noFish: v } }),
|
||||
)}
|
||||
{renderCheckbox('Finger Food', lunch.restrictions.fingerFood, (v) =>
|
||||
setLunch({ ...lunch, restrictions: { ...lunch.restrictions, fingerFood: v } }),
|
||||
)}
|
||||
{renderCheckbox('Only Sweet (nur süß)', lunch.restrictions.onlySweet, (v) =>
|
||||
setLunch({ ...lunch, restrictions: { ...lunch.restrictions, onlySweet: v } }),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* DINNER OPTIONS */}
|
||||
{mealType === 'dinner' && (
|
||||
<>
|
||||
<div className="section">
|
||||
<h3 className="section__title">General</h3>
|
||||
<div className="checkbox-group">
|
||||
{renderCheckbox('According to Plan (lt. Plan)', dinner.accordingToPlan, (v) =>
|
||||
setDinner({ ...dinner, accordingToPlan: v }),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3 className="section__title">Bread (Brot)</h3>
|
||||
<div className="checkbox-group checkbox-group--cols-2">
|
||||
{renderCheckbox('Grey Bread (Graubrot)', dinner.bread.greyBread, (v) =>
|
||||
setDinner({ ...dinner, bread: { ...dinner.bread, greyBread: v } }),
|
||||
)}
|
||||
{renderCheckbox('Whole Grain Bread (Vollkornbrot)', dinner.bread.wholeGrainBread, (v) =>
|
||||
setDinner({ ...dinner, bread: { ...dinner.bread, wholeGrainBread: v } }),
|
||||
)}
|
||||
{renderCheckbox('White Bread (Weißbrot)', dinner.bread.whiteBread, (v) =>
|
||||
setDinner({ ...dinner, bread: { ...dinner.bread, whiteBread: v } }),
|
||||
)}
|
||||
{renderCheckbox('Crispbread (Knäckebrot)', dinner.bread.crispbread, (v) =>
|
||||
setDinner({ ...dinner, bread: { ...dinner.bread, crispbread: v } }),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3 className="section__title">Preparation</h3>
|
||||
<div className="checkbox-group checkbox-group--cols-2">
|
||||
{renderCheckbox('Spread (geschmiert)', dinner.preparation.spread, (v) =>
|
||||
setDinner({ ...dinner, preparation: { ...dinner.preparation, spread: v } }),
|
||||
)}
|
||||
{renderCheckbox('Sliced (geschnitten)', dinner.preparation.sliced, (v) =>
|
||||
setDinner({ ...dinner, preparation: { ...dinner.preparation, sliced: v } }),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3 className="section__title">Spreads (Aufstrich)</h3>
|
||||
<div className="checkbox-group checkbox-group--cols-2">
|
||||
{renderCheckbox('Butter', dinner.spreads.butter, (v) =>
|
||||
setDinner({ ...dinner, spreads: { ...dinner.spreads, butter: v } }),
|
||||
)}
|
||||
{renderCheckbox('Margarine', dinner.spreads.margarine, (v) =>
|
||||
setDinner({ ...dinner, spreads: { ...dinner.spreads, margarine: v } }),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3 className="section__title">Additional Items</h3>
|
||||
<div className="checkbox-group checkbox-group--cols-3">
|
||||
{renderCheckbox('Soup (Suppe)', dinner.soup, (v) => setDinner({ ...dinner, soup: v }))}
|
||||
{renderCheckbox('Porridge (Brei)', dinner.porridge, (v) =>
|
||||
setDinner({ ...dinner, porridge: v }),
|
||||
)}
|
||||
{renderCheckbox('No Fish (ohne Fisch)', dinner.noFish, (v) =>
|
||||
setDinner({ ...dinner, noFish: v }),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3 className="section__title">Beverages (Getränke)</h3>
|
||||
<div className="checkbox-group checkbox-group--cols-2">
|
||||
{renderCheckbox('Tea (Tee)', dinner.beverages.tea, (v) =>
|
||||
setDinner({ ...dinner, beverages: { ...dinner.beverages, tea: v } }),
|
||||
)}
|
||||
{renderCheckbox('Cocoa (Kakao)', dinner.beverages.cocoa, (v) =>
|
||||
setDinner({ ...dinner, beverages: { ...dinner.beverages, cocoa: v } }),
|
||||
)}
|
||||
{renderCheckbox('Hot Milk (Milch heiß)', dinner.beverages.hotMilk, (v) =>
|
||||
setDinner({ ...dinner, beverages: { ...dinner.beverages, hotMilk: v } }),
|
||||
)}
|
||||
{renderCheckbox('Cold Milk (Milch kalt)', dinner.beverages.coldMilk, (v) =>
|
||||
setDinner({ ...dinner, beverages: { ...dinner.beverages, coldMilk: v } }),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3 className="section__title">Additions (Zusätze)</h3>
|
||||
<div className="checkbox-group checkbox-group--cols-2">
|
||||
{renderCheckbox('Sugar (Zucker)', dinner.additions.sugar, (v) =>
|
||||
setDinner({ ...dinner, additions: { ...dinner.additions, sugar: v } }),
|
||||
)}
|
||||
{renderCheckbox('Sweetener (Süßstoff)', dinner.additions.sweetener, (v) =>
|
||||
setDinner({ ...dinner, additions: { ...dinner.additions, sweetener: v } }),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '1.5rem', display: 'flex', gap: '1rem' }}>
|
||||
<button className="btn btn--secondary" onClick={() => setStep(2)}>
|
||||
Back
|
||||
</button>
|
||||
<button className="btn btn--primary btn--block btn--large" onClick={() => setStep(4)}>
|
||||
Review Order
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Review and Submit */}
|
||||
{step === 4 && (
|
||||
<div className="card">
|
||||
<div className="card__header">
|
||||
<h2>Step 4: Review & Submit</h2>
|
||||
</div>
|
||||
<div className="card__body">
|
||||
<div className="order-summary">
|
||||
<div className="order-summary__row">
|
||||
<span className="order-summary__label">Resident</span>
|
||||
<span className="order-summary__value">{selectedResident?.name}</span>
|
||||
</div>
|
||||
<div className="order-summary__row">
|
||||
<span className="order-summary__label">Room</span>
|
||||
<span className="order-summary__value">{selectedResident?.room}</span>
|
||||
</div>
|
||||
<div className="order-summary__row">
|
||||
<span className="order-summary__label">Date</span>
|
||||
<span className="order-summary__value">{date}</span>
|
||||
</div>
|
||||
<div className="order-summary__row">
|
||||
<span className="order-summary__label">Meal Type</span>
|
||||
<span className="order-summary__value">{mealType && getMealTypeLabel(mealType)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedResident?.highCaloric && (
|
||||
<div className="message message--warning" style={{ marginTop: '1rem' }}>
|
||||
<strong>Note:</strong> This resident requires high caloric meals.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '1.5rem', display: 'flex', gap: '1rem' }}>
|
||||
<button className="btn btn--secondary" onClick={() => setStep(3)}>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--success btn--block btn--large"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Creating...' : 'Create Order'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NewOrderPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="login-page">
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<NewOrderContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
168
src/app/(app)/caregiver/orders/page.tsx
Normal file
168
src/app/(app)/caregiver/orders/page.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Resident {
|
||||
id: number
|
||||
name: string
|
||||
room: string
|
||||
}
|
||||
|
||||
interface MealOrder {
|
||||
id: number
|
||||
title: string
|
||||
date: string
|
||||
mealType: 'breakfast' | 'lunch' | 'dinner'
|
||||
status: 'pending' | 'preparing' | 'prepared'
|
||||
resident: Resident | number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export default function OrdersListPage() {
|
||||
const router = useRouter()
|
||||
const [orders, setOrders] = useState<MealOrder[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dateFilter, setDateFilter] = useState(() => new Date().toISOString().split('T')[0])
|
||||
const [mealTypeFilter, setMealTypeFilter] = useState<string>('all')
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOrders = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
let url = `/api/meal-orders?sort=-createdAt&limit=100&depth=1`
|
||||
if (dateFilter) {
|
||||
url += `&where[date][equals]=${dateFilter}`
|
||||
}
|
||||
if (mealTypeFilter !== 'all') {
|
||||
url += `&where[mealType][equals]=${mealTypeFilter}`
|
||||
}
|
||||
|
||||
const res = await fetch(url, { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setOrders(data.docs || [])
|
||||
} else if (res.status === 401) {
|
||||
router.push('/caregiver/login')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching orders:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchOrders()
|
||||
}, [router, dateFilter, mealTypeFilter])
|
||||
|
||||
const getMealTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'breakfast':
|
||||
return 'Breakfast'
|
||||
case 'lunch':
|
||||
return 'Lunch'
|
||||
case 'dinner':
|
||||
return 'Dinner'
|
||||
default:
|
||||
return type
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
return <span className={`badge badge--${status}`}>{status.charAt(0).toUpperCase() + status.slice(1)}</span>
|
||||
}
|
||||
|
||||
const getResidentName = (resident: Resident | number) => {
|
||||
if (typeof resident === 'object') {
|
||||
return resident.name
|
||||
}
|
||||
return `Resident #${resident}`
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="header">
|
||||
<div className="header__content">
|
||||
<Link href="/caregiver/dashboard" className="btn btn--secondary">
|
||||
← Back
|
||||
</Link>
|
||||
<h1 className="header__title">Meal Orders</h1>
|
||||
<Link href="/caregiver/orders/new" className="btn btn--primary">
|
||||
+ New Order
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container">
|
||||
<div className="card">
|
||||
<div className="card__header">
|
||||
<h2>Filter Orders</h2>
|
||||
</div>
|
||||
<div className="card__body">
|
||||
<div className="grid grid--2">
|
||||
<div className="form-group">
|
||||
<label htmlFor="date">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
className="input"
|
||||
value={dateFilter}
|
||||
onChange={(e) => setDateFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="mealType">Meal Type</label>
|
||||
<select
|
||||
id="mealType"
|
||||
className="select"
|
||||
value={mealTypeFilter}
|
||||
onChange={(e) => setMealTypeFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="breakfast">Breakfast</option>
|
||||
<option value="lunch">Lunch</option>
|
||||
<option value="dinner">Dinner</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: '1rem' }}>
|
||||
<div className="card__body" style={{ padding: 0, overflowX: 'auto' }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<div className="spinner" style={{ margin: '0 auto' }} />
|
||||
</div>
|
||||
) : orders.length === 0 ? (
|
||||
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--gray-500)' }}>
|
||||
No orders found for the selected criteria.
|
||||
</div>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Resident</th>
|
||||
<th>Date</th>
|
||||
<th>Meal</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.map((order) => (
|
||||
<tr key={order.id}>
|
||||
<td>{getResidentName(order.resident)}</td>
|
||||
<td>{order.date}</td>
|
||||
<td>{getMealTypeLabel(order.mealType)}</td>
|
||||
<td>{getStatusBadge(order.status)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
133
src/app/(app)/caregiver/residents/page.tsx
Normal file
133
src/app/(app)/caregiver/residents/page.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Resident {
|
||||
id: number
|
||||
name: string
|
||||
room: string
|
||||
table?: string
|
||||
station?: string
|
||||
highCaloric?: boolean
|
||||
aversions?: string
|
||||
notes?: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export default function ResidentsListPage() {
|
||||
const router = useRouter()
|
||||
const [residents, setResidents] = useState<Resident[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const fetchResidents = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/residents?where[active][equals]=true&limit=100&sort=name', {
|
||||
credentials: 'include',
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setResidents(data.docs || [])
|
||||
} else if (res.status === 401) {
|
||||
router.push('/caregiver/login')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching residents:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchResidents()
|
||||
}, [router])
|
||||
|
||||
const filteredResidents = residents.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
r.room.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(r.station && r.station.toLowerCase().includes(searchQuery.toLowerCase())),
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="header">
|
||||
<div className="header__content">
|
||||
<Link href="/caregiver/dashboard" className="btn btn--secondary">
|
||||
← Back
|
||||
</Link>
|
||||
<h1 className="header__title">Residents</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container">
|
||||
<div className="page-title">
|
||||
<h1>Residents</h1>
|
||||
<p>View resident information and dietary requirements</p>
|
||||
</div>
|
||||
|
||||
<div className="actions-bar">
|
||||
<div className="search-box">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name, room, or station..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<div className="spinner" style={{ margin: '0 auto' }} />
|
||||
</div>
|
||||
) : filteredResidents.length === 0 ? (
|
||||
<div className="card">
|
||||
<div className="card__body" style={{ textAlign: 'center', color: 'var(--gray-500)' }}>
|
||||
No residents found.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="resident-list">
|
||||
{filteredResidents.map((resident) => (
|
||||
<div key={resident.id} className="resident-card">
|
||||
<div className="resident-card__name">{resident.name}</div>
|
||||
<div className="resident-card__details">
|
||||
<span>Room {resident.room}</span>
|
||||
{resident.table && <span>Table {resident.table}</span>}
|
||||
{resident.station && <span>{resident.station}</span>}
|
||||
</div>
|
||||
{resident.highCaloric && (
|
||||
<div className="resident-card__badge">High Caloric</div>
|
||||
)}
|
||||
{(resident.aversions || resident.notes) && (
|
||||
<div style={{ marginTop: '0.75rem', fontSize: '0.875rem', color: 'var(--gray-600)' }}>
|
||||
{resident.aversions && (
|
||||
<div>
|
||||
<strong>Aversions:</strong> {resident.aversions}
|
||||
</div>
|
||||
)}
|
||||
{resident.notes && (
|
||||
<div>
|
||||
<strong>Notes:</strong> {resident.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<Link
|
||||
href={`/caregiver/orders/new?resident=${resident.id}`}
|
||||
className="btn btn--primary btn--block"
|
||||
>
|
||||
Create Order
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,704 @@
|
||||
.multi-tenant {
|
||||
body {
|
||||
/* Caregiver Tablet App Styles */
|
||||
:root {
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--success: #16a34a;
|
||||
--success-hover: #15803d;
|
||||
--warning: #ca8a04;
|
||||
--error: #dc2626;
|
||||
--gray-50: #f9fafb;
|
||||
--gray-100: #f3f4f6;
|
||||
--gray-200: #e5e7eb;
|
||||
--gray-300: #d1d5db;
|
||||
--gray-400: #9ca3af;
|
||||
--gray-500: #6b7280;
|
||||
--gray-600: #4b5563;
|
||||
--gray-700: #374151;
|
||||
--gray-800: #1f2937;
|
||||
--gray-900: #111827;
|
||||
--radius: 12px;
|
||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.caregiver-app {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--gray-100);
|
||||
color: var(--gray-900);
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: white;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
padding: 1rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: var(--shadow);
|
||||
|
||||
&__content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
&__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&__user-name {
|
||||
color: var(--gray-600);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Page Title */
|
||||
.page-title {
|
||||
margin: 1.5rem 0;
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--gray-900);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--gray-500);
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-height: 52px;
|
||||
text-decoration: none;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&--success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: var(--success-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: white;
|
||||
color: var(--gray-700);
|
||||
border: 1px solid var(--gray-300);
|
||||
|
||||
&:hover {
|
||||
background: var(--gray-50);
|
||||
}
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
&--large {
|
||||
padding: 1.25rem 2rem;
|
||||
font-size: 1.125rem;
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
&--block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
|
||||
&__header {
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
background: var(--gray-50);
|
||||
|
||||
h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--gray-800);
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Form elements */
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: var(--gray-700);
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
font-size: 1rem;
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: var(--radius);
|
||||
background: white;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.select {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
font-size: 1rem;
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: var(--radius);
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Checkbox Group */
|
||||
.checkbox-group {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
|
||||
&--cols-2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
&--cols-3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem;
|
||||
background: var(--gray-50);
|
||||
border: 2px solid var(--gray-200);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
min-height: 52px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--gray-300);
|
||||
}
|
||||
|
||||
&--checked {
|
||||
background: #eff6ff;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
accent-color: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
}
|
||||
|
||||
/* Resident Card */
|
||||
.resident-card {
|
||||
background: white;
|
||||
border: 2px solid var(--gray-200);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--gray-300);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background: #eff6ff;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--gray-900);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
&__details {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.875rem;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
&__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: var(--warning);
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Resident List */
|
||||
.resident-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
/* Meal Type Buttons */
|
||||
.meal-type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.meal-type-btn {
|
||||
background: white;
|
||||
border: 2px solid var(--gray-200);
|
||||
border-radius: var(--radius);
|
||||
padding: 2rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--gray-300);
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background: #eff6ff;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--gray-800);
|
||||
}
|
||||
|
||||
&__sublabel {
|
||||
font-size: 0.875rem;
|
||||
color: var(--gray-500);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Section */
|
||||
.section {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
&__title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--gray-700);
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
}
|
||||
}
|
||||
|
||||
/* Steps indicator */
|
||||
.steps {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
&__step {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: var(--gray-200);
|
||||
border-radius: 2px;
|
||||
transition: background 0.3s;
|
||||
|
||||
&--active {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
&--completed {
|
||||
background: var(--success);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Order Summary */
|
||||
.order-summary {
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-weight: 500;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
}
|
||||
|
||||
/* Message boxes */
|
||||
.message {
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&--success {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
border: 1px solid #86efac;
|
||||
}
|
||||
|
||||
&--error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fca5a5;
|
||||
}
|
||||
|
||||
&--warning {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border: 1px solid #fcd34d;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid var(--gray-200);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
&--2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
&--3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Login page */
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
|
||||
&__card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
&__logo {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
color: var(--gray-900);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--gray-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Dashboard stats */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
box-shadow: var(--shadow);
|
||||
|
||||
&__value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
&__label {
|
||||
color: var(--gray-500);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Quick actions */
|
||||
.quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.quick-action {
|
||||
background: white;
|
||||
border: 2px solid var(--gray-200);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-weight: 500;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.875rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--gray-50);
|
||||
font-weight: 600;
|
||||
color: var(--gray-700);
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--gray-50);
|
||||
}
|
||||
}
|
||||
|
||||
/* Badge */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
|
||||
&--pending {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
&--preparing {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
&--prepared {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
}
|
||||
|
||||
/* Actions bar */
|
||||
.actions-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-box {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 400px;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem 0.75rem 2.5rem;
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: var(--radius);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '🔍';
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.meal-type-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.checkbox-group--cols-2,
|
||||
.checkbox-group--cols-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.grid--2,
|
||||
.grid--3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.resident-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,15 @@ import React from 'react'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
const baseClass = 'multi-tenant'
|
||||
|
||||
export const metadata = {
|
||||
description: 'Generated by Next.js',
|
||||
title: 'Next.js',
|
||||
description: 'Meal ordering for caregivers',
|
||||
title: 'Meal Planner - Caregiver',
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-exports
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html className={baseClass} lang="en">
|
||||
<html className="caregiver-app" lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -1,30 +1,6 @@
|
||||
export default async ({ params: paramsPromise }: { params: Promise<{ slug: string[] }> }) => {
|
||||
return (
|
||||
<div>
|
||||
<h1>Multi-Tenant Example</h1>
|
||||
<p>
|
||||
This multi-tenant example allows you to explore multi-tenancy with domains and with slugs.
|
||||
</p>
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
<h2>Domains</h2>
|
||||
<p>When you visit a tenant by domain, the domain is used to determine the tenant.</p>
|
||||
<p>
|
||||
For example, visiting{' '}
|
||||
<a href="http://gold.localhost:3000/tenant-domains/login">
|
||||
http://gold.localhost:3000/tenant-domains/login
|
||||
</a>{' '}
|
||||
will show the tenant with the domain "gold.localhost".
|
||||
</p>
|
||||
|
||||
<h2>Slugs</h2>
|
||||
<p>When you visit a tenant by slug, the slug is used to determine the tenant.</p>
|
||||
<p>
|
||||
For example, visiting{' '}
|
||||
<a href="http://localhost:3000/tenant-slugs/silver/login">
|
||||
http://localhost:3000/tenant-slugs/silver/login
|
||||
</a>{' '}
|
||||
will show the tenant with the slug "silver".
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
export default function HomePage() {
|
||||
// Redirect to caregiver login by default
|
||||
redirect('/caregiver/login')
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import type { Where } from 'payload'
|
||||
|
||||
import configPromise from '@payload-config'
|
||||
import { headers as getHeaders } from 'next/headers'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
import React from 'react'
|
||||
|
||||
import { RenderPage } from '../../../../components/RenderPage'
|
||||
|
||||
// eslint-disable-next-line no-restricted-exports
|
||||
export default async function Page({
|
||||
params: paramsPromise,
|
||||
}: {
|
||||
params: Promise<{ slug?: string[]; tenant: string }>
|
||||
}) {
|
||||
const params = await paramsPromise
|
||||
let slug = undefined
|
||||
if (params?.slug) {
|
||||
// remove the domain route param
|
||||
params.slug.splice(0, 1)
|
||||
slug = params.slug
|
||||
}
|
||||
|
||||
const headers = await getHeaders()
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { user } = await payload.auth({ headers })
|
||||
|
||||
try {
|
||||
const tenantsQuery = await payload.find({
|
||||
collection: 'tenants',
|
||||
overrideAccess: false,
|
||||
user,
|
||||
where: {
|
||||
domain: {
|
||||
equals: params.tenant,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// If no tenant is found, the user does not have access
|
||||
// Show the login view
|
||||
if (tenantsQuery.docs.length === 0) {
|
||||
redirect(
|
||||
`/tenant-domains/login?redirect=${encodeURIComponent(
|
||||
`/tenant-domains${slug ? `/${slug.join('/')}` : ''}`,
|
||||
)}`,
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
// If the query fails, it means the user did not have access to query on the domain field
|
||||
// Show the login view
|
||||
redirect(
|
||||
`/tenant-domains/login?redirect=${encodeURIComponent(
|
||||
`/tenant-domains${slug ? `/${slug.join('/')}` : ''}`,
|
||||
)}`,
|
||||
)
|
||||
}
|
||||
|
||||
const slugConstraint: Where = slug
|
||||
? {
|
||||
slug: {
|
||||
equals: slug.join('/'),
|
||||
},
|
||||
}
|
||||
: {
|
||||
or: [
|
||||
{
|
||||
slug: {
|
||||
equals: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: {
|
||||
equals: 'home',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: {
|
||||
exists: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const pageQuery = await payload.find({
|
||||
collection: 'pages',
|
||||
overrideAccess: false,
|
||||
user,
|
||||
where: {
|
||||
and: [
|
||||
{
|
||||
'tenant.domain': {
|
||||
equals: params.tenant,
|
||||
},
|
||||
},
|
||||
slugConstraint,
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const pageData = pageQuery.docs?.[0]
|
||||
|
||||
// The page with the provided slug could not be found
|
||||
if (!pageData) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
// The page was found, render the page with data
|
||||
return <RenderPage data={pageData} />
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import { Login } from '../../../../components/Login/client.page'
|
||||
|
||||
type RouteParams = {
|
||||
tenant: string
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-exports
|
||||
export default async function Page({ params: paramsPromise }: { params: Promise<RouteParams> }) {
|
||||
const params = await paramsPromise
|
||||
|
||||
return <Login tenantDomain={params.tenant} />
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import Page from './[...slug]/page'
|
||||
|
||||
export default Page
|
||||
@@ -1,106 +0,0 @@
|
||||
import type { Where } from 'payload'
|
||||
|
||||
import configPromise from '@payload-config'
|
||||
import { headers as getHeaders } from 'next/headers'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
import React from 'react'
|
||||
|
||||
import { RenderPage } from '../../../../components/RenderPage'
|
||||
|
||||
// eslint-disable-next-line no-restricted-exports
|
||||
export default async function Page({
|
||||
params: paramsPromise,
|
||||
}: {
|
||||
params: Promise<{ slug?: string[]; tenant: string }>
|
||||
}) {
|
||||
const params = await paramsPromise
|
||||
|
||||
const headers = await getHeaders()
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { user } = await payload.auth({ headers })
|
||||
|
||||
const slug = params?.slug
|
||||
|
||||
try {
|
||||
const tenantsQuery = await payload.find({
|
||||
collection: 'tenants',
|
||||
overrideAccess: false,
|
||||
user,
|
||||
where: {
|
||||
slug: {
|
||||
equals: params.tenant,
|
||||
},
|
||||
},
|
||||
})
|
||||
// If no tenant is found, the user does not have access
|
||||
// Show the login view
|
||||
if (tenantsQuery.docs.length === 0) {
|
||||
redirect(
|
||||
`/tenant-slugs/${params.tenant}/login?redirect=${encodeURIComponent(
|
||||
`/tenant-slugs/${params.tenant}${slug ? `/${slug.join('/')}` : ''}`,
|
||||
)}`,
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
// If the query fails, it means the user did not have access to query on the slug field
|
||||
// Show the login view
|
||||
redirect(
|
||||
`/tenant-slugs/${params.tenant}/login?redirect=${encodeURIComponent(
|
||||
`/tenant-slugs/${params.tenant}${slug ? `/${slug.join('/')}` : ''}`,
|
||||
)}`,
|
||||
)
|
||||
}
|
||||
|
||||
const slugConstraint: Where = slug
|
||||
? {
|
||||
slug: {
|
||||
equals: slug.join('/'),
|
||||
},
|
||||
}
|
||||
: {
|
||||
or: [
|
||||
{
|
||||
slug: {
|
||||
equals: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: {
|
||||
equals: 'home',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: {
|
||||
exists: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const pageQuery = await payload.find({
|
||||
collection: 'pages',
|
||||
overrideAccess: false,
|
||||
user,
|
||||
where: {
|
||||
and: [
|
||||
{
|
||||
'tenant.slug': {
|
||||
equals: params.tenant,
|
||||
},
|
||||
},
|
||||
slugConstraint,
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const pageData = pageQuery.docs?.[0]
|
||||
|
||||
// The page with the provided slug could not be found
|
||||
if (!pageData) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
// The page was found, render the page with data
|
||||
return <RenderPage data={pageData} />
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import { Login } from '../../../../components/Login/client.page'
|
||||
|
||||
type RouteParams = {
|
||||
tenant: string
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-exports
|
||||
export default async function Page({ params: paramsPromise }: { params: Promise<RouteParams> }) {
|
||||
const params = await paramsPromise
|
||||
|
||||
return <Login tenantSlug={params.tenant} />
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import Page from './[...slug]/page'
|
||||
|
||||
export default Page
|
||||
@@ -1,9 +1,13 @@
|
||||
import { TenantField as TenantField_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'
|
||||
import { TenantSelector as TenantSelector_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'
|
||||
import { AssignTenantFieldTrigger as AssignTenantFieldTrigger_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'
|
||||
import { WatchTenantCollection as WatchTenantCollection_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'
|
||||
import { TenantSelector as TenantSelector_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc'
|
||||
import { TenantSelectionProvider as TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc'
|
||||
|
||||
export const importMap = {
|
||||
"@payloadcms/plugin-multi-tenant/client#TenantField": TenantField_1d0591e3cf4f332c83a86da13a0de59a,
|
||||
"@payloadcms/plugin-multi-tenant/client#TenantSelector": TenantSelector_1d0591e3cf4f332c83a86da13a0de59a,
|
||||
"@payloadcms/plugin-multi-tenant/client#AssignTenantFieldTrigger": AssignTenantFieldTrigger_1d0591e3cf4f332c83a86da13a0de59a,
|
||||
"@payloadcms/plugin-multi-tenant/client#WatchTenantCollection": WatchTenantCollection_1d0591e3cf4f332c83a86da13a0de59a,
|
||||
"@payloadcms/plugin-multi-tenant/rsc#TenantSelector": TenantSelector_d6d5f193a167989e2ee7d14202901e62,
|
||||
"@payloadcms/plugin-multi-tenant/rsc#TenantSelectionProvider": TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62
|
||||
}
|
||||
|
||||
220
src/app/(payload)/admin/views/KitchenDashboard/index.tsx
Normal file
220
src/app/(payload)/admin/views/KitchenDashboard/index.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { Gutter } from '@payloadcms/ui'
|
||||
import './styles.scss'
|
||||
|
||||
interface KitchenReportResponse {
|
||||
date: string
|
||||
mealType: string
|
||||
totalOrders: number
|
||||
ingredients: Record<string, number>
|
||||
labels: Record<string, string>
|
||||
portionSizes?: Record<string, number>
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const KitchenDashboard: React.FC = () => {
|
||||
const [date, setDate] = useState(() => {
|
||||
const today = new Date()
|
||||
return today.toISOString().split('T')[0]
|
||||
})
|
||||
const [mealType, setMealType] = useState<'breakfast' | 'lunch' | 'dinner'>('breakfast')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [report, setReport] = useState<KitchenReportResponse | null>(null)
|
||||
|
||||
const generateReport = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setReport(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/meal-orders/kitchen-report?date=${date}&mealType=${mealType}`,
|
||||
{
|
||||
credentials: 'include',
|
||||
},
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to generate report')
|
||||
}
|
||||
|
||||
setReport(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getMealTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'breakfast':
|
||||
return 'Breakfast (Frühstück)'
|
||||
case 'lunch':
|
||||
return 'Lunch (Mittagessen)'
|
||||
case 'dinner':
|
||||
return 'Dinner (Abendessen)'
|
||||
default:
|
||||
return type
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Gutter>
|
||||
<div className="kitchen-dashboard">
|
||||
<header className="kitchen-dashboard__header">
|
||||
<h1>Kitchen Dashboard</h1>
|
||||
<p>Generate ingredient reports for meal preparation</p>
|
||||
</header>
|
||||
|
||||
<section className="kitchen-dashboard__controls">
|
||||
<div className="kitchen-dashboard__form">
|
||||
<div className="kitchen-dashboard__field">
|
||||
<label htmlFor="report-date">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
id="report-date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="kitchen-dashboard__input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="kitchen-dashboard__field">
|
||||
<label htmlFor="meal-type">Meal Type</label>
|
||||
<select
|
||||
id="meal-type"
|
||||
value={mealType}
|
||||
onChange={(e) => setMealType(e.target.value as 'breakfast' | 'lunch' | 'dinner')}
|
||||
className="kitchen-dashboard__select"
|
||||
>
|
||||
<option value="breakfast">Breakfast (Frühstück)</option>
|
||||
<option value="lunch">Lunch (Mittagessen)</option>
|
||||
<option value="dinner">Dinner (Abendessen)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={generateReport}
|
||||
disabled={loading}
|
||||
className="kitchen-dashboard__button"
|
||||
>
|
||||
{loading ? 'Generating...' : 'Generate Report'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && (
|
||||
<div className="kitchen-dashboard__error">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<section className="kitchen-dashboard__report">
|
||||
<div className="kitchen-dashboard__report-header">
|
||||
<h2>Ingredient Report</h2>
|
||||
<div className="kitchen-dashboard__report-meta">
|
||||
<span className="kitchen-dashboard__meta-item">
|
||||
<strong>Date:</strong> {formatDate(report.date)}
|
||||
</span>
|
||||
<span className="kitchen-dashboard__meta-item">
|
||||
<strong>Meal:</strong> {getMealTypeLabel(report.mealType)}
|
||||
</span>
|
||||
<span className="kitchen-dashboard__meta-item kitchen-dashboard__meta-item--highlight">
|
||||
<strong>Total Orders:</strong> {report.totalOrders}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{report.totalOrders === 0 ? (
|
||||
<div className="kitchen-dashboard__empty">
|
||||
<p>No orders found for this date and meal type.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{report.portionSizes && Object.keys(report.portionSizes).length > 0 && (
|
||||
<div className="kitchen-dashboard__portion-sizes">
|
||||
<h3>Portion Sizes</h3>
|
||||
<div className="kitchen-dashboard__portion-grid">
|
||||
{Object.entries(report.portionSizes).map(([size, count]) => (
|
||||
<div key={size} className="kitchen-dashboard__portion-item">
|
||||
<span className="kitchen-dashboard__portion-label">
|
||||
{size === 'small'
|
||||
? 'Small (Kleine)'
|
||||
: size === 'large'
|
||||
? 'Large (Große)'
|
||||
: 'Vegetarian'}
|
||||
</span>
|
||||
<span className="kitchen-dashboard__portion-count">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="kitchen-dashboard__ingredients">
|
||||
<h3>Ingredients Required</h3>
|
||||
{Object.keys(report.ingredients).length === 0 ? (
|
||||
<p className="kitchen-dashboard__no-ingredients">
|
||||
No specific ingredients selected in orders.
|
||||
</p>
|
||||
) : (
|
||||
<table className="kitchen-dashboard__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ingredient</th>
|
||||
<th>Quantity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(report.ingredients)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([key, count]) => (
|
||||
<tr key={key}>
|
||||
<td>{report.labels[key] || key}</td>
|
||||
<td className="kitchen-dashboard__count">{count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Total Items</strong>
|
||||
</td>
|
||||
<td className="kitchen-dashboard__count">
|
||||
<strong>
|
||||
{Object.values(report.ingredients).reduce((a, b) => a + b, 0)}
|
||||
</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</Gutter>
|
||||
)
|
||||
}
|
||||
|
||||
export default KitchenDashboard
|
||||
254
src/app/(payload)/admin/views/KitchenDashboard/styles.scss
Normal file
254
src/app/(payload)/admin/views/KitchenDashboard/styles.scss
Normal file
@@ -0,0 +1,254 @@
|
||||
.kitchen-dashboard {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 0;
|
||||
|
||||
&__header {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--theme-elevation-500);
|
||||
}
|
||||
}
|
||||
|
||||
&__controls {
|
||||
background: var(--theme-elevation-50);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
&__form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: var(--theme-elevation-700);
|
||||
}
|
||||
}
|
||||
|
||||
&__input,
|
||||
&__select {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--theme-elevation-200);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
min-width: 200px;
|
||||
background: var(--theme-elevation-0);
|
||||
color: var(--theme-text);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--theme-success-500);
|
||||
box-shadow: 0 0 0 2px var(--theme-success-100);
|
||||
}
|
||||
}
|
||||
|
||||
&__button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--theme-success-500);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--theme-success-600);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
background: var(--theme-error-100);
|
||||
color: var(--theme-error-600);
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 2rem;
|
||||
border-left: 4px solid var(--theme-error-500);
|
||||
}
|
||||
|
||||
&__report {
|
||||
background: var(--theme-elevation-0);
|
||||
border: 1px solid var(--theme-elevation-100);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__report-header {
|
||||
background: var(--theme-elevation-50);
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--theme-elevation-100);
|
||||
|
||||
h2 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&__report-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
&__meta-item {
|
||||
font-size: 0.875rem;
|
||||
|
||||
strong {
|
||||
color: var(--theme-elevation-600);
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
&--highlight {
|
||||
background: var(--theme-success-100);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
color: var(--theme-success-700);
|
||||
|
||||
strong {
|
||||
color: var(--theme-success-700);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__empty {
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
color: var(--theme-elevation-500);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__portion-sizes {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--theme-elevation-100);
|
||||
|
||||
h3 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--theme-elevation-700);
|
||||
}
|
||||
}
|
||||
|
||||
&__portion-grid {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__portion-item {
|
||||
background: var(--theme-elevation-50);
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
&__portion-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--theme-elevation-600);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
&__portion-count {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--theme-text);
|
||||
}
|
||||
|
||||
&__ingredients {
|
||||
padding: 1.5rem;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--theme-elevation-700);
|
||||
}
|
||||
}
|
||||
|
||||
&__no-ingredients {
|
||||
color: var(--theme-elevation-500);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th, td {
|
||||
padding: 0.875rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--theme-elevation-100);
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--theme-elevation-50);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--theme-elevation-700);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tbody tr:nth-child(even) {
|
||||
background: var(--theme-elevation-25);
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--theme-elevation-50);
|
||||
}
|
||||
|
||||
tfoot {
|
||||
td {
|
||||
background: var(--theme-elevation-50);
|
||||
border-top: 2px solid var(--theme-elevation-200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__count {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// Dark theme adjustments
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.kitchen-dashboard {
|
||||
&__table tbody tr:nth-child(even) {
|
||||
background: var(--theme-elevation-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Page } from '@payload-types'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export const RenderPage = ({ data }: { data: Page }) => {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<form action="/api/users/logout" method="post">
|
||||
<button type="submit">Logout</button>
|
||||
</form>
|
||||
<h2>Here you can decide how you would like to render the page data!</h2>
|
||||
|
||||
<code>{JSON.stringify(data)}</code>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
241
src/collections/MealOrders/endpoints/kitchenReport.ts
Normal file
241
src/collections/MealOrders/endpoints/kitchenReport.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import type { Endpoint } from 'payload'
|
||||
import { isSuperAdmin } from '@/access/isSuperAdmin'
|
||||
import { canAccessKitchen } from '@/access/roles'
|
||||
|
||||
/**
|
||||
* Field mappings for aggregation
|
||||
* Maps meal type to their respective boolean fields that should be counted
|
||||
*/
|
||||
const breakfastFields = {
|
||||
'breakfast.accordingToPlan': 'According to Plan',
|
||||
'breakfast.bread.breadRoll': 'Bread Roll (Brötchen)',
|
||||
'breakfast.bread.wholeGrainRoll': 'Whole Grain Roll (Vollkornbrötchen)',
|
||||
'breakfast.bread.greyBread': 'Grey Bread (Graubrot)',
|
||||
'breakfast.bread.wholeGrainBread': 'Whole Grain Bread (Vollkornbrot)',
|
||||
'breakfast.bread.whiteBread': 'White Bread (Weißbrot)',
|
||||
'breakfast.bread.crispbread': 'Crispbread (Knäckebrot)',
|
||||
'breakfast.porridge': 'Porridge (Brei)',
|
||||
'breakfast.preparation.sliced': 'Sliced (geschnitten)',
|
||||
'breakfast.preparation.spread': 'Spread (geschmiert)',
|
||||
'breakfast.spreads.butter': 'Butter',
|
||||
'breakfast.spreads.margarine': 'Margarine',
|
||||
'breakfast.spreads.jam': 'Jam (Konfitüre)',
|
||||
'breakfast.spreads.diabeticJam': 'Diabetic Jam (Diab. Konfitüre)',
|
||||
'breakfast.spreads.honey': 'Honey (Honig)',
|
||||
'breakfast.spreads.cheese': 'Cheese (Käse)',
|
||||
'breakfast.spreads.quark': 'Quark',
|
||||
'breakfast.spreads.sausage': 'Sausage (Wurst)',
|
||||
'breakfast.beverages.coffee': 'Coffee (Kaffee)',
|
||||
'breakfast.beverages.tea': 'Tea (Tee)',
|
||||
'breakfast.beverages.hotMilk': 'Hot Milk (Milch heiß)',
|
||||
'breakfast.beverages.coldMilk': 'Cold Milk (Milch kalt)',
|
||||
'breakfast.additions.sugar': 'Sugar (Zucker)',
|
||||
'breakfast.additions.sweetener': 'Sweetener (Süßstoff)',
|
||||
'breakfast.additions.coffeeCreamer': 'Coffee Creamer (Kaffeesahne)',
|
||||
}
|
||||
|
||||
const lunchFields = {
|
||||
'lunch.soup': 'Soup (Suppe)',
|
||||
'lunch.dessert': 'Dessert',
|
||||
'lunch.specialPreparations.pureedFood': 'Pureed Food (passierte Kost)',
|
||||
'lunch.specialPreparations.pureedMeat': 'Pureed Meat (passiertes Fleisch)',
|
||||
'lunch.specialPreparations.slicedMeat': 'Sliced Meat (geschnittenes Fleisch)',
|
||||
'lunch.specialPreparations.mashedPotatoes': 'Mashed Potatoes (Kartoffelbrei)',
|
||||
'lunch.restrictions.noFish': 'No Fish (ohne Fisch)',
|
||||
'lunch.restrictions.fingerFood': 'Finger Food',
|
||||
'lunch.restrictions.onlySweet': 'Only Sweet (nur süß)',
|
||||
}
|
||||
|
||||
const dinnerFields = {
|
||||
'dinner.accordingToPlan': 'According to Plan',
|
||||
'dinner.bread.greyBread': 'Grey Bread (Graubrot)',
|
||||
'dinner.bread.wholeGrainBread': 'Whole Grain Bread (Vollkornbrot)',
|
||||
'dinner.bread.whiteBread': 'White Bread (Weißbrot)',
|
||||
'dinner.bread.crispbread': 'Crispbread (Knäckebrot)',
|
||||
'dinner.preparation.spread': 'Spread (geschmiert)',
|
||||
'dinner.preparation.sliced': 'Sliced (geschnitten)',
|
||||
'dinner.spreads.butter': 'Butter',
|
||||
'dinner.spreads.margarine': 'Margarine',
|
||||
'dinner.soup': 'Soup (Suppe)',
|
||||
'dinner.porridge': 'Porridge (Brei)',
|
||||
'dinner.noFish': 'No Fish (ohne Fisch)',
|
||||
'dinner.beverages.tea': 'Tea (Tee)',
|
||||
'dinner.beverages.cocoa': 'Cocoa (Kakao)',
|
||||
'dinner.beverages.hotMilk': 'Hot Milk (Milch heiß)',
|
||||
'dinner.beverages.coldMilk': 'Cold Milk (Milch kalt)',
|
||||
'dinner.additions.sugar': 'Sugar (Zucker)',
|
||||
'dinner.additions.sweetener': 'Sweetener (Süßstoff)',
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nested value from object using dot notation path
|
||||
*/
|
||||
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
return path.split('.').reduce((current: unknown, key) => {
|
||||
if (current && typeof current === 'object' && key in (current as Record<string, unknown>)) {
|
||||
return (current as Record<string, unknown>)[key]
|
||||
}
|
||||
return undefined
|
||||
}, obj)
|
||||
}
|
||||
|
||||
/**
|
||||
* Kitchen Report API Endpoint
|
||||
*
|
||||
* GET /api/meal-orders/kitchen-report
|
||||
*
|
||||
* Query Parameters:
|
||||
* - date (required): YYYY-MM-DD format
|
||||
* - mealType (required): breakfast | lunch | dinner
|
||||
*
|
||||
* Returns aggregated ingredient counts for the specified date and meal type.
|
||||
* Only accessible by users with admin or kitchen role.
|
||||
*/
|
||||
export const kitchenReportEndpoint: Endpoint = {
|
||||
path: '/kitchen-report',
|
||||
method: 'get',
|
||||
handler: async (req) => {
|
||||
const { payload, user } = req
|
||||
|
||||
// Check authentication
|
||||
if (!user) {
|
||||
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check authorization - must be super admin, tenant admin, or kitchen staff
|
||||
if (!isSuperAdmin(user) && !canAccessKitchen(user)) {
|
||||
return Response.json(
|
||||
{ error: 'Forbidden - Kitchen or Admin role required' },
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
const url = new URL(req.url || '', 'http://localhost')
|
||||
const date = url.searchParams.get('date')
|
||||
const mealType = url.searchParams.get('mealType')
|
||||
|
||||
// Validate parameters
|
||||
if (!date) {
|
||||
return Response.json({ error: 'Missing required parameter: date' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!mealType || !['breakfast', 'lunch', 'dinner'].includes(mealType)) {
|
||||
return Response.json(
|
||||
{ error: 'Invalid or missing mealType. Must be: breakfast, lunch, or dinner' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Validate date format
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/
|
||||
if (!dateRegex.test(date)) {
|
||||
return Response.json({ error: 'Invalid date format. Use YYYY-MM-DD' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Query meal orders for the specified date and meal type
|
||||
const orders = await payload.find({
|
||||
collection: 'meal-orders',
|
||||
where: {
|
||||
and: [
|
||||
{
|
||||
date: {
|
||||
equals: date,
|
||||
},
|
||||
},
|
||||
{
|
||||
mealType: {
|
||||
equals: mealType,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
limit: 1000, // Get all orders for the day
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
// Select the appropriate field mapping
|
||||
const fieldMapping =
|
||||
mealType === 'breakfast'
|
||||
? breakfastFields
|
||||
: mealType === 'lunch'
|
||||
? lunchFields
|
||||
: dinnerFields
|
||||
|
||||
// Aggregate counts
|
||||
const ingredients: Record<string, { count: number; label: string }> = {}
|
||||
|
||||
// Initialize all fields with 0
|
||||
for (const [fieldPath, label] of Object.entries(fieldMapping)) {
|
||||
ingredients[fieldPath] = { count: 0, label }
|
||||
}
|
||||
|
||||
// Count lunch portion sizes separately
|
||||
const portionSizes: Record<string, number> = {
|
||||
small: 0,
|
||||
large: 0,
|
||||
vegetarian: 0,
|
||||
}
|
||||
|
||||
// Count occurrences
|
||||
for (const order of orders.docs) {
|
||||
// Count boolean fields
|
||||
for (const fieldPath of Object.keys(fieldMapping)) {
|
||||
const value = getNestedValue(order as unknown as Record<string, unknown>, fieldPath)
|
||||
if (value === true) {
|
||||
ingredients[fieldPath].count++
|
||||
}
|
||||
}
|
||||
|
||||
// Count lunch portion sizes
|
||||
if (mealType === 'lunch' && order.lunch?.portionSize) {
|
||||
const size = order.lunch.portionSize as string
|
||||
if (size in portionSizes) {
|
||||
portionSizes[size]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build response with non-zero items
|
||||
const ingredientCounts: Record<string, number> = {}
|
||||
const ingredientLabels: Record<string, string> = {}
|
||||
|
||||
for (const [fieldPath, { count, label }] of Object.entries(ingredients)) {
|
||||
if (count > 0) {
|
||||
// Use a cleaner key name (last part of the path)
|
||||
const key = fieldPath.split('.').pop() || fieldPath
|
||||
ingredientCounts[key] = count
|
||||
ingredientLabels[key] = label
|
||||
}
|
||||
}
|
||||
|
||||
// Build the response
|
||||
const response: Record<string, unknown> = {
|
||||
date,
|
||||
mealType,
|
||||
totalOrders: orders.totalDocs,
|
||||
ingredients: ingredientCounts,
|
||||
labels: ingredientLabels,
|
||||
}
|
||||
|
||||
// Add portion sizes for lunch
|
||||
if (mealType === 'lunch') {
|
||||
const nonZeroPortions: Record<string, number> = {}
|
||||
for (const [size, count] of Object.entries(portionSizes)) {
|
||||
if (count > 0) {
|
||||
nonZeroPortions[size] = count
|
||||
}
|
||||
}
|
||||
if (Object.keys(nonZeroPortions).length > 0) {
|
||||
response.portionSizes = nonZeroPortions
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json(response, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error('Kitchen report error:', error)
|
||||
return Response.json({ error: 'Failed to generate report' }, { status: 500 })
|
||||
}
|
||||
},
|
||||
}
|
||||
52
src/collections/MealOrders/hooks/generateTitle.ts
Normal file
52
src/collections/MealOrders/hooks/generateTitle.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { CollectionBeforeValidateHook } from 'payload'
|
||||
|
||||
/**
|
||||
* Hook to auto-generate the title field from date, meal type, and resident name
|
||||
* Format: "Breakfast - 2024-01-15 - John Doe"
|
||||
*/
|
||||
export const generateTitle: CollectionBeforeValidateHook = async ({ data, req, operation }) => {
|
||||
if (!data) return data
|
||||
|
||||
const mealType = data.mealType
|
||||
const date = data.date
|
||||
|
||||
// Format meal type with first letter capitalized
|
||||
const mealTypeLabel =
|
||||
mealType === 'breakfast' ? 'Breakfast' : mealType === 'lunch' ? 'Lunch' : 'Dinner'
|
||||
|
||||
// Format date as YYYY-MM-DD
|
||||
let dateStr = ''
|
||||
if (date) {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date
|
||||
dateStr = dateObj.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
// Get resident name if we have the resident ID
|
||||
let residentName = ''
|
||||
if (data.resident && req.payload) {
|
||||
try {
|
||||
const residentId = typeof data.resident === 'object' ? data.resident.id : data.resident
|
||||
if (residentId) {
|
||||
const resident = await req.payload.findByID({
|
||||
collection: 'residents',
|
||||
id: residentId,
|
||||
depth: 0,
|
||||
})
|
||||
if (resident?.name) {
|
||||
residentName = resident.name
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If we can't fetch the resident, just skip the name
|
||||
}
|
||||
}
|
||||
|
||||
// Compose title
|
||||
const parts = [mealTypeLabel, dateStr, residentName].filter(Boolean)
|
||||
const title = parts.join(' - ')
|
||||
|
||||
return {
|
||||
...data,
|
||||
title: title || 'New Meal Order',
|
||||
}
|
||||
}
|
||||
14
src/collections/MealOrders/hooks/setCreatedBy.ts
Normal file
14
src/collections/MealOrders/hooks/setCreatedBy.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { CollectionBeforeChangeHook } from 'payload'
|
||||
|
||||
/**
|
||||
* Hook to automatically set the createdBy field to the current user on creation
|
||||
*/
|
||||
export const setCreatedBy: CollectionBeforeChangeHook = async ({ data, req, operation }) => {
|
||||
if (operation === 'create' && req.user) {
|
||||
return {
|
||||
...data,
|
||||
createdBy: req.user.id,
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
439
src/collections/MealOrders/index.ts
Normal file
439
src/collections/MealOrders/index.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
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'
|
||||
|
||||
/**
|
||||
* Meal Orders Collection
|
||||
*
|
||||
* Represents a single meal order 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 order belongs to a specific care home.
|
||||
*/
|
||||
export const MealOrders: CollectionConfig = {
|
||||
slug: 'meal-orders',
|
||||
labels: {
|
||||
singular: 'Meal Order',
|
||||
plural: 'Meal Orders',
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
description: 'Manage meal orders for residents',
|
||||
defaultColumns: ['title', 'resident', 'date', 'mealType', 'status'],
|
||||
group: 'Meal Planning',
|
||||
},
|
||||
endpoints: [kitchenReportEndpoint],
|
||||
hooks: {
|
||||
beforeChange: [setCreatedBy],
|
||||
beforeValidate: [generateTitle],
|
||||
},
|
||||
access: {
|
||||
// Admin and caregiver can create orders
|
||||
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 orders
|
||||
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 orders, 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 orders
|
||||
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: 'resident',
|
||||
type: 'relationship',
|
||||
relationTo: 'residents',
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: 'Select the resident for this meal order',
|
||||
},
|
||||
},
|
||||
{
|
||||
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: 'Order status for kitchen tracking',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'createdBy',
|
||||
type: 'relationship',
|
||||
relationTo: 'users',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
readOnly: true,
|
||||
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',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Special notes for this order',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ============================================
|
||||
// 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,25 +0,0 @@
|
||||
import { getUserTenantIDs } from '@/utilities/getUserTenantIDs'
|
||||
import { isSuperAdmin } from '../../../access/isSuperAdmin'
|
||||
import { Access } from 'payload'
|
||||
|
||||
/**
|
||||
* Tenant admins and super admins can will be allowed access
|
||||
*/
|
||||
export const superAdminOrTenantAdminAccess: Access = ({ req }) => {
|
||||
if (!req.user) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isSuperAdmin(req.user)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const adminTenantAccessIDs = getUserTenantIDs(req.user, 'tenant-admin')
|
||||
const requestedTenant = req?.data?.tenant
|
||||
|
||||
if (requestedTenant && adminTenantAccessIDs.includes(requestedTenant)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import type { FieldHook, Where } from 'payload'
|
||||
|
||||
import { ValidationError } from 'payload'
|
||||
|
||||
import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs'
|
||||
import { extractID } from '@/utilities/extractID'
|
||||
|
||||
export const ensureUniqueSlug: FieldHook = async ({ data, originalDoc, req, value }) => {
|
||||
// if value is unchanged, skip validation
|
||||
if (originalDoc.slug === value) {
|
||||
return value
|
||||
}
|
||||
|
||||
const constraints: Where[] = [
|
||||
{
|
||||
slug: {
|
||||
equals: value,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const incomingTenantID = extractID(data?.tenant)
|
||||
const currentTenantID = extractID(originalDoc?.tenant)
|
||||
const tenantIDToMatch = incomingTenantID || currentTenantID
|
||||
|
||||
if (tenantIDToMatch) {
|
||||
constraints.push({
|
||||
tenant: {
|
||||
equals: tenantIDToMatch,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const findDuplicatePages = await req.payload.find({
|
||||
collection: 'pages',
|
||||
where: {
|
||||
and: constraints,
|
||||
},
|
||||
})
|
||||
|
||||
if (findDuplicatePages.docs.length > 0 && req.user) {
|
||||
const tenantIDs = getUserTenantIDs(req.user)
|
||||
// if the user is an admin or has access to more than 1 tenant
|
||||
// provide a more specific error message
|
||||
if (req.user.roles?.includes('super-admin') || tenantIDs.length > 1) {
|
||||
const attemptedTenantChange = await req.payload.findByID({
|
||||
id: tenantIDToMatch,
|
||||
collection: 'tenants',
|
||||
})
|
||||
|
||||
throw new ValidationError({
|
||||
errors: [
|
||||
{
|
||||
message: `The "${attemptedTenantChange.name}" tenant already has a page with the slug "${value}". Slugs must be unique per tenant.`,
|
||||
path: 'slug',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
throw new ValidationError({
|
||||
errors: [
|
||||
{
|
||||
message: `A page with the slug ${value} already exists. Slug must be unique per tenant.`,
|
||||
path: 'slug',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { ensureUniqueSlug } from './hooks/ensureUniqueSlug'
|
||||
import { superAdminOrTenantAdminAccess } from '@/collections/Pages/access/superAdminOrTenantAdmin'
|
||||
|
||||
export const Pages: CollectionConfig = {
|
||||
slug: 'pages',
|
||||
access: {
|
||||
create: superAdminOrTenantAdminAccess,
|
||||
delete: superAdminOrTenantAdminAccess,
|
||||
read: () => true,
|
||||
update: superAdminOrTenantAdminAccess,
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
defaultValue: 'home',
|
||||
hooks: {
|
||||
beforeValidate: [ensureUniqueSlug],
|
||||
},
|
||||
index: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
118
src/collections/Residents/index.ts
Normal file
118
src/collections/Residents/index.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
import { isSuperAdmin, isSuperAdminAccess } from '@/access/isSuperAdmin'
|
||||
import { hasTenantRole } from '@/access/roles'
|
||||
|
||||
/**
|
||||
* Residents Collection
|
||||
*
|
||||
* Stores permanent resident information for each care home.
|
||||
* Multi-tenant: each resident belongs to a specific care home (tenant).
|
||||
*/
|
||||
export const Residents: CollectionConfig = {
|
||||
slug: 'residents',
|
||||
labels: {
|
||||
singular: 'Resident',
|
||||
plural: 'Residents',
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'name',
|
||||
description: 'Manage residents in your care home',
|
||||
defaultColumns: ['name', 'room', 'station', 'table', 'active'],
|
||||
group: 'Meal Planning',
|
||||
},
|
||||
access: {
|
||||
// Only super-admin and tenant admin can create residents
|
||||
create: ({ req }) => {
|
||||
if (!req.user) return false
|
||||
if (isSuperAdmin(req.user)) return true
|
||||
return hasTenantRole(req.user, 'admin')
|
||||
},
|
||||
// All authenticated users within the tenant can read residents
|
||||
read: ({ req }) => {
|
||||
if (!req.user) return false
|
||||
return true // Multi-tenant plugin will filter by tenant
|
||||
},
|
||||
// Only super-admin and tenant admin can update residents
|
||||
update: ({ req }) => {
|
||||
if (!req.user) return false
|
||||
if (isSuperAdmin(req.user)) return true
|
||||
return hasTenantRole(req.user, 'admin')
|
||||
},
|
||||
// Only super-admin and tenant admin can delete residents
|
||||
delete: ({ req }) => {
|
||||
if (!req.user) return false
|
||||
if (isSuperAdmin(req.user)) return true
|
||||
return hasTenantRole(req.user, 'admin')
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Full name of the resident',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'room',
|
||||
type: 'text',
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: 'Room number (Zimmer)',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'table',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Table assignment in dining area (Tisch)',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'station',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Station or ward',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'highCaloric',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description: 'Requires high-caloric meals (Hochkalorisch)',
|
||||
width: '50%',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'active',
|
||||
type: 'checkbox',
|
||||
defaultValue: true,
|
||||
admin: {
|
||||
description: 'Is the resident currently active?',
|
||||
width: '50%',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'aversions',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Food aversions and dislikes (Abneigungen)',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Other notes and special requirements (Sonstiges)',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -29,8 +29,8 @@ export const canMutateTenant: Access = ({ req }) => {
|
||||
in:
|
||||
req.user?.tenants
|
||||
?.map(({ roles, tenant }) =>
|
||||
roles?.includes('tenant-admin')
|
||||
? tenant && (typeof tenant === 'string' ? tenant : tenant.id)
|
||||
roles?.includes('admin')
|
||||
? tenant && (typeof tenant === 'object' ? tenant.id : tenant)
|
||||
: null,
|
||||
)
|
||||
.filter(Boolean) || [],
|
||||
|
||||
@@ -13,7 +13,7 @@ export const updateAndDeleteAccess: Access = ({ req }) => {
|
||||
|
||||
return {
|
||||
id: {
|
||||
in: getUserTenantIDs(req.user, 'tenant-admin'),
|
||||
in: getUserTenantIDs(req.user, 'admin'),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,20 @@ import type { CollectionConfig } from 'payload'
|
||||
import { isSuperAdminAccess } from '@/access/isSuperAdmin'
|
||||
import { updateAndDeleteAccess } from './access/updateAndDelete'
|
||||
|
||||
/**
|
||||
* Tenants Collection - Represents Care Homes
|
||||
*
|
||||
* Each tenant is an elderly care home with their own:
|
||||
* - Residents
|
||||
* - Meal orders
|
||||
* - Staff (caregivers, kitchen staff)
|
||||
*/
|
||||
export const Tenants: CollectionConfig = {
|
||||
slug: 'tenants',
|
||||
labels: {
|
||||
singular: 'Care Home',
|
||||
plural: 'Care Homes',
|
||||
},
|
||||
access: {
|
||||
create: isSuperAdminAccess,
|
||||
delete: updateAndDeleteAccess,
|
||||
@@ -13,39 +25,48 @@ export const Tenants: CollectionConfig = {
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'name',
|
||||
description: 'Manage care homes in the system',
|
||||
defaultColumns: ['name', 'slug', 'phone'],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'domain',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Used for domain-based tenant handling',
|
||||
description: 'Care home name',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Used for url paths, example: /tenant-slug/page-slug',
|
||||
},
|
||||
index: true,
|
||||
required: true,
|
||||
index: true,
|
||||
unique: true,
|
||||
admin: {
|
||||
description: 'URL-friendly identifier (e.g., sunny-meadows)',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'allowPublicRead',
|
||||
type: 'checkbox',
|
||||
name: 'domain',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description:
|
||||
'If checked, logging in is not required to read. Useful for building public pages.',
|
||||
position: 'sidebar',
|
||||
description: 'Optional custom domain (e.g., sunny-meadows.localhost)',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'address',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Physical address of the care home',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'phone',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Contact phone number',
|
||||
},
|
||||
defaultValue: false,
|
||||
index: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export const createAccess: Access<User> = ({ req }) => {
|
||||
return false
|
||||
}
|
||||
|
||||
const adminTenantAccessIDs = getUserTenantIDs(req.user, 'tenant-admin')
|
||||
const adminTenantAccessIDs = getUserTenantIDs(req.user, 'admin')
|
||||
|
||||
const requestedTenants: Tenant['id'][] =
|
||||
req.data?.tenants?.map((t: { tenant: Tenant['id'] }) => t.tenant) ?? []
|
||||
|
||||
@@ -21,7 +21,7 @@ export const readAccess: Access<User> = ({ req, id }) => {
|
||||
req.headers,
|
||||
getCollectionIDType({ payload: req.payload, collectionSlug: 'tenants' }),
|
||||
)
|
||||
const adminTenantAccessIDs = getUserTenantIDs(req.user, 'tenant-admin')
|
||||
const adminTenantAccessIDs = getUserTenantIDs(req.user, 'admin')
|
||||
|
||||
if (selectedTenant) {
|
||||
// If it's a super admin, or they have access to the tenant ID set in cookie
|
||||
|
||||
@@ -17,15 +17,15 @@ export const updateAndDeleteAccess: Access = ({ req, id }) => {
|
||||
|
||||
/**
|
||||
* Constrains update and delete access to users that belong
|
||||
* to the same tenant as the tenant-admin making the request
|
||||
* to the same tenant as the admin making the request
|
||||
*
|
||||
* You may want to take this a step further with a beforeChange
|
||||
* hook to ensure that the a tenant-admin can only remove users
|
||||
* hook to ensure that the admin can only remove users
|
||||
* from their own tenant in the tenants array.
|
||||
*/
|
||||
return {
|
||||
'tenants.tenant': {
|
||||
in: getUserTenantIDs(user, 'tenant-admin'),
|
||||
in: getUserTenantIDs(user, 'admin'),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,12 @@ import { isSuperAdmin } from '@/access/isSuperAdmin'
|
||||
import { setCookieBasedOnDomain } from './hooks/setCookieBasedOnDomain'
|
||||
import { tenantsArrayField } from '@payloadcms/plugin-multi-tenant/fields'
|
||||
|
||||
/**
|
||||
* Tenant Roles for Care Home Staff:
|
||||
* - admin: Full access within their care home(s)
|
||||
* - caregiver: Can create/manage meal orders for residents
|
||||
* - kitchen: Can view orders and mark as prepared
|
||||
*/
|
||||
const defaultTenantArrayField = tenantsArrayField({
|
||||
tenantsArrayFieldName: 'tenants',
|
||||
tenantsArrayTenantFieldName: 'tenant',
|
||||
@@ -19,28 +25,38 @@ const defaultTenantArrayField = tenantsArrayField({
|
||||
{
|
||||
name: 'roles',
|
||||
type: 'select',
|
||||
defaultValue: ['tenant-viewer'],
|
||||
defaultValue: ['caregiver'],
|
||||
hasMany: true,
|
||||
options: ['tenant-admin', 'tenant-viewer'],
|
||||
options: [
|
||||
{ label: 'Admin', value: 'admin' },
|
||||
{ label: 'Caregiver', value: 'caregiver' },
|
||||
{ label: 'Kitchen', value: 'kitchen' },
|
||||
],
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Role(s) for this user within the care home',
|
||||
},
|
||||
access: {
|
||||
update: ({ req }) => {
|
||||
const { user } = req
|
||||
if (!user) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isSuperAdmin(user)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
// Super admins and tenant admins can update roles
|
||||
return isSuperAdmin(user) || true
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
/**
|
||||
* Users Collection
|
||||
*
|
||||
* Two-level role system:
|
||||
* - Global roles: super-admin (system-wide access), user (access via tenant roles)
|
||||
* - Tenant roles: admin, caregiver, kitchen (per care home)
|
||||
*/
|
||||
const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
access: {
|
||||
@@ -51,27 +67,32 @@ const Users: CollectionConfig = {
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
defaultColumns: ['email', 'roles', 'createdAt'],
|
||||
},
|
||||
auth: true,
|
||||
endpoints: [externalUsersLogin],
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Full name of the user',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'password',
|
||||
hidden: true,
|
||||
access: {
|
||||
read: () => false, // Hide password field from read access
|
||||
read: () => false,
|
||||
update: ({ req, id }) => {
|
||||
const { user } = req
|
||||
if (!user) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (id === user.id) {
|
||||
// Allow user to update their own password
|
||||
return true
|
||||
}
|
||||
|
||||
return isSuperAdmin(user)
|
||||
},
|
||||
},
|
||||
@@ -79,12 +100,16 @@ const Users: CollectionConfig = {
|
||||
{
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
description: 'Global system role',
|
||||
},
|
||||
name: 'roles',
|
||||
type: 'select',
|
||||
defaultValue: ['user'],
|
||||
hasMany: true,
|
||||
options: ['super-admin', 'user'],
|
||||
options: [
|
||||
{ label: 'Super Admin', value: 'super-admin' },
|
||||
{ label: 'User', value: 'user' },
|
||||
],
|
||||
access: {
|
||||
update: ({ req }) => {
|
||||
return isSuperAdmin(req.user)
|
||||
@@ -104,13 +129,10 @@ const Users: CollectionConfig = {
|
||||
admin: {
|
||||
...(defaultTenantArrayField?.admin || {}),
|
||||
position: 'sidebar',
|
||||
description: 'Care homes this user has access to',
|
||||
},
|
||||
},
|
||||
],
|
||||
// The following hook sets a cookie based on the domain a user logs in from.
|
||||
// It checks the domain and matches it to a tenant in the system, then sets
|
||||
// a 'payload-tenant' cookie for that tenant.
|
||||
|
||||
hooks: {
|
||||
afterLogin: [setCookieBasedOnDomain],
|
||||
},
|
||||
|
||||
@@ -54,6 +54,7 @@ export type SupportedTimezones =
|
||||
| 'Asia/Singapore'
|
||||
| 'Asia/Tokyo'
|
||||
| 'Asia/Seoul'
|
||||
| 'Australia/Brisbane'
|
||||
| 'Australia/Sydney'
|
||||
| 'Pacific/Guam'
|
||||
| 'Pacific/Noumea'
|
||||
@@ -66,18 +67,22 @@ export interface Config {
|
||||
};
|
||||
blocks: {};
|
||||
collections: {
|
||||
pages: Page;
|
||||
users: User;
|
||||
tenants: Tenant;
|
||||
residents: Resident;
|
||||
'meal-orders': MealOrder;
|
||||
'payload-kv': PayloadKv;
|
||||
'payload-locked-documents': PayloadLockedDocument;
|
||||
'payload-preferences': PayloadPreference;
|
||||
'payload-migrations': PayloadMigration;
|
||||
};
|
||||
collectionsJoins: {};
|
||||
collectionsSelect: {
|
||||
pages: PagesSelect<false> | PagesSelect<true>;
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
tenants: TenantsSelect<false> | TenantsSelect<true>;
|
||||
residents: ResidentsSelect<false> | ResidentsSelect<true>;
|
||||
'meal-orders': MealOrdersSelect<false> | MealOrdersSelect<true>;
|
||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||
@@ -85,6 +90,7 @@ export interface Config {
|
||||
db: {
|
||||
defaultIDType: number;
|
||||
};
|
||||
fallbackLocale: null;
|
||||
globals: {};
|
||||
globalsSelect: {};
|
||||
locale: null;
|
||||
@@ -114,52 +120,32 @@ export interface UserAuthOperations {
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "pages".
|
||||
*/
|
||||
export interface Page {
|
||||
id: number;
|
||||
tenant?: (number | null) | Tenant;
|
||||
title?: string | null;
|
||||
slug?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "tenants".
|
||||
*/
|
||||
export interface Tenant {
|
||||
id: number;
|
||||
name: string;
|
||||
/**
|
||||
* Used for domain-based tenant handling
|
||||
*/
|
||||
domain?: string | null;
|
||||
/**
|
||||
* Used for url paths, example: /tenant-slug/page-slug
|
||||
*/
|
||||
slug: string;
|
||||
/**
|
||||
* If checked, logging in is not required to read. Useful for building public pages.
|
||||
*/
|
||||
allowPublicRead?: boolean | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users".
|
||||
*/
|
||||
export interface User {
|
||||
id: number;
|
||||
/**
|
||||
* Full name of the user
|
||||
*/
|
||||
name?: string | null;
|
||||
password?: string | null;
|
||||
/**
|
||||
* Global system role
|
||||
*/
|
||||
roles?: ('super-admin' | 'user')[] | null;
|
||||
username?: string | null;
|
||||
/**
|
||||
* Care homes this user has access to
|
||||
*/
|
||||
tenants?:
|
||||
| {
|
||||
tenant: number | Tenant;
|
||||
roles: ('tenant-admin' | 'tenant-viewer')[];
|
||||
/**
|
||||
* Role(s) for this user within the care home
|
||||
*/
|
||||
roles: ('admin' | 'caregiver' | 'kitchen')[];
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
@@ -172,7 +158,230 @@ export interface User {
|
||||
hash?: string | null;
|
||||
loginAttempts?: number | null;
|
||||
lockUntil?: string | null;
|
||||
password?: string | null;
|
||||
sessions?:
|
||||
| {
|
||||
id: string;
|
||||
createdAt?: string | null;
|
||||
expiresAt: string;
|
||||
}[]
|
||||
| null;
|
||||
}
|
||||
/**
|
||||
* Manage care homes in the system
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "tenants".
|
||||
*/
|
||||
export interface Tenant {
|
||||
id: number;
|
||||
/**
|
||||
* Care home name
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* URL-friendly identifier (e.g., sunny-meadows)
|
||||
*/
|
||||
slug: string;
|
||||
/**
|
||||
* Optional custom domain (e.g., sunny-meadows.localhost)
|
||||
*/
|
||||
domain?: string | null;
|
||||
/**
|
||||
* Physical address of the care home
|
||||
*/
|
||||
address?: string | null;
|
||||
/**
|
||||
* Contact phone number
|
||||
*/
|
||||
phone?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Manage residents in your care home
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "residents".
|
||||
*/
|
||||
export interface Resident {
|
||||
id: number;
|
||||
tenant?: (number | null) | Tenant;
|
||||
/**
|
||||
* Full name of the resident
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Room number (Zimmer)
|
||||
*/
|
||||
room: string;
|
||||
/**
|
||||
* Table assignment in dining area (Tisch)
|
||||
*/
|
||||
table?: string | null;
|
||||
/**
|
||||
* Station or ward
|
||||
*/
|
||||
station?: string | null;
|
||||
/**
|
||||
* Requires high-caloric meals (Hochkalorisch)
|
||||
*/
|
||||
highCaloric?: boolean | null;
|
||||
/**
|
||||
* Is the resident currently active?
|
||||
*/
|
||||
active?: boolean | null;
|
||||
/**
|
||||
* Food aversions and dislikes (Abneigungen)
|
||||
*/
|
||||
aversions?: string | null;
|
||||
/**
|
||||
* Other notes and special requirements (Sonstiges)
|
||||
*/
|
||||
notes?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Manage meal orders for residents
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "meal-orders".
|
||||
*/
|
||||
export interface MealOrder {
|
||||
id: number;
|
||||
tenant?: (number | null) | Tenant;
|
||||
/**
|
||||
* Auto-generated title
|
||||
*/
|
||||
title?: string | null;
|
||||
/**
|
||||
* Select the resident for this meal order
|
||||
*/
|
||||
resident: number | Resident;
|
||||
date: string;
|
||||
mealType: 'breakfast' | 'lunch' | 'dinner';
|
||||
/**
|
||||
* Order status for kitchen tracking
|
||||
*/
|
||||
status: 'pending' | 'preparing' | 'prepared';
|
||||
/**
|
||||
* User who created this order
|
||||
*/
|
||||
createdBy?: (number | null) | User;
|
||||
/**
|
||||
* Override: high-caloric requirement for this order
|
||||
*/
|
||||
highCaloric?: boolean | null;
|
||||
/**
|
||||
* Override: specific aversions for this order
|
||||
*/
|
||||
aversions?: string | null;
|
||||
/**
|
||||
* Special notes for this order
|
||||
*/
|
||||
notes?: string | null;
|
||||
breakfast?: {
|
||||
accordingToPlan?: boolean | null;
|
||||
bread?: {
|
||||
breadRoll?: boolean | null;
|
||||
wholeGrainRoll?: boolean | null;
|
||||
greyBread?: boolean | null;
|
||||
wholeGrainBread?: boolean | null;
|
||||
whiteBread?: boolean | null;
|
||||
crispbread?: boolean | null;
|
||||
};
|
||||
porridge?: boolean | null;
|
||||
preparation?: {
|
||||
sliced?: boolean | null;
|
||||
spread?: boolean | null;
|
||||
};
|
||||
spreads?: {
|
||||
butter?: boolean | null;
|
||||
margarine?: boolean | null;
|
||||
jam?: boolean | null;
|
||||
diabeticJam?: boolean | null;
|
||||
honey?: boolean | null;
|
||||
cheese?: boolean | null;
|
||||
quark?: boolean | null;
|
||||
sausage?: boolean | null;
|
||||
};
|
||||
beverages?: {
|
||||
coffee?: boolean | null;
|
||||
tea?: boolean | null;
|
||||
hotMilk?: boolean | null;
|
||||
coldMilk?: boolean | null;
|
||||
};
|
||||
additions?: {
|
||||
sugar?: boolean | null;
|
||||
sweetener?: boolean | null;
|
||||
coffeeCreamer?: boolean | null;
|
||||
};
|
||||
};
|
||||
lunch?: {
|
||||
portionSize?: ('small' | 'large' | 'vegetarian') | null;
|
||||
soup?: boolean | null;
|
||||
dessert?: boolean | null;
|
||||
specialPreparations?: {
|
||||
pureedFood?: boolean | null;
|
||||
pureedMeat?: boolean | null;
|
||||
slicedMeat?: boolean | null;
|
||||
mashedPotatoes?: boolean | null;
|
||||
};
|
||||
restrictions?: {
|
||||
noFish?: boolean | null;
|
||||
fingerFood?: boolean | null;
|
||||
onlySweet?: boolean | null;
|
||||
};
|
||||
};
|
||||
dinner?: {
|
||||
accordingToPlan?: boolean | null;
|
||||
bread?: {
|
||||
greyBread?: boolean | null;
|
||||
wholeGrainBread?: boolean | null;
|
||||
whiteBread?: boolean | null;
|
||||
crispbread?: boolean | null;
|
||||
};
|
||||
preparation?: {
|
||||
spread?: boolean | null;
|
||||
sliced?: boolean | null;
|
||||
};
|
||||
spreads?: {
|
||||
butter?: boolean | null;
|
||||
margarine?: boolean | null;
|
||||
};
|
||||
soup?: boolean | null;
|
||||
porridge?: boolean | null;
|
||||
noFish?: boolean | null;
|
||||
beverages?: {
|
||||
tea?: boolean | null;
|
||||
cocoa?: boolean | null;
|
||||
hotMilk?: boolean | null;
|
||||
coldMilk?: boolean | null;
|
||||
};
|
||||
additions?: {
|
||||
sugar?: boolean | null;
|
||||
sweetener?: boolean | null;
|
||||
};
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-kv".
|
||||
*/
|
||||
export interface PayloadKv {
|
||||
id: number;
|
||||
key: string;
|
||||
data:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
@@ -181,10 +390,6 @@ export interface User {
|
||||
export interface PayloadLockedDocument {
|
||||
id: number;
|
||||
document?:
|
||||
| ({
|
||||
relationTo: 'pages';
|
||||
value: number | Page;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'users';
|
||||
value: number | User;
|
||||
@@ -192,6 +397,14 @@ export interface PayloadLockedDocument {
|
||||
| ({
|
||||
relationTo: 'tenants';
|
||||
value: number | Tenant;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'residents';
|
||||
value: number | Resident;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'meal-orders';
|
||||
value: number | MealOrder;
|
||||
} | null);
|
||||
globalSlug?: string | null;
|
||||
user: {
|
||||
@@ -235,22 +448,13 @@ export interface PayloadMigration {
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "pages_select".
|
||||
*/
|
||||
export interface PagesSelect<T extends boolean = true> {
|
||||
tenant?: T;
|
||||
title?: T;
|
||||
slug?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users_select".
|
||||
*/
|
||||
export interface UsersSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
password?: T;
|
||||
roles?: T;
|
||||
username?: T;
|
||||
tenants?:
|
||||
@@ -269,6 +473,13 @@ export interface UsersSelect<T extends boolean = true> {
|
||||
hash?: T;
|
||||
loginAttempts?: T;
|
||||
lockUntil?: T;
|
||||
sessions?:
|
||||
| T
|
||||
| {
|
||||
id?: T;
|
||||
createdAt?: T;
|
||||
expiresAt?: T;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
@@ -276,12 +487,169 @@ export interface UsersSelect<T extends boolean = true> {
|
||||
*/
|
||||
export interface TenantsSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
domain?: T;
|
||||
slug?: T;
|
||||
allowPublicRead?: T;
|
||||
domain?: T;
|
||||
address?: T;
|
||||
phone?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "residents_select".
|
||||
*/
|
||||
export interface ResidentsSelect<T extends boolean = true> {
|
||||
tenant?: T;
|
||||
name?: T;
|
||||
room?: T;
|
||||
table?: T;
|
||||
station?: T;
|
||||
highCaloric?: T;
|
||||
active?: T;
|
||||
aversions?: T;
|
||||
notes?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "meal-orders_select".
|
||||
*/
|
||||
export interface MealOrdersSelect<T extends boolean = true> {
|
||||
tenant?: T;
|
||||
title?: T;
|
||||
resident?: T;
|
||||
date?: T;
|
||||
mealType?: T;
|
||||
status?: T;
|
||||
createdBy?: T;
|
||||
highCaloric?: T;
|
||||
aversions?: T;
|
||||
notes?: T;
|
||||
breakfast?:
|
||||
| T
|
||||
| {
|
||||
accordingToPlan?: T;
|
||||
bread?:
|
||||
| T
|
||||
| {
|
||||
breadRoll?: T;
|
||||
wholeGrainRoll?: T;
|
||||
greyBread?: T;
|
||||
wholeGrainBread?: T;
|
||||
whiteBread?: T;
|
||||
crispbread?: T;
|
||||
};
|
||||
porridge?: T;
|
||||
preparation?:
|
||||
| T
|
||||
| {
|
||||
sliced?: T;
|
||||
spread?: T;
|
||||
};
|
||||
spreads?:
|
||||
| T
|
||||
| {
|
||||
butter?: T;
|
||||
margarine?: T;
|
||||
jam?: T;
|
||||
diabeticJam?: T;
|
||||
honey?: T;
|
||||
cheese?: T;
|
||||
quark?: T;
|
||||
sausage?: T;
|
||||
};
|
||||
beverages?:
|
||||
| T
|
||||
| {
|
||||
coffee?: T;
|
||||
tea?: T;
|
||||
hotMilk?: T;
|
||||
coldMilk?: T;
|
||||
};
|
||||
additions?:
|
||||
| T
|
||||
| {
|
||||
sugar?: T;
|
||||
sweetener?: T;
|
||||
coffeeCreamer?: T;
|
||||
};
|
||||
};
|
||||
lunch?:
|
||||
| T
|
||||
| {
|
||||
portionSize?: T;
|
||||
soup?: T;
|
||||
dessert?: T;
|
||||
specialPreparations?:
|
||||
| T
|
||||
| {
|
||||
pureedFood?: T;
|
||||
pureedMeat?: T;
|
||||
slicedMeat?: T;
|
||||
mashedPotatoes?: T;
|
||||
};
|
||||
restrictions?:
|
||||
| T
|
||||
| {
|
||||
noFish?: T;
|
||||
fingerFood?: T;
|
||||
onlySweet?: T;
|
||||
};
|
||||
};
|
||||
dinner?:
|
||||
| T
|
||||
| {
|
||||
accordingToPlan?: T;
|
||||
bread?:
|
||||
| T
|
||||
| {
|
||||
greyBread?: T;
|
||||
wholeGrainBread?: T;
|
||||
whiteBread?: T;
|
||||
crispbread?: T;
|
||||
};
|
||||
preparation?:
|
||||
| T
|
||||
| {
|
||||
spread?: T;
|
||||
sliced?: T;
|
||||
};
|
||||
spreads?:
|
||||
| T
|
||||
| {
|
||||
butter?: T;
|
||||
margarine?: T;
|
||||
};
|
||||
soup?: T;
|
||||
porridge?: T;
|
||||
noFish?: T;
|
||||
beverages?:
|
||||
| T
|
||||
| {
|
||||
tea?: T;
|
||||
cocoa?: T;
|
||||
hotMilk?: T;
|
||||
coldMilk?: T;
|
||||
};
|
||||
additions?:
|
||||
| T
|
||||
| {
|
||||
sugar?: T;
|
||||
sweetener?: T;
|
||||
};
|
||||
};
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-kv_select".
|
||||
*/
|
||||
export interface PayloadKvSelect<T extends boolean = true> {
|
||||
key?: T;
|
||||
data?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-locked-documents_select".
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { mongooseAdapter } from '@payloadcms/db-mongodb'
|
||||
import { postgresAdapter } from '@payloadcms/db-postgres'
|
||||
import { sqliteAdapter } from '@payloadcms/db-sqlite'
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import path from 'path'
|
||||
import { buildConfig } from 'payload'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import { Pages } from './collections/Pages'
|
||||
import { Tenants } from './collections/Tenants'
|
||||
import Users from './collections/Users'
|
||||
import { Residents } from './collections/Residents'
|
||||
import { MealOrders } from './collections/MealOrders'
|
||||
import { multiTenantPlugin } from '@payloadcms/plugin-multi-tenant'
|
||||
import { isSuperAdmin } from './access/isSuperAdmin'
|
||||
import type { Config } from './payload-types'
|
||||
@@ -21,14 +21,22 @@ const dirname = path.dirname(filename)
|
||||
export default buildConfig({
|
||||
admin: {
|
||||
user: 'users',
|
||||
meta: {
|
||||
titleSuffix: '- Meal Planner',
|
||||
},
|
||||
components: {
|
||||
views: {
|
||||
kitchenDashboard: {
|
||||
Component: '/app/(payload)/admin/views/KitchenDashboard#KitchenDashboard',
|
||||
path: '/kitchen-dashboard',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
collections: [Pages, Users, Tenants],
|
||||
// db: mongooseAdapter({
|
||||
// url: process.env.DATABASE_URI as string,
|
||||
// }),
|
||||
db: postgresAdapter({
|
||||
pool: {
|
||||
connectionString: process.env.POSTGRES_URL,
|
||||
collections: [Users, Tenants, Residents, MealOrders],
|
||||
db: sqliteAdapter({
|
||||
client: {
|
||||
url: 'file:./payload.db',
|
||||
},
|
||||
}),
|
||||
onInit: async (args) => {
|
||||
@@ -47,7 +55,9 @@ export default buildConfig({
|
||||
plugins: [
|
||||
multiTenantPlugin<Config>({
|
||||
collections: {
|
||||
pages: {},
|
||||
// Enable multi-tenancy for residents and meal orders
|
||||
residents: {},
|
||||
'meal-orders': {},
|
||||
},
|
||||
tenantField: {
|
||||
access: {
|
||||
|
||||
430
src/seed.ts
430
src/seed.ts
@@ -1,134 +1,360 @@
|
||||
import { Config } from 'payload'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
export const seed: NonNullable<Config['onInit']> = async (payload): Promise<void> => {
|
||||
const tenant1 = await payload.create({
|
||||
/**
|
||||
* Seed script for the Meal Planner application
|
||||
*
|
||||
* Creates:
|
||||
* - 1 care home (tenant)
|
||||
* - 3 users (admin, caregiver, kitchen)
|
||||
* - 8 residents with varied data
|
||||
* - 20+ meal orders covering multiple dates and meal types
|
||||
*/
|
||||
export const seed = async (payload: Payload): Promise<void> => {
|
||||
// Check if already seeded
|
||||
const existingResidents = await payload.find({
|
||||
collection: 'residents',
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
if (existingResidents.totalDocs > 0) {
|
||||
payload.logger.info('Database already seeded, skipping...')
|
||||
return
|
||||
}
|
||||
|
||||
payload.logger.info('Seeding database...')
|
||||
|
||||
// ============================================
|
||||
// CREATE CARE HOME (TENANT)
|
||||
// ============================================
|
||||
const careHome = await payload.create({
|
||||
collection: 'tenants',
|
||||
data: {
|
||||
name: 'Tenant 1',
|
||||
slug: 'gold',
|
||||
domain: 'gold.localhost',
|
||||
name: 'Sunny Meadows Care Home',
|
||||
slug: 'sunny-meadows',
|
||||
domain: 'sunny-meadows.localhost',
|
||||
address: 'Sonnenweg 123\n12345 Musterstadt\nGermany',
|
||||
phone: '+49 123 456 7890',
|
||||
},
|
||||
})
|
||||
|
||||
const tenant2 = await payload.create({
|
||||
collection: 'tenants',
|
||||
data: {
|
||||
name: 'Tenant 2',
|
||||
slug: 'silver',
|
||||
domain: 'silver.localhost',
|
||||
},
|
||||
})
|
||||
payload.logger.info(`Created care home: ${careHome.name}`)
|
||||
|
||||
const tenant3 = await payload.create({
|
||||
collection: 'tenants',
|
||||
data: {
|
||||
name: 'Tenant 3',
|
||||
slug: 'bronze',
|
||||
domain: 'bronze.localhost',
|
||||
},
|
||||
})
|
||||
// ============================================
|
||||
// CREATE USERS
|
||||
// ============================================
|
||||
|
||||
// Super Admin (can access all care homes)
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: 'demo@payloadcms.com',
|
||||
password: 'demo',
|
||||
email: 'admin@example.com',
|
||||
password: 'test',
|
||||
name: 'System Administrator',
|
||||
roles: ['super-admin'],
|
||||
},
|
||||
})
|
||||
payload.logger.info('Created admin user: admin@example.com')
|
||||
|
||||
// Caregiver (can create meal orders)
|
||||
const caregiver = await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: 'caregiver@example.com',
|
||||
password: 'test',
|
||||
name: 'Maria Schmidt',
|
||||
roles: ['user'],
|
||||
tenants: [
|
||||
{
|
||||
tenant: careHome.id,
|
||||
roles: ['caregiver'],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
payload.logger.info('Created caregiver user: caregiver@example.com')
|
||||
|
||||
// Kitchen Staff (can view orders and mark as prepared)
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: 'tenant1@payloadcms.com',
|
||||
password: 'demo',
|
||||
email: 'kitchen@example.com',
|
||||
password: 'test',
|
||||
name: 'Hans Weber',
|
||||
roles: ['user'],
|
||||
tenants: [
|
||||
{
|
||||
roles: ['tenant-admin'],
|
||||
tenant: tenant1.id,
|
||||
tenant: careHome.id,
|
||||
roles: ['kitchen'],
|
||||
},
|
||||
],
|
||||
username: 'tenant1',
|
||||
},
|
||||
})
|
||||
payload.logger.info('Created kitchen user: kitchen@example.com')
|
||||
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: 'tenant2@payloadcms.com',
|
||||
password: 'demo',
|
||||
tenants: [
|
||||
{
|
||||
roles: ['tenant-admin'],
|
||||
tenant: tenant2.id,
|
||||
// ============================================
|
||||
// CREATE RESIDENTS
|
||||
// ============================================
|
||||
const residentsData = [
|
||||
{
|
||||
name: 'Hans Mueller',
|
||||
room: '101',
|
||||
table: '1',
|
||||
station: 'Station A',
|
||||
highCaloric: false,
|
||||
aversions: '',
|
||||
notes: '',
|
||||
},
|
||||
{
|
||||
name: 'Ingrid Schmidt',
|
||||
room: '102',
|
||||
table: '1',
|
||||
station: 'Station A',
|
||||
highCaloric: true,
|
||||
aversions: 'Keine Nüsse (no nuts)',
|
||||
notes: 'Prefers soft foods',
|
||||
},
|
||||
{
|
||||
name: 'Wilhelm Bauer',
|
||||
room: '103',
|
||||
table: '2',
|
||||
station: 'Station A',
|
||||
highCaloric: false,
|
||||
aversions: 'Kein Fisch (no fish)',
|
||||
notes: '',
|
||||
},
|
||||
{
|
||||
name: 'Gertrude Fischer',
|
||||
room: '104',
|
||||
table: '2',
|
||||
station: 'Station A',
|
||||
highCaloric: false,
|
||||
aversions: '',
|
||||
notes: 'Diabetic - use sugar-free options',
|
||||
},
|
||||
{
|
||||
name: 'Karl Hoffmann',
|
||||
room: '105',
|
||||
table: '3',
|
||||
station: 'Station B',
|
||||
highCaloric: true,
|
||||
aversions: 'Keine Milchprodukte (no dairy)',
|
||||
notes: 'Lactose intolerant',
|
||||
},
|
||||
{
|
||||
name: 'Elisabeth Schulz',
|
||||
room: '106',
|
||||
table: '3',
|
||||
station: 'Station B',
|
||||
highCaloric: false,
|
||||
aversions: '',
|
||||
notes: '',
|
||||
},
|
||||
{
|
||||
name: 'Friedrich Wagner',
|
||||
room: '107',
|
||||
table: '4',
|
||||
station: 'Station B',
|
||||
highCaloric: false,
|
||||
aversions: 'Kein Schweinefleisch (no pork)',
|
||||
notes: '',
|
||||
},
|
||||
{
|
||||
name: 'Helga Meyer',
|
||||
room: '108',
|
||||
table: '4',
|
||||
station: 'Station B',
|
||||
highCaloric: true,
|
||||
aversions: '',
|
||||
notes: 'Requires pureed food',
|
||||
},
|
||||
]
|
||||
|
||||
const residents: Array<{ id: number; name: string }> = []
|
||||
for (const residentData of residentsData) {
|
||||
const resident = await payload.create({
|
||||
collection: 'residents',
|
||||
data: {
|
||||
...residentData,
|
||||
active: true,
|
||||
tenant: careHome.id,
|
||||
},
|
||||
})
|
||||
residents.push({ id: resident.id, name: resident.name })
|
||||
}
|
||||
|
||||
payload.logger.info(`Created ${residents.length} residents`)
|
||||
|
||||
// ============================================
|
||||
// CREATE MEAL ORDERS
|
||||
// ============================================
|
||||
const today = new Date()
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
|
||||
const formatDate = (date: Date) => date.toISOString().split('T')[0]
|
||||
|
||||
const dates = [formatDate(yesterday), formatDate(today), formatDate(tomorrow)]
|
||||
|
||||
const statuses: Array<'pending' | 'preparing' | 'prepared'> = [
|
||||
'pending',
|
||||
'preparing',
|
||||
'prepared',
|
||||
]
|
||||
|
||||
let orderCount = 0
|
||||
|
||||
// Create varied breakfast orders
|
||||
for (let i = 0; i < residents.length; i++) {
|
||||
const resident = residents[i]
|
||||
const dateIndex = i % dates.length
|
||||
const statusIndex = i % statuses.length
|
||||
|
||||
await payload.create({
|
||||
collection: 'meal-orders',
|
||||
data: {
|
||||
resident: resident.id,
|
||||
date: dates[dateIndex],
|
||||
mealType: 'breakfast',
|
||||
status: statuses[statusIndex],
|
||||
createdBy: caregiver.id,
|
||||
tenant: careHome.id,
|
||||
breakfast: {
|
||||
accordingToPlan: i % 3 === 0,
|
||||
bread: {
|
||||
breadRoll: i % 2 === 0,
|
||||
wholeGrainRoll: i % 3 === 0,
|
||||
greyBread: i % 4 === 0,
|
||||
wholeGrainBread: i % 5 === 0,
|
||||
whiteBread: false,
|
||||
crispbread: i % 6 === 0,
|
||||
},
|
||||
porridge: i % 4 === 0,
|
||||
preparation: {
|
||||
sliced: i % 2 === 0,
|
||||
spread: i % 2 === 1,
|
||||
},
|
||||
spreads: {
|
||||
butter: true,
|
||||
margarine: false,
|
||||
jam: i % 2 === 0,
|
||||
diabeticJam: i === 3, // For diabetic resident
|
||||
honey: i % 4 === 0,
|
||||
cheese: i % 3 === 0,
|
||||
quark: i % 5 === 0,
|
||||
sausage: i % 2 === 0,
|
||||
},
|
||||
beverages: {
|
||||
coffee: i % 2 === 0,
|
||||
tea: i % 2 === 1,
|
||||
hotMilk: i % 4 === 0,
|
||||
coldMilk: i % 5 === 0,
|
||||
},
|
||||
additions: {
|
||||
sugar: i !== 3, // Not for diabetic
|
||||
sweetener: i === 3, // For diabetic
|
||||
coffeeCreamer: i % 3 === 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
username: 'tenant2',
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
orderCount++
|
||||
}
|
||||
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: 'tenant3@payloadcms.com',
|
||||
password: 'demo',
|
||||
tenants: [
|
||||
{
|
||||
roles: ['tenant-admin'],
|
||||
tenant: tenant3.id,
|
||||
// Create varied lunch orders
|
||||
for (let i = 0; i < residents.length; i++) {
|
||||
const resident = residents[i]
|
||||
const dateIndex = (i + 1) % dates.length
|
||||
const statusIndex = (i + 1) % statuses.length
|
||||
|
||||
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],
|
||||
soup: i % 2 === 0,
|
||||
dessert: true,
|
||||
specialPreparations: {
|
||||
pureedFood: i === 7, // For resident who needs pureed food
|
||||
pureedMeat: i === 7,
|
||||
slicedMeat: i % 3 === 0 && i !== 7,
|
||||
mashedPotatoes: i % 4 === 0,
|
||||
},
|
||||
restrictions: {
|
||||
noFish: i === 2, // For resident with fish aversion
|
||||
fingerFood: i % 6 === 0,
|
||||
onlySweet: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
username: 'tenant3',
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
orderCount++
|
||||
}
|
||||
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: 'multi-admin@payloadcms.com',
|
||||
password: 'demo',
|
||||
tenants: [
|
||||
{
|
||||
roles: ['tenant-admin'],
|
||||
tenant: tenant1.id,
|
||||
// Create varied dinner orders
|
||||
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,
|
||||
bread: {
|
||||
greyBread: i % 2 === 0,
|
||||
wholeGrainBread: i % 3 === 0,
|
||||
whiteBread: i % 4 === 0,
|
||||
crispbread: i % 5 === 0,
|
||||
},
|
||||
preparation: {
|
||||
spread: i % 2 === 0,
|
||||
sliced: i % 2 === 1,
|
||||
},
|
||||
spreads: {
|
||||
butter: true,
|
||||
margarine: i % 3 === 0,
|
||||
},
|
||||
soup: i % 2 === 0,
|
||||
porridge: i === 7, // For resident who needs pureed food
|
||||
noFish: i === 2, // For resident with fish aversion
|
||||
beverages: {
|
||||
tea: i % 2 === 0,
|
||||
cocoa: i % 4 === 0,
|
||||
hotMilk: i % 3 === 0,
|
||||
coldMilk: i % 5 === 0,
|
||||
},
|
||||
additions: {
|
||||
sugar: i !== 3, // Not for diabetic
|
||||
sweetener: i === 3, // For diabetic
|
||||
},
|
||||
},
|
||||
{
|
||||
roles: ['tenant-admin'],
|
||||
tenant: tenant2.id,
|
||||
},
|
||||
{
|
||||
roles: ['tenant-admin'],
|
||||
tenant: tenant3.id,
|
||||
},
|
||||
],
|
||||
username: 'multi-admin',
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
orderCount++
|
||||
}
|
||||
|
||||
await payload.create({
|
||||
collection: 'pages',
|
||||
data: {
|
||||
slug: 'home',
|
||||
tenant: tenant1.id,
|
||||
title: 'Page for Tenant 1',
|
||||
},
|
||||
})
|
||||
|
||||
await payload.create({
|
||||
collection: 'pages',
|
||||
data: {
|
||||
slug: 'home',
|
||||
tenant: tenant2.id,
|
||||
title: 'Page for Tenant 2',
|
||||
},
|
||||
})
|
||||
|
||||
await payload.create({
|
||||
collection: 'pages',
|
||||
data: {
|
||||
slug: 'home',
|
||||
tenant: tenant3.id,
|
||||
title: 'Page for Tenant 3',
|
||||
},
|
||||
})
|
||||
payload.logger.info(`Created ${orderCount} meal orders`)
|
||||
payload.logger.info('Database seeding complete!')
|
||||
payload.logger.info('')
|
||||
payload.logger.info('Login credentials:')
|
||||
payload.logger.info(' Admin: admin@example.com / test')
|
||||
payload.logger.info(' Caregiver: caregiver@example.com / test')
|
||||
payload.logger.info(' Kitchen: kitchen@example.com / test')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user