feat: initial setup, collections, caregiver frontend
This commit is contained in:
10
.env.example
10
.env.example
@@ -1,5 +1,9 @@
|
||||
DATABASE_URI=mongodb://127.0.0.1/payload-example-multi-tenant
|
||||
POSTGRES_URL=postgres://127.0.0.1:5432/payload-example-multi-tenant
|
||||
PAYLOAD_SECRET=PAYLOAD_MULTI_TENANT_EXAMPLE_SECRET_KEY
|
||||
# Meal Planner - Elderly Care Home Meal Ordering System
|
||||
# SQLite database is stored locally at ./payload.db
|
||||
# No DATABASE_URL needed for SQLite file-based storage
|
||||
|
||||
PAYLOAD_SECRET=your-secret-key-change-in-production
|
||||
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000
|
||||
|
||||
# Set to true to seed the database with sample data on first run
|
||||
SEED_DB=true
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -3,3 +3,9 @@ dist
|
||||
node_modules
|
||||
package-lock.json
|
||||
.env
|
||||
.next
|
||||
.claude
|
||||
CLAUDE.md
|
||||
*.db
|
||||
*.db*
|
||||
digest-*.md
|
||||
25
.mcp.json
Normal file
25
.mcp.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-filesystem-server"]
|
||||
},
|
||||
"shadcn": {
|
||||
"command": "npx",
|
||||
"args": ["shadcn@latest", "mcp"]
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest"
|
||||
]
|
||||
},
|
||||
"context7": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.context7.com/mcp",
|
||||
"headers": {
|
||||
"CONTEXT7_API_KEY": "${CONTEXT7_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
254
README.md
254
README.md
@@ -1,92 +1,252 @@
|
||||
# Payload Multi-Tenant Example
|
||||
# Meal Planner for Elderly Care Homes
|
||||
|
||||
This example demonstrates how to achieve a multi-tenancy in [Payload](https://github.com/payloadcms/payload). Tenants are separated by a `Tenants` collection.
|
||||
A digital meal ordering system for elderly care homes, built with Payload CMS 3.x and Next.js 15. This application digitizes the paper-based workflow where caregivers collect meal preferences from residents and kitchen staff prepare meals.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-tenant architecture**: Each care home operates as a separate tenant with isolated data
|
||||
- **Role-based access control**: Super-admin, tenant admin, caregiver, and kitchen staff roles
|
||||
- **Meal order management**: Support for breakfast, lunch, and dinner with customizable options
|
||||
- **Kitchen dashboard**: Aggregated ingredient reports for meal preparation
|
||||
- **Tablet-friendly caregiver interface**: Touch-optimized UI for creating meal orders
|
||||
- **Resident management**: Track dietary requirements, aversions, and special notes
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Next.js 15** - React framework with App Router
|
||||
- **Payload CMS 3.65** - Headless CMS with admin panel
|
||||
- **SQLite** - Database (easily upgradeable to PostgreSQL)
|
||||
- **TypeScript** - Type safety
|
||||
- **SCSS** - Styling
|
||||
|
||||
## Quick Start
|
||||
|
||||
To spin up this example locally, follow these steps:
|
||||
### Prerequisites
|
||||
|
||||
1. Run the following command to create a project from the example:
|
||||
- Node.js 18+
|
||||
- pnpm (recommended) or npm
|
||||
|
||||
- `npx create-payload-app --example multi-tenant`
|
||||
### Installation
|
||||
|
||||
2. `cp .env.example .env` to copy the example environment variables
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone <repository-url>
|
||||
cd meal-planner
|
||||
|
||||
3. `pnpm dev`, `yarn dev` or `npm run dev` to start the server
|
||||
- Press `y` when prompted to seed the database
|
||||
4. `open http://localhost:3000` to access the home page
|
||||
5. `open http://localhost:3000/admin` to access the admin panel
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
### Default users
|
||||
# Copy environment variables
|
||||
cp .env.example .env
|
||||
|
||||
The seed script seeds 3 tenants.
|
||||
Login with email `demo@payloadcms.com` and password `demo`
|
||||
# Start development server with seeding
|
||||
SEED_DB=true pnpm dev
|
||||
```
|
||||
|
||||
## How it works
|
||||
The application will be available at `http://localhost:3000`.
|
||||
|
||||
A multi-tenant Payload application is a single server that hosts multiple "tenants". Examples of tenants may be your agency's clients, your business conglomerate's organizations, or your SaaS customers.
|
||||
### Default Users
|
||||
|
||||
Each tenant has its own set of users, pages, and other data that is scoped to that tenant. This means that your application will be shared across tenants but the data will be scoped to each tenant.
|
||||
The seed script creates the following users:
|
||||
|
||||
| Email | Password | Role | Access |
|
||||
|------------------------|----------|-------------|-------------------------------------|
|
||||
| admin@example.com | test | Super Admin | Full system access |
|
||||
| caregiver@example.com | test | Caregiver | Create/view meal orders |
|
||||
| kitchen@example.com | test | Kitchen | View orders, update status, reports |
|
||||
|
||||
## Application Structure
|
||||
|
||||
### URLs
|
||||
|
||||
- `/admin` - Payload CMS admin panel
|
||||
- `/admin/kitchen-dashboard` - Kitchen ingredient report dashboard
|
||||
- `/caregiver/login` - Caregiver login page
|
||||
- `/caregiver/dashboard` - Caregiver main dashboard
|
||||
- `/caregiver/orders/new` - Create new meal order
|
||||
- `/caregiver/orders` - View meal orders
|
||||
- `/caregiver/residents` - View residents list
|
||||
|
||||
## Data Model
|
||||
|
||||
### Collections
|
||||
|
||||
See the [Collections](https://payloadcms.com/docs/configuration/collections) docs for details on how to extend any of this functionality.
|
||||
#### Tenants (Care Homes)
|
||||
- `name` - Care home name
|
||||
- `slug` - URL-friendly identifier
|
||||
- `domain` - Optional custom domain
|
||||
- `address` - Physical address
|
||||
- `phone` - Contact phone
|
||||
|
||||
- #### Users
|
||||
#### Users
|
||||
Global roles:
|
||||
- `super-admin` - Full system access
|
||||
- `user` - Standard user (access based on tenant roles)
|
||||
|
||||
The `users` collection is auth-enabled and encompasses both app-wide and tenant-scoped users based on the value of their `roles` and `tenants` fields. Users with the role `super-admin` can manage your entire application, while users with the _tenant role_ of `admin` have limited access to the platform and can manage only the tenant(s) they are assigned to, see [Tenants](#tenants) for more details.
|
||||
Tenant roles:
|
||||
- `admin` - Full access within tenant
|
||||
- `caregiver` - Create and manage meal orders
|
||||
- `kitchen` - View orders, update status, generate reports
|
||||
|
||||
For additional help with authentication, see the official [Auth Example](https://github.com/payloadcms/payload/tree/main/examples/cms#readme) or the [Authentication](https://payloadcms.com/docs/authentication/overview#authentication-overview) docs.
|
||||
#### Residents
|
||||
- `name` - Full name
|
||||
- `room` - Room number
|
||||
- `table` - Table assignment
|
||||
- `station` - Ward/station
|
||||
- `highCaloric` - High caloric diet flag
|
||||
- `aversions` - Food aversions
|
||||
- `notes` - Additional notes
|
||||
- `active` - Active status
|
||||
|
||||
- #### Tenants
|
||||
#### Meal Orders
|
||||
Core fields:
|
||||
- `resident` - Reference to resident
|
||||
- `date` - Order date
|
||||
- `mealType` - breakfast, lunch, or dinner
|
||||
- `status` - pending, preparing, or prepared
|
||||
- `createdBy` - User who created the order
|
||||
|
||||
A `tenants` collection is used to achieve tenant-based access control. Each user is assigned an array of `tenants` which includes a relationship to a `tenant` and their `roles` within that tenant. You can then scope any document within your application to any of your tenants using a simple [relationship](https://payloadcms.com/docs/fields/relationship) field on the `users` or `pages` collections, or any other collection that your application needs. The value of this field is used to filter documents in the admin panel and API to ensure that users can only access documents that belong to their tenant and are within their role. See [Access Control](#access-control) for more details.
|
||||
Conditional meal options (based on mealType):
|
||||
- **Breakfast**: Bread types, spreads, beverages, additions
|
||||
- **Lunch**: Portion size, soup, dessert, special preparations
|
||||
- **Dinner**: Bread, spreads, soup, beverages, additions
|
||||
|
||||
For more details on how to extend this functionality, see the [Payload Access Control](https://payloadcms.com/docs/access-control/overview) docs.
|
||||
## Access Control Matrix
|
||||
|
||||
**Domain-based Tenant Setting**:
|
||||
| Operation | Super Admin | Tenant Admin | Caregiver | Kitchen |
|
||||
|-----------------|-------------|--------------|-------------------|--------------------|
|
||||
| Manage users | All | Own tenant | No | No |
|
||||
| Manage tenants | All | Own tenant | No | No |
|
||||
| View residents | All | Own tenant | Own tenant | Own tenant |
|
||||
| Manage residents| All | Own tenant | No | No |
|
||||
| Create orders | All | Own tenant | Own tenant | No |
|
||||
| View orders | All | Own tenant | Own tenant | Own tenant |
|
||||
| Update orders | All | Own tenant | Own pending only | Status only |
|
||||
| Delete orders | All | Own tenant | No | No |
|
||||
| Kitchen reports | All | Own tenant | No | Own tenant |
|
||||
|
||||
This example also supports domain-based tenant selection, where tenants can be associated with a specific domain. If a tenant is associated with a domain (e.g., `gold.localhost:3000`), when a user logs in from that domain, they will be automatically scoped to the matching tenant. This is accomplished through an optional `afterLogin` hook that sets a `payload-tenant` cookie based on the domain.
|
||||
## Kitchen Dashboard
|
||||
|
||||
For the domain portion of the example to function properly, you will need to add the following entries to your system's `/etc/hosts` file:
|
||||
The kitchen dashboard (`/admin/kitchen-dashboard`) provides:
|
||||
|
||||
1. Date and meal type selection
|
||||
2. Aggregated ingredient counts for all orders
|
||||
3. Portion size breakdown (for lunch)
|
||||
4. Total order count
|
||||
|
||||
### API Endpoint
|
||||
|
||||
```
|
||||
127.0.0.1 gold.localhost silver.localhost bronze.localhost
|
||||
GET /api/meal-orders/kitchen-report?date=YYYY-MM-DD&mealType=breakfast|lunch|dinner
|
||||
```
|
||||
|
||||
- #### Pages
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"date": "2024-01-15",
|
||||
"mealType": "breakfast",
|
||||
"totalOrders": 45,
|
||||
"ingredients": {
|
||||
"breadRoll": 32,
|
||||
"butter": 40,
|
||||
"coffee": 38
|
||||
},
|
||||
"labels": {
|
||||
"breadRoll": "Bread Roll (Brötchen)",
|
||||
"butter": "Butter",
|
||||
"coffee": "Coffee (Kaffee)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each page is assigned a `tenant`, which is used to control access and scope API requests. Only users with the `super-admin` role can create pages, and pages are assigned to specific tenants. Other users can view only the pages assigned to the tenant they are associated with.
|
||||
## Caregiver Interface
|
||||
|
||||
## Access control
|
||||
The tablet-optimized caregiver interface provides:
|
||||
|
||||
Basic role-based access control is set up to determine what users can and cannot do based on their roles, which are:
|
||||
1. **Dashboard**: Today's order statistics and quick actions
|
||||
2. **New Order Flow**:
|
||||
- Step 1: Select date and meal type
|
||||
- Step 2: Select resident
|
||||
- Step 3: Configure meal options
|
||||
- Step 4: Review and submit
|
||||
3. **Orders List**: Filter by date and meal type
|
||||
4. **Residents List**: Search and view dietary requirements
|
||||
|
||||
- `super-admin`: They can access the Payload admin panel to manage your multi-tenant application. They can see all tenants and make all operations.
|
||||
- `user`: They can only access the Payload admin panel if they are a tenant-admin, in which case they have a limited access to operations based on their tenant (see below).
|
||||
## Development
|
||||
|
||||
This applies to each collection in the following ways:
|
||||
### Scripts
|
||||
|
||||
- `users`: Only super-admins, tenant-admins, and the user themselves can access their profile. Anyone can create a user, but only these admins can delete users. See [Users](#users) for more details.
|
||||
- `tenants`: Only super-admins and tenant-admins can read, create, update, or delete tenants. See [Tenants](#tenants) for more details.
|
||||
- `pages`: Everyone can access pages, but only super-admins and tenant-admins can create, update, or delete them.
|
||||
```bash
|
||||
pnpm dev # Start development server
|
||||
pnpm build # Build for production
|
||||
pnpm start # Start production server
|
||||
pnpm seed # Run database seed
|
||||
pnpm generate:types # Generate TypeScript types
|
||||
```
|
||||
|
||||
> If you have versions and drafts enabled on your pages, you will need to add additional read access control condition to check the user's tenants that prevents them from accessing draft documents of other tenants.
|
||||
### Database
|
||||
|
||||
For more details on how to extend this functionality, see the [Payload Access Control](https://payloadcms.com/docs/access-control/overview#access-control) docs.
|
||||
The application uses SQLite by default (`payload.db`). To migrate to PostgreSQL:
|
||||
|
||||
## CORS
|
||||
1. Install the PostgreSQL adapter: `pnpm add @payloadcms/db-postgres`
|
||||
2. Update `payload.config.ts`:
|
||||
```typescript
|
||||
import { postgresAdapter } from '@payloadcms/db-postgres'
|
||||
|
||||
This multi-tenant setup requires an open CORS policy. Since each tenant contains a dynamic list of domains, there's no way to know specifically which domains to whitelist at runtime without significant performance implications. This also means that the `serverURL` is not set, as this scopes all requests to a single domain.
|
||||
db: postgresAdapter({
|
||||
pool: { connectionString: process.env.DATABASE_URI }
|
||||
}),
|
||||
```
|
||||
3. Set `DATABASE_URI` in `.env`
|
||||
|
||||
Alternatively, if you know the domains of your tenants ahead of time and these values won't change often, you could simply remove the `domains` field altogether and instead use static values.
|
||||
### Environment Variables
|
||||
|
||||
For more details on this, see the [CORS](https://payloadcms.com/docs/production/preventing-abuse#cross-origin-resource-sharing-cors) docs.
|
||||
```env
|
||||
PAYLOAD_SECRET=your-secret-key-here
|
||||
SEED_DB=true # Set to seed database on startup
|
||||
```
|
||||
|
||||
## Front-end
|
||||
## Domain-Based Tenant Selection
|
||||
|
||||
The frontend is scaffolded out in this example directory. You can view the code for rendering pages at `/src/app/(app)/[tenant]/[...slug]/page.tsx`. This is a starter template, you may need to adjust the app to better fit your needs.
|
||||
For domain-based tenant routing, add entries to `/etc/hosts`:
|
||||
|
||||
## Questions
|
||||
```
|
||||
127.0.0.1 sunny-meadows.localhost
|
||||
```
|
||||
|
||||
If you have any issues or questions, reach out to us on [Discord](https://discord.com/invite/payload) or start a [GitHub discussion](https://github.com/payloadcms/payload/discussions).
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── access/ # Access control utilities
|
||||
├── app/
|
||||
│ ├── (app)/ # Caregiver frontend
|
||||
│ │ ├── caregiver/
|
||||
│ │ │ ├── login/ # Login page
|
||||
│ │ │ ├── dashboard/ # Dashboard
|
||||
│ │ │ ├── orders/ # Orders list and create
|
||||
│ │ │ └── residents/ # Residents list
|
||||
│ │ └── index.scss # Frontend styles
|
||||
│ └── (payload)/ # Payload admin
|
||||
│ └── admin/views/ # Custom admin views
|
||||
├── collections/
|
||||
│ ├── Tenants/ # Care homes
|
||||
│ ├── Users/ # Users with roles
|
||||
│ ├── Residents/ # Resident information
|
||||
│ └── MealOrders/ # Meal orders
|
||||
├── utilities/ # Helper functions
|
||||
├── payload.config.ts # Payload configuration
|
||||
└── seed.ts # Database seeding
|
||||
```
|
||||
|
||||
## Seed Data
|
||||
|
||||
The seed script creates:
|
||||
- 1 care home (Sunny Meadows Care Home)
|
||||
- 3 users (admin, caregiver, kitchen)
|
||||
- 8 residents with varied dietary requirements
|
||||
- 24 meal orders across different dates and meal types
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
BIN
docs/img/breakfast-form.jpeg
Normal file
BIN
docs/img/breakfast-form.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 226 KiB |
BIN
docs/img/dinner-form.jpeg
Normal file
BIN
docs/img/dinner-form.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 208 KiB |
BIN
docs/img/lunch-form.jpeg
Normal file
BIN
docs/img/lunch-form.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 240 KiB |
200
docs/instructions.md
Normal file
200
docs/instructions.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# Payload CMS Backend Developer Challenge: Meal Planner
|
||||
|
||||
Thank you for your interest in this role. This challenge is designed to assess your skills in backend development, data modeling, problem-solving, and your ability to customize the **Payload CMS Admin Panel** to build a complete, practical solution.
|
||||
|
||||
## 1\. The Problem
|
||||
|
||||
### The Current Manual Workflow
|
||||
|
||||
In a busy elderly care home, the meal ordering process is entirely manual and paper-based.
|
||||
|
||||
**The Caregiver's Daily Routine:**
|
||||
Every day, three times a day (breakfast, lunch, dinner), a caregiver walks from room to room with a stack of pre-printed paper forms. They ask each resident for their meal preferences and mark the choices on paper. These forms are then collected and delivered to the kitchen.
|
||||
|
||||
**The Kitchen Staff's Daily Routine:**
|
||||
The kitchen staff receives all the collected paper forms. Their workflow involves:
|
||||
* **Reading each individual form** to understand what needs to be prepared for each resident
|
||||
* **Manually tallying all ingredients** across all forms (e.g., "82 bread rolls, 65 portions of butter...") to know what quantities are needed for the next day
|
||||
* **Checking off each form** one by one as they prepare individual meal trays
|
||||
* **Discarding the forms** after the meal is served
|
||||
|
||||
### The Original Paper Forms
|
||||
|
||||

|
||||
*Figure 1: Original paper form for Breakfast orders*
|
||||
|
||||

|
||||
*Figure 2: Original paper form for Lunch orders*
|
||||
|
||||

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