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

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