feat: initial setup, collections, caregiver frontend

This commit is contained in:
2025-12-02 11:32:45 +01:00
parent cee5925f25
commit 274ac8afa5
48 changed files with 6149 additions and 909 deletions

89
src/access/roles.ts Normal file
View 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')
}

View 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&apos;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>
</>
)
}

View 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>
)
}

View 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">
&larr; 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>
)
}

View 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">
&larr; 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>
</>
)
}

View 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">
&larr; 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>
</>
)
}

View File

@@ -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;
}
}

View File

@@ -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>
)

View File

@@ -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')
}

View File

@@ -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} />
}

View File

@@ -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} />
}

View File

@@ -1,3 +0,0 @@
import Page from './[...slug]/page'
export default Page

View File

@@ -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} />
}

View File

@@ -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} />
}

View File

@@ -1,3 +0,0 @@
import Page from './[...slug]/page'
export default Page

View File

@@ -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
}

View 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

View 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);
}
}
}

View File

@@ -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>
)
}

View 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 })
}
},
}

View 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',
}
}

View 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
}

View 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)' },
],
},
],
},
],
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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,
},
],
}

View 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)',
},
},
],
}

View File

@@ -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) || [],

View File

@@ -13,7 +13,7 @@ export const updateAndDeleteAccess: Access = ({ req }) => {
return {
id: {
in: getUserTenantIDs(req.user, 'tenant-admin'),
in: getUserTenantIDs(req.user, 'admin'),
},
}
}

View File

@@ -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,
},
],
}

View File

@@ -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) ?? []

View File

@@ -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

View File

@@ -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'),
},
}
}

View File

@@ -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],
},

View File

@@ -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".

View File

@@ -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: {

View File

@@ -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')
}