feat: add kitchen dashboard, format codebase

This commit is contained in:
2025-12-02 17:00:17 +01:00
parent 2a9956c3e6
commit 5ce1b4728b
82 changed files with 5206 additions and 3134 deletions

View File

@@ -27,6 +27,7 @@
"@payloadcms/ui": "3.65.0", "@payloadcms/ui": "3.65.0",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "^1.1.8",
@@ -62,6 +63,7 @@
"@types/react-dom": "19.0.1", "@types/react-dom": "19.0.1",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-next": "^15.0.0", "eslint-config-next": "^15.0.0",
"prettier": "^3.7.3",
"tsx": "^4.16.2", "tsx": "^4.16.2",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "5.5.2" "typescript": "5.5.2"

91
pnpm-lock.yaml generated
View File

@@ -38,6 +38,9 @@ importers:
'@radix-ui/react-checkbox': '@radix-ui/react-checkbox':
specifier: ^1.3.3 specifier: ^1.3.3
version: 1.3.3(@types/react-dom@19.0.1)(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) version: 1.3.3(@types/react-dom@19.0.1)(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-collapsible':
specifier: ^1.1.12
version: 1.1.12(@types/react-dom@19.0.1)(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-dialog': '@radix-ui/react-dialog':
specifier: ^1.1.15 specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.0.1)(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) version: 1.1.15(@types/react-dom@19.0.1)(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -119,7 +122,7 @@ importers:
devDependencies: devDependencies:
'@payloadcms/eslint-config': '@payloadcms/eslint-config':
specifier: ^3.28.0 specifier: ^3.28.0
version: 3.28.0(@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.26.1(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1)(typescript@5.5.2))(jiti@2.6.1)(ts-api-utils@2.1.0(typescript@5.5.2)) version: 3.28.0(@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1)(typescript@5.5.2))(jiti@2.6.1)(ts-api-utils@2.1.0(typescript@5.5.2))
'@payloadcms/graphql': '@payloadcms/graphql':
specifier: latest specifier: latest
version: 3.65.0(graphql@16.12.0)(payload@3.65.0(graphql@16.12.0)(typescript@5.5.2))(typescript@5.5.2) version: 3.65.0(graphql@16.12.0)(payload@3.65.0(graphql@16.12.0)(typescript@5.5.2))(typescript@5.5.2)
@@ -138,6 +141,9 @@ importers:
eslint-config-next: eslint-config-next:
specifier: ^15.0.0 specifier: ^15.0.0
version: 15.5.6(eslint-plugin-import-x@4.6.1(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1)(typescript@5.5.2) version: 15.5.6(eslint-plugin-import-x@4.6.1(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1)(typescript@5.5.2)
prettier:
specifier: ^3.7.3
version: 3.7.3
tsx: tsx:
specifier: ^4.16.2 specifier: ^4.16.2
version: 4.21.0 version: 4.21.0
@@ -1551,6 +1557,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-collapsible@1.1.12':
resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-collection@1.1.7': '@radix-ui/react-collection@1.1.7':
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
peerDependencies: peerDependencies:
@@ -6962,17 +6981,17 @@ snapshots:
- sql.js - sql.js
- sqlite3 - sqlite3
'@payloadcms/eslint-config@3.28.0(@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.26.1(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1)(typescript@5.5.2))(jiti@2.6.1)(ts-api-utils@2.1.0(typescript@5.5.2))': '@payloadcms/eslint-config@3.28.0(@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1)(typescript@5.5.2))(jiti@2.6.1)(ts-api-utils@2.1.0(typescript@5.5.2))':
dependencies: dependencies:
'@eslint-react/eslint-plugin': 1.31.0(eslint@9.22.0(jiti@2.6.1))(ts-api-utils@2.1.0(typescript@5.5.2))(typescript@5.7.3) '@eslint-react/eslint-plugin': 1.31.0(eslint@9.22.0(jiti@2.6.1))(ts-api-utils@2.1.0(typescript@5.5.2))(typescript@5.7.3)
'@eslint/js': 9.22.0 '@eslint/js': 9.22.0
'@payloadcms/eslint-plugin': 3.28.0(@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.26.1(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1)(typescript@5.5.2))(jiti@2.6.1)(ts-api-utils@2.1.0(typescript@5.5.2)) '@payloadcms/eslint-plugin': 3.28.0(@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1)(typescript@5.5.2))(jiti@2.6.1)(ts-api-utils@2.1.0(typescript@5.5.2))
'@types/eslint': 9.6.1 '@types/eslint': 9.6.1
'@typescript-eslint/parser': 8.26.1(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3) '@typescript-eslint/parser': 8.26.1(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3)
eslint: 9.22.0(jiti@2.6.1) eslint: 9.22.0(jiti@2.6.1)
eslint-config-prettier: 10.1.1(eslint@9.22.0(jiti@2.6.1)) eslint-config-prettier: 10.1.1(eslint@9.22.0(jiti@2.6.1))
eslint-plugin-import-x: 4.6.1(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3) eslint-plugin-import-x: 4.6.1(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3)
eslint-plugin-jest: 28.11.0(@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.26.1(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1)(typescript@5.5.2))(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3) eslint-plugin-jest: 28.11.0(@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1)(typescript@5.5.2))(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3)
eslint-plugin-jest-dom: 5.5.0(eslint@9.22.0(jiti@2.6.1)) eslint-plugin-jest-dom: 5.5.0(eslint@9.22.0(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.22.0(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.22.0(jiti@2.6.1))
eslint-plugin-perfectionist: 3.9.1(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3) eslint-plugin-perfectionist: 3.9.1(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3)
@@ -6993,7 +7012,7 @@ snapshots:
- ts-api-utils - ts-api-utils
- vue-eslint-parser - vue-eslint-parser
'@payloadcms/eslint-plugin@3.28.0(@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.26.1(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1)(typescript@5.5.2))(jiti@2.6.1)(ts-api-utils@2.1.0(typescript@5.5.2))': '@payloadcms/eslint-plugin@3.28.0(@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1)(typescript@5.5.2))(jiti@2.6.1)(ts-api-utils@2.1.0(typescript@5.5.2))':
dependencies: dependencies:
'@eslint-react/eslint-plugin': 1.31.0(eslint@9.22.0(jiti@2.6.1))(ts-api-utils@2.1.0(typescript@5.5.2))(typescript@5.7.3) '@eslint-react/eslint-plugin': 1.31.0(eslint@9.22.0(jiti@2.6.1))(ts-api-utils@2.1.0(typescript@5.5.2))(typescript@5.7.3)
'@eslint/js': 9.22.0 '@eslint/js': 9.22.0
@@ -7002,7 +7021,7 @@ snapshots:
eslint: 9.22.0(jiti@2.6.1) eslint: 9.22.0(jiti@2.6.1)
eslint-config-prettier: 10.1.1(eslint@9.22.0(jiti@2.6.1)) eslint-config-prettier: 10.1.1(eslint@9.22.0(jiti@2.6.1))
eslint-plugin-import-x: 4.6.1(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3) eslint-plugin-import-x: 4.6.1(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3)
eslint-plugin-jest: 28.11.0(@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.26.1(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1)(typescript@5.5.2))(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3) eslint-plugin-jest: 28.11.0(@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1)(typescript@5.5.2))(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3)
eslint-plugin-jest-dom: 5.5.0(eslint@9.22.0(jiti@2.6.1)) eslint-plugin-jest-dom: 5.5.0(eslint@9.22.0(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.22.0(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.22.0(jiti@2.6.1))
eslint-plugin-perfectionist: 3.9.1(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3) eslint-plugin-perfectionist: 3.9.1(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3)
@@ -7227,6 +7246,22 @@ snapshots:
'@types/react': 19.0.1 '@types/react': 19.0.1
'@types/react-dom': 19.0.1 '@types/react-dom': 19.0.1
'@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.0.1)(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.1)(react@19.0.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.0.1)(react@19.0.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.0.1)(react@19.0.0)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.0.1)(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.1)(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.1)(react@19.0.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.1)(react@19.0.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
optionalDependencies:
'@types/react': 19.0.1
'@types/react-dom': 19.0.1
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.0.1)(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': '@radix-ui/react-collection@1.1.7(@types/react-dom@19.0.1)(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies: dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.1)(react@19.0.0) '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.1)(react@19.0.0)
@@ -8121,7 +8156,7 @@ snapshots:
dependencies: dependencies:
'@types/node': 24.10.1 '@types/node': 24.10.1
'@typescript-eslint/eslint-plugin@8.26.1(@typescript-eslint/parser@8.26.1(eslint@8.57.1)(typescript@5.5.2))(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3)': '@typescript-eslint/eslint-plugin@8.26.1(@typescript-eslint/parser@8.26.1(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3))(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.26.1(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3) '@typescript-eslint/parser': 8.26.1(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3)
@@ -8138,24 +8173,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.26.1(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1)(typescript@5.5.2)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.26.1(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3)
'@typescript-eslint/scope-manager': 8.48.0
'@typescript-eslint/type-utils': 8.48.0(eslint@8.57.1)(typescript@5.5.2)
'@typescript-eslint/utils': 8.48.0(eslint@8.57.1)(typescript@5.5.2)
'@typescript-eslint/visitor-keys': 8.48.0
eslint: 8.57.1
graphemer: 1.4.0
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.1.0(typescript@5.5.2)
typescript: 5.5.2
transitivePeerDependencies:
- supports-color
optional: true
'@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1)(typescript@5.5.2)': '@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1)(typescript@5.5.2)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
@@ -9094,8 +9111,8 @@ snapshots:
'@typescript-eslint/parser': 8.48.0(eslint@8.57.1)(typescript@5.5.2) '@typescript-eslint/parser': 8.48.0(eslint@8.57.1)(typescript@5.5.2)
eslint: 8.57.1 eslint: 8.57.1
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.6.1(eslint@8.57.1)(typescript@5.5.2))(eslint-plugin-import@2.32.0)(eslint@8.57.1) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.6.1(eslint@8.57.1)(typescript@5.5.2))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.26.1(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.5.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.6.1(eslint@8.57.1)(typescript@5.5.2))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1)
eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1) eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1)
@@ -9118,7 +9135,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.6.1(eslint@8.57.1)(typescript@5.5.2))(eslint-plugin-import@2.32.0)(eslint@8.57.1): eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.6.1(eslint@8.57.1)(typescript@5.5.2))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1))(eslint@8.57.1):
dependencies: dependencies:
'@nolyfill/is-core-module': 1.0.39 '@nolyfill/is-core-module': 1.0.39
debug: 4.4.3 debug: 4.4.3
@@ -9129,19 +9146,19 @@ snapshots:
tinyglobby: 0.2.15 tinyglobby: 0.2.15
unrs-resolver: 1.11.1 unrs-resolver: 1.11.1
optionalDependencies: optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.26.1(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.5.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.6.1(eslint@8.57.1)(typescript@5.5.2))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-import-x: 4.6.1(eslint@8.57.1)(typescript@5.5.2) eslint-plugin-import-x: 4.6.1(eslint@8.57.1)(typescript@5.5.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.5.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.5.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.6.1(eslint@8.57.1)(typescript@5.5.2))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies: dependencies:
debug: 3.2.7 debug: 3.2.7
optionalDependencies: optionalDependencies:
'@typescript-eslint/parser': 8.48.0(eslint@8.57.1)(typescript@5.5.2) '@typescript-eslint/parser': 8.48.0(eslint@8.57.1)(typescript@5.5.2)
eslint: 8.57.1 eslint: 8.57.1
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.6.1(eslint@8.57.1)(typescript@5.5.2))(eslint-plugin-import@2.32.0)(eslint@8.57.1) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.6.1(eslint@8.57.1)(typescript@5.5.2))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1))(eslint@8.57.1)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -9186,7 +9203,7 @@ snapshots:
- supports-color - supports-color
- typescript - typescript
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.26.1(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1): eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.5.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.6.1(eslint@8.57.1)(typescript@5.5.2))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies: dependencies:
'@rtsao/scc': 1.1.0 '@rtsao/scc': 1.1.0
array-includes: 3.1.9 array-includes: 3.1.9
@@ -9197,7 +9214,7 @@ snapshots:
doctrine: 2.1.0 doctrine: 2.1.0
eslint: 8.57.1 eslint: 8.57.1
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.5.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.5.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.6.1(eslint@8.57.1)(typescript@5.5.2))(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
hasown: 2.0.2 hasown: 2.0.2
is-core-module: 2.16.1 is-core-module: 2.16.1
is-glob: 4.0.3 is-glob: 4.0.3
@@ -9209,7 +9226,7 @@ snapshots:
string.prototype.trimend: 1.0.9 string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0 tsconfig-paths: 3.15.0
optionalDependencies: optionalDependencies:
'@typescript-eslint/parser': 8.26.1(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3) '@typescript-eslint/parser': 8.48.0(eslint@8.57.1)(typescript@5.5.2)
transitivePeerDependencies: transitivePeerDependencies:
- eslint-import-resolver-typescript - eslint-import-resolver-typescript
- eslint-import-resolver-webpack - eslint-import-resolver-webpack
@@ -9221,12 +9238,12 @@ snapshots:
eslint: 9.22.0(jiti@2.6.1) eslint: 9.22.0(jiti@2.6.1)
requireindex: 1.2.0 requireindex: 1.2.0
eslint-plugin-jest@28.11.0(@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.26.1(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1)(typescript@5.5.2))(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3): eslint-plugin-jest@28.11.0(@typescript-eslint/eslint-plugin@8.48.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1)(typescript@5.5.2))(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3):
dependencies: dependencies:
'@typescript-eslint/utils': 8.48.0(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3) '@typescript-eslint/utils': 8.48.0(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3)
eslint: 9.22.0(jiti@2.6.1) eslint: 9.22.0(jiti@2.6.1)
optionalDependencies: optionalDependencies:
'@typescript-eslint/eslint-plugin': 8.48.0(@typescript-eslint/parser@8.26.1(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1)(typescript@5.5.2) '@typescript-eslint/eslint-plugin': 8.48.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.5.2))(eslint@8.57.1)(typescript@5.5.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
- typescript - typescript
@@ -11502,7 +11519,7 @@ snapshots:
typescript-eslint@8.26.1(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3): typescript-eslint@8.26.1(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.26.1(@typescript-eslint/parser@8.26.1(eslint@8.57.1)(typescript@5.5.2))(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3) '@typescript-eslint/eslint-plugin': 8.26.1(@typescript-eslint/parser@8.26.1(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3))(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3)
'@typescript-eslint/parser': 8.26.1(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3) '@typescript-eslint/parser': 8.26.1(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3)
'@typescript-eslint/utils': 8.26.1(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3) '@typescript-eslint/utils': 8.26.1(eslint@9.22.0(jiti@2.6.1))(typescript@5.7.3)
eslint: 9.22.0(jiti@2.6.1) eslint: 9.22.0(jiti@2.6.1)

View File

@@ -1,10 +1,10 @@
import type { Access } from 'payload' import type { Access } from "payload";
import { User } from '../payload-types' import { User } from "../payload-types";
export const isSuperAdminAccess: Access = ({ req }): boolean => { export const isSuperAdminAccess: Access = ({ req }): boolean => {
return isSuperAdmin(req.user) return isSuperAdmin(req.user);
} };
export const isSuperAdmin = (user: User | null): boolean => { export const isSuperAdmin = (user: User | null): boolean => {
return Boolean(user?.roles?.includes('super-admin')) return Boolean(user?.roles?.includes("super-admin"));
} };

View File

@@ -1,18 +1,21 @@
import type { User, Tenant } from '../payload-types' import type { User, Tenant } from "../payload-types";
import { extractID } from '../utilities/extractID' import { extractID } from "../utilities/extractID";
/** /**
* Tenant role types for care home staff * Tenant role types for care home staff
*/ */
export type TenantRole = 'admin' | 'caregiver' | 'kitchen' export type TenantRole = "admin" | "caregiver" | "kitchen";
/** /**
* Check if user has a specific tenant role in any tenant * Check if user has a specific tenant role in any tenant
*/ */
export const hasTenantRole = (user: User | null | undefined, role: TenantRole): boolean => { export const hasTenantRole = (
if (!user?.tenants) return false user: User | null | undefined,
return user.tenants.some((t) => t.roles?.includes(role)) 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 * Check if user has a specific tenant role in a specific tenant
@@ -22,12 +25,12 @@ export const hasTenantRoleInTenant = (
role: TenantRole, role: TenantRole,
tenantId: number | string, tenantId: number | string,
): boolean => { ): boolean => {
if (!user?.tenants) return false if (!user?.tenants) return false;
const targetId = String(tenantId) const targetId = String(tenantId);
return user.tenants.some( return user.tenants.some(
(t) => String(extractID(t.tenant)) === targetId && t.roles?.includes(role), (t) => String(extractID(t.tenant)) === targetId && t.roles?.includes(role),
) );
} };
/** /**
* Get tenant IDs where user has a specific role * Get tenant IDs where user has a specific role
@@ -35,55 +38,57 @@ export const hasTenantRoleInTenant = (
export const getTenantIDsWithRole = ( export const getTenantIDsWithRole = (
user: User | null | undefined, user: User | null | undefined,
role: TenantRole, role: TenantRole,
): Tenant['id'][] => { ): Tenant["id"][] => {
if (!user?.tenants) return [] if (!user?.tenants) return [];
return user.tenants return user.tenants
.filter((t) => t.roles?.includes(role)) .filter((t) => t.roles?.includes(role))
.map((t) => extractID(t.tenant)) .map((t) => extractID(t.tenant))
.filter((id): id is Tenant['id'] => id !== null && id !== undefined) .filter((id): id is Tenant["id"] => id !== null && id !== undefined);
} };
/** /**
* Get all tenant IDs for a user (regardless of role) * Get all tenant IDs for a user (regardless of role)
*/ */
export const getAllUserTenantIDs = (user: User | null | undefined): Tenant['id'][] => { export const getAllUserTenantIDs = (
if (!user?.tenants) return [] user: User | null | undefined,
): Tenant["id"][] => {
if (!user?.tenants) return [];
return user.tenants return user.tenants
.map((t) => extractID(t.tenant)) .map((t) => extractID(t.tenant))
.filter((id): id is Tenant['id'] => id !== null && id !== undefined) .filter((id): id is Tenant["id"] => id !== null && id !== undefined);
} };
/** /**
* Check if user is a tenant admin in any tenant * Check if user is a tenant admin in any tenant
*/ */
export const isTenantAdmin = (user: User | null | undefined): boolean => { export const isTenantAdmin = (user: User | null | undefined): boolean => {
return hasTenantRole(user, 'admin') return hasTenantRole(user, "admin");
} };
/** /**
* Check if user is a caregiver in any tenant * Check if user is a caregiver in any tenant
*/ */
export const isCaregiver = (user: User | null | undefined): boolean => { export const isCaregiver = (user: User | null | undefined): boolean => {
return hasTenantRole(user, 'caregiver') return hasTenantRole(user, "caregiver");
} };
/** /**
* Check if user is kitchen staff in any tenant * Check if user is kitchen staff in any tenant
*/ */
export const isKitchenStaff = (user: User | null | undefined): boolean => { export const isKitchenStaff = (user: User | null | undefined): boolean => {
return hasTenantRole(user, 'kitchen') return hasTenantRole(user, "kitchen");
} };
/** /**
* Check if user can access kitchen features (admin or kitchen role) * Check if user can access kitchen features (admin or kitchen role)
*/ */
export const canAccessKitchen = (user: User | null | undefined): boolean => { export const canAccessKitchen = (user: User | null | undefined): boolean => {
return hasTenantRole(user, 'admin') || hasTenantRole(user, 'kitchen') return hasTenantRole(user, "admin") || hasTenantRole(user, "kitchen");
} };
/** /**
* Check if user can create meal orders (admin or caregiver role) * Check if user can create meal orders (admin or caregiver role)
*/ */
export const canCreateOrders = (user: User | null | undefined): boolean => { export const canCreateOrders = (user: User | null | undefined): boolean => {
return hasTenantRole(user, 'admin') || hasTenantRole(user, 'caregiver') return hasTenantRole(user, "admin") || hasTenantRole(user, "caregiver");
} };

View File

@@ -1,9 +1,9 @@
'use client' "use client";
import React, { useState, useEffect, useCallback } from 'react' import React, { useState, useEffect, useCallback } from "react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import Link from 'next/link' import Link from "next/link";
import { format, parseISO } from 'date-fns' import { format, parseISO } from "date-fns";
import { import {
LogOut, LogOut,
ClipboardList, ClipboardList,
@@ -19,20 +19,26 @@ import {
UserCheck, UserCheck,
UserX, UserX,
Check, Check,
} from 'lucide-react' } from "lucide-react";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import {
import { Input } from '@/components/ui/input' Card,
import { Label } from '@/components/ui/label' CardContent,
import { Progress } from '@/components/ui/progress' CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from "@/components/ui/select";
import { import {
Table, Table,
TableBody, TableBody,
@@ -40,7 +46,7 @@ import {
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from "@/components/ui/table";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -48,14 +54,14 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from "@/components/ui/dialog";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from "@/components/ui/tooltip";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
import { import {
LoadingSpinner, LoadingSpinner,
@@ -64,76 +70,73 @@ import {
StatCard, StatCard,
EmptyState, EmptyState,
MealTypeSelector, MealTypeSelector,
} from '@/components/caregiver' } from "@/components/caregiver";
import { import {
ORDER_STATUSES, ORDER_STATUSES,
getMealTypeLabel, getMealTypeLabel,
type MealType, type MealType,
type OrderStatus, type OrderStatus,
} from '@/lib/constants/meal' } from "@/lib/constants/meal";
import { useAuth } from "@/hooks/useAuth";
interface User {
id: number
name?: string
email: string
tenants?: Array<{
tenant: { id: number; name: string } | number
roles?: string[]
}>
}
interface MealOrder { interface MealOrder {
id: number id: number;
title: string title: string;
date: string date: string;
mealType: MealType mealType: MealType;
status: OrderStatus status: OrderStatus;
mealCount: number mealCount: number;
createdAt: string createdAt: string;
} }
interface Resident { interface Resident {
id: number id: number;
name: string name: string;
room: string room: string;
} }
interface Meal { interface Meal {
id: number id: number;
resident: number | { id: number; name: string } resident: number | { id: number; name: string };
} }
interface OrderStats { interface OrderStats {
draft: number draft: number;
submitted: number submitted: number;
preparing: number preparing: number;
completed: number completed: number;
total: number total: number;
} }
interface PaginationInfo { interface PaginationInfo {
totalDocs: number totalDocs: number;
totalPages: number totalPages: number;
page: number page: number;
limit: number limit: number;
hasNextPage: boolean hasNextPage: boolean;
hasPrevPage: boolean hasPrevPage: boolean;
} }
const ITEMS_PER_PAGE = 10 const ITEMS_PER_PAGE = 10;
export default function CaregiverDashboardPage() { export default function CaregiverDashboardPage() {
const router = useRouter() const router = useRouter();
const [user, setUser] = useState<User | null>(null) const {
const [orders, setOrders] = useState<MealOrder[]>([]) user,
const [residents, setResidents] = useState<Resident[]>([]) loading: authLoading,
tenantName,
logout,
} = useAuth({ requiredRole: "caregiver" });
const [orders, setOrders] = useState<MealOrder[]>([]);
const [residents, setResidents] = useState<Resident[]>([]);
const [stats, setStats] = useState<OrderStats>({ const [stats, setStats] = useState<OrderStats>({
draft: 0, draft: 0,
submitted: 0, submitted: 0,
preparing: 0, preparing: 0,
completed: 0, completed: 0,
total: 0, total: 0,
}) });
const [pagination, setPagination] = useState<PaginationInfo>({ const [pagination, setPagination] = useState<PaginationInfo>({
totalDocs: 0, totalDocs: 0,
totalPages: 1, totalPages: 1,
@@ -141,39 +144,42 @@ export default function CaregiverDashboardPage() {
limit: ITEMS_PER_PAGE, limit: ITEMS_PER_PAGE,
hasNextPage: false, hasNextPage: false,
hasPrevPage: false, hasPrevPage: false,
}) });
const [loading, setLoading] = useState(true) const [dataLoading, setDataLoading] = useState(true);
const [ordersLoading, setOrdersLoading] = useState(false) const [ordersLoading, setOrdersLoading] = useState(false);
const [dateFilter, setDateFilter] = useState('') const [dateFilter, setDateFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<string>('all') const [statusFilter, setStatusFilter] = useState<string>("all");
const [coverageDialogOpen, setCoverageDialogOpen] = useState(false) const [coverageDialogOpen, setCoverageDialogOpen] = useState(false);
const [selectedOrder, setSelectedOrder] = useState<MealOrder | null>(null) const [selectedOrder, setSelectedOrder] = useState<MealOrder | null>(null);
const [orderMeals, setOrderMeals] = useState<Meal[]>([]) const [orderMeals, setOrderMeals] = useState<Meal[]>([]);
const [coverageLoading, setCoverageLoading] = useState(false) const [coverageLoading, setCoverageLoading] = useState(false);
const [createDialogOpen, setCreateDialogOpen] = useState(false) const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [newOrderDate, setNewOrderDate] = useState(() => format(new Date(), 'yyyy-MM-dd')) const [newOrderDate, setNewOrderDate] = useState(() =>
const [newOrderMealType, setNewOrderMealType] = useState<MealType>('breakfast') format(new Date(), "yyyy-MM-dd"),
const [creating, setCreating] = useState(false) );
const [newOrderMealType, setNewOrderMealType] =
useState<MealType>("breakfast");
const [creating, setCreating] = useState(false);
const fetchOrders = useCallback( const fetchOrders = useCallback(
async (page: number = 1) => { async (page: number = 1) => {
setOrdersLoading(true) setOrdersLoading(true);
try { try {
let url = `/api/meal-orders?sort=-date,-createdAt&limit=${ITEMS_PER_PAGE}&page=${page}&depth=0` let url = `/api/meal-orders?sort=-date,-createdAt&limit=${ITEMS_PER_PAGE}&page=${page}&depth=0`;
if (dateFilter) { if (dateFilter) {
url += `&where[date][equals]=${dateFilter}` url += `&where[date][equals]=${dateFilter}`;
} }
if (statusFilter !== 'all') { if (statusFilter !== "all") {
url += `&where[status][equals]=${statusFilter}` url += `&where[status][equals]=${statusFilter}`;
} }
const res = await fetch(url, { credentials: 'include' }) const res = await fetch(url, { credentials: "include" });
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json();
setOrders(data.docs || []) setOrders(data.docs || []);
setPagination({ setPagination({
totalDocs: data.totalDocs || 0, totalDocs: data.totalDocs || 0,
totalPages: data.totalPages || 1, totalPages: data.totalPages || 1,
@@ -181,183 +187,186 @@ export default function CaregiverDashboardPage() {
limit: data.limit || ITEMS_PER_PAGE, limit: data.limit || ITEMS_PER_PAGE,
hasNextPage: data.hasNextPage || false, hasNextPage: data.hasNextPage || false,
hasPrevPage: data.hasPrevPage || false, hasPrevPage: data.hasPrevPage || false,
}) });
} else if (res.status === 401) { } else if (res.status === 401) {
router.push('/caregiver/login') router.push("/login");
} }
} catch (err) { } catch (err) {
console.error('Error fetching orders:', err) console.error("Error fetching orders:", err);
} finally { } finally {
setOrdersLoading(false) setOrdersLoading(false);
} }
}, },
[router, dateFilter, statusFilter], [router, dateFilter, statusFilter],
) );
useEffect(() => { useEffect(() => {
const fetchInitialData = async () => { const fetchInitialData = async () => {
if (authLoading || !user) return;
try { try {
const userRes = await fetch('/api/users/me', { credentials: 'include' }) const residentsRes = await fetch(
if (!userRes.ok) { "/api/residents?where[active][equals]=true&limit=500",
router.push('/caregiver/login') {
return credentials: "include",
} },
const userData = await userRes.json() );
if (!userData.user) {
router.push('/caregiver/login')
return
}
setUser(userData.user)
const residentsRes = await fetch('/api/residents?where[active][equals]=true&limit=500', {
credentials: 'include',
})
if (residentsRes.ok) { if (residentsRes.ok) {
const residentsData = await residentsRes.json() const residentsData = await residentsRes.json();
setResidents(residentsData.docs || []) setResidents(residentsData.docs || []);
} }
const weekAgo = new Date() const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7) weekAgo.setDate(weekAgo.getDate() - 7);
const statsRes = await fetch( const statsRes = await fetch(
`/api/meal-orders?where[date][greater_than_equal]=${weekAgo.toISOString().split('T')[0]}&limit=1000&depth=0`, `/api/meal-orders?where[date][greater_than_equal]=${weekAgo.toISOString().split("T")[0]}&limit=1000&depth=0`,
{ credentials: 'include' }, { credentials: "include" },
) );
if (statsRes.ok) { if (statsRes.ok) {
const statsData = await statsRes.json() const statsData = await statsRes.json();
const allOrders = statsData.docs || [] const allOrders = statsData.docs || [];
setStats({ setStats({
draft: allOrders.filter((o: MealOrder) => o.status === 'draft').length, draft: allOrders.filter((o: MealOrder) => o.status === "draft")
submitted: allOrders.filter((o: MealOrder) => o.status === 'submitted').length, .length,
preparing: allOrders.filter((o: MealOrder) => o.status === 'preparing').length, submitted: allOrders.filter(
completed: allOrders.filter((o: MealOrder) => o.status === 'completed').length, (o: MealOrder) => o.status === "submitted",
).length,
preparing: allOrders.filter(
(o: MealOrder) => o.status === "preparing",
).length,
completed: allOrders.filter(
(o: MealOrder) => o.status === "completed",
).length,
total: allOrders.length, total: allOrders.length,
}) });
} }
} catch (error) { } catch (error) {
console.error('Error fetching data:', error) console.error("Error fetching data:", error);
} finally { } finally {
setLoading(false) setDataLoading(false);
} }
} };
fetchInitialData() fetchInitialData();
}, [router]) }, [authLoading, user]);
useEffect(() => { useEffect(() => {
if (!loading) { if (!authLoading && user && !dataLoading) {
fetchOrders(1) fetchOrders(1);
}
}, [loading, fetchOrders])
const handleLogout = async () => {
await fetch('/api/users/logout', {
method: 'POST',
credentials: 'include',
})
router.push('/caregiver/login')
} }
}, [authLoading, user, dataLoading, fetchOrders]);
const handleCreateOrder = async () => { const handleCreateOrder = async () => {
setCreating(true) setCreating(true);
try { try {
const res = await fetch('/api/meal-orders', { const res = await fetch("/api/meal-orders", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
date: newOrderDate, date: newOrderDate,
mealType: newOrderMealType, mealType: newOrderMealType,
status: 'draft', status: "draft",
}), }),
credentials: 'include', credentials: "include",
}) });
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json();
setCreateDialogOpen(false) setCreateDialogOpen(false);
router.push(`/caregiver/orders/${data.doc.id}`) router.push(`/caregiver/orders/${data.doc.id}`);
} }
} catch (err) { } catch (err) {
console.error('Error creating order:', err) console.error("Error creating order:", err);
} finally { } finally {
setCreating(false) setCreating(false);
}
} }
};
const handleViewCoverage = async (order: MealOrder) => { const handleViewCoverage = async (order: MealOrder) => {
setSelectedOrder(order) setSelectedOrder(order);
setCoverageDialogOpen(true) setCoverageDialogOpen(true);
setCoverageLoading(true) setCoverageLoading(true);
try { try {
const res = await fetch(`/api/meals?where[order][equals]=${order.id}&depth=1&limit=500`, { const res = await fetch(
credentials: 'include', `/api/meals?where[order][equals]=${order.id}&depth=1&limit=500`,
}) {
credentials: "include",
},
);
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json();
setOrderMeals(data.docs || []) setOrderMeals(data.docs || []);
} }
} catch (err) { } catch (err) {
console.error('Error fetching meals:', err) console.error("Error fetching meals:", err);
} finally { } finally {
setCoverageLoading(false) setCoverageLoading(false);
}
} }
};
const getCoverageInfo = (order: MealOrder) => { const getCoverageInfo = (order: MealOrder) => {
const totalResidents = residents.length const totalResidents = residents.length;
const coveredCount = order.mealCount const coveredCount = order.mealCount;
const percentage = totalResidents > 0 ? Math.round((coveredCount / totalResidents) * 100) : 0 const percentage =
return { coveredCount, totalResidents, percentage } totalResidents > 0
} ? Math.round((coveredCount / totalResidents) * 100)
: 0;
return { coveredCount, totalResidents, percentage };
};
const getCoverageColor = (percentage: number) => { const getCoverageColor = (percentage: number) => {
if (percentage === 100) return 'bg-green-500' if (percentage === 100) return "bg-green-500";
if (percentage >= 75) return 'bg-blue-500' if (percentage >= 75) return "bg-blue-500";
if (percentage >= 50) return 'bg-yellow-500' if (percentage >= 50) return "bg-yellow-500";
return 'bg-red-500' return "bg-red-500";
} };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return format(parseISO(dateStr), 'EEE, MMM d') return format(parseISO(dateStr), "EEE, MMM d");
} };
if (loading) { if (authLoading || dataLoading) {
return <LoadingSpinner fullPage /> return <LoadingSpinner fullPage />;
} }
const tenantName =
user?.tenants?.[0]?.tenant && typeof user.tenants[0].tenant === 'object'
? user.tenants[0].tenant.name
: 'Care Home'
const coveredResidentIds = new Set( const coveredResidentIds = new Set(
orderMeals.map((m) => (typeof m.resident === 'object' ? m.resident.id : m.resident)), orderMeals.map((m) =>
) typeof m.resident === "object" ? m.resident.id : m.resident,
const coveredResidents = residents.filter((r) => coveredResidentIds.has(r.id)) ),
const uncoveredResidents = residents.filter((r) => !coveredResidentIds.has(r.id)) );
const coveredResidents = residents.filter((r) =>
coveredResidentIds.has(r.id),
);
const uncoveredResidents = residents.filter(
(r) => !coveredResidentIds.has(r.id),
);
return ( return (
<div className="min-h-screen bg-muted/50"> <div className="min-h-screen bg-muted/50">
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-16 items-center justify-between"> <div className="container flex h-14 sm:h-16 items-center justify-between gap-2">
<h1 className="text-xl font-semibold">{tenantName}</h1> <h1 className="text-lg sm:text-xl font-semibold truncate">{tenantName}</h1>
<div className="flex items-center gap-4"> <div className="flex items-center gap-2 sm:gap-4">
<span className="text-sm text-muted-foreground">{user?.name || user?.email}</span> <span className="hidden sm:inline text-sm text-muted-foreground">
<Button variant="outline" size="sm" onClick={handleLogout}> {user?.name || user?.email}
<LogOut className="mr-2 h-4 w-4" /> </span>
Logout <Button variant="outline" size="sm" onClick={logout}>
<LogOut className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Logout</span>
</Button> </Button>
</div> </div>
</div> </div>
</header> </header>
<main className="container py-6"> <main className="container py-4 sm:py-6">
<div className="flex items-center justify-between mb-6"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<div> <div>
<h2 className="text-2xl font-bold tracking-tight">Dashboard</h2> <h2 className="text-xl sm:text-2xl font-bold tracking-tight">Dashboard</h2>
<p className="text-muted-foreground">Manage meal orders for your care home</p> <p className="text-sm sm:text-base text-muted-foreground">
Manage meal orders for your care home
</p>
</div> </div>
<Button onClick={() => setCreateDialogOpen(true)}> <Button onClick={() => setCreateDialogOpen(true)} className="w-full sm:w-auto">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
New Meal Order New Meal Order
</Button> </Button>
@@ -430,7 +439,8 @@ export default function CaregiverDashboardPage() {
<div> <div>
<CardTitle>Meal Orders</CardTitle> <CardTitle>Meal Orders</CardTitle>
<CardDescription> <CardDescription>
View and manage all meal orders. Click on coverage to see resident details. View and manage all meal orders. Click on coverage to see
resident details.
</CardDescription> </CardDescription>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -458,13 +468,13 @@ export default function CaregiverDashboardPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{(dateFilter || statusFilter !== 'all') && ( {(dateFilter || statusFilter !== "all") && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => { onClick={() => {
setDateFilter('') setDateFilter("");
setStatusFilter('all') setStatusFilter("all");
}} }}
> >
Clear Clear
@@ -502,7 +512,7 @@ export default function CaregiverDashboardPage() {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{orders.map((order) => { {orders.map((order) => {
const coverage = getCoverageInfo(order) const coverage = getCoverageInfo(order);
return ( return (
<TableRow key={order.id}> <TableRow key={order.id}>
<TableCell> <TableCell>
@@ -523,16 +533,22 @@ export default function CaregiverDashboardPage() {
onClick={() => handleViewCoverage(order)} onClick={() => handleViewCoverage(order)}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Progress value={coverage.percentage} className="h-2 flex-1" /> <Progress
value={coverage.percentage}
className="h-2 flex-1"
/>
<span className="text-xs text-muted-foreground whitespace-nowrap"> <span className="text-xs text-muted-foreground whitespace-nowrap">
{coverage.coveredCount}/{coverage.totalResidents} {coverage.coveredCount}/
{coverage.totalResidents}
</span> </span>
</div> </div>
{coverage.percentage < 100 && ( {coverage.percentage < 100 && (
<div className="flex items-center gap-1 mt-1"> <div className="flex items-center gap-1 mt-1">
<AlertTriangle className="h-3 w-3 text-yellow-500" /> <AlertTriangle className="h-3 w-3 text-yellow-500" />
<span className="text-xs text-yellow-600"> <span className="text-xs text-yellow-600">
{coverage.totalResidents - coverage.coveredCount} missing {coverage.totalResidents -
coverage.coveredCount}{" "}
missing
</span> </span>
</div> </div>
)} )}
@@ -550,7 +566,7 @@ export default function CaregiverDashboardPage() {
<TableCell className="text-right"> <TableCell className="text-right">
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild>
<Link href={`/caregiver/orders/${order.id}`}> <Link href={`/caregiver/orders/${order.id}`}>
{order.status === 'draft' ? ( {order.status === "draft" ? (
<> <>
<Pencil className="mr-1 h-3 w-3" /> <Pencil className="mr-1 h-3 w-3" />
Edit Edit
@@ -565,7 +581,7 @@ export default function CaregiverDashboardPage() {
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
) );
})} })}
</TableBody> </TableBody>
</Table> </Table>
@@ -573,9 +589,12 @@ export default function CaregiverDashboardPage() {
{pagination.totalPages > 1 && ( {pagination.totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-4 border-t"> <div className="flex items-center justify-between px-4 py-4 border-t">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.limit + 1} to{' '} Showing {(pagination.page - 1) * pagination.limit + 1} to{" "}
{Math.min(pagination.page * pagination.limit, pagination.totalDocs)} of{' '} {Math.min(
{pagination.totalDocs} orders pagination.page * pagination.limit,
pagination.totalDocs,
)}{" "}
of {pagination.totalDocs} orders
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
@@ -588,21 +607,30 @@ export default function CaregiverDashboardPage() {
Previous Previous
</Button> </Button>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, pagination.totalPages) }, (_, i) => { {Array.from(
let pageNum: number { length: Math.min(5, pagination.totalPages) },
(_, i) => {
let pageNum: number;
if (pagination.totalPages <= 5) { if (pagination.totalPages <= 5) {
pageNum = i + 1 pageNum = i + 1;
} else if (pagination.page <= 3) { } else if (pagination.page <= 3) {
pageNum = i + 1 pageNum = i + 1;
} else if (pagination.page >= pagination.totalPages - 2) { } else if (
pageNum = pagination.totalPages - 4 + i pagination.page >=
pagination.totalPages - 2
) {
pageNum = pagination.totalPages - 4 + i;
} else { } else {
pageNum = pagination.page - 2 + i pageNum = pagination.page - 2 + i;
} }
return ( return (
<Button <Button
key={pageNum} key={pageNum}
variant={pagination.page === pageNum ? 'default' : 'outline'} variant={
pagination.page === pageNum
? "default"
: "outline"
}
size="sm" size="sm"
className="w-8 h-8 p-0" className="w-8 h-8 p-0"
onClick={() => fetchOrders(pageNum)} onClick={() => fetchOrders(pageNum)}
@@ -610,8 +638,9 @@ export default function CaregiverDashboardPage() {
> >
{pageNum} {pageNum}
</Button> </Button>
) );
})} },
)}
</div> </div>
<Button <Button
variant="outline" variant="outline"
@@ -635,7 +664,9 @@ export default function CaregiverDashboardPage() {
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Create New Meal Order</DialogTitle> <DialogTitle>Create New Meal Order</DialogTitle>
<DialogDescription>Select a date and meal type to create a new order.</DialogDescription> <DialogDescription>
Select a date and meal type to create a new order.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -649,11 +680,18 @@ export default function CaregiverDashboardPage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Meal Type</Label> <Label>Meal Type</Label>
<MealTypeSelector value={newOrderMealType} onChange={setNewOrderMealType} variant="button" /> <MealTypeSelector
value={newOrderMealType}
onChange={setNewOrderMealType}
variant="button"
/>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}> <Button
variant="outline"
onClick={() => setCreateDialogOpen(false)}
>
Cancel Cancel
</Button> </Button>
<Button onClick={handleCreateOrder} disabled={creating}> <Button onClick={handleCreateOrder} disabled={creating}>
@@ -662,7 +700,7 @@ export default function CaregiverDashboardPage() {
) : ( ) : (
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
)} )}
{creating ? 'Creating...' : 'Create Order'} {creating ? "Creating..." : "Create Order"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -678,7 +716,8 @@ export default function CaregiverDashboardPage() {
<DialogDescription> <DialogDescription>
{selectedOrder && ( {selectedOrder && (
<> <>
{getMealTypeLabel(selectedOrder.mealType)} - {formatDate(selectedOrder.date)} {getMealTypeLabel(selectedOrder.mealType)} -{" "}
{formatDate(selectedOrder.date)}
</> </>
)} )}
</DialogDescription> </DialogDescription>
@@ -691,21 +730,25 @@ export default function CaregiverDashboardPage() {
<div className="flex items-center gap-4 p-4 bg-muted rounded-lg"> <div className="flex items-center gap-4 p-4 bg-muted rounded-lg">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Coverage Progress</span> <span className="text-sm font-medium">
Coverage Progress
</span>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{coveredResidents.length}/{residents.length} residents {coveredResidents.length}/{residents.length} residents
</span> </span>
</div> </div>
<Progress <Progress
value={ value={
residents.length > 0 ? (coveredResidents.length / residents.length) * 100 : 0 residents.length > 0
? (coveredResidents.length / residents.length) * 100
: 0
} }
className="h-3" className="h-3"
/> />
</div> </div>
<div <div
className={cn( className={cn(
'flex items-center justify-center w-16 h-16 rounded-full text-white font-bold', "flex items-center justify-center w-16 h-16 rounded-full text-white font-bold",
getCoverageColor( getCoverageColor(
residents.length > 0 residents.length > 0
? (coveredResidents.length / residents.length) * 100 ? (coveredResidents.length / residents.length) * 100
@@ -714,7 +757,9 @@ export default function CaregiverDashboardPage() {
)} )}
> >
{residents.length > 0 {residents.length > 0
? Math.round((coveredResidents.length / residents.length) * 100) ? Math.round(
(coveredResidents.length / residents.length) * 100,
)
: 0} : 0}
% %
</div> </div>
@@ -735,8 +780,12 @@ export default function CaregiverDashboardPage() {
className="flex items-center justify-between p-3 bg-red-50 border border-red-200 rounded-lg" className="flex items-center justify-between p-3 bg-red-50 border border-red-200 rounded-lg"
> >
<div> <div>
<div className="font-medium text-red-900">{resident.name}</div> <div className="font-medium text-red-900">
<div className="text-sm text-red-600">Room {resident.room}</div> {resident.name}
</div>
<div className="text-sm text-red-600">
Room {resident.room}
</div>
</div> </div>
<AlertTriangle className="h-4 w-4 text-red-500" /> <AlertTriangle className="h-4 w-4 text-red-500" />
</div> </div>
@@ -760,8 +809,12 @@ export default function CaregiverDashboardPage() {
className="flex items-center justify-between p-3 bg-green-50 border border-green-200 rounded-lg" className="flex items-center justify-between p-3 bg-green-50 border border-green-200 rounded-lg"
> >
<div> <div>
<div className="font-medium text-green-900">{resident.name}</div> <div className="font-medium text-green-900">
<div className="text-sm text-green-600">Room {resident.room}</div> {resident.name}
</div>
<div className="text-sm text-green-600">
Room {resident.room}
</div>
</div> </div>
<Check className="h-4 w-4 text-green-500" /> <Check className="h-4 w-4 text-green-500" />
</div> </div>
@@ -773,7 +826,7 @@ export default function CaregiverDashboardPage() {
)} )}
<DialogFooter> <DialogFooter>
{selectedOrder?.status === 'draft' && ( {selectedOrder?.status === "draft" && (
<Button asChild> <Button asChild>
<Link href={`/caregiver/orders/${selectedOrder.id}`}> <Link href={`/caregiver/orders/${selectedOrder.id}`}>
<Pencil className="mr-2 h-4 w-4" /> <Pencil className="mr-2 h-4 w-4" />
@@ -781,12 +834,15 @@ export default function CaregiverDashboardPage() {
</Link> </Link>
</Button> </Button>
)} )}
<Button variant="outline" onClick={() => setCoverageDialogOpen(false)}> <Button
variant="outline"
onClick={() => setCoverageDialogOpen(false)}
>
Close Close
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
) );
} }

View File

@@ -1,154 +1,5 @@
'use client' import { redirect } from "next/navigation";
import React, { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
export default function CaregiverLoginPage() { export default function CaregiverLoginPage() {
const router = useRouter() redirect("/login");
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)
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')
}
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) {
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="min-h-screen flex items-center justify-center bg-muted/50">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-muted/50 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold tracking-tight">Meal Planner</h1>
<p className="text-muted-foreground">Caregiver Portal</p>
</div>
<Card>
<CardHeader>
<CardTitle>Login</CardTitle>
<CardDescription>Enter your credentials to access the caregiver portal</CardDescription>
</CardHeader>
<CardContent>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
required
autoComplete="email"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
autoComplete="current-password"
/>
</div>
<Button type="submit" className="w-full" size="lg" disabled={loading}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Logging in...
</>
) : (
'Login'
)}
</Button>
</form>
</CardContent>
</Card>
</div>
</div>
)
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,22 @@
'use client' "use client";
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from "react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import Link from 'next/link' import Link from "next/link";
import { format, parseISO } from 'date-fns' import { format, parseISO } from "date-fns";
import { Plus, Pencil, Eye } from 'lucide-react' import { Plus, Pencil, Eye } from "lucide-react";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from '@/components/ui/input' import { Input } from "@/components/ui/input";
import { Label } from '@/components/ui/label' import { Label } from "@/components/ui/label";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from "@/components/ui/select";
import { import {
Table, Table,
TableBody, TableBody,
@@ -24,7 +24,7 @@ import {
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from "@/components/ui/table";
import { import {
PageHeader, PageHeader,
@@ -32,80 +32,82 @@ import {
StatusBadge, StatusBadge,
MealTypeIcon, MealTypeIcon,
EmptyState, EmptyState,
} from '@/components/caregiver' } from "@/components/caregiver";
import { import {
ORDER_STATUSES, ORDER_STATUSES,
getMealTypeLabel, getMealTypeLabel,
type MealType, type MealType,
type OrderStatus, type OrderStatus,
} from '@/lib/constants/meal' } from "@/lib/constants/meal";
interface MealOrder { interface MealOrder {
id: number id: number;
title: string title: string;
date: string date: string;
mealType: MealType mealType: MealType;
status: OrderStatus status: OrderStatus;
mealCount: number mealCount: number;
createdAt: string createdAt: string;
} }
export default function OrdersListPage() { export default function OrdersListPage() {
const router = useRouter() const router = useRouter();
const [orders, setOrders] = useState<MealOrder[]>([]) const [orders, setOrders] = useState<MealOrder[]>([]);
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const [dateFilter, setDateFilter] = useState(() => format(new Date(), 'yyyy-MM-dd')) const [dateFilter, setDateFilter] = useState(() =>
const [statusFilter, setStatusFilter] = useState<string>('all') format(new Date(), "yyyy-MM-dd"),
);
const [statusFilter, setStatusFilter] = useState<string>("all");
useEffect(() => { useEffect(() => {
const fetchOrders = async () => { const fetchOrders = async () => {
setLoading(true) setLoading(true);
try { try {
let url = `/api/meal-orders?sort=-date&limit=100&depth=0` let url = `/api/meal-orders?sort=-date&limit=100&depth=0`;
if (dateFilter) { if (dateFilter) {
url += `&where[date][equals]=${dateFilter}` url += `&where[date][equals]=${dateFilter}`;
} }
if (statusFilter !== 'all') { if (statusFilter !== "all") {
url += `&where[status][equals]=${statusFilter}` url += `&where[status][equals]=${statusFilter}`;
} }
const res = await fetch(url, { credentials: 'include' }) const res = await fetch(url, { credentials: "include" });
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json();
setOrders(data.docs || []) setOrders(data.docs || []);
} else if (res.status === 401) { } else if (res.status === 401) {
router.push('/caregiver/login') router.push("/login");
} }
} catch (err) { } catch (err) {
console.error('Error fetching orders:', err) console.error("Error fetching orders:", err);
} finally { } finally {
setLoading(false) setLoading(false);
} }
} };
fetchOrders() fetchOrders();
}, [router, dateFilter, statusFilter]) }, [router, dateFilter, statusFilter]);
const handleCreateOrder = async (mealType: MealType) => { const handleCreateOrder = async (mealType: MealType) => {
try { try {
const res = await fetch('/api/meal-orders', { const res = await fetch("/api/meal-orders", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
date: dateFilter, date: dateFilter,
mealType, mealType,
status: 'draft', status: "draft",
}), }),
credentials: 'include', credentials: "include",
}) });
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json();
router.push(`/caregiver/orders/${data.doc.id}`) router.push(`/caregiver/orders/${data.doc.id}`);
} }
} catch (err) { } catch (err) {
console.error('Error creating order:', err) console.error("Error creating order:", err);
}
} }
};
return ( return (
<div className="min-h-screen bg-muted/50"> <div className="min-h-screen bg-muted/50">
@@ -146,7 +148,7 @@ export default function OrdersListPage() {
<div className="space-y-2"> <div className="space-y-2">
<Label>Create New Order</Label> <Label>Create New Order</Label>
<div className="flex gap-2"> <div className="flex gap-2">
{(['breakfast', 'lunch', 'dinner'] as const).map((type) => ( {(["breakfast", "lunch", "dinner"] as const).map((type) => (
<Button <Button
key={type} key={type}
variant="outline" variant="outline"
@@ -190,7 +192,9 @@ export default function OrdersListPage() {
<TableCell> <TableCell>
<MealTypeIcon type={order.mealType} showLabel /> <MealTypeIcon type={order.mealType} showLabel />
</TableCell> </TableCell>
<TableCell>{format(parseISO(order.date), 'MMM d, yyyy')}</TableCell> <TableCell>
{format(parseISO(order.date), "MMM d, yyyy")}
</TableCell>
<TableCell>{order.mealCount} residents</TableCell> <TableCell>{order.mealCount} residents</TableCell>
<TableCell> <TableCell>
<StatusBadge status={order.status} /> <StatusBadge status={order.status} />
@@ -198,7 +202,7 @@ export default function OrdersListPage() {
<TableCell className="text-right"> <TableCell className="text-right">
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild>
<Link href={`/caregiver/orders/${order.id}`}> <Link href={`/caregiver/orders/${order.id}`}>
{order.status === 'draft' ? ( {order.status === "draft" ? (
<> <>
<Pencil className="mr-1 h-3 w-3" /> <Pencil className="mr-1 h-3 w-3" />
Edit Edit
@@ -221,5 +225,5 @@ export default function OrdersListPage() {
</Card> </Card>
</main> </main>
</div> </div>
) );
} }

View File

@@ -1,60 +1,64 @@
'use client' "use client";
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from "react";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import Link from 'next/link' import Link from "next/link";
import { ArrowLeft, Search, Loader2 } from 'lucide-react' import { ArrowLeft, Search, Loader2 } from "lucide-react";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from "@/components/ui/card";
import { Input } from '@/components/ui/input' import { Input } from "@/components/ui/input";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
interface Resident { interface Resident {
id: number id: number;
name: string name: string;
room: string room: string;
table?: string table?: string;
station?: string station?: string;
highCaloric?: boolean highCaloric?: boolean;
aversions?: string aversions?: string;
notes?: string notes?: string;
active: boolean active: boolean;
} }
export default function ResidentsListPage() { export default function ResidentsListPage() {
const router = useRouter() const router = useRouter();
const [residents, setResidents] = useState<Resident[]>([]) const [residents, setResidents] = useState<Resident[]>([]);
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState("");
useEffect(() => { useEffect(() => {
const fetchResidents = async () => { const fetchResidents = async () => {
try { try {
const res = await fetch('/api/residents?where[active][equals]=true&limit=100&sort=name', { const res = await fetch(
credentials: 'include', "/api/residents?where[active][equals]=true&limit=100&sort=name",
}) {
credentials: "include",
},
);
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json();
setResidents(data.docs || []) setResidents(data.docs || []);
} else if (res.status === 401) { } else if (res.status === 401) {
router.push('/caregiver/login') router.push("/login");
} }
} catch (err) { } catch (err) {
console.error('Error fetching residents:', err) console.error("Error fetching residents:", err);
} finally { } finally {
setLoading(false) setLoading(false);
} }
} };
fetchResidents() fetchResidents();
}, [router]) }, [router]);
const filteredResidents = residents.filter( const filteredResidents = residents.filter(
(r) => (r) =>
r.name.toLowerCase().includes(searchQuery.toLowerCase()) || r.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
r.room.toLowerCase().includes(searchQuery.toLowerCase()) || r.room.toLowerCase().includes(searchQuery.toLowerCase()) ||
(r.station && r.station.toLowerCase().includes(searchQuery.toLowerCase())), (r.station &&
) r.station.toLowerCase().includes(searchQuery.toLowerCase())),
);
return ( return (
<div className="min-h-screen bg-muted/50"> <div className="min-h-screen bg-muted/50">
@@ -73,7 +77,9 @@ export default function ResidentsListPage() {
<main className="container py-6"> <main className="container py-6">
<div className="mb-6"> <div className="mb-6">
<h2 className="text-2xl font-bold tracking-tight">Residents</h2> <h2 className="text-2xl font-bold tracking-tight">Residents</h2>
<p className="text-muted-foreground">View resident information and dietary requirements</p> <p className="text-muted-foreground">
View resident information and dietary requirements
</p>
</div> </div>
<div className="mb-6"> <div className="mb-6">
@@ -107,7 +113,10 @@ export default function ResidentsListPage() {
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="font-medium">{resident.name}</div> <div className="font-medium">{resident.name}</div>
{resident.highCaloric && ( {resident.highCaloric && (
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800 text-xs px-1.5 py-0"> <Badge
variant="secondary"
className="bg-yellow-100 text-yellow-800 text-xs px-1.5 py-0"
>
High Cal High Cal
</Badge> </Badge>
)} )}
@@ -122,12 +131,14 @@ export default function ResidentsListPage() {
<div className="mt-1 text-xs text-muted-foreground"> <div className="mt-1 text-xs text-muted-foreground">
{resident.aversions && ( {resident.aversions && (
<div className="truncate"> <div className="truncate">
<span className="font-medium">Aversions:</span> {resident.aversions} <span className="font-medium">Aversions:</span>{" "}
{resident.aversions}
</div> </div>
)} )}
{resident.notes && ( {resident.notes && (
<div className="truncate"> <div className="truncate">
<span className="font-medium">Notes:</span> {resident.notes} <span className="font-medium">Notes:</span>{" "}
{resident.notes}
</div> </div>
)} )}
</div> </div>
@@ -139,5 +150,5 @@ export default function ResidentsListPage() {
)} )}
</main> </main>
</div> </div>
) );
} }

View File

@@ -0,0 +1,730 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { format } from "date-fns";
import {
LogOut,
ChefHat,
Users,
Clock,
CheckCircle2,
Loader2,
RefreshCw,
ChevronDown,
ChevronUp,
AlertTriangle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { useAuth } from "@/hooks/useAuth";
import {
LoadingSpinner,
MealTypeSelector,
StatCard,
} from "@/components/caregiver";
import { MEAL_TYPES, type MealType } from "@/lib/constants/meal";
interface Resident {
id: number;
name: string;
room: string;
table?: string;
station?: string;
}
interface MealOrder {
id: number;
title: string;
date: string;
mealType: MealType;
status: string;
mealCount: number;
}
interface Meal {
id: number;
title: string;
resident: Resident | number;
mealType: MealType;
status: "pending" | "preparing" | "prepared";
order?: MealOrder | number;
highCaloric?: boolean;
notes?: string;
}
interface KitchenReport {
date: string;
mealType: string;
totalMeals: number;
ingredients: Record<string, number>;
labels: Record<string, string>;
portionSizes?: Record<string, number>;
}
interface IngredientCategory {
name: string;
items: { key: string; label: string; count: number }[];
}
const MEAL_STATUS_CONFIG = {
pending: {
label: "Pending",
bgColor: "bg-gray-100",
textColor: "text-gray-700",
icon: Clock,
},
preparing: {
label: "Preparing",
bgColor: "bg-yellow-100",
textColor: "text-yellow-700",
icon: ChefHat,
},
prepared: {
label: "Prepared",
bgColor: "bg-green-100",
textColor: "text-green-700",
icon: CheckCircle2,
},
};
function categorizeIngredients(
ingredients: Record<string, number>,
labels: Record<string, string>,
): IngredientCategory[] {
const categories: Record<string, IngredientCategory> = {
bread: { name: "Bread", items: [] },
preparation: { name: "Preparation", items: [] },
spreads: { name: "Spreads", items: [] },
beverages: { name: "Beverages", items: [] },
additions: { name: "Additions", items: [] },
mealOptions: { name: "Meal Options", items: [] },
specialPreparations: { name: "Special Preparations", items: [] },
restrictions: { name: "Restrictions", items: [] },
other: { name: "Other", items: [] },
};
for (const [key, count] of Object.entries(ingredients)) {
const label = labels[key] || key;
const item = { key, label, count };
if (
key.includes("Bread") ||
key.includes("Roll") ||
key.includes("bread") ||
key === "crispbread"
) {
categories.bread.items.push(item);
} else if (key === "sliced" || key === "spread" || key === "porridge") {
categories.preparation.items.push(item);
} else if (
[
"butter",
"margarine",
"jam",
"diabeticJam",
"honey",
"cheese",
"quark",
"sausage",
].includes(key)
) {
categories.spreads.items.push(item);
} else if (
["coffee", "tea", "hotMilk", "coldMilk", "cocoa"].includes(key)
) {
categories.beverages.items.push(item);
} else if (["sugar", "sweetener", "coffeeCreamer"].includes(key)) {
categories.additions.items.push(item);
} else if (["soup", "dessert"].includes(key)) {
categories.mealOptions.items.push(item);
} else if (
["pureedFood", "pureedMeat", "slicedMeat", "mashedPotatoes"].includes(key)
) {
categories.specialPreparations.items.push(item);
} else if (["noFish", "fingerFood", "onlySweet"].includes(key)) {
categories.restrictions.items.push(item);
} else {
categories.other.items.push(item);
}
}
return Object.values(categories).filter((cat) => cat.items.length > 0);
}
export default function KitchenDashboardPage() {
const {
user,
loading: authLoading,
tenantName,
logout,
} = useAuth({ requiredRole: "kitchen" });
const [date, setDate] = useState(() => format(new Date(), "yyyy-MM-dd"));
const [mealType, setMealType] = useState<MealType>("breakfast");
const [report, setReport] = useState<KitchenReport | null>(null);
const [meals, setMeals] = useState<Meal[]>([]);
const [orders, setOrders] = useState<MealOrder[]>([]);
const [loading, setLoading] = useState(false);
const [updatingMealId, setUpdatingMealId] = useState<number | null>(null);
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(["bread", "mealOptions"]),
);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [reportRes, mealsRes, ordersRes] = await Promise.all([
fetch(`/api/meals/kitchen-report?date=${date}&mealType=${mealType}`, {
credentials: "include",
}),
fetch(
`/api/meals?where[date][equals]=${date}&where[mealType][equals]=${mealType}&depth=2&limit=200&sort=resident`,
{ credentials: "include" },
),
fetch(
`/api/meal-orders?where[date][equals]=${date}&where[mealType][equals]=${mealType}&depth=0`,
{ credentials: "include" },
),
]);
if (reportRes.ok) {
const reportData = await reportRes.json();
setReport(reportData);
}
if (mealsRes.ok) {
const mealsData = await mealsRes.json();
setMeals(mealsData.docs || []);
}
if (ordersRes.ok) {
const ordersData = await ordersRes.json();
setOrders(ordersData.docs || []);
}
} catch (err) {
console.error("Error fetching data:", err);
} finally {
setLoading(false);
}
}, [date, mealType]);
useEffect(() => {
if (!authLoading && user) {
fetchData();
}
}, [authLoading, user, fetchData]);
const handleStatusChange = async (
mealId: number,
newStatus: Meal["status"],
) => {
setUpdatingMealId(mealId);
try {
const res = await fetch(`/api/meals/${mealId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
credentials: "include",
});
if (res.ok) {
setMeals((prev) =>
prev.map((meal) =>
meal.id === mealId ? { ...meal, status: newStatus } : meal,
),
);
}
} catch (err) {
console.error("Error updating meal status:", err);
} finally {
setUpdatingMealId(null);
}
};
const handleBulkStatusChange = async (newStatus: Meal["status"]) => {
const mealsToUpdate = meals.filter((m) => m.status !== newStatus);
if (mealsToUpdate.length === 0) return;
setLoading(true);
try {
await Promise.all(
mealsToUpdate.map((meal) =>
fetch(`/api/meals/${meal.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
credentials: "include",
}),
),
);
fetchData();
} catch (err) {
console.error("Error bulk updating meals:", err);
} finally {
setLoading(false);
}
};
const toggleCategory = (categoryName: string) => {
setExpandedCategories((prev) => {
const newSet = new Set(prev);
if (newSet.has(categoryName)) {
newSet.delete(categoryName);
} else {
newSet.add(categoryName);
}
return newSet;
});
};
if (authLoading) {
return <LoadingSpinner fullPage />;
}
const mealStats = {
total: meals.length,
pending: meals.filter((m) => m.status === "pending").length,
preparing: meals.filter((m) => m.status === "preparing").length,
prepared: meals.filter((m) => m.status === "prepared").length,
};
const progressPercent =
mealStats.total > 0 ? (mealStats.prepared / mealStats.total) * 100 : 0;
const ingredientCategories = report
? categorizeIngredients(report.ingredients, report.labels)
: [];
const submittedOrders = orders.filter((o) => o.status !== "draft");
return (
<div className="min-h-screen bg-muted/50">
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 sm:h-16 items-center justify-between gap-2">
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
<ChefHat className="h-5 w-5 sm:h-6 sm:w-6 text-primary flex-shrink-0" />
<div className="min-w-0">
<h1 className="text-base sm:text-xl font-semibold truncate">Kitchen Dashboard</h1>
<p className="text-xs text-muted-foreground truncate">{tenantName}</p>
</div>
</div>
<div className="flex items-center gap-2 sm:gap-4 flex-shrink-0">
<span className="hidden sm:inline text-sm text-muted-foreground">
{user?.name || user?.email}
</span>
<Button variant="outline" size="sm" onClick={logout}>
<LogOut className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Logout</span>
</Button>
</div>
</div>
</header>
<main className="container py-4 sm:py-6">
<Card className="mb-4 sm:mb-6">
<CardHeader className="pb-3 sm:pb-6">
<CardTitle className="text-lg sm:text-xl">Select Date & Meal</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="date">Date</Label>
<Input
type="date"
id="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Meal Type</Label>
<MealTypeSelector
value={mealType}
onChange={setMealType}
variant="button"
/>
</div>
</div>
<Button
variant="outline"
className="mt-4"
onClick={fetchData}
disabled={loading}
>
<RefreshCw
className={cn("mr-2 h-4 w-4", loading && "animate-spin")}
/>
Refresh Data
</Button>
</CardContent>
</Card>
{submittedOrders.length === 0 && !loading ? (
<Card>
<CardContent className="py-12 text-center">
<AlertTriangle className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<h3 className="text-lg font-semibold mb-2">
No Orders Submitted
</h3>
<p className="text-muted-foreground">
No meal orders have been submitted for{" "}
{MEAL_TYPES.find((m) => m.value === mealType)?.label} on{" "}
{format(new Date(date), "MMMM d, yyyy")}.
</p>
</CardContent>
</Card>
) : (
<>
<div className="grid grid-cols-2 gap-3 sm:gap-4 md:grid-cols-4 mb-4 sm:mb-6">
<StatCard
title="Total Meals"
value={mealStats.total}
icon={Users}
iconColor="text-blue-600"
/>
<StatCard
title="Pending"
value={mealStats.pending}
dotColor="bg-gray-400"
/>
<StatCard
title="Preparing"
value={mealStats.preparing}
dotColor="bg-yellow-500"
/>
<StatCard
title="Prepared"
value={mealStats.prepared}
dotColor="bg-green-500"
/>
</div>
<Card className="mb-4 sm:mb-6">
<CardContent className="pt-4 sm:pt-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-1 sm:gap-0 mb-2">
<span className="text-sm font-medium">
Preparation Progress
</span>
<span className="text-xs sm:text-sm text-muted-foreground">
{mealStats.prepared} of {mealStats.total} complete
</span>
</div>
<Progress value={progressPercent} className="h-2 sm:h-3" />
</CardContent>
</Card>
<div className="grid gap-4 sm:gap-6 lg:grid-cols-2 mb-4 sm:mb-6">
<Card>
<CardHeader>
<CardTitle>Ingredient Summary</CardTitle>
<CardDescription>
Total quantities needed for {mealStats.total} meals
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{loading ? (
<LoadingSpinner />
) : ingredientCategories.length === 0 ? (
<p className="text-muted-foreground text-center py-4">
No ingredients to display
</p>
) : (
<>
{report?.portionSizes && (
<div className="mb-4 p-4 bg-muted rounded-lg">
<h4 className="font-medium mb-2">Portion Sizes</h4>
<div className="flex gap-4">
{Object.entries(report.portionSizes).map(
([size, count]) => (
<div
key={size}
className="flex items-center gap-2"
>
<Badge
variant="outline"
className="capitalize"
>
{size}
</Badge>
<span className="font-semibold">{count}</span>
</div>
),
)}
</div>
</div>
)}
{ingredientCategories.map((category) => (
<Collapsible
key={category.name}
open={expandedCategories.has(
category.name.toLowerCase(),
)}
onOpenChange={() =>
toggleCategory(category.name.toLowerCase())
}
>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="w-full justify-between p-3 h-auto"
>
<span className="font-medium">
{category.name}
</span>
<div className="flex items-center gap-2">
<Badge variant="secondary">
{category.items.reduce(
(sum, item) => sum + item.count,
0,
)}
</Badge>
{expandedCategories.has(
category.name.toLowerCase(),
) ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</div>
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="grid gap-2 pl-3 pr-3 pb-3">
{category.items.map((item) => (
<div
key={item.key}
className="flex items-center justify-between p-2 bg-muted rounded"
>
<span className="text-sm">{item.label}</span>
<Badge>{item.count}</Badge>
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
))}
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3 sm:pb-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<CardTitle className="text-lg sm:text-xl">Meal Status</CardTitle>
<CardDescription className="text-xs sm:text-sm">
Update status as meals are prepared
</CardDescription>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleBulkStatusChange("preparing")}
disabled={loading || mealStats.pending === 0}
className="flex-1 sm:flex-none text-xs sm:text-sm"
>
Start All
</Button>
<Button
size="sm"
variant="outline"
className="flex-1 sm:flex-none bg-green-50 hover:bg-green-100 text-green-700 text-xs sm:text-sm"
onClick={() => handleBulkStatusChange("prepared")}
disabled={
loading || mealStats.prepared === mealStats.total
}
>
Complete All
</Button>
</div>
</div>
</CardHeader>
<CardContent className="px-3 sm:px-6">
{loading ? (
<LoadingSpinner />
) : meals.length === 0 ? (
<p className="text-muted-foreground text-center py-4">
No meals found
</p>
) : (
<div className="max-h-80 sm:max-h-96 overflow-y-auto -mx-3 sm:mx-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs sm:text-sm">Resident</TableHead>
<TableHead className="text-xs sm:text-sm hidden sm:table-cell">Room</TableHead>
<TableHead className="text-xs sm:text-sm">Status</TableHead>
<TableHead className="text-right text-xs sm:text-sm">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{meals.map((meal) => {
const resident =
typeof meal.resident === "object"
? meal.resident
: null;
const statusConfig =
MEAL_STATUS_CONFIG[meal.status];
const StatusIcon = statusConfig.icon;
return (
<TableRow key={meal.id}>
<TableCell className="font-medium text-xs sm:text-sm py-2 sm:py-4">
<div className="flex flex-col sm:flex-row sm:items-center gap-1">
<span className="truncate max-w-[120px] sm:max-w-none">{resident?.name || "Unknown"}</span>
<span className="text-muted-foreground sm:hidden text-[10px]">Room {resident?.room || "-"}</span>
{meal.highCaloric && (
<Badge
variant="secondary"
className="w-fit bg-yellow-100 text-yellow-800 text-[10px] sm:text-xs sm:ml-2"
>
HC
</Badge>
)}
</div>
</TableCell>
<TableCell className="hidden sm:table-cell">{resident?.room || "-"}</TableCell>
<TableCell className="py-2 sm:py-4">
<Badge
className={cn(
statusConfig.bgColor,
statusConfig.textColor,
"gap-1 text-[10px] sm:text-xs px-1.5 sm:px-2",
)}
>
<StatusIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
<span className="hidden sm:inline">{statusConfig.label}</span>
</Badge>
</TableCell>
<TableCell className="text-right py-2 sm:py-4">
{updatingMealId === meal.id ? (
<Loader2 className="h-4 w-4 animate-spin ml-auto" />
) : (
<Select
value={meal.status}
onValueChange={(value) =>
handleStatusChange(
meal.id,
value as Meal["status"],
)
}
>
<SelectTrigger className="w-20 sm:w-28 h-7 sm:h-8 text-xs sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pending">
Pending
</SelectItem>
<SelectItem value="preparing">
Preparing
</SelectItem>
<SelectItem value="prepared">
Prepared
</SelectItem>
</SelectContent>
</Select>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
{meals.some((m) => m.notes) && (
<Card>
<CardHeader>
<CardTitle>Special Notes</CardTitle>
<CardDescription>
Meals with special requirements or notes
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{meals
.filter((m) => m.notes || m.highCaloric)
.map((meal) => {
const resident =
typeof meal.resident === "object"
? meal.resident
: null;
return (
<div
key={meal.id}
className="flex items-start gap-4 p-3 bg-muted rounded-lg"
>
<div className="flex-1">
<div className="font-medium">
{resident?.name} - Room {resident?.room}
</div>
{meal.highCaloric && (
<Badge
variant="secondary"
className="bg-yellow-100 text-yellow-800 mt-1"
>
High Caloric Requirement
</Badge>
)}
{meal.notes && (
<p className="text-sm text-muted-foreground mt-1">
{meal.notes}
</p>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
</>
)}
</main>
</div>
);
}

View File

@@ -1,24 +1,30 @@
import React from 'react' import React from "react";
import type { Viewport } from 'next' import type { Viewport } from "next";
import '@/app/globals.css' import "@/app/globals.css";
export const metadata = { export const metadata = {
description: 'Meal ordering for caregivers', description: "Meal ordering for caregivers",
title: 'Meal Planner - Caregiver', title: "Meal Planner - Caregiver",
} };
export const viewport: Viewport = { export const viewport: Viewport = {
width: 'device-width', width: "device-width",
initialScale: 1, initialScale: 1,
viewportFit: 'cover', viewportFit: "cover",
} };
// eslint-disable-next-line no-restricted-exports // eslint-disable-next-line no-restricted-exports
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return ( return (
<html lang="en"> <html lang="en">
<body className="min-h-screen bg-background font-sans antialiased m-auto">{children}</body> <body className="min-h-screen bg-background font-sans antialiased m-auto">
{children}
</body>
</html> </html>
) );
} }

View File

@@ -0,0 +1,179 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Loader2, ChefHat, Heart } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { type User, getPrimaryRole, getRedirectPath } from "@/lib/auth";
export default function UnifiedLoginPage() {
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);
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) {
const role = getPrimaryRole(data.user as User);
router.push(getRedirectPath(role));
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");
}
const user = data.user as User;
const role = getPrimaryRole(user);
if (!role) {
await fetch("/api/users/logout", {
method: "POST",
credentials: "include",
});
throw new Error("You do not have access to this application");
}
router.push(getRedirectPath(role));
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
} finally {
setLoading(false);
}
};
if (checking) {
return (
<div className="min-h-screen flex items-center justify-center bg-muted/50">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-muted/50 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold tracking-tight">Meal Planner</h1>
<p className="text-muted-foreground">Care Home Management</p>
</div>
<Card>
<CardHeader>
<CardTitle>Welcome Back</CardTitle>
<CardDescription>Sign in to access your portal</CardDescription>
</CardHeader>
<CardContent>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
required
autoComplete="email"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
autoComplete="current-password"
/>
</div>
<Button
type="submit"
className="w-full"
size="lg"
disabled={loading}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</>
) : (
"Sign In"
)}
</Button>
</form>
<div className="mt-6 pt-6 border-t">
<p className="text-xs text-center text-muted-foreground mb-3">
You will be redirected based on your role
</p>
<div className="flex justify-center gap-6 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Heart className="h-3 w-3" />
<span>Caregivers</span>
</div>
<div className="flex items-center gap-1">
<ChefHat className="h-3 w-3" />
<span>Kitchen Staff</span>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -1,6 +1,5 @@
import { redirect } from 'next/navigation' import { redirect } from "next/navigation";
export default function HomePage() { export default function HomePage() {
// Redirect to caregiver login by default redirect("/login");
redirect('/caregiver/login')
} }

View File

@@ -1,25 +1,28 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next' import type { Metadata } from "next";
import config from '@payload-config' import config from "@payload-config";
import { generatePageMetadata, NotFoundPage } from '@payloadcms/next/views' import { generatePageMetadata, NotFoundPage } from "@payloadcms/next/views";
import { importMap } from '../importMap.js' import { importMap } from "../importMap.js";
type Args = { type Args = {
params: Promise<{ params: Promise<{
segments: string[] segments: string[];
}> }>;
searchParams: Promise<{ searchParams: Promise<{
[key: string]: string | string[] [key: string]: string | string[];
}> }>;
} };
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> => export const generateMetadata = ({
generatePageMetadata({ config, params, searchParams }) params,
searchParams,
}: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams });
const NotFound = ({ params, searchParams }: Args) => const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, importMap, params, searchParams }) NotFoundPage({ config, importMap, params, searchParams });
export default NotFound export default NotFound;

View File

@@ -1,25 +1,28 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next' import type { Metadata } from "next";
import config from '@payload-config' import config from "@payload-config";
import { generatePageMetadata, RootPage } from '@payloadcms/next/views' import { generatePageMetadata, RootPage } from "@payloadcms/next/views";
import { importMap } from '../importMap.js' import { importMap } from "../importMap.js";
type Args = { type Args = {
params: Promise<{ params: Promise<{
segments: string[] segments: string[];
}> }>;
searchParams: Promise<{ searchParams: Promise<{
[key: string]: string | string[] [key: string]: string | string[];
}> }>;
} };
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> => export const generateMetadata = ({
generatePageMetadata({ config, params, searchParams }) params,
searchParams,
}: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams });
const Page = ({ params, searchParams }: Args) => const Page = ({ params, searchParams }: Args) =>
RootPage({ config, importMap, params, searchParams }) RootPage({ config, importMap, params, searchParams });
export default Page export default Page;

View File

@@ -1,80 +1,82 @@
'use client' "use client";
import React, { useState } from 'react' import React, { useState } from "react";
import { format, parseISO } from 'date-fns' import { format, parseISO } from "date-fns";
import { Gutter } from '@payloadcms/ui' import { Gutter } from "@payloadcms/ui";
import './styles.scss' import "./styles.scss";
interface MealWithImage { interface MealWithImage {
id: number id: number;
residentName: string residentName: string;
residentRoom: string residentRoom: string;
status: string status: string;
formImageUrl?: string formImageUrl?: string;
formImageThumbnail?: string formImageThumbnail?: string;
} }
interface KitchenReportResponse { interface KitchenReportResponse {
date: string date: string;
mealType: string mealType: string;
totalMeals: number totalMeals: number;
ingredients: Record<string, number> ingredients: Record<string, number>;
labels: Record<string, string> labels: Record<string, string>;
portionSizes?: Record<string, number> portionSizes?: Record<string, number>;
mealsWithImages?: MealWithImage[] mealsWithImages?: MealWithImage[];
error?: string error?: string;
} }
export const KitchenDashboard: React.FC = () => { export const KitchenDashboard: React.FC = () => {
const [date, setDate] = useState(() => format(new Date(), 'yyyy-MM-dd')) const [date, setDate] = useState(() => format(new Date(), "yyyy-MM-dd"));
const [mealType, setMealType] = useState<'breakfast' | 'lunch' | 'dinner'>('breakfast') const [mealType, setMealType] = useState<"breakfast" | "lunch" | "dinner">(
const [loading, setLoading] = useState(false) "breakfast",
const [error, setError] = useState<string | null>(null) );
const [report, setReport] = useState<KitchenReportResponse | null>(null) const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [report, setReport] = useState<KitchenReportResponse | null>(null);
const generateReport = async () => { const generateReport = async () => {
setLoading(true) setLoading(true);
setError(null) setError(null);
setReport(null) setReport(null);
try { try {
const response = await fetch( const response = await fetch(
`/api/meals/kitchen-report?date=${date}&mealType=${mealType}`, `/api/meals/kitchen-report?date=${date}&mealType=${mealType}`,
{ {
credentials: 'include', credentials: "include",
}, },
) );
const data = await response.json() const data = await response.json();
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || 'Failed to generate report') throw new Error(data.error || "Failed to generate report");
} }
setReport(data) setReport(data);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred') setError(err instanceof Error ? err.message : "An error occurred");
} finally { } finally {
setLoading(false) setLoading(false);
}
} }
};
const getMealTypeLabel = (type: string) => { const getMealTypeLabel = (type: string) => {
switch (type) { switch (type) {
case 'breakfast': case "breakfast":
return 'Breakfast (Frühstück)' return "Breakfast (Frühstück)";
case 'lunch': case "lunch":
return 'Lunch (Mittagessen)' return "Lunch (Mittagessen)";
case 'dinner': case "dinner":
return 'Dinner (Abendessen)' return "Dinner (Abendessen)";
default: default:
return type return type;
}
} }
};
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return format(parseISO(dateStr), 'EEEE, MMMM d, yyyy') return format(parseISO(dateStr), "EEEE, MMMM d, yyyy");
} };
return ( return (
<Gutter> <Gutter>
@@ -102,7 +104,11 @@ export const KitchenDashboard: React.FC = () => {
<select <select
id="meal-type" id="meal-type"
value={mealType} value={mealType}
onChange={(e) => setMealType(e.target.value as 'breakfast' | 'lunch' | 'dinner')} onChange={(e) =>
setMealType(
e.target.value as "breakfast" | "lunch" | "dinner",
)
}
className="kitchen-dashboard__select" className="kitchen-dashboard__select"
> >
<option value="breakfast">Breakfast (Frühstück)</option> <option value="breakfast">Breakfast (Frühstück)</option>
@@ -117,7 +123,7 @@ export const KitchenDashboard: React.FC = () => {
disabled={loading} disabled={loading}
className="kitchen-dashboard__button" className="kitchen-dashboard__button"
> >
{loading ? 'Generating...' : 'Generate Report'} {loading ? "Generating..." : "Generate Report"}
</button> </button>
</div> </div>
</section> </section>
@@ -151,22 +157,30 @@ export const KitchenDashboard: React.FC = () => {
</div> </div>
) : ( ) : (
<> <>
{report.portionSizes && Object.keys(report.portionSizes).length > 0 && ( {report.portionSizes &&
Object.keys(report.portionSizes).length > 0 && (
<div className="kitchen-dashboard__portion-sizes"> <div className="kitchen-dashboard__portion-sizes">
<h3>Portion Sizes</h3> <h3>Portion Sizes</h3>
<div className="kitchen-dashboard__portion-grid"> <div className="kitchen-dashboard__portion-grid">
{Object.entries(report.portionSizes).map(([size, count]) => ( {Object.entries(report.portionSizes).map(
<div key={size} className="kitchen-dashboard__portion-item"> ([size, count]) => (
<div
key={size}
className="kitchen-dashboard__portion-item"
>
<span className="kitchen-dashboard__portion-label"> <span className="kitchen-dashboard__portion-label">
{size === 'small' {size === "small"
? 'Small (Kleine)' ? "Small (Kleine)"
: size === 'large' : size === "large"
? 'Large (Große)' ? "Large (Große)"
: 'Vegetarian'} : "Vegetarian"}
</span>
<span className="kitchen-dashboard__portion-count">
{count}
</span> </span>
<span className="kitchen-dashboard__portion-count">{count}</span>
</div> </div>
))} ),
)}
</div> </div>
</div> </div>
)} )}
@@ -191,7 +205,9 @@ export const KitchenDashboard: React.FC = () => {
.map(([key, count]) => ( .map(([key, count]) => (
<tr key={key}> <tr key={key}>
<td>{report.labels[key] || key}</td> <td>{report.labels[key] || key}</td>
<td className="kitchen-dashboard__count">{count}</td> <td className="kitchen-dashboard__count">
{count}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -202,7 +218,10 @@ export const KitchenDashboard: React.FC = () => {
</td> </td>
<td className="kitchen-dashboard__count"> <td className="kitchen-dashboard__count">
<strong> <strong>
{Object.values(report.ingredients).reduce((a, b) => a + b, 0)} {Object.values(report.ingredients).reduce(
(a, b) => a + b,
0,
)}
</strong> </strong>
</td> </td>
</tr> </tr>
@@ -211,15 +230,20 @@ export const KitchenDashboard: React.FC = () => {
)} )}
</div> </div>
{report.mealsWithImages && report.mealsWithImages.length > 0 && ( {report.mealsWithImages &&
report.mealsWithImages.length > 0 && (
<div className="kitchen-dashboard__form-images"> <div className="kitchen-dashboard__form-images">
<h3>Paper Form Images</h3> <h3>Paper Form Images</h3>
<p className="kitchen-dashboard__form-images-desc"> <p className="kitchen-dashboard__form-images-desc">
{report.mealsWithImages.length} meal(s) have attached paper form photos {report.mealsWithImages.length} meal(s) have attached
paper form photos
</p> </p>
<div className="kitchen-dashboard__images-grid"> <div className="kitchen-dashboard__images-grid">
{report.mealsWithImages.map((meal) => ( {report.mealsWithImages.map((meal) => (
<div key={meal.id} className="kitchen-dashboard__image-card"> <div
key={meal.id}
className="kitchen-dashboard__image-card"
>
<div className="kitchen-dashboard__image-header"> <div className="kitchen-dashboard__image-header">
<strong>{meal.residentName}</strong> <strong>{meal.residentName}</strong>
<span>Room {meal.residentRoom}</span> <span>Room {meal.residentRoom}</span>
@@ -231,6 +255,7 @@ export const KitchenDashboard: React.FC = () => {
rel="noopener noreferrer" rel="noopener noreferrer"
className="kitchen-dashboard__image-link" className="kitchen-dashboard__image-link"
> >
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={meal.formImageThumbnail} src={meal.formImageThumbnail}
alt={`Form for ${meal.residentName}`} alt={`Form for ${meal.residentName}`}
@@ -242,7 +267,10 @@ export const KitchenDashboard: React.FC = () => {
</a> </a>
)} )}
<div className="kitchen-dashboard__image-status"> <div className="kitchen-dashboard__image-status">
Status: <span className={`status-${meal.status}`}>{meal.status}</span> Status:{" "}
<span className={`status-${meal.status}`}>
{meal.status}
</span>
</div> </div>
</div> </div>
))} ))}
@@ -255,7 +283,7 @@ export const KitchenDashboard: React.FC = () => {
)} )}
</div> </div>
</Gutter> </Gutter>
) );
} };
export default KitchenDashboard export default KitchenDashboard;

View File

@@ -206,7 +206,8 @@
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
th, td { th,
td {
padding: 0.875rem 1rem; padding: 0.875rem 1rem;
text-align: left; text-align: left;
border-bottom: 1px solid var(--theme-elevation-100); border-bottom: 1px solid var(--theme-elevation-100);

View File

@@ -1,10 +1,16 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config' import config from "@payload-config";
import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes' import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
} from "@payloadcms/next/routes";
export const GET = REST_GET(config) export const GET = REST_GET(config);
export const POST = REST_POST(config) export const POST = REST_POST(config);
export const DELETE = REST_DELETE(config) export const DELETE = REST_DELETE(config);
export const PATCH = REST_PATCH(config) export const PATCH = REST_PATCH(config);
export const OPTIONS = REST_OPTIONS(config) export const OPTIONS = REST_OPTIONS(config);

View File

@@ -1,6 +1,6 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config' import config from "@payload-config";
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes' import { GRAPHQL_PLAYGROUND_GET } from "@payloadcms/next/routes";
export const GET = GRAPHQL_PLAYGROUND_GET(config) export const GET = GRAPHQL_PLAYGROUND_GET(config);

View File

@@ -1,8 +1,8 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config' import config from "@payload-config";
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes' import { GRAPHQL_POST, REST_OPTIONS } from "@payloadcms/next/routes";
export const POST = GRAPHQL_POST(config) export const POST = GRAPHQL_POST(config);
export const OPTIONS = REST_OPTIONS(config) export const OPTIONS = REST_OPTIONS(config);

View File

@@ -1,32 +1,36 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { ServerFunctionClient } from 'payload' import type { ServerFunctionClient } from "payload";
import config from '@payload-config' import config from "@payload-config";
import '@payloadcms/next/css' import "@payloadcms/next/css";
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts' import { handleServerFunctions, RootLayout } from "@payloadcms/next/layouts";
import React from 'react' import React from "react";
import { importMap } from './admin/importMap.js' import { importMap } from "./admin/importMap.js";
import './custom.scss' import "./custom.scss";
type Args = { type Args = {
children: React.ReactNode children: React.ReactNode;
} };
const serverFunction: ServerFunctionClient = async function (args) { const serverFunction: ServerFunctionClient = async function (args) {
'use server' "use server";
return handleServerFunctions({ return handleServerFunctions({
...args, ...args,
config, config,
importMap, importMap,
}) });
} };
const Layout = ({ children }: Args) => ( const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}> <RootLayout
config={config}
importMap={importMap}
serverFunction={serverFunction}
>
{children} {children}
</RootLayout> </RootLayout>
) );
export default Layout export default Layout;

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from "next/server";
import OpenAI from 'openai' import OpenAI from "openai";
import { getPayload } from 'payload' import { getPayload } from "payload";
import config from '@payload-config' import config from "@payload-config";
/** /**
* Analyzes a meal order form image using OpenAI Vision API * Analyzes a meal order form image using OpenAI Vision API
@@ -10,103 +10,103 @@ import config from '@payload-config'
// Define the expected structure of extracted meal data // Define the expected structure of extracted meal data
interface BreakfastData { interface BreakfastData {
accordingToPlan?: boolean accordingToPlan?: boolean;
bread?: { bread?: {
breadRoll?: boolean breadRoll?: boolean;
wholeGrainRoll?: boolean wholeGrainRoll?: boolean;
greyBread?: boolean greyBread?: boolean;
wholeGrainBread?: boolean wholeGrainBread?: boolean;
whiteBread?: boolean whiteBread?: boolean;
crispbread?: boolean crispbread?: boolean;
} };
porridge?: boolean porridge?: boolean;
preparation?: { preparation?: {
sliced?: boolean sliced?: boolean;
spread?: boolean spread?: boolean;
} };
spreads?: { spreads?: {
butter?: boolean butter?: boolean;
margarine?: boolean margarine?: boolean;
jam?: boolean jam?: boolean;
diabeticJam?: boolean diabeticJam?: boolean;
honey?: boolean honey?: boolean;
cheese?: boolean cheese?: boolean;
quark?: boolean quark?: boolean;
sausage?: boolean sausage?: boolean;
} };
beverages?: { beverages?: {
coffee?: boolean coffee?: boolean;
tea?: boolean tea?: boolean;
hotMilk?: boolean hotMilk?: boolean;
coldMilk?: boolean coldMilk?: boolean;
} };
additions?: { additions?: {
sugar?: boolean sugar?: boolean;
sweetener?: boolean sweetener?: boolean;
coffeeCreamer?: boolean coffeeCreamer?: boolean;
} };
} }
interface LunchData { interface LunchData {
portionSize?: 'small' | 'large' | 'vegetarian' portionSize?: "small" | "large" | "vegetarian";
soup?: boolean soup?: boolean;
dessert?: boolean dessert?: boolean;
specialPreparations?: { specialPreparations?: {
pureedFood?: boolean pureedFood?: boolean;
pureedMeat?: boolean pureedMeat?: boolean;
slicedMeat?: boolean slicedMeat?: boolean;
mashedPotatoes?: boolean mashedPotatoes?: boolean;
} };
restrictions?: { restrictions?: {
noFish?: boolean noFish?: boolean;
fingerFood?: boolean fingerFood?: boolean;
onlySweet?: boolean onlySweet?: boolean;
} };
} }
interface DinnerData { interface DinnerData {
accordingToPlan?: boolean accordingToPlan?: boolean;
bread?: { bread?: {
greyBread?: boolean greyBread?: boolean;
wholeGrainBread?: boolean wholeGrainBread?: boolean;
whiteBread?: boolean whiteBread?: boolean;
crispbread?: boolean crispbread?: boolean;
} };
preparation?: { preparation?: {
spread?: boolean spread?: boolean;
sliced?: boolean sliced?: boolean;
} };
spreads?: { spreads?: {
butter?: boolean butter?: boolean;
margarine?: boolean margarine?: boolean;
} };
soup?: boolean soup?: boolean;
porridge?: boolean porridge?: boolean;
noFish?: boolean noFish?: boolean;
beverages?: { beverages?: {
tea?: boolean tea?: boolean;
cocoa?: boolean cocoa?: boolean;
hotMilk?: boolean hotMilk?: boolean;
coldMilk?: boolean coldMilk?: boolean;
} };
additions?: { additions?: {
sugar?: boolean sugar?: boolean;
sweetener?: boolean sweetener?: boolean;
} };
} }
interface AnalysisResult { interface AnalysisResult {
mealType: 'breakfast' | 'lunch' | 'dinner' mealType: "breakfast" | "lunch" | "dinner";
residentName?: string residentName?: string;
roomNumber?: string roomNumber?: string;
highCaloric?: boolean highCaloric?: boolean;
aversions?: string aversions?: string;
notes?: string notes?: string;
breakfast?: BreakfastData breakfast?: BreakfastData;
lunch?: LunchData lunch?: LunchData;
dinner?: DinnerData dinner?: DinnerData;
confidence: number confidence: number;
rawAnalysis?: string rawAnalysis?: string;
} }
const getSystemPrompt = (mealType?: string) => { const getSystemPrompt = (mealType?: string) => {
@@ -118,9 +118,9 @@ The form may be in ANY language (German, English, or other). Look for visual ind
- Handwritten marks - Handwritten marks
- Any indication that an option is selected - Any indication that an option is selected
IMPORTANT: Return ONLY a JSON object with the EXACT structure shown below. Set boolean fields to true ONLY if they are clearly marked/checked on the form.` IMPORTANT: Return ONLY a JSON object with the EXACT structure shown below. Set boolean fields to true ONLY if they are clearly marked/checked on the form.`;
if (mealType === 'breakfast') { if (mealType === "breakfast") {
return `${basePrompt} return `${basePrompt}
You MUST return this EXACT JSON structure for breakfast: You MUST return this EXACT JSON structure for breakfast:
@@ -177,10 +177,10 @@ Common terms to look for (in any language):
- Spread / geschmiert / buttered - Spread / geschmiert / buttered
- Butter, Margarine, Jam/Konfitüre, Honey/Honig, Cheese/Käse, Quark, Sausage/Wurst - Butter, Margarine, Jam/Konfitüre, Honey/Honig, Cheese/Käse, Quark, Sausage/Wurst
- Coffee/Kaffee, Tea/Tee, Hot milk/Milch heiß, Cold milk/Milch kalt - Coffee/Kaffee, Tea/Tee, Hot milk/Milch heiß, Cold milk/Milch kalt
- Sugar/Zucker, Sweetener/Süßstoff, Coffee creamer/Kaffeesahne` - Sugar/Zucker, Sweetener/Süßstoff, Coffee creamer/Kaffeesahne`;
} }
if (mealType === 'lunch') { if (mealType === "lunch") {
return `${basePrompt} return `${basePrompt}
You MUST return this EXACT JSON structure for lunch: You MUST return this EXACT JSON structure for lunch:
@@ -217,10 +217,10 @@ Common terms to look for (in any language):
- Mashed potatoes / Kartoffelbrei - Mashed potatoes / Kartoffelbrei
- No fish / ohne Fisch - No fish / ohne Fisch
- Finger food / Fingerfood - Finger food / Fingerfood
- Only sweet / nur süß` - Only sweet / nur süß`;
} }
if (mealType === 'dinner') { if (mealType === "dinner") {
return `${basePrompt} return `${basePrompt}
You MUST return this EXACT JSON structure for dinner: You MUST return this EXACT JSON structure for dinner:
@@ -272,7 +272,7 @@ Common terms to look for (in any language):
- No fish / ohne Fisch - No fish / ohne Fisch
- Tea / Tee, Cocoa / Kakao - Tea / Tee, Cocoa / Kakao
- Hot milk / Milch heiß, Cold milk / Milch kalt - Hot milk / Milch heiß, Cold milk / Milch kalt
- Sugar / Zucker, Sweetener / Süßstoff` - Sugar / Zucker, Sweetener / Süßstoff`;
} }
// Generic prompt if meal type is not specified // Generic prompt if meal type is not specified
@@ -299,80 +299,77 @@ For DINNER, return:
"mealType": "dinner", "mealType": "dinner",
"confidence": <number>, "confidence": <number>,
"dinner": { /* dinner options */ } "dinner": { /* dinner options */ }
}` }`;
} };
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
// Check for OpenAI API key // Check for OpenAI API key
const openaiApiKey = process.env.OPENAI_API_KEY const openaiApiKey = process.env.OPENAI_API_KEY;
if (!openaiApiKey) { if (!openaiApiKey) {
return NextResponse.json( return NextResponse.json(
{ error: 'OpenAI API key not configured' }, { error: "OpenAI API key not configured" },
{ status: 500 } { status: 500 },
) );
} }
// Check authentication // Check authentication
const payload = await getPayload({ config }) const payload = await getPayload({ config });
const { user } = await payload.auth({ headers: request.headers }) const { user } = await payload.auth({ headers: request.headers });
if (!user) { if (!user) {
return NextResponse.json( return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
{ error: 'Unauthorized' },
{ status: 401 }
)
} }
// Get the image data from the request // Get the image data from the request
const body = await request.json() const body = await request.json();
const { imageUrl, imageBase64, mealType } = body const { imageUrl, imageBase64, mealType } = body;
if (!imageUrl && !imageBase64) { if (!imageUrl && !imageBase64) {
return NextResponse.json( return NextResponse.json(
{ error: 'Image URL or base64 data is required' }, { error: "Image URL or base64 data is required" },
{ status: 400 } { status: 400 },
) );
} }
// Initialize OpenAI client // Initialize OpenAI client
const openai = new OpenAI({ const openai = new OpenAI({
apiKey: openaiApiKey, apiKey: openaiApiKey,
}) });
// Prepare the image content for the API // Prepare the image content for the API
let imageContent: OpenAI.Chat.ChatCompletionContentPart let imageContent: OpenAI.Chat.ChatCompletionContentPart;
if (imageBase64) { if (imageBase64) {
imageContent = { imageContent = {
type: 'image_url', type: "image_url",
image_url: { image_url: {
url: `data:image/jpeg;base64,${imageBase64}`, url: `data:image/jpeg;base64,${imageBase64}`,
detail: 'high', detail: "high",
}, },
} };
} else { } else {
imageContent = { imageContent = {
type: 'image_url', type: "image_url",
image_url: { image_url: {
url: imageUrl, url: imageUrl,
detail: 'high', detail: "high",
}, },
} };
} }
// Call OpenAI Vision API // Call OpenAI Vision API
const response = await openai.chat.completions.create({ const response = await openai.chat.completions.create({
model: 'gpt-4o', model: "gpt-4o",
messages: [ messages: [
{ {
role: 'system', role: "system",
content: getSystemPrompt(mealType), content: getSystemPrompt(mealType),
}, },
{ {
role: 'user', role: "user",
content: [ content: [
{ {
type: 'text', type: "text",
text: `Analyze this meal order form image. Extract ALL checked/marked options and return the JSON structure exactly as specified. Look carefully for any marks, checks, X's, or filled boxes that indicate a selection.`, text: `Analyze this meal order form image. Extract ALL checked/marked options and return the JSON structure exactly as specified. Look carefully for any marks, checks, X's, or filled boxes that indicate a selection.`,
}, },
imageContent, imageContent,
@@ -380,37 +377,40 @@ export async function POST(request: NextRequest) {
}, },
], ],
max_tokens: 2000, max_tokens: 2000,
response_format: { type: 'json_object' }, response_format: { type: "json_object" },
}) });
const content = response.choices[0]?.message?.content const content = response.choices[0]?.message?.content;
if (!content) { if (!content) {
return NextResponse.json( return NextResponse.json(
{ error: 'No response from vision API' }, { error: "No response from vision API" },
{ status: 500 } { status: 500 },
) );
} }
// Parse the response // Parse the response
let analysisResult: AnalysisResult let analysisResult: AnalysisResult;
try { try {
analysisResult = JSON.parse(content) analysisResult = JSON.parse(content);
} catch { } catch {
// If JSON parsing fails, return the raw content // If JSON parsing fails, return the raw content
return NextResponse.json({ return NextResponse.json({
mealType: mealType || 'unknown', mealType: mealType || "unknown",
confidence: 0, confidence: 0,
rawAnalysis: content, rawAnalysis: content,
error: 'Could not parse structured response', error: "Could not parse structured response",
}) });
} }
return NextResponse.json(analysisResult) return NextResponse.json(analysisResult);
} catch (error) { } catch (error) {
console.error('Form analysis error:', error) console.error("Form analysis error:", error);
return NextResponse.json( return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to analyze form' }, {
{ status: 500 } error:
) error instanceof Error ? error.message : "Failed to analyze form",
},
{ status: 500 },
);
} }
} }

View File

@@ -1,7 +1,7 @@
import type { CollectionConfig } from 'payload' import type { CollectionConfig } from "payload";
import { format, formatISO, parseISO } from 'date-fns' import { format, formatISO, parseISO } from "date-fns";
import { isSuperAdmin } from '@/access/isSuperAdmin' import { isSuperAdmin } from "@/access/isSuperAdmin";
import { hasTenantRole } from '@/access/roles' import { hasTenantRole } from "@/access/roles";
/** /**
* Meal Orders Collection * Meal Orders Collection
@@ -16,158 +16,163 @@ import { hasTenantRole } from '@/access/roles'
* 4. Kitchen processes orders (preparing -> completed statuses) * 4. Kitchen processes orders (preparing -> completed statuses)
*/ */
export const MealOrders: CollectionConfig = { export const MealOrders: CollectionConfig = {
slug: 'meal-orders', slug: "meal-orders",
labels: { labels: {
singular: 'Meal Order', singular: "Meal Order",
plural: 'Meal Orders', plural: "Meal Orders",
}, },
admin: { admin: {
useAsTitle: 'title', useAsTitle: "title",
description: 'Batch meals by date and meal type', description: "Batch meals by date and meal type",
defaultColumns: ['title', 'date', 'mealType', 'status', 'mealCount'], defaultColumns: ["title", "date", "mealType", "status", "mealCount"],
group: 'Meal Planning', group: "Meal Planning",
}, },
access: { access: {
// Admin and caregiver can create orders // Admin and caregiver can create orders
create: ({ req }) => { create: ({ req }) => {
if (!req.user) return false if (!req.user) return false;
if (isSuperAdmin(req.user)) return true if (isSuperAdmin(req.user)) return true;
return hasTenantRole(req.user, 'admin') || hasTenantRole(req.user, 'caregiver') return (
hasTenantRole(req.user, "admin") || hasTenantRole(req.user, "caregiver")
);
}, },
// All authenticated users within the tenant can read // All authenticated users within the tenant can read
read: ({ req }) => { read: ({ req }) => {
if (!req.user) return false if (!req.user) return false;
return true // Multi-tenant plugin will filter by tenant return true; // Multi-tenant plugin will filter by tenant
}, },
// Admin, caregiver (if draft), and kitchen can update // Admin, caregiver (if draft), and kitchen can update
update: ({ req }) => { update: ({ req }) => {
if (!req.user) return false if (!req.user) return false;
if (isSuperAdmin(req.user)) return true if (isSuperAdmin(req.user)) return true;
return ( return (
hasTenantRole(req.user, 'admin') || hasTenantRole(req.user, "admin") ||
hasTenantRole(req.user, 'caregiver') || hasTenantRole(req.user, "caregiver") ||
hasTenantRole(req.user, 'kitchen') hasTenantRole(req.user, "kitchen")
) );
}, },
// Only admin can delete // Only admin can delete
delete: ({ req }) => { delete: ({ req }) => {
if (!req.user) return false if (!req.user) return false;
if (isSuperAdmin(req.user)) return true if (isSuperAdmin(req.user)) return true;
return hasTenantRole(req.user, 'admin') return hasTenantRole(req.user, "admin");
}, },
}, },
fields: [ fields: [
{ {
name: 'title', name: "title",
type: 'text', type: "text",
admin: { admin: {
readOnly: true, readOnly: true,
description: 'Auto-generated title', description: "Auto-generated title",
}, },
hooks: { hooks: {
beforeChange: [ beforeChange: [
({ data, operation }) => { ({ data, operation }) => {
if (operation === 'create' || operation === 'update') { if (operation === "create" || operation === "update") {
const mealLabels: Record<string, string> = { const mealLabels: Record<string, string> = {
breakfast: 'Breakfast', breakfast: "Breakfast",
lunch: 'Lunch', lunch: "Lunch",
dinner: 'Dinner', dinner: "Dinner",
};
const date = data?.date
? format(parseISO(data.date), "EEE, MMM d")
: "";
const mealType =
mealLabels[data?.mealType || ""] || data?.mealType || "";
return `${mealType} - ${date}`;
} }
const date = data?.date ? format(parseISO(data.date), 'EEE, MMM d') : '' return data?.title;
const mealType = mealLabels[data?.mealType || ''] || data?.mealType || ''
return `${mealType} - ${date}`
}
return data?.title
}, },
], ],
}, },
}, },
{ {
type: 'row', type: "row",
fields: [ fields: [
{ {
name: 'date', name: "date",
type: 'date', type: "date",
required: true, required: true,
index: true, index: true,
admin: { admin: {
date: { date: {
pickerAppearance: 'dayOnly', pickerAppearance: "dayOnly",
displayFormat: 'yyyy-MM-dd', displayFormat: "yyyy-MM-dd",
}, },
width: '50%', width: "50%",
}, },
}, },
{ {
name: 'mealType', name: "mealType",
type: 'select', type: "select",
required: true, required: true,
index: true, index: true,
options: [ options: [
{ label: 'Breakfast (Frühstück)', value: 'breakfast' }, { label: "Breakfast (Frühstück)", value: "breakfast" },
{ label: 'Lunch (Mittagessen)', value: 'lunch' }, { label: "Lunch (Mittagessen)", value: "lunch" },
{ label: 'Dinner (Abendessen)', value: 'dinner' }, { label: "Dinner (Abendessen)", value: "dinner" },
], ],
admin: { admin: {
width: '50%', width: "50%",
}, },
}, },
], ],
}, },
{ {
name: 'status', name: "status",
type: 'select', type: "select",
required: true, required: true,
defaultValue: 'draft', defaultValue: "draft",
index: true, index: true,
options: [ options: [
{ label: 'Draft (In Progress)', value: 'draft' }, { label: "Draft (In Progress)", value: "draft" },
{ label: 'Submitted to Kitchen', value: 'submitted' }, { label: "Submitted to Kitchen", value: "submitted" },
{ label: 'Preparing', value: 'preparing' }, { label: "Preparing", value: "preparing" },
{ label: 'Completed', value: 'completed' }, { label: "Completed", value: "completed" },
], ],
admin: { admin: {
position: 'sidebar', position: "sidebar",
description: 'Order status for workflow tracking', description: "Order status for workflow tracking",
}, },
}, },
{ {
name: 'mealCount', name: "mealCount",
type: 'number', type: "number",
defaultValue: 0, defaultValue: 0,
admin: { admin: {
position: 'sidebar', position: "sidebar",
readOnly: true, readOnly: true,
description: 'Number of meals in this order', description: "Number of meals in this order",
}, },
}, },
{ {
name: 'submittedAt', name: "submittedAt",
type: 'date', type: "date",
admin: { admin: {
position: 'sidebar', position: "sidebar",
readOnly: true, readOnly: true,
description: 'When the order was submitted to kitchen', description: "When the order was submitted to kitchen",
date: { date: {
pickerAppearance: 'dayAndTime', pickerAppearance: "dayAndTime",
}, },
}, },
}, },
{ {
name: 'createdBy', name: "createdBy",
type: 'relationship', type: "relationship",
relationTo: 'users', relationTo: "users",
admin: { admin: {
position: 'sidebar', position: "sidebar",
readOnly: true, readOnly: true,
description: 'User who created this order', description: "User who created this order",
}, },
}, },
{ {
name: 'notes', name: "notes",
type: 'textarea', type: "textarea",
admin: { admin: {
description: 'General notes for this batch of meals', description: "General notes for this batch of meals",
}, },
}, },
], ],
@@ -175,15 +180,15 @@ export const MealOrders: CollectionConfig = {
beforeChange: [ beforeChange: [
// Set createdBy on create // Set createdBy on create
({ req, operation, data }) => { ({ req, operation, data }) => {
if (operation === 'create' && req.user) { if (operation === "create" && req.user) {
data.createdBy = req.user.id data.createdBy = req.user.id;
} }
// Set submittedAt when status changes to submitted // Set submittedAt when status changes to submitted
if (data.status === 'submitted' && !data.submittedAt) { if (data.status === "submitted" && !data.submittedAt) {
data.submittedAt = formatISO(new Date()) data.submittedAt = formatISO(new Date());
} }
return data return data;
}, },
], ],
}, },
} };

View File

@@ -1,82 +1,86 @@
import type { Endpoint } from 'payload' import type { Endpoint } from "payload";
import { isSuperAdmin } from '@/access/isSuperAdmin' import { isSuperAdmin } from "@/access/isSuperAdmin";
import { canAccessKitchen } from '@/access/roles' import { canAccessKitchen } from "@/access/roles";
/** /**
* Field mappings for aggregation * Field mappings for aggregation
* Maps meal type to their respective boolean fields that should be counted * Maps meal type to their respective boolean fields that should be counted
*/ */
const breakfastFields = { const breakfastFields = {
'breakfast.accordingToPlan': 'According to Plan', "breakfast.accordingToPlan": "According to Plan",
'breakfast.bread.breadRoll': 'Bread Roll (Brötchen)', "breakfast.bread.breadRoll": "Bread Roll (Brötchen)",
'breakfast.bread.wholeGrainRoll': 'Whole Grain Roll (Vollkornbrötchen)', "breakfast.bread.wholeGrainRoll": "Whole Grain Roll (Vollkornbrötchen)",
'breakfast.bread.greyBread': 'Grey Bread (Graubrot)', "breakfast.bread.greyBread": "Grey Bread (Graubrot)",
'breakfast.bread.wholeGrainBread': 'Whole Grain Bread (Vollkornbrot)', "breakfast.bread.wholeGrainBread": "Whole Grain Bread (Vollkornbrot)",
'breakfast.bread.whiteBread': 'White Bread (Weißbrot)', "breakfast.bread.whiteBread": "White Bread (Weißbrot)",
'breakfast.bread.crispbread': 'Crispbread (Knäckebrot)', "breakfast.bread.crispbread": "Crispbread (Knäckebrot)",
'breakfast.porridge': 'Porridge (Brei)', "breakfast.porridge": "Porridge (Brei)",
'breakfast.preparation.sliced': 'Sliced (geschnitten)', "breakfast.preparation.sliced": "Sliced (geschnitten)",
'breakfast.preparation.spread': 'Spread (geschmiert)', "breakfast.preparation.spread": "Spread (geschmiert)",
'breakfast.spreads.butter': 'Butter', "breakfast.spreads.butter": "Butter",
'breakfast.spreads.margarine': 'Margarine', "breakfast.spreads.margarine": "Margarine",
'breakfast.spreads.jam': 'Jam (Konfitüre)', "breakfast.spreads.jam": "Jam (Konfitüre)",
'breakfast.spreads.diabeticJam': 'Diabetic Jam (Diab. Konfitüre)', "breakfast.spreads.diabeticJam": "Diabetic Jam (Diab. Konfitüre)",
'breakfast.spreads.honey': 'Honey (Honig)', "breakfast.spreads.honey": "Honey (Honig)",
'breakfast.spreads.cheese': 'Cheese (Käse)', "breakfast.spreads.cheese": "Cheese (Käse)",
'breakfast.spreads.quark': 'Quark', "breakfast.spreads.quark": "Quark",
'breakfast.spreads.sausage': 'Sausage (Wurst)', "breakfast.spreads.sausage": "Sausage (Wurst)",
'breakfast.beverages.coffee': 'Coffee (Kaffee)', "breakfast.beverages.coffee": "Coffee (Kaffee)",
'breakfast.beverages.tea': 'Tea (Tee)', "breakfast.beverages.tea": "Tea (Tee)",
'breakfast.beverages.hotMilk': 'Hot Milk (Milch heiß)', "breakfast.beverages.hotMilk": "Hot Milk (Milch heiß)",
'breakfast.beverages.coldMilk': 'Cold Milk (Milch kalt)', "breakfast.beverages.coldMilk": "Cold Milk (Milch kalt)",
'breakfast.additions.sugar': 'Sugar (Zucker)', "breakfast.additions.sugar": "Sugar (Zucker)",
'breakfast.additions.sweetener': 'Sweetener (Süßstoff)', "breakfast.additions.sweetener": "Sweetener (Süßstoff)",
'breakfast.additions.coffeeCreamer': 'Coffee Creamer (Kaffeesahne)', "breakfast.additions.coffeeCreamer": "Coffee Creamer (Kaffeesahne)",
} };
const lunchFields = { const lunchFields = {
'lunch.soup': 'Soup (Suppe)', "lunch.soup": "Soup (Suppe)",
'lunch.dessert': 'Dessert', "lunch.dessert": "Dessert",
'lunch.specialPreparations.pureedFood': 'Pureed Food (passierte Kost)', "lunch.specialPreparations.pureedFood": "Pureed Food (passierte Kost)",
'lunch.specialPreparations.pureedMeat': 'Pureed Meat (passiertes Fleisch)', "lunch.specialPreparations.pureedMeat": "Pureed Meat (passiertes Fleisch)",
'lunch.specialPreparations.slicedMeat': 'Sliced Meat (geschnittenes Fleisch)', "lunch.specialPreparations.slicedMeat": "Sliced Meat (geschnittenes Fleisch)",
'lunch.specialPreparations.mashedPotatoes': 'Mashed Potatoes (Kartoffelbrei)', "lunch.specialPreparations.mashedPotatoes": "Mashed Potatoes (Kartoffelbrei)",
'lunch.restrictions.noFish': 'No Fish (ohne Fisch)', "lunch.restrictions.noFish": "No Fish (ohne Fisch)",
'lunch.restrictions.fingerFood': 'Finger Food', "lunch.restrictions.fingerFood": "Finger Food",
'lunch.restrictions.onlySweet': 'Only Sweet (nur süß)', "lunch.restrictions.onlySweet": "Only Sweet (nur süß)",
} };
const dinnerFields = { const dinnerFields = {
'dinner.accordingToPlan': 'According to Plan', "dinner.accordingToPlan": "According to Plan",
'dinner.bread.greyBread': 'Grey Bread (Graubrot)', "dinner.bread.greyBread": "Grey Bread (Graubrot)",
'dinner.bread.wholeGrainBread': 'Whole Grain Bread (Vollkornbrot)', "dinner.bread.wholeGrainBread": "Whole Grain Bread (Vollkornbrot)",
'dinner.bread.whiteBread': 'White Bread (Weißbrot)', "dinner.bread.whiteBread": "White Bread (Weißbrot)",
'dinner.bread.crispbread': 'Crispbread (Knäckebrot)', "dinner.bread.crispbread": "Crispbread (Knäckebrot)",
'dinner.preparation.spread': 'Spread (geschmiert)', "dinner.preparation.spread": "Spread (geschmiert)",
'dinner.preparation.sliced': 'Sliced (geschnitten)', "dinner.preparation.sliced": "Sliced (geschnitten)",
'dinner.spreads.butter': 'Butter', "dinner.spreads.butter": "Butter",
'dinner.spreads.margarine': 'Margarine', "dinner.spreads.margarine": "Margarine",
'dinner.soup': 'Soup (Suppe)', "dinner.soup": "Soup (Suppe)",
'dinner.porridge': 'Porridge (Brei)', "dinner.porridge": "Porridge (Brei)",
'dinner.noFish': 'No Fish (ohne Fisch)', "dinner.noFish": "No Fish (ohne Fisch)",
'dinner.beverages.tea': 'Tea (Tee)', "dinner.beverages.tea": "Tea (Tee)",
'dinner.beverages.cocoa': 'Cocoa (Kakao)', "dinner.beverages.cocoa": "Cocoa (Kakao)",
'dinner.beverages.hotMilk': 'Hot Milk (Milch heiß)', "dinner.beverages.hotMilk": "Hot Milk (Milch heiß)",
'dinner.beverages.coldMilk': 'Cold Milk (Milch kalt)', "dinner.beverages.coldMilk": "Cold Milk (Milch kalt)",
'dinner.additions.sugar': 'Sugar (Zucker)', "dinner.additions.sugar": "Sugar (Zucker)",
'dinner.additions.sweetener': 'Sweetener (Süßstoff)', "dinner.additions.sweetener": "Sweetener (Süßstoff)",
} };
/** /**
* Get nested value from object using dot notation path * Get nested value from object using dot notation path
*/ */
function getNestedValue(obj: Record<string, unknown>, path: string): unknown { function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
return path.split('.').reduce((current: unknown, key) => { return path.split(".").reduce((current: unknown, key) => {
if (current && typeof current === 'object' && key in (current as Record<string, unknown>)) { if (
return (current as Record<string, unknown>)[key] current &&
typeof current === "object" &&
key in (current as Record<string, unknown>)
) {
return (current as Record<string, unknown>)[key];
} }
return undefined return undefined;
}, obj) }, obj);
} }
/** /**
@@ -93,86 +97,96 @@ function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
* Only accessible by users with admin or kitchen role. * Only accessible by users with admin or kitchen role.
*/ */
export const kitchenReportEndpoint: Endpoint = { export const kitchenReportEndpoint: Endpoint = {
path: '/kitchen-report', path: "/kitchen-report",
method: 'get', method: "get",
handler: async (req) => { handler: async (req) => {
const { payload, user } = req const { payload, user } = req;
// Check authentication // Check authentication
if (!user) { if (!user) {
return Response.json({ error: 'Unauthorized' }, { status: 401 }) return Response.json({ error: "Unauthorized" }, { status: 401 });
} }
// Check authorization - must be super admin, tenant admin, or kitchen staff // Check authorization - must be super admin, tenant admin, or kitchen staff
if (!isSuperAdmin(user) && !canAccessKitchen(user)) { if (!isSuperAdmin(user) && !canAccessKitchen(user)) {
return Response.json( return Response.json(
{ error: 'Forbidden - Kitchen or Admin role required' }, { error: "Forbidden - Kitchen or Admin role required" },
{ status: 403 }, { status: 403 },
) );
} }
// Parse query parameters // Parse query parameters
const url = new URL(req.url || '', 'http://localhost') const url = new URL(req.url || "", "http://localhost");
const date = url.searchParams.get('date') const date = url.searchParams.get("date");
const mealType = url.searchParams.get('mealType') const mealType = url.searchParams.get("mealType");
const orderId = url.searchParams.get('order') const orderId = url.searchParams.get("order");
// Validate parameters // Validate parameters
if (!date) { if (!date) {
return Response.json({ error: 'Missing required parameter: date' }, { status: 400 }) return Response.json(
{ error: "Missing required parameter: date" },
{ status: 400 },
);
} }
if (!mealType || !['breakfast', 'lunch', 'dinner'].includes(mealType)) { if (!mealType || !["breakfast", "lunch", "dinner"].includes(mealType)) {
return Response.json( return Response.json(
{ error: 'Invalid or missing mealType. Must be: breakfast, lunch, or dinner' }, {
error:
"Invalid or missing mealType. Must be: breakfast, lunch, or dinner",
},
{ status: 400 }, { status: 400 },
) );
} }
// Validate date format // Validate date format
const dateRegex = /^\d{4}-\d{2}-\d{2}$/ const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(date)) { if (!dateRegex.test(date)) {
return Response.json({ error: 'Invalid date format. Use YYYY-MM-DD' }, { status: 400 }) return Response.json(
{ error: "Invalid date format. Use YYYY-MM-DD" },
{ status: 400 },
);
} }
try { try {
// Build the where clause // Build the where clause
const whereClause: { const whereClause: {
and: Array<{ date?: { equals: string }; mealType?: { equals: string }; order?: { equals: number } }> and: Array<{
date?: { equals: string };
mealType?: { equals: string };
order?: { equals: number };
}>;
} = { } = {
and: [ and: [{ date: { equals: date } }, { mealType: { equals: mealType } }],
{ date: { equals: date } }, };
{ mealType: { equals: mealType } },
],
}
// Optionally filter by meal order // Optionally filter by meal order
if (orderId) { if (orderId) {
whereClause.and.push({ order: { equals: Number(orderId) } }) whereClause.and.push({ order: { equals: Number(orderId) } });
} }
// Query meals for the specified date and meal type // Query meals for the specified date and meal type
const meals = await payload.find({ const meals = await payload.find({
collection: 'meals', collection: "meals",
where: whereClause, where: whereClause,
limit: 1000, // Get all meals for the day limit: 1000, // Get all meals for the day
depth: 2, // Include resident and formImage details depth: 2, // Include resident and formImage details
}) });
// Select the appropriate field mapping // Select the appropriate field mapping
const fieldMapping = const fieldMapping =
mealType === 'breakfast' mealType === "breakfast"
? breakfastFields ? breakfastFields
: mealType === 'lunch' : mealType === "lunch"
? lunchFields ? lunchFields
: dinnerFields : dinnerFields;
// Aggregate counts // Aggregate counts
const ingredients: Record<string, { count: number; label: string }> = {} const ingredients: Record<string, { count: number; label: string }> = {};
// Initialize all fields with 0 // Initialize all fields with 0
for (const [fieldPath, label] of Object.entries(fieldMapping)) { for (const [fieldPath, label] of Object.entries(fieldMapping)) {
ingredients[fieldPath] = { count: 0, label } ingredients[fieldPath] = { count: 0, label };
} }
// Count lunch portion sizes separately // Count lunch portion sizes separately
@@ -180,64 +194,72 @@ export const kitchenReportEndpoint: Endpoint = {
small: 0, small: 0,
large: 0, large: 0,
vegetarian: 0, vegetarian: 0,
} };
// Count occurrences // Count occurrences
for (const meal of meals.docs) { for (const meal of meals.docs) {
// Count boolean fields // Count boolean fields
for (const fieldPath of Object.keys(fieldMapping)) { for (const fieldPath of Object.keys(fieldMapping)) {
const value = getNestedValue(meal as unknown as Record<string, unknown>, fieldPath) const value = getNestedValue(
meal as unknown as Record<string, unknown>,
fieldPath,
);
if (value === true) { if (value === true) {
ingredients[fieldPath].count++ ingredients[fieldPath].count++;
} }
} }
// Count lunch portion sizes // Count lunch portion sizes
if (mealType === 'lunch' && meal.lunch?.portionSize) { if (mealType === "lunch" && meal.lunch?.portionSize) {
const size = meal.lunch.portionSize as string const size = meal.lunch.portionSize as string;
if (size in portionSizes) { if (size in portionSizes) {
portionSizes[size]++ portionSizes[size]++;
} }
} }
} }
// Build response with non-zero items // Build response with non-zero items
const ingredientCounts: Record<string, number> = {} const ingredientCounts: Record<string, number> = {};
const ingredientLabels: Record<string, string> = {} const ingredientLabels: Record<string, string> = {};
for (const [fieldPath, { count, label }] of Object.entries(ingredients)) { for (const [fieldPath, { count, label }] of Object.entries(ingredients)) {
if (count > 0) { if (count > 0) {
// Use a cleaner key name (last part of the path) // Use a cleaner key name (last part of the path)
const key = fieldPath.split('.').pop() || fieldPath const key = fieldPath.split(".").pop() || fieldPath;
ingredientCounts[key] = count ingredientCounts[key] = count;
ingredientLabels[key] = label ingredientLabels[key] = label;
} }
} }
// Extract meals with form images for kitchen reference // Extract meals with form images for kitchen reference
interface MealWithImage { interface MealWithImage {
id: number id: number;
residentName: string residentName: string;
residentRoom: string residentRoom: string;
status: string status: string;
formImageUrl?: string formImageUrl?: string;
formImageThumbnail?: string formImageThumbnail?: string;
} }
const mealsWithImages: MealWithImage[] = meals.docs const mealsWithImages: MealWithImage[] = meals.docs
.filter((meal) => meal.formImage && typeof meal.formImage === 'object') .filter((meal) => meal.formImage && typeof meal.formImage === "object")
.map((meal) => { .map((meal) => {
const resident = typeof meal.resident === 'object' ? meal.resident : null const resident =
const formImage = meal.formImage as { url?: string; sizes?: { thumbnail?: { url?: string } } } typeof meal.resident === "object" ? meal.resident : null;
const formImage = meal.formImage as {
url?: string;
sizes?: { thumbnail?: { url?: string } };
};
return { return {
id: meal.id, id: meal.id,
residentName: resident?.name || 'Unknown', residentName: resident?.name || "Unknown",
residentRoom: resident?.room || '-', residentRoom: resident?.room || "-",
status: meal.status, status: meal.status,
formImageUrl: formImage?.url, formImageUrl: formImage?.url,
formImageThumbnail: formImage?.sizes?.thumbnail?.url || formImage?.url, formImageThumbnail:
} formImage?.sizes?.thumbnail?.url || formImage?.url,
}) };
});
// Build the response // Build the response
const response: Record<string, unknown> = { const response: Record<string, unknown> = {
@@ -247,25 +269,28 @@ export const kitchenReportEndpoint: Endpoint = {
ingredients: ingredientCounts, ingredients: ingredientCounts,
labels: ingredientLabels, labels: ingredientLabels,
mealsWithImages, mealsWithImages,
} };
// Add portion sizes for lunch // Add portion sizes for lunch
if (mealType === 'lunch') { if (mealType === "lunch") {
const nonZeroPortions: Record<string, number> = {} const nonZeroPortions: Record<string, number> = {};
for (const [size, count] of Object.entries(portionSizes)) { for (const [size, count] of Object.entries(portionSizes)) {
if (count > 0) { if (count > 0) {
nonZeroPortions[size] = count nonZeroPortions[size] = count;
} }
} }
if (Object.keys(nonZeroPortions).length > 0) { if (Object.keys(nonZeroPortions).length > 0) {
response.portionSizes = nonZeroPortions response.portionSizes = nonZeroPortions;
} }
} }
return Response.json(response, { status: 200 }) return Response.json(response, { status: 200 });
} catch (error) { } catch (error) {
console.error('Kitchen report error:', error) console.error("Kitchen report error:", error);
return Response.json({ error: 'Failed to generate report' }, { status: 500 }) return Response.json(
{ error: "Failed to generate report" },
{ status: 500 },
);
} }
}, },
} };

View File

@@ -1,39 +1,48 @@
import type { CollectionBeforeValidateHook } from 'payload' import type { CollectionBeforeValidateHook } from "payload";
/** /**
* Hook to auto-generate the title field from date, meal type, and resident name * Hook to auto-generate the title field from date, meal type, and resident name
* Format: "Breakfast - 2024-01-15 - John Doe" * Format: "Breakfast - 2024-01-15 - John Doe"
*/ */
export const generateTitle: CollectionBeforeValidateHook = async ({ data, req, operation: _operation }) => { export const generateTitle: CollectionBeforeValidateHook = async ({
if (!data) return data data,
req,
operation: _operation,
}) => {
if (!data) return data;
const mealType = data.mealType const mealType = data.mealType;
const date = data.date const date = data.date;
// Format meal type with first letter capitalized // Format meal type with first letter capitalized
const mealTypeLabel = const mealTypeLabel =
mealType === 'breakfast' ? 'Breakfast' : mealType === 'lunch' ? 'Lunch' : 'Dinner' mealType === "breakfast"
? "Breakfast"
: mealType === "lunch"
? "Lunch"
: "Dinner";
// Format date as YYYY-MM-DD // Format date as YYYY-MM-DD
let dateStr = '' let dateStr = "";
if (date) { if (date) {
const dateObj = typeof date === 'string' ? new Date(date) : date const dateObj = typeof date === "string" ? new Date(date) : date;
dateStr = dateObj.toISOString().split('T')[0] dateStr = dateObj.toISOString().split("T")[0];
} }
// Get resident name if we have the resident ID // Get resident name if we have the resident ID
let residentName = '' let residentName = "";
if (data.resident && req.payload) { if (data.resident && req.payload) {
try { try {
const residentId = typeof data.resident === 'object' ? data.resident.id : data.resident const residentId =
typeof data.resident === "object" ? data.resident.id : data.resident;
if (residentId) { if (residentId) {
const resident = await req.payload.findByID({ const resident = await req.payload.findByID({
collection: 'residents', collection: "residents",
id: residentId, id: residentId,
depth: 0, depth: 0,
}) });
if (resident?.name) { if (resident?.name) {
residentName = resident.name residentName = resident.name;
} }
} }
} catch { } catch {
@@ -42,11 +51,11 @@ export const generateTitle: CollectionBeforeValidateHook = async ({ data, req, o
} }
// Compose title // Compose title
const parts = [mealTypeLabel, dateStr, residentName].filter(Boolean) const parts = [mealTypeLabel, dateStr, residentName].filter(Boolean);
const title = parts.join(' - ') const title = parts.join(" - ");
return { return {
...data, ...data,
title: title || 'New Meal Order', title: title || "New Meal Order",
} };
} };

View File

@@ -1,14 +1,18 @@
import type { CollectionBeforeChangeHook } from 'payload' import type { CollectionBeforeChangeHook } from "payload";
/** /**
* Hook to automatically set the createdBy field to the current user on creation * Hook to automatically set the createdBy field to the current user on creation
*/ */
export const setCreatedBy: CollectionBeforeChangeHook = async ({ data, req, operation }) => { export const setCreatedBy: CollectionBeforeChangeHook = async ({
if (operation === 'create' && req.user) { data,
req,
operation,
}) => {
if (operation === "create" && req.user) {
return { return {
...data, ...data,
createdBy: req.user.id, createdBy: req.user.id,
};
} }
} return data;
return data };
}

View File

@@ -1,9 +1,9 @@
import type { CollectionConfig } from 'payload' import type { CollectionConfig } from "payload";
import { isSuperAdmin } from '@/access/isSuperAdmin' import { isSuperAdmin } from "@/access/isSuperAdmin";
import { hasTenantRole } from '@/access/roles' import { hasTenantRole } from "@/access/roles";
import { setCreatedBy } from './hooks/setCreatedBy' import { setCreatedBy } from "./hooks/setCreatedBy";
import { generateTitle } from './hooks/generateTitle' import { generateTitle } from "./hooks/generateTitle";
import { kitchenReportEndpoint } from './endpoints/kitchenReport' import { kitchenReportEndpoint } from "./endpoints/kitchenReport";
/** /**
* Meals Collection * Meals Collection
@@ -16,16 +16,16 @@ import { kitchenReportEndpoint } from './endpoints/kitchenReport'
* Multi-tenant: each meal belongs to a specific care home. * Multi-tenant: each meal belongs to a specific care home.
*/ */
export const Meals: CollectionConfig = { export const Meals: CollectionConfig = {
slug: 'meals', slug: "meals",
labels: { labels: {
singular: 'Meal', singular: "Meal",
plural: 'Meals', plural: "Meals",
}, },
admin: { admin: {
useAsTitle: 'title', useAsTitle: "title",
description: 'Manage meals for residents', description: "Manage meals for residents",
defaultColumns: ['title', 'resident', 'date', 'mealType', 'status'], defaultColumns: ["title", "resident", "date", "mealType", "status"],
group: 'Meal Planning', group: "Meal Planning",
}, },
endpoints: [kitchenReportEndpoint], endpoints: [kitchenReportEndpoint],
hooks: { hooks: {
@@ -35,171 +35,173 @@ export const Meals: CollectionConfig = {
access: { access: {
// Admin and caregiver can create meals // Admin and caregiver can create meals
create: ({ req }) => { create: ({ req }) => {
if (!req.user) return false if (!req.user) return false;
if (isSuperAdmin(req.user)) return true if (isSuperAdmin(req.user)) return true;
return hasTenantRole(req.user, 'admin') || hasTenantRole(req.user, 'caregiver') return (
hasTenantRole(req.user, "admin") || hasTenantRole(req.user, "caregiver")
);
}, },
// All authenticated users within the tenant can read meals // All authenticated users within the tenant can read meals
read: ({ req }) => { read: ({ req }) => {
if (!req.user) return false if (!req.user) return false;
return true // Multi-tenant plugin will filter by tenant return true; // Multi-tenant plugin will filter by tenant
}, },
// Admin can update all, caregiver can update own pending meals, kitchen can update status // Admin can update all, caregiver can update own pending meals, kitchen can update status
update: ({ req }) => { update: ({ req }) => {
if (!req.user) return false if (!req.user) return false;
if (isSuperAdmin(req.user)) return true if (isSuperAdmin(req.user)) return true;
// All tenant roles can update (with field-level restrictions) // All tenant roles can update (with field-level restrictions)
return ( return (
hasTenantRole(req.user, 'admin') || hasTenantRole(req.user, "admin") ||
hasTenantRole(req.user, 'caregiver') || hasTenantRole(req.user, "caregiver") ||
hasTenantRole(req.user, 'kitchen') hasTenantRole(req.user, "kitchen")
) );
}, },
// Admin can always delete, caregiver can only delete meals from draft orders // Admin can always delete, caregiver can only delete meals from draft orders
delete: async ({ req, id }) => { delete: async ({ req, id }) => {
if (!req.user) return false if (!req.user) return false;
if (isSuperAdmin(req.user)) return true if (isSuperAdmin(req.user)) return true;
if (hasTenantRole(req.user, 'admin')) return true if (hasTenantRole(req.user, "admin")) return true;
// Caregivers can only delete meals from draft orders // Caregivers can only delete meals from draft orders
if (hasTenantRole(req.user, 'caregiver') && id) { if (hasTenantRole(req.user, "caregiver") && id) {
const meal = await req.payload.findByID({ const meal = await req.payload.findByID({
collection: 'meals', collection: "meals",
id, id,
depth: 1, depth: 1,
}) });
if (meal?.order && typeof meal.order === 'object') { if (meal?.order && typeof meal.order === "object") {
return meal.order.status === 'draft' return meal.order.status === "draft";
} }
} }
return false return false;
}, },
}, },
fields: [ fields: [
// Core Fields // Core Fields
{ {
name: 'title', name: "title",
type: 'text', type: "text",
admin: { admin: {
readOnly: true, readOnly: true,
description: 'Auto-generated title', description: "Auto-generated title",
}, },
}, },
{ {
name: 'order', name: "order",
type: 'relationship', type: "relationship",
relationTo: 'meal-orders', relationTo: "meal-orders",
index: true, index: true,
admin: { admin: {
description: 'The meal order this meal belongs to', description: "The meal order this meal belongs to",
}, },
}, },
{ {
name: 'resident', name: "resident",
type: 'relationship', type: "relationship",
relationTo: 'residents', relationTo: "residents",
required: true, required: true,
index: true, index: true,
admin: { admin: {
description: 'Select the resident for this meal', description: "Select the resident for this meal",
}, },
}, },
{ {
type: 'row', type: "row",
fields: [ fields: [
{ {
name: 'date', name: "date",
type: 'date', type: "date",
required: true, required: true,
index: true, index: true,
admin: { admin: {
date: { date: {
pickerAppearance: 'dayOnly', pickerAppearance: "dayOnly",
displayFormat: 'yyyy-MM-dd', displayFormat: "yyyy-MM-dd",
}, },
width: '50%', width: "50%",
}, },
}, },
{ {
name: 'mealType', name: "mealType",
type: 'select', type: "select",
required: true, required: true,
index: true, index: true,
options: [ options: [
{ label: 'Breakfast (Frühstück)', value: 'breakfast' }, { label: "Breakfast (Frühstück)", value: "breakfast" },
{ label: 'Lunch (Mittagessen)', value: 'lunch' }, { label: "Lunch (Mittagessen)", value: "lunch" },
{ label: 'Dinner (Abendessen)', value: 'dinner' }, { label: "Dinner (Abendessen)", value: "dinner" },
], ],
admin: { admin: {
width: '50%', width: "50%",
}, },
}, },
], ],
}, },
{ {
name: 'status', name: "status",
type: 'select', type: "select",
required: true, required: true,
defaultValue: 'pending', defaultValue: "pending",
index: true, index: true,
options: [ options: [
{ label: 'Pending', value: 'pending' }, { label: "Pending", value: "pending" },
{ label: 'Preparing', value: 'preparing' }, { label: "Preparing", value: "preparing" },
{ label: 'Prepared', value: 'prepared' }, { label: "Prepared", value: "prepared" },
], ],
admin: { admin: {
position: 'sidebar', position: "sidebar",
description: 'Meal status for kitchen tracking', description: "Meal status for kitchen tracking",
}, },
}, },
{ {
name: 'formImage', name: "formImage",
type: 'upload', type: "upload",
relationTo: 'media', relationTo: "media",
admin: { admin: {
position: 'sidebar', position: "sidebar",
description: 'Photo of the paper meal order form', description: "Photo of the paper meal order form",
}, },
}, },
{ {
name: 'createdBy', name: "createdBy",
type: 'relationship', type: "relationship",
relationTo: 'users', relationTo: "users",
admin: { admin: {
position: 'sidebar', position: "sidebar",
readOnly: true, readOnly: true,
description: 'User who created this meal', description: "User who created this meal",
}, },
}, },
// Override Fields (optional per-meal overrides) // Override Fields (optional per-meal overrides)
{ {
type: 'collapsible', type: "collapsible",
label: 'Meal Overrides', label: "Meal Overrides",
admin: { admin: {
initCollapsed: true, initCollapsed: true,
}, },
fields: [ fields: [
{ {
name: 'highCaloric', name: "highCaloric",
type: 'checkbox', type: "checkbox",
defaultValue: false, defaultValue: false,
admin: { admin: {
description: 'Override: high-caloric requirement for this meal', description: "Override: high-caloric requirement for this meal",
}, },
}, },
{ {
name: 'aversions', name: "aversions",
type: 'textarea', type: "textarea",
admin: { admin: {
description: 'Override: specific aversions for this meal', description: "Override: specific aversions for this meal",
}, },
}, },
{ {
name: 'notes', name: "notes",
type: 'textarea', type: "textarea",
admin: { admin: {
description: 'Special notes for this meal', description: "Special notes for this meal",
}, },
}, },
], ],
@@ -209,111 +211,155 @@ export const Meals: CollectionConfig = {
// BREAKFAST FIELDS GROUP // BREAKFAST FIELDS GROUP
// ============================================ // ============================================
{ {
type: 'group', type: "group",
name: 'breakfast', name: "breakfast",
label: 'Breakfast Options (Frühstück)', label: "Breakfast Options (Frühstück)",
admin: { admin: {
condition: (data) => data?.mealType === 'breakfast', condition: (data) => data?.mealType === "breakfast",
}, },
fields: [ fields: [
{ {
name: 'accordingToPlan', name: "accordingToPlan",
type: 'checkbox', type: "checkbox",
label: 'According to Plan (Frühstück lt. Plan)', label: "According to Plan (Frühstück lt. Plan)",
defaultValue: false, defaultValue: false,
}, },
{ {
type: 'row', type: "row",
fields: [ fields: [
{ {
type: 'group', type: "group",
name: 'bread', name: "bread",
label: 'Bread Selection', label: "Bread Selection",
fields: [ fields: [
{ name: 'breadRoll', type: 'checkbox', label: 'Bread Roll (Brötchen)' },
{ {
name: 'wholeGrainRoll', name: "breadRoll",
type: 'checkbox', type: "checkbox",
label: 'Whole Grain Roll (Vollkornbrötchen)', label: "Bread Roll (Brötchen)",
}, },
{ name: 'greyBread', type: 'checkbox', label: 'Grey Bread (Graubrot)' },
{ {
name: 'wholeGrainBread', name: "wholeGrainRoll",
type: 'checkbox', type: "checkbox",
label: 'Whole Grain Bread (Vollkornbrot)', 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: 'whiteBread', type: 'checkbox', label: 'White Bread (Weißbrot)' },
{ name: 'crispbread', type: 'checkbox', label: 'Crispbread (Knäckebrot)' },
], ],
}, },
], ],
}, },
{ {
name: 'porridge', name: "porridge",
type: 'checkbox', type: "checkbox",
label: 'Porridge/Puree (Brei)', label: "Porridge/Puree (Brei)",
}, },
{ {
type: 'row', type: "row",
fields: [ fields: [
{ {
type: 'group', type: "group",
name: 'preparation', name: "preparation",
label: 'Bread Preparation', label: "Bread Preparation",
fields: [ fields: [
{ name: 'sliced', type: 'checkbox', label: 'Sliced (geschnitten)' }, {
{ name: 'spread', type: 'checkbox', label: 'Spread (geschmiert)' }, name: "sliced",
type: "checkbox",
label: "Sliced (geschnitten)",
},
{
name: "spread",
type: "checkbox",
label: "Spread (geschmiert)",
},
], ],
}, },
], ],
}, },
{ {
type: 'row', type: "row",
fields: [ fields: [
{ {
type: 'group', type: "group",
name: 'spreads', name: "spreads",
label: 'Spreads', label: "Spreads",
fields: [ fields: [
{ name: 'butter', type: 'checkbox', label: 'Butter' }, { name: "butter", type: "checkbox", label: "Butter" },
{ name: 'margarine', type: 'checkbox', label: 'Margarine' }, { name: "margarine", type: "checkbox", label: "Margarine" },
{ name: 'jam', type: 'checkbox', label: 'Jam (Konfitüre)' }, { 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: "diabeticJam",
{ name: 'cheese', type: 'checkbox', label: 'Cheese (Käse)' }, type: "checkbox",
{ name: 'quark', type: 'checkbox', label: 'Quark' }, label: "Diabetic Jam (Diab. Konfitüre)",
{ name: 'sausage', type: 'checkbox', label: 'Sausage (Wurst)' }, },
{ 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', type: "row",
fields: [ fields: [
{ {
type: 'group', type: "group",
name: 'beverages', name: "beverages",
label: 'Beverages', label: "Beverages",
fields: [ fields: [
{ name: 'coffee', type: 'checkbox', label: 'Coffee (Kaffee)' }, { name: "coffee", type: "checkbox", label: "Coffee (Kaffee)" },
{ name: 'tea', type: 'checkbox', label: 'Tea (Tee)' }, { 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)' }, name: "hotMilk",
type: "checkbox",
label: "Hot Milk (Milch heiß)",
},
{
name: "coldMilk",
type: "checkbox",
label: "Cold Milk (Milch kalt)",
},
], ],
}, },
], ],
}, },
{ {
type: 'row', type: "row",
fields: [ fields: [
{ {
type: 'group', type: "group",
name: 'additions', name: "additions",
label: 'Additions', label: "Additions",
fields: [ fields: [
{ name: 'sugar', type: 'checkbox', label: 'Sugar (Zucker)' }, { name: "sugar", type: "checkbox", label: "Sugar (Zucker)" },
{ name: 'sweetener', type: 'checkbox', label: 'Sweetener (Süßstoff)' }, {
{ name: 'coffeeCreamer', type: 'checkbox', label: 'Coffee Creamer (Kaffeesahne)' }, name: "sweetener",
type: "checkbox",
label: "Sweetener (Süßstoff)",
},
{
name: "coffeeCreamer",
type: "checkbox",
label: "Coffee Creamer (Kaffeesahne)",
},
], ],
}, },
], ],
@@ -325,52 +371,82 @@ export const Meals: CollectionConfig = {
// LUNCH FIELDS GROUP // LUNCH FIELDS GROUP
// ============================================ // ============================================
{ {
type: 'group', type: "group",
name: 'lunch', name: "lunch",
label: 'Lunch Options (Mittagessen)', label: "Lunch Options (Mittagessen)",
admin: { admin: {
condition: (data) => data?.mealType === 'lunch', condition: (data) => data?.mealType === "lunch",
}, },
fields: [ fields: [
{ {
name: 'portionSize', name: "portionSize",
type: 'select', type: "select",
label: 'Portion Size', label: "Portion Size",
options: [ options: [
{ label: 'Small Portion (Kleine Portion)', value: 'small' }, { label: "Small Portion (Kleine Portion)", value: "small" },
{ label: 'Large Portion (Große Portion)', value: 'large' }, { label: "Large Portion (Große Portion)", value: "large" },
{ {
label: 'Vegetarian Whole-Food (Vollwertkost vegetarisch)', label: "Vegetarian Whole-Food (Vollwertkost vegetarisch)",
value: 'vegetarian', value: "vegetarian",
}, },
], ],
}, },
{ {
type: 'row', type: "row",
fields: [ fields: [
{ name: 'soup', type: 'checkbox', label: 'Soup (Suppe)', admin: { width: '50%' } }, {
{ name: 'dessert', type: 'checkbox', label: 'Dessert', admin: { width: '50%' } }, name: "soup",
type: "checkbox",
label: "Soup (Suppe)",
admin: { width: "50%" },
},
{
name: "dessert",
type: "checkbox",
label: "Dessert",
admin: { width: "50%" },
},
], ],
}, },
{ {
type: 'group', type: "group",
name: 'specialPreparations', name: "specialPreparations",
label: 'Special Preparations', label: "Special Preparations",
fields: [ fields: [
{ name: 'pureedFood', type: 'checkbox', label: 'Pureed Food (passierte Kost)' }, {
{ name: 'pureedMeat', type: 'checkbox', label: 'Pureed Meat (passiertes Fleisch)' }, name: "pureedFood",
{ name: 'slicedMeat', type: 'checkbox', label: 'Sliced Meat (geschnittenes Fleisch)' }, type: "checkbox",
{ name: 'mashedPotatoes', type: 'checkbox', label: 'Mashed Potatoes (Kartoffelbrei)' }, 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', type: "group",
name: 'restrictions', name: "restrictions",
label: 'Restrictions', label: "Restrictions",
fields: [ fields: [
{ name: 'noFish', type: 'checkbox', label: 'No Fish (ohne Fisch)' }, { name: "noFish", type: "checkbox", label: "No Fish (ohne Fisch)" },
{ name: 'fingerFood', type: 'checkbox', label: 'Finger Food' }, { name: "fingerFood", type: "checkbox", label: "Finger Food" },
{ name: 'onlySweet', type: 'checkbox', label: 'Only Sweet (nur süß)' }, {
name: "onlySweet",
type: "checkbox",
label: "Only Sweet (nur süß)",
},
], ],
}, },
], ],
@@ -380,91 +456,120 @@ export const Meals: CollectionConfig = {
// DINNER FIELDS GROUP // DINNER FIELDS GROUP
// ============================================ // ============================================
{ {
type: 'group', type: "group",
name: 'dinner', name: "dinner",
label: 'Dinner Options (Abendessen)', label: "Dinner Options (Abendessen)",
admin: { admin: {
condition: (data) => data?.mealType === 'dinner', condition: (data) => data?.mealType === "dinner",
}, },
fields: [ fields: [
{ {
name: 'accordingToPlan', name: "accordingToPlan",
type: 'checkbox', type: "checkbox",
label: 'According to Plan (Abendessen lt. Plan)', label: "According to Plan (Abendessen lt. Plan)",
defaultValue: false, defaultValue: false,
}, },
{ {
type: 'group', type: "group",
name: 'bread', name: "bread",
label: 'Bread Selection', label: "Bread Selection",
fields: [ fields: [
{ name: 'greyBread', type: 'checkbox', label: 'Grey Bread (Graubrot)' },
{ {
name: 'wholeGrainBread', name: "greyBread",
type: 'checkbox', type: "checkbox",
label: 'Whole Grain Bread (Vollkornbrot)', 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: 'whiteBread', type: 'checkbox', label: 'White Bread (Weißbrot)' },
{ name: 'crispbread', type: 'checkbox', label: 'Crispbread (Knäckebrot)' },
], ],
}, },
{ {
type: 'group', type: "group",
name: 'preparation', name: "preparation",
label: 'Bread Preparation', label: "Bread Preparation",
fields: [ fields: [
{ name: 'spread', type: 'checkbox', label: 'Spread (geschmiert)' }, { name: "spread", type: "checkbox", label: "Spread (geschmiert)" },
{ name: 'sliced', type: 'checkbox', label: 'Sliced (geschnitten)' }, { name: "sliced", type: "checkbox", label: "Sliced (geschnitten)" },
], ],
}, },
{ {
type: 'group', type: "group",
name: 'spreads', name: "spreads",
label: 'Spreads', label: "Spreads",
fields: [ fields: [
{ name: 'butter', type: 'checkbox', label: 'Butter' }, { name: "butter", type: "checkbox", label: "Butter" },
{ name: 'margarine', type: 'checkbox', label: 'Margarine' }, { name: "margarine", type: "checkbox", label: "Margarine" },
], ],
}, },
{ {
type: 'row', type: "row",
fields: [ fields: [
{ name: 'soup', type: 'checkbox', label: 'Soup (Suppe)', admin: { width: '33%' } },
{ {
name: 'porridge', name: "soup",
type: 'checkbox', type: "checkbox",
label: 'Porridge (Brei)', label: "Soup (Suppe)",
admin: { width: '33%' }, admin: { width: "33%" },
}, },
{ {
name: 'noFish', name: "porridge",
type: 'checkbox', type: "checkbox",
label: 'No Fish (ohne Fisch)', label: "Porridge (Brei)",
admin: { width: '33%' }, admin: { width: "33%" },
},
{
name: "noFish",
type: "checkbox",
label: "No Fish (ohne Fisch)",
admin: { width: "33%" },
}, },
], ],
}, },
{ {
type: 'group', type: "group",
name: 'beverages', name: "beverages",
label: 'Beverages', label: "Beverages",
fields: [ fields: [
{ name: 'tea', type: 'checkbox', label: 'Tea (Tee)' }, { name: "tea", type: "checkbox", label: "Tea (Tee)" },
{ name: 'cocoa', type: 'checkbox', label: 'Cocoa (Kakao)' }, { 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)' }, name: "hotMilk",
type: "checkbox",
label: "Hot Milk (Milch heiß)",
},
{
name: "coldMilk",
type: "checkbox",
label: "Cold Milk (Milch kalt)",
},
], ],
}, },
{ {
type: 'group', type: "group",
name: 'additions', name: "additions",
label: 'Additions', label: "Additions",
fields: [ fields: [
{ name: 'sugar', type: 'checkbox', label: 'Sugar (Zucker)' }, { name: "sugar", type: "checkbox", label: "Sugar (Zucker)" },
{ name: 'sweetener', type: 'checkbox', label: 'Sweetener (Süßstoff)' }, {
name: "sweetener",
type: "checkbox",
label: "Sweetener (Süßstoff)",
},
], ],
}, },
], ],
}, },
], ],
} };

View File

@@ -1,6 +1,6 @@
import type { CollectionConfig } from 'payload' import type { CollectionConfig } from "payload";
import { isSuperAdmin } from '@/access/isSuperAdmin' import { isSuperAdmin } from "@/access/isSuperAdmin";
import { hasTenantRole } from '@/access/roles' import { hasTenantRole } from "@/access/roles";
/** /**
* Media Collection * Media Collection
@@ -9,72 +9,76 @@ import { hasTenantRole } from '@/access/roles'
* Used by caregivers to upload photos of paper meal order forms. * Used by caregivers to upload photos of paper meal order forms.
*/ */
export const Media: CollectionConfig = { export const Media: CollectionConfig = {
slug: 'media', slug: "media",
labels: { labels: {
singular: 'Media', singular: "Media",
plural: 'Media', plural: "Media",
}, },
admin: { admin: {
description: 'Uploaded images and files', description: "Uploaded images and files",
group: 'System', group: "System",
}, },
access: { access: {
// Admin and caregiver can upload // Admin and caregiver can upload
create: ({ req }) => { create: ({ req }) => {
if (!req.user) return false if (!req.user) return false;
if (isSuperAdmin(req.user)) return true if (isSuperAdmin(req.user)) return true;
return hasTenantRole(req.user, 'admin') || hasTenantRole(req.user, 'caregiver') return (
hasTenantRole(req.user, "admin") || hasTenantRole(req.user, "caregiver")
);
}, },
// All authenticated users can read // All authenticated users can read
read: ({ req }) => { read: ({ req }) => {
if (!req.user) return false if (!req.user) return false;
return true return true;
}, },
// Admin and caregiver can update // Admin and caregiver can update
update: ({ req }) => { update: ({ req }) => {
if (!req.user) return false if (!req.user) return false;
if (isSuperAdmin(req.user)) return true if (isSuperAdmin(req.user)) return true;
return hasTenantRole(req.user, 'admin') || hasTenantRole(req.user, 'caregiver') return (
hasTenantRole(req.user, "admin") || hasTenantRole(req.user, "caregiver")
);
}, },
// Only admin can delete // Only admin can delete
delete: ({ req }) => { delete: ({ req }) => {
if (!req.user) return false if (!req.user) return false;
if (isSuperAdmin(req.user)) return true if (isSuperAdmin(req.user)) return true;
return hasTenantRole(req.user, 'admin') return hasTenantRole(req.user, "admin");
}, },
}, },
upload: { upload: {
staticDir: 'media', staticDir: "media",
mimeTypes: ['image/*'], mimeTypes: ["image/*"],
imageSizes: [ imageSizes: [
{ {
name: 'thumbnail', name: "thumbnail",
width: 200, width: 200,
height: 200, height: 200,
position: 'centre', position: "centre",
}, },
{ {
name: 'preview', name: "preview",
width: 800, width: 800,
height: 800, height: 800,
position: 'centre', position: "centre",
}, },
], ],
}, },
fields: [ fields: [
{ {
name: 'alt', name: "alt",
type: 'text', type: "text",
admin: { admin: {
description: 'Alternative text for accessibility', description: "Alternative text for accessibility",
}, },
}, },
{ {
name: 'caption', name: "caption",
type: 'text', type: "text",
admin: { admin: {
description: 'Optional caption for the image', description: "Optional caption for the image",
}, },
}, },
], ],
} };

View File

@@ -1,6 +1,6 @@
import type { CollectionConfig } from 'payload' import type { CollectionConfig } from "payload";
import { isSuperAdmin } from '@/access/isSuperAdmin' import { isSuperAdmin } from "@/access/isSuperAdmin";
import { hasTenantRole } from '@/access/roles' import { hasTenantRole } from "@/access/roles";
/** /**
* Residents Collection * Residents Collection
@@ -9,110 +9,110 @@ import { hasTenantRole } from '@/access/roles'
* Multi-tenant: each resident belongs to a specific care home (tenant). * Multi-tenant: each resident belongs to a specific care home (tenant).
*/ */
export const Residents: CollectionConfig = { export const Residents: CollectionConfig = {
slug: 'residents', slug: "residents",
labels: { labels: {
singular: 'Resident', singular: "Resident",
plural: 'Residents', plural: "Residents",
}, },
admin: { admin: {
useAsTitle: 'name', useAsTitle: "name",
description: 'Manage residents in your care home', description: "Manage residents in your care home",
defaultColumns: ['name', 'room', 'station', 'table', 'active'], defaultColumns: ["name", "room", "station", "table", "active"],
group: 'Meal Planning', group: "Meal Planning",
}, },
access: { access: {
// Only super-admin and tenant admin can create residents // Only super-admin and tenant admin can create residents
create: ({ req }) => { create: ({ req }) => {
if (!req.user) return false if (!req.user) return false;
if (isSuperAdmin(req.user)) return true if (isSuperAdmin(req.user)) return true;
return hasTenantRole(req.user, 'admin') return hasTenantRole(req.user, "admin");
}, },
// All authenticated users within the tenant can read residents // All authenticated users within the tenant can read residents
read: ({ req }) => { read: ({ req }) => {
if (!req.user) return false if (!req.user) return false;
return true // Multi-tenant plugin will filter by tenant return true; // Multi-tenant plugin will filter by tenant
}, },
// Only super-admin and tenant admin can update residents // Only super-admin and tenant admin can update residents
update: ({ req }) => { update: ({ req }) => {
if (!req.user) return false if (!req.user) return false;
if (isSuperAdmin(req.user)) return true if (isSuperAdmin(req.user)) return true;
return hasTenantRole(req.user, 'admin') return hasTenantRole(req.user, "admin");
}, },
// Only super-admin and tenant admin can delete residents // Only super-admin and tenant admin can delete residents
delete: ({ req }) => { delete: ({ req }) => {
if (!req.user) return false if (!req.user) return false;
if (isSuperAdmin(req.user)) return true if (isSuperAdmin(req.user)) return true;
return hasTenantRole(req.user, 'admin') return hasTenantRole(req.user, "admin");
}, },
}, },
fields: [ fields: [
{ {
name: 'name', name: "name",
type: 'text', type: "text",
required: true, required: true,
admin: { admin: {
description: 'Full name of the resident', description: "Full name of the resident",
}, },
}, },
{ {
name: 'room', name: "room",
type: 'text', type: "text",
required: true, required: true,
index: true, index: true,
admin: { admin: {
description: 'Room number (Zimmer)', description: "Room number (Zimmer)",
}, },
}, },
{ {
name: 'table', name: "table",
type: 'text', type: "text",
admin: { admin: {
description: 'Table assignment in dining area (Tisch)', description: "Table assignment in dining area (Tisch)",
}, },
}, },
{ {
name: 'station', name: "station",
type: 'text', type: "text",
admin: { admin: {
description: 'Station or ward', description: "Station or ward",
}, },
}, },
{ {
type: 'row', type: "row",
fields: [ fields: [
{ {
name: 'highCaloric', name: "highCaloric",
type: 'checkbox', type: "checkbox",
defaultValue: false, defaultValue: false,
admin: { admin: {
description: 'Requires high-caloric meals (Hochkalorisch)', description: "Requires high-caloric meals (Hochkalorisch)",
width: '50%', width: "50%",
}, },
}, },
{ {
name: 'active', name: "active",
type: 'checkbox', type: "checkbox",
defaultValue: true, defaultValue: true,
admin: { admin: {
description: 'Is the resident currently active?', description: "Is the resident currently active?",
width: '50%', width: "50%",
}, },
}, },
], ],
}, },
{ {
name: 'aversions', name: "aversions",
type: 'textarea', type: "textarea",
admin: { admin: {
description: 'Food aversions and dislikes (Abneigungen)', description: "Food aversions and dislikes (Abneigungen)",
}, },
}, },
{ {
name: 'notes', name: "notes",
type: 'textarea', type: "textarea",
admin: { admin: {
description: 'Other notes and special requirements (Sonstiges)', description: "Other notes and special requirements (Sonstiges)",
}, },
}, },
], ],
} };

View File

@@ -1,6 +1,6 @@
import type { Access } from 'payload' import type { Access } from "payload";
import { isSuperAdmin } from '../../../access/isSuperAdmin' import { isSuperAdmin } from "../../../access/isSuperAdmin";
export const filterByTenantRead: Access = (args) => { export const filterByTenantRead: Access = (args) => {
// Allow public tenants to be read by anyone // Allow public tenants to be read by anyone
@@ -9,19 +9,19 @@ export const filterByTenantRead: Access = (args) => {
allowPublicRead: { allowPublicRead: {
equals: true, equals: true,
}, },
} };
} }
return true return true;
} };
export const canMutateTenant: Access = ({ req }) => { export const canMutateTenant: Access = ({ req }) => {
if (!req.user) { if (!req.user) {
return false return false;
} }
if (isSuperAdmin(req.user)) { if (isSuperAdmin(req.user)) {
return true return true;
} }
return { return {
@@ -29,11 +29,11 @@ export const canMutateTenant: Access = ({ req }) => {
in: in:
req.user?.tenants req.user?.tenants
?.map(({ roles, tenant }) => ?.map(({ roles, tenant }) =>
roles?.includes('admin') roles?.includes("admin")
? tenant && (typeof tenant === 'object' ? tenant.id : tenant) ? tenant && (typeof tenant === "object" ? tenant.id : tenant)
: null, : null,
) )
.filter(Boolean) || [], .filter(Boolean) || [],
}, },
} };
} };

View File

@@ -1,19 +1,19 @@
import { isSuperAdmin } from '@/access/isSuperAdmin' import { isSuperAdmin } from "@/access/isSuperAdmin";
import { getUserTenantIDs } from '@/utilities/getUserTenantIDs' import { getUserTenantIDs } from "@/utilities/getUserTenantIDs";
import { Access } from 'payload' import { Access } from "payload";
export const updateAndDeleteAccess: Access = ({ req }) => { export const updateAndDeleteAccess: Access = ({ req }) => {
if (!req.user) { if (!req.user) {
return false return false;
} }
if (isSuperAdmin(req.user)) { if (isSuperAdmin(req.user)) {
return true return true;
} }
return { return {
id: { id: {
in: getUserTenantIDs(req.user, 'admin'), in: getUserTenantIDs(req.user, "admin"),
}, },
} };
} };

View File

@@ -1,7 +1,7 @@
import type { CollectionConfig } from 'payload' import type { CollectionConfig } from "payload";
import { isSuperAdminAccess } from '@/access/isSuperAdmin' import { isSuperAdminAccess } from "@/access/isSuperAdmin";
import { updateAndDeleteAccess } from './access/updateAndDelete' import { updateAndDeleteAccess } from "./access/updateAndDelete";
/** /**
* Tenants Collection - Represents Care Homes * Tenants Collection - Represents Care Homes
@@ -12,10 +12,10 @@ import { updateAndDeleteAccess } from './access/updateAndDelete'
* - Staff (caregivers, kitchen staff) * - Staff (caregivers, kitchen staff)
*/ */
export const Tenants: CollectionConfig = { export const Tenants: CollectionConfig = {
slug: 'tenants', slug: "tenants",
labels: { labels: {
singular: 'Care Home', singular: "Care Home",
plural: 'Care Homes', plural: "Care Homes",
}, },
access: { access: {
create: isSuperAdminAccess, create: isSuperAdminAccess,
@@ -24,49 +24,49 @@ export const Tenants: CollectionConfig = {
update: updateAndDeleteAccess, update: updateAndDeleteAccess,
}, },
admin: { admin: {
useAsTitle: 'name', useAsTitle: "name",
description: 'Manage care homes in the system', description: "Manage care homes in the system",
defaultColumns: ['name', 'slug', 'phone'], defaultColumns: ["name", "slug", "phone"],
}, },
fields: [ fields: [
{ {
name: 'name', name: "name",
type: 'text', type: "text",
required: true, required: true,
admin: { admin: {
description: 'Care home name', description: "Care home name",
}, },
}, },
{ {
name: 'slug', name: "slug",
type: 'text', type: "text",
required: true, required: true,
index: true, index: true,
unique: true, unique: true,
admin: { admin: {
description: 'URL-friendly identifier (e.g., sunny-meadows)', description: "URL-friendly identifier (e.g., sunny-meadows)",
}, },
}, },
{ {
name: 'domain', name: "domain",
type: 'text', type: "text",
admin: { admin: {
description: 'Optional custom domain (e.g., sunny-meadows.localhost)', description: "Optional custom domain (e.g., sunny-meadows.localhost)",
}, },
}, },
{ {
name: 'address', name: "address",
type: 'textarea', type: "textarea",
admin: { admin: {
description: 'Physical address of the care home', description: "Physical address of the care home",
}, },
}, },
{ {
name: 'phone', name: "phone",
type: 'text', type: "text",
admin: { admin: {
description: 'Contact phone number', description: "Contact phone number",
}, },
}, },
], ],
} };

View File

@@ -1,35 +1,35 @@
import type { Access } from 'payload' import type { Access } from "payload";
import type { Tenant, User } from '../../../payload-types' import type { Tenant, User } from "../../../payload-types";
import { isSuperAdmin } from '../../../access/isSuperAdmin' import { isSuperAdmin } from "../../../access/isSuperAdmin";
import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs' import { getUserTenantIDs } from "../../../utilities/getUserTenantIDs";
export const createAccess: Access<User> = ({ req }) => { export const createAccess: Access<User> = ({ req }) => {
if (!req.user) { if (!req.user) {
return false return false;
} }
if (isSuperAdmin(req.user)) { if (isSuperAdmin(req.user)) {
return true return true;
} }
if (!isSuperAdmin(req.user) && req.data?.roles?.includes('super-admin')) { if (!isSuperAdmin(req.user) && req.data?.roles?.includes("super-admin")) {
return false return false;
} }
const adminTenantAccessIDs = getUserTenantIDs(req.user, 'admin') const adminTenantAccessIDs = getUserTenantIDs(req.user, "admin");
const requestedTenants: Tenant['id'][] = const requestedTenants: Tenant["id"][] =
req.data?.tenants?.map((t: { tenant: Tenant['id'] }) => t.tenant) ?? [] req.data?.tenants?.map((t: { tenant: Tenant["id"] }) => t.tenant) ?? [];
const hasAccessToAllRequestedTenants = requestedTenants.every((tenantID) => const hasAccessToAllRequestedTenants = requestedTenants.every((tenantID) =>
adminTenantAccessIDs.includes(tenantID), adminTenantAccessIDs.includes(tenantID),
) );
if (hasAccessToAllRequestedTenants) { if (hasAccessToAllRequestedTenants) {
return true return true;
} }
return false return false;
} };

View File

@@ -1,5 +1,11 @@
import { User } from '@/payload-types' import { User } from "@/payload-types";
export const isAccessingSelf = ({ id, user }: { user?: User; id?: string | number }): boolean => { export const isAccessingSelf = ({
return user ? Boolean(user.id === id) : false id,
} user,
}: {
user?: User;
id?: string | number;
}): boolean => {
return user ? Boolean(user.id === id) : false;
};

View File

@@ -1,42 +1,44 @@
import type { User } from '@/payload-types' import type { User } from "@/payload-types";
import type { Access, Where } from 'payload' import type { Access, Where } from "payload";
import { getTenantFromCookie } from '@payloadcms/plugin-multi-tenant/utilities' import { getTenantFromCookie } from "@payloadcms/plugin-multi-tenant/utilities";
import { isSuperAdmin } from '../../../access/isSuperAdmin' import { isSuperAdmin } from "../../../access/isSuperAdmin";
import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs' import { getUserTenantIDs } from "../../../utilities/getUserTenantIDs";
import { isAccessingSelf } from './isAccessingSelf' import { isAccessingSelf } from "./isAccessingSelf";
import { getCollectionIDType } from '@/utilities/getCollectionIDType' import { getCollectionIDType } from "@/utilities/getCollectionIDType";
export const readAccess: Access<User> = ({ req, id }) => { export const readAccess: Access<User> = ({ req, id }) => {
if (!req?.user) { if (!req?.user) {
return false return false;
} }
if (isAccessingSelf({ id, user: req.user })) { if (isAccessingSelf({ id, user: req.user })) {
return true return true;
} }
const superAdmin = isSuperAdmin(req.user) const superAdmin = isSuperAdmin(req.user);
const selectedTenant = getTenantFromCookie( const selectedTenant = getTenantFromCookie(
req.headers, req.headers,
getCollectionIDType({ payload: req.payload, collectionSlug: 'tenants' }), getCollectionIDType({ payload: req.payload, collectionSlug: "tenants" }),
) );
const adminTenantAccessIDs = getUserTenantIDs(req.user, 'admin') const adminTenantAccessIDs = getUserTenantIDs(req.user, "admin");
if (selectedTenant) { if (selectedTenant) {
// If it's a super admin, or they have access to the tenant ID set in cookie // If it's a super admin, or they have access to the tenant ID set in cookie
const hasTenantAccess = adminTenantAccessIDs.some((id) => id === selectedTenant) const hasTenantAccess = adminTenantAccessIDs.some(
(id) => id === selectedTenant,
);
if (superAdmin || hasTenantAccess) { if (superAdmin || hasTenantAccess) {
return { return {
'tenants.tenant': { "tenants.tenant": {
equals: selectedTenant, equals: selectedTenant,
}, },
} };
} }
} }
if (superAdmin) { if (superAdmin) {
return true return true;
} }
return { return {
@@ -47,10 +49,10 @@ export const readAccess: Access<User> = ({ req, id }) => {
}, },
}, },
{ {
'tenants.tenant': { "tenants.tenant": {
in: adminTenantAccessIDs, in: adminTenantAccessIDs,
}, },
}, },
], ],
} as Where } as Where;
} };

View File

@@ -1,18 +1,18 @@
import type { Access } from 'payload' import type { Access } from "payload";
import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs' import { getUserTenantIDs } from "../../../utilities/getUserTenantIDs";
import { isSuperAdmin } from '@/access/isSuperAdmin' import { isSuperAdmin } from "@/access/isSuperAdmin";
import { isAccessingSelf } from './isAccessingSelf' import { isAccessingSelf } from "./isAccessingSelf";
export const updateAndDeleteAccess: Access = ({ req, id }) => { export const updateAndDeleteAccess: Access = ({ req, id }) => {
const { user } = req const { user } = req;
if (!user) { if (!user) {
return false return false;
} }
if (isSuperAdmin(user) || isAccessingSelf({ user, id })) { if (isSuperAdmin(user) || isAccessingSelf({ user, id })) {
return true return true;
} }
/** /**
@@ -24,8 +24,8 @@ export const updateAndDeleteAccess: Access = ({ req, id }) => {
* from their own tenant in the tenants array. * from their own tenant in the tenants array.
*/ */
return { return {
'tenants.tenant': { "tenants.tenant": {
in: getUserTenantIDs(user, 'admin'), in: getUserTenantIDs(user, "admin"),
}, },
} };
} };

View File

@@ -1,30 +1,35 @@
import type { Collection, Endpoint } from 'payload' import type { Collection, Endpoint } from "payload";
import { headersWithCors } from '@payloadcms/next/utilities' import { headersWithCors } from "@payloadcms/next/utilities";
import { APIError, generatePayloadCookie } from 'payload' import { APIError, generatePayloadCookie } from "payload";
// A custom endpoint that can be reached by POST request // A custom endpoint that can be reached by POST request
// at: /api/users/external-users/login // at: /api/users/external-users/login
export const externalUsersLogin: Endpoint = { export const externalUsersLogin: Endpoint = {
handler: async (req) => { handler: async (req) => {
let data: { [key: string]: string } = {} let data: { [key: string]: string } = {};
try { try {
if (typeof req.json === 'function') { if (typeof req.json === "function") {
data = await req.json() data = await req.json();
} }
} catch (_error) { } catch (_error) {
// swallow error, data is already empty object // swallow error, data is already empty object
} }
const { password, tenantSlug, tenantDomain, username } = data const { password, tenantSlug, tenantDomain, username } = data;
if (!username || !password) { if (!username || !password) {
throw new APIError('Username and Password are required for login.', 400, null, true) throw new APIError(
"Username and Password are required for login.",
400,
null,
true,
);
} }
const fullTenant = ( const fullTenant = (
await req.payload.find({ await req.payload.find({
collection: 'tenants', collection: "tenants",
where: tenantDomain where: tenantDomain
? { ? {
domain: { domain: {
@@ -37,10 +42,10 @@ export const externalUsersLogin: Endpoint = {
}, },
}, },
}) })
).docs[0] ).docs[0];
const foundUser = await req.payload.find({ const foundUser = await req.payload.find({
collection: 'users', collection: "users",
where: { where: {
or: [ or: [
{ {
@@ -51,7 +56,7 @@ export const externalUsersLogin: Endpoint = {
}, },
}, },
{ {
'tenants.tenant': { "tenants.tenant": {
equals: fullTenant.id, equals: fullTenant.id,
}, },
}, },
@@ -65,7 +70,7 @@ export const externalUsersLogin: Endpoint = {
}, },
}, },
{ {
'tenants.tenant': { "tenants.tenant": {
equals: fullTenant.id, equals: fullTenant.id,
}, },
}, },
@@ -73,58 +78,63 @@ export const externalUsersLogin: Endpoint = {
}, },
], ],
}, },
}) });
if (foundUser.totalDocs > 0) { if (foundUser.totalDocs > 0) {
try { try {
const loginAttempt = await req.payload.login({ const loginAttempt = await req.payload.login({
collection: 'users', collection: "users",
data: { data: {
email: foundUser.docs[0].email, email: foundUser.docs[0].email,
password, password,
}, },
req, req,
}) });
if (loginAttempt?.token) { if (loginAttempt?.token) {
const collection: Collection = (req.payload.collections as { [key: string]: Collection })[ const collection: Collection = (
'users' req.payload.collections as { [key: string]: Collection }
] )["users"];
const cookie = generatePayloadCookie({ const cookie = generatePayloadCookie({
collectionAuthConfig: collection.config.auth, collectionAuthConfig: collection.config.auth,
cookiePrefix: req.payload.config.cookiePrefix, cookiePrefix: req.payload.config.cookiePrefix,
token: loginAttempt.token, token: loginAttempt.token,
}) });
return Response.json(loginAttempt, { return Response.json(loginAttempt, {
headers: headersWithCors({ headers: headersWithCors({
headers: new Headers({ headers: new Headers({
'Set-Cookie': cookie, "Set-Cookie": cookie,
}), }),
req, req,
}), }),
status: 200, status: 200,
}) });
} }
throw new APIError( throw new APIError(
'Unable to login with the provided username and password.', "Unable to login with the provided username and password.",
400, 400,
null, null,
true, true,
) );
} catch (_e) { } catch (_e) {
throw new APIError( throw new APIError(
'Unable to login with the provided username and password.', "Unable to login with the provided username and password.",
400, 400,
null, null,
true, true,
) );
} }
} }
throw new APIError('Unable to login with the provided username and password.', 400, null, true) throw new APIError(
"Unable to login with the provided username and password.",
400,
null,
true,
);
}, },
method: 'post', method: "post",
path: '/external-users/login', path: "/external-users/login",
} };

View File

@@ -1,15 +1,20 @@
import type { FieldHook, Where } from 'payload' import type { FieldHook, Where } from "payload";
import { ValidationError } from 'payload' import { ValidationError } from "payload";
import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs' import { getUserTenantIDs } from "../../../utilities/getUserTenantIDs";
import { getTenantFromCookie } from '@payloadcms/plugin-multi-tenant/utilities' import { getTenantFromCookie } from "@payloadcms/plugin-multi-tenant/utilities";
import { getCollectionIDType } from '@/utilities/getCollectionIDType' import { getCollectionIDType } from "@/utilities/getCollectionIDType";
export const ensureUniqueUsername: FieldHook = async ({ data: _data, originalDoc, req, value }) => { export const ensureUniqueUsername: FieldHook = async ({
data: _data,
originalDoc,
req,
value,
}) => {
// if value is unchanged, skip validation // if value is unchanged, skip validation
if (originalDoc.username === value) { if (originalDoc.username === value) {
return value return value;
} }
const constraints: Where[] = [ const constraints: Where[] = [
@@ -18,58 +23,58 @@ export const ensureUniqueUsername: FieldHook = async ({ data: _data, originalDoc
equals: value, equals: value,
}, },
}, },
] ];
const selectedTenant = getTenantFromCookie( const selectedTenant = getTenantFromCookie(
req.headers, req.headers,
getCollectionIDType({ payload: req.payload, collectionSlug: 'tenants' }), getCollectionIDType({ payload: req.payload, collectionSlug: "tenants" }),
) );
if (selectedTenant) { if (selectedTenant) {
constraints.push({ constraints.push({
'tenants.tenant': { "tenants.tenant": {
equals: selectedTenant, equals: selectedTenant,
}, },
}) });
} }
const findDuplicateUsers = await req.payload.find({ const findDuplicateUsers = await req.payload.find({
collection: 'users', collection: "users",
where: { where: {
and: constraints, and: constraints,
}, },
}) });
if (findDuplicateUsers.docs.length > 0 && req.user) { if (findDuplicateUsers.docs.length > 0 && req.user) {
const tenantIDs = getUserTenantIDs(req.user) const tenantIDs = getUserTenantIDs(req.user);
// if the user is an admin or has access to more than 1 tenant // if the user is an admin or has access to more than 1 tenant
// provide a more specific error message // provide a more specific error message
if (req.user.roles?.includes('super-admin') || tenantIDs.length > 1) { if (req.user.roles?.includes("super-admin") || tenantIDs.length > 1) {
const attemptedTenantChange = await req.payload.findByID({ const attemptedTenantChange = await req.payload.findByID({
// @ts-expect-error - selectedTenant will match DB ID type // @ts-expect-error - selectedTenant will match DB ID type
id: selectedTenant, id: selectedTenant,
collection: 'tenants', collection: "tenants",
}) });
throw new ValidationError({ throw new ValidationError({
errors: [ errors: [
{ {
message: `The "${attemptedTenantChange.name}" tenant already has a user with the username "${value}". Usernames must be unique per tenant.`, message: `The "${attemptedTenantChange.name}" tenant already has a user with the username "${value}". Usernames must be unique per tenant.`,
path: 'username', path: "username",
}, },
], ],
}) });
} }
throw new ValidationError({ throw new ValidationError({
errors: [ errors: [
{ {
message: `A user with the username ${value} already exists. Usernames must be unique per tenant.`, message: `A user with the username ${value} already exists. Usernames must be unique per tenant.`,
path: 'username', path: "username",
}, },
], ],
}) });
} }
return value return value;
} };

View File

@@ -1,39 +1,42 @@
import type { CollectionAfterLoginHook } from 'payload' import type { CollectionAfterLoginHook } from "payload";
import { mergeHeaders, generateCookie, getCookieExpiration } from 'payload' import { mergeHeaders, generateCookie, getCookieExpiration } from "payload";
export const setCookieBasedOnDomain: CollectionAfterLoginHook = async ({ req, user }) => { export const setCookieBasedOnDomain: CollectionAfterLoginHook = async ({
req,
user,
}) => {
const relatedOrg = await req.payload.find({ const relatedOrg = await req.payload.find({
collection: 'tenants', collection: "tenants",
depth: 0, depth: 0,
limit: 1, limit: 1,
where: { where: {
domain: { domain: {
equals: req.headers.get('host'), equals: req.headers.get("host"),
}, },
}, },
}) });
// If a matching tenant is found, set the 'payload-tenant' cookie // If a matching tenant is found, set the 'payload-tenant' cookie
if (relatedOrg && relatedOrg.docs.length > 0) { if (relatedOrg && relatedOrg.docs.length > 0) {
const tenantCookie = generateCookie({ const tenantCookie = generateCookie({
name: 'payload-tenant', name: "payload-tenant",
expires: getCookieExpiration({ seconds: 7200 }), expires: getCookieExpiration({ seconds: 7200 }),
path: '/', path: "/",
returnCookieAsObject: false, returnCookieAsObject: false,
value: String(relatedOrg.docs[0].id), value: String(relatedOrg.docs[0].id),
}) });
// Merge existing responseHeaders with the new Set-Cookie header // Merge existing responseHeaders with the new Set-Cookie header
const newHeaders = new Headers({ const newHeaders = new Headers({
'Set-Cookie': tenantCookie as string, "Set-Cookie": tenantCookie as string,
}) });
// Ensure you merge existing response headers if they already exist // Ensure you merge existing response headers if they already exist
req.responseHeaders = req.responseHeaders req.responseHeaders = req.responseHeaders
? mergeHeaders(req.responseHeaders, newHeaders) ? mergeHeaders(req.responseHeaders, newHeaders)
: newHeaders : newHeaders;
} }
return user return user;
} };

View File

@@ -1,13 +1,13 @@
import type { CollectionConfig } from 'payload' import type { CollectionConfig } from "payload";
import { createAccess } from './access/create' import { createAccess } from "./access/create";
import { readAccess } from './access/read' import { readAccess } from "./access/read";
import { updateAndDeleteAccess } from './access/updateAndDelete' import { updateAndDeleteAccess } from "./access/updateAndDelete";
import { externalUsersLogin } from './endpoints/externalUsersLogin' import { externalUsersLogin } from "./endpoints/externalUsersLogin";
import { ensureUniqueUsername } from './hooks/ensureUniqueUsername' import { ensureUniqueUsername } from "./hooks/ensureUniqueUsername";
import { isSuperAdmin } from '@/access/isSuperAdmin' import { isSuperAdmin } from "@/access/isSuperAdmin";
import { setCookieBasedOnDomain } from './hooks/setCookieBasedOnDomain' import { setCookieBasedOnDomain } from "./hooks/setCookieBasedOnDomain";
import { tenantsArrayField } from '@payloadcms/plugin-multi-tenant/fields' import { tenantsArrayField } from "@payloadcms/plugin-multi-tenant/fields";
/** /**
* Tenant Roles for Care Home Staff: * Tenant Roles for Care Home Staff:
@@ -16,39 +16,39 @@ import { tenantsArrayField } from '@payloadcms/plugin-multi-tenant/fields'
* - kitchen: Can view orders and mark as prepared * - kitchen: Can view orders and mark as prepared
*/ */
const defaultTenantArrayField = tenantsArrayField({ const defaultTenantArrayField = tenantsArrayField({
tenantsArrayFieldName: 'tenants', tenantsArrayFieldName: "tenants",
tenantsArrayTenantFieldName: 'tenant', tenantsArrayTenantFieldName: "tenant",
tenantsCollectionSlug: 'tenants', tenantsCollectionSlug: "tenants",
arrayFieldAccess: {}, arrayFieldAccess: {},
tenantFieldAccess: {}, tenantFieldAccess: {},
rowFields: [ rowFields: [
{ {
name: 'roles', name: "roles",
type: 'select', type: "select",
defaultValue: ['caregiver'], defaultValue: ["caregiver"],
hasMany: true, hasMany: true,
options: [ options: [
{ label: 'Admin', value: 'admin' }, { label: "Admin", value: "admin" },
{ label: 'Caregiver', value: 'caregiver' }, { label: "Caregiver", value: "caregiver" },
{ label: 'Kitchen', value: 'kitchen' }, { label: "Kitchen", value: "kitchen" },
], ],
required: true, required: true,
admin: { admin: {
description: 'Role(s) for this user within the care home', description: "Role(s) for this user within the care home",
}, },
access: { access: {
update: ({ req }) => { update: ({ req }) => {
const { user } = req const { user } = req;
if (!user) { if (!user) {
return false return false;
} }
// Super admins and tenant admins can update roles // Super admins and tenant admins can update roles
return isSuperAdmin(user) || true return isSuperAdmin(user) || true;
}, },
}, },
}, },
], ],
}) });
/** /**
* Users Collection * Users Collection
@@ -58,7 +58,7 @@ const defaultTenantArrayField = tenantsArrayField({
* - Tenant roles: admin, caregiver, kitchen (per care home) * - Tenant roles: admin, caregiver, kitchen (per care home)
*/ */
const Users: CollectionConfig = { const Users: CollectionConfig = {
slug: 'users', slug: "users",
access: { access: {
create: createAccess, create: createAccess,
delete: updateAndDeleteAccess, delete: updateAndDeleteAccess,
@@ -66,59 +66,59 @@ const Users: CollectionConfig = {
update: updateAndDeleteAccess, update: updateAndDeleteAccess,
}, },
admin: { admin: {
useAsTitle: 'email', useAsTitle: "email",
defaultColumns: ['email', 'roles', 'createdAt'], defaultColumns: ["email", "roles", "createdAt"],
}, },
auth: true, auth: true,
endpoints: [externalUsersLogin], endpoints: [externalUsersLogin],
fields: [ fields: [
{ {
name: 'name', name: "name",
type: 'text', type: "text",
admin: { admin: {
description: 'Full name of the user', description: "Full name of the user",
}, },
}, },
{ {
type: 'text', type: "text",
name: 'password', name: "password",
hidden: true, hidden: true,
access: { access: {
read: () => false, read: () => false,
update: ({ req, id }) => { update: ({ req, id }) => {
const { user } = req const { user } = req;
if (!user) { if (!user) {
return false return false;
} }
if (id === user.id) { if (id === user.id) {
return true return true;
} }
return isSuperAdmin(user) return isSuperAdmin(user);
}, },
}, },
}, },
{ {
admin: { admin: {
position: 'sidebar', position: "sidebar",
description: 'Global system role', description: "Global system role",
}, },
name: 'roles', name: "roles",
type: 'select', type: "select",
defaultValue: ['user'], defaultValue: ["user"],
hasMany: true, hasMany: true,
options: [ options: [
{ label: 'Super Admin', value: 'super-admin' }, { label: "Super Admin", value: "super-admin" },
{ label: 'User', value: 'user' }, { label: "User", value: "user" },
], ],
access: { access: {
update: ({ req }) => { update: ({ req }) => {
return isSuperAdmin(req.user) return isSuperAdmin(req.user);
}, },
}, },
}, },
{ {
name: 'username', name: "username",
type: 'text', type: "text",
hooks: { hooks: {
beforeValidate: [ensureUniqueUsername], beforeValidate: [ensureUniqueUsername],
}, },
@@ -128,14 +128,14 @@ const Users: CollectionConfig = {
...defaultTenantArrayField, ...defaultTenantArrayField,
admin: { admin: {
...(defaultTenantArrayField?.admin || {}), ...(defaultTenantArrayField?.admin || {}),
position: 'sidebar', position: "sidebar",
description: 'Care homes this user has access to', description: "Care homes this user has access to",
}, },
}, },
], ],
hooks: { hooks: {
afterLogin: [setCookieBasedOnDomain], afterLogin: [setCookieBasedOnDomain],
}, },
} };
export default Users export default Users;

View File

@@ -1,16 +1,16 @@
'use client' "use client";
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from "@/components/ui/checkbox";
import { Label } from '@/components/ui/label' import { Label } from "@/components/ui/label";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
interface CheckboxOptionProps { interface CheckboxOptionProps {
id: string id: string;
label: string label: string;
checked: boolean checked: boolean;
onCheckedChange: (checked: boolean) => void onCheckedChange: (checked: boolean) => void;
disabled?: boolean disabled?: boolean;
className?: string className?: string;
} }
export function CheckboxOption({ export function CheckboxOption({
@@ -24,18 +24,20 @@ export function CheckboxOption({
return ( return (
<div <div
className={cn( className={cn(
'flex items-center space-x-3 rounded-lg border p-3 cursor-pointer transition-colors select-none', "flex items-center space-x-3 rounded-lg border p-3 cursor-pointer transition-colors select-none",
checked ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted/50', checked
disabled && 'opacity-50 cursor-not-allowed', ? "border-primary bg-primary/5"
: "border-border hover:bg-muted/50",
disabled && "opacity-50 cursor-not-allowed",
className, className,
)} )}
onClick={() => !disabled && onCheckedChange(!checked)} onClick={() => !disabled && onCheckedChange(!checked)}
role="button" role="button"
tabIndex={disabled ? -1 : 0} tabIndex={disabled ? -1 : 0}
onKeyDown={(e) => { onKeyDown={(e) => {
if (!disabled && (e.key === 'Enter' || e.key === ' ')) { if (!disabled && (e.key === "Enter" || e.key === " ")) {
e.preventDefault() e.preventDefault();
onCheckedChange(!checked) onCheckedChange(!checked);
} }
}} }}
> >
@@ -48,11 +50,14 @@ export function CheckboxOption({
/> />
<Label <Label
htmlFor={id} htmlFor={id}
className={cn('cursor-pointer flex-1 text-sm', disabled && 'cursor-not-allowed')} className={cn(
"cursor-pointer flex-1 text-sm",
disabled && "cursor-not-allowed",
)}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{label} {label}
</Label> </Label>
</div> </div>
) );
} }

View File

@@ -1,23 +1,36 @@
'use client' "use client";
import { type LucideIcon } from 'lucide-react' import { type LucideIcon } from "lucide-react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
interface EmptyStateProps { interface EmptyStateProps {
icon?: LucideIcon icon?: LucideIcon;
title: string title: string;
description?: string description?: string;
action?: React.ReactNode action?: React.ReactNode;
className?: string className?: string;
} }
export function EmptyState({ icon: Icon, title, description, action, className }: EmptyStateProps) { export function EmptyState({
icon: Icon,
title,
description,
action,
className,
}: EmptyStateProps) {
return ( return (
<div className={cn('flex flex-col items-center justify-center p-8 text-center', className)}> <div
className={cn(
"flex flex-col items-center justify-center p-8 text-center",
className,
)}
>
{Icon && <Icon className="h-12 w-12 text-muted-foreground mb-4" />} {Icon && <Icon className="h-12 w-12 text-muted-foreground mb-4" />}
<p className="text-muted-foreground">{title}</p> <p className="text-muted-foreground">{title}</p>
{description && <p className="text-sm text-muted-foreground mt-2">{description}</p>} {description && (
<p className="text-sm text-muted-foreground mt-2">{description}</p>
)}
{action && <div className="mt-4">{action}</div>} {action && <div className="mt-4">{action}</div>}
</div> </div>
) );
} }

View File

@@ -1,36 +1,38 @@
'use client' "use client";
import { Loader2 } from 'lucide-react' import { Loader2 } from "lucide-react";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
interface LoadingSpinnerProps { interface LoadingSpinnerProps {
fullPage?: boolean fullPage?: boolean;
size?: 'sm' | 'md' | 'lg' size?: "sm" | "md" | "lg";
className?: string className?: string;
} }
const sizeClasses = { const sizeClasses = {
sm: 'h-4 w-4', sm: "h-4 w-4",
md: 'h-8 w-8', md: "h-8 w-8",
lg: 'h-12 w-12', lg: "h-12 w-12",
} };
export function LoadingSpinner({ fullPage = false, size = 'md', className }: LoadingSpinnerProps) { export function LoadingSpinner({
fullPage = false,
size = "md",
className,
}: LoadingSpinnerProps) {
const spinner = ( const spinner = (
<Loader2 className={cn('animate-spin text-primary', sizeClasses[size], className)} /> <Loader2
) className={cn("animate-spin text-primary", sizeClasses[size], className)}
/>
);
if (fullPage) { if (fullPage) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-muted/50"> <div className="min-h-screen flex items-center justify-center bg-muted/50">
{spinner} {spinner}
</div> </div>
) );
} }
return ( return <div className="flex items-center justify-center p-8">{spinner}</div>;
<div className="flex items-center justify-center p-8">
{spinner}
</div>
)
} }

View File

@@ -1,38 +1,47 @@
'use client' "use client";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
import { getMealTypeConfig, getMealTypeLabel, type MealType } from '@/lib/constants/meal' import {
getMealTypeConfig,
getMealTypeLabel,
type MealType,
} from "@/lib/constants/meal";
interface MealTypeIconProps { interface MealTypeIconProps {
type: MealType type: MealType;
showLabel?: boolean showLabel?: boolean;
size?: 'sm' | 'md' | 'lg' size?: "sm" | "md" | "lg";
className?: string className?: string;
} }
const sizeClasses = { const sizeClasses = {
sm: 'h-4 w-4', sm: "h-4 w-4",
md: 'h-6 w-6', md: "h-6 w-6",
lg: 'h-10 w-10', lg: "h-10 w-10",
} };
export function MealTypeIcon({ type, showLabel = false, size = 'sm', className }: MealTypeIconProps) { export function MealTypeIcon({
const config = getMealTypeConfig(type) type,
showLabel = false,
size = "sm",
className,
}: MealTypeIconProps) {
const config = getMealTypeConfig(type);
if (!config) { if (!config) {
return showLabel ? <span>{type}</span> : null return showLabel ? <span>{type}</span> : null;
} }
const Icon = config.icon const Icon = config.icon;
if (showLabel) { if (showLabel) {
return ( return (
<div className={cn('flex items-center gap-2', className)}> <div className={cn("flex items-center gap-2", className)}>
<Icon className={cn(sizeClasses[size], config.color)} /> <Icon className={cn(sizeClasses[size], config.color)} />
<span className="font-medium">{getMealTypeLabel(type)}</span> <span className="font-medium">{getMealTypeLabel(type)}</span>
</div> </div>
) );
} }
return <Icon className={cn(sizeClasses[size], config.color, className)} /> return <Icon className={cn(sizeClasses[size], config.color, className)} />;
} }

View File

@@ -1,60 +1,62 @@
'use client' "use client";
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from "@/components/ui/card";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
import { MEAL_TYPES, type MealType } from '@/lib/constants/meal' import { MEAL_TYPES, type MealType } from "@/lib/constants/meal";
interface MealTypeSelectorProps { interface MealTypeSelectorProps {
value: MealType | null value: MealType | null;
onChange: (type: MealType) => void onChange: (type: MealType) => void;
showSublabel?: boolean showSublabel?: boolean;
variant?: 'card' | 'button' variant?: "card" | "button";
className?: string className?: string;
} }
export function MealTypeSelector({ export function MealTypeSelector({
value, value,
onChange, onChange,
showSublabel = false, showSublabel = false,
variant = 'card', variant = "card",
className, className,
}: MealTypeSelectorProps) { }: MealTypeSelectorProps) {
if (variant === 'button') { if (variant === "button") {
return ( return (
<div className={cn('grid grid-cols-3 gap-2', className)}> <div className={cn("grid grid-cols-3 gap-2", className)}>
{MEAL_TYPES.map(({ value: type, label, icon: Icon, color }) => ( {MEAL_TYPES.map(({ value: type, label, icon: Icon, color }) => (
<button <button
key={type} key={type}
type="button" type="button"
className={cn( className={cn(
'flex flex-col items-center justify-center p-4 rounded-lg border-2 transition-colors', "flex flex-col items-center justify-center p-4 rounded-lg border-2 transition-colors",
value === type value === type
? 'border-primary bg-primary/5' ? "border-primary bg-primary/5"
: 'border-border hover:bg-muted', : "border-border hover:bg-muted",
)} )}
onClick={() => onChange(type)} onClick={() => onChange(type)}
> >
<Icon className={cn('h-6 w-6 mb-1', color)} /> <Icon className={cn("h-6 w-6 mb-1", color)} />
<span className="text-sm font-medium">{label}</span> <span className="text-sm font-medium">{label}</span>
</button> </button>
))} ))}
</div> </div>
) );
} }
return ( return (
<div className={cn('grid gap-4 sm:grid-cols-3', className)}> <div className={cn("grid gap-4 sm:grid-cols-3", className)}>
{MEAL_TYPES.map(({ value: type, label, sublabel, icon: Icon, color }) => ( {MEAL_TYPES.map(({ value: type, label, sublabel, icon: Icon, color }) => (
<Card <Card
key={type} key={type}
className={cn( className={cn(
'cursor-pointer transition-colors', "cursor-pointer transition-colors",
value === type ? 'border-primary bg-primary/5' : 'hover:bg-muted/50', value === type
? "border-primary bg-primary/5"
: "hover:bg-muted/50",
)} )}
onClick={() => onChange(type)} onClick={() => onChange(type)}
> >
<CardContent className="flex flex-col items-center justify-center p-6"> <CardContent className="flex flex-col items-center justify-center p-6">
<Icon className={cn('h-10 w-10 mb-2', color)} /> <Icon className={cn("h-10 w-10 mb-2", color)} />
<span className="font-semibold">{label}</span> <span className="font-semibold">{label}</span>
{showSublabel && sublabel && ( {showSublabel && sublabel && (
<span className="text-sm text-muted-foreground">{sublabel}</span> <span className="text-sm text-muted-foreground">{sublabel}</span>
@@ -63,5 +65,5 @@ export function MealTypeSelector({
</Card> </Card>
))} ))}
</div> </div>
) );
} }

View File

@@ -1,22 +1,22 @@
'use client' "use client";
import Link from 'next/link' import Link from "next/link";
import { ArrowLeft } from 'lucide-react' import { ArrowLeft } from "lucide-react";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
interface PageHeaderProps { interface PageHeaderProps {
title: string title: string;
subtitle?: string subtitle?: string;
backHref?: string backHref?: string;
backLabel?: string backLabel?: string;
rightContent?: React.ReactNode rightContent?: React.ReactNode;
} }
export function PageHeader({ export function PageHeader({
title, title,
subtitle, subtitle,
backHref, backHref,
backLabel = 'Back', backLabel = "Back",
rightContent, rightContent,
}: PageHeaderProps) { }: PageHeaderProps) {
return ( return (
@@ -33,11 +33,15 @@ export function PageHeader({
)} )}
<div> <div>
<h1 className="text-xl font-semibold">{title}</h1> <h1 className="text-xl font-semibold">{title}</h1>
{subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>} {subtitle && (
<p className="text-sm text-muted-foreground">{subtitle}</p>
)}
</div> </div>
</div> </div>
{rightContent && <div className="flex items-center gap-2">{rightContent}</div>} {rightContent && (
<div className="flex items-center gap-2">{rightContent}</div>
)}
</div> </div>
</header> </header>
) );
} }

View File

@@ -1,17 +1,17 @@
'use client' "use client";
import { type LucideIcon } from 'lucide-react' import { type LucideIcon } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
interface StatCardProps { interface StatCardProps {
title: string title: string;
value: string | number value: string | number;
description?: string description?: string;
icon?: LucideIcon icon?: LucideIcon;
iconColor?: string iconColor?: string;
dotColor?: string dotColor?: string;
className?: string className?: string;
} }
export function StatCard({ export function StatCard({
@@ -27,13 +27,19 @@ export function StatCard({
<Card className={className}> <Card className={className}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle> <CardTitle className="text-sm font-medium">{title}</CardTitle>
{Icon && <Icon className={cn('h-4 w-4 text-muted-foreground', iconColor)} />} {Icon && (
{!Icon && dotColor && <div className={cn('h-2 w-2 rounded-full', dotColor)} />} <Icon className={cn("h-4 w-4 text-muted-foreground", iconColor)} />
)}
{!Icon && dotColor && (
<div className={cn("h-2 w-2 rounded-full", dotColor)} />
)}
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{value}</div> <div className="text-2xl font-bold">{value}</div>
{description && <p className="text-xs text-muted-foreground">{description}</p>} {description && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
</CardContent> </CardContent>
</Card> </Card>
) );
} }

View File

@@ -1,31 +1,40 @@
'use client' "use client";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
import { getStatusConfig, type OrderStatus } from '@/lib/constants/meal' import { getStatusConfig, type OrderStatus } from "@/lib/constants/meal";
interface StatusBadgeProps { interface StatusBadgeProps {
status: OrderStatus status: OrderStatus;
showIcon?: boolean showIcon?: boolean;
className?: string className?: string;
} }
export function StatusBadge({ status, showIcon = true, className }: StatusBadgeProps) { export function StatusBadge({
const config = getStatusConfig(status) status,
showIcon = true,
className,
}: StatusBadgeProps) {
const config = getStatusConfig(status);
if (!config) { if (!config) {
return <Badge variant="outline">{status}</Badge> return <Badge variant="outline">{status}</Badge>;
} }
const Icon = config.icon const Icon = config.icon;
return ( return (
<Badge <Badge
variant="outline" variant="outline"
className={cn(config.bgColor, config.textColor, config.borderColor, className)} className={cn(
config.bgColor,
config.textColor,
config.borderColor,
className,
)}
> >
{showIcon && <Icon className="mr-1 h-3 w-3" />} {showIcon && <Icon className="mr-1 h-3 w-3" />}
{config.label} {config.label}
</Badge> </Badge>
) );
} }

View File

@@ -1,8 +1,8 @@
export { PageHeader } from './PageHeader' export { PageHeader } from "./PageHeader";
export { LoadingSpinner } from './LoadingSpinner' export { LoadingSpinner } from "./LoadingSpinner";
export { StatusBadge } from './StatusBadge' export { StatusBadge } from "./StatusBadge";
export { MealTypeIcon } from './MealTypeIcon' export { MealTypeIcon } from "./MealTypeIcon";
export { StatCard } from './StatCard' export { StatCard } from "./StatCard";
export { CheckboxOption } from './CheckboxOption' export { CheckboxOption } from "./CheckboxOption";
export { EmptyState } from './EmptyState' export { EmptyState } from "./EmptyState";
export { MealTypeSelector } from './MealTypeSelector' export { MealTypeSelector } from "./MealTypeSelector";

View File

@@ -1,15 +1,15 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/button";
function AlertDialog({ function AlertDialog({
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} /> return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
} }
function AlertDialogTrigger({ function AlertDialogTrigger({
@@ -17,7 +17,7 @@ function AlertDialogTrigger({
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return ( return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} /> <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
) );
} }
function AlertDialogPortal({ function AlertDialogPortal({
@@ -25,7 +25,7 @@ function AlertDialogPortal({
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return ( return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} /> <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
) );
} }
function AlertDialogOverlay({ function AlertDialogOverlay({
@@ -37,11 +37,11 @@ function AlertDialogOverlay({
data-slot="alert-dialog-overlay" data-slot="alert-dialog-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function AlertDialogContent({ function AlertDialogContent({
@@ -55,12 +55,12 @@ function AlertDialogContent({
data-slot="alert-dialog-content" data-slot="alert-dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className className,
)} )}
{...props} {...props}
/> />
</AlertDialogPortal> </AlertDialogPortal>
) );
} }
function AlertDialogHeader({ function AlertDialogHeader({
@@ -73,7 +73,7 @@ function AlertDialogHeader({
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogFooter({ function AlertDialogFooter({
@@ -85,11 +85,11 @@ function AlertDialogFooter({
data-slot="alert-dialog-footer" data-slot="alert-dialog-footer"
className={cn( className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function AlertDialogTitle({ function AlertDialogTitle({
@@ -102,7 +102,7 @@ function AlertDialogTitle({
className={cn("text-lg font-semibold", className)} className={cn("text-lg font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogDescription({ function AlertDialogDescription({
@@ -115,7 +115,7 @@ function AlertDialogDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogAction({ function AlertDialogAction({
@@ -127,7 +127,7 @@ function AlertDialogAction({
className={cn(buttonVariants(), className)} className={cn(buttonVariants(), className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogCancel({ function AlertDialogCancel({
@@ -139,7 +139,7 @@ function AlertDialogCancel({
className={cn(buttonVariants({ variant: "outline" }), className)} className={cn(buttonVariants({ variant: "outline" }), className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -154,4 +154,4 @@ export {
AlertDialogDescription, AlertDialogDescription,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
} };

View File

@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const alertVariants = cva( const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
@@ -16,8 +16,8 @@ const alertVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
function Alert({ function Alert({
className, className,
@@ -31,7 +31,7 @@ function Alert({
className={cn(alertVariants({ variant }), className)} className={cn(alertVariants({ variant }), className)}
{...props} {...props}
/> />
) );
} }
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
@@ -40,11 +40,11 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
data-slot="alert-title" data-slot="alert-title"
className={cn( className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", "col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function AlertDescription({ function AlertDescription({
@@ -56,11 +56,11 @@ function AlertDescription({
data-slot="alert-description" data-slot="alert-description"
className={cn( className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed", "text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Alert, AlertTitle, AlertDescription } export { Alert, AlertTitle, AlertDescription };

View File

@@ -1,8 +1,8 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
@@ -22,8 +22,8 @@ const badgeVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
function Badge({ function Badge({
className, className,
@@ -32,7 +32,7 @@ function Badge({
...props ...props
}: React.ComponentProps<"span"> & }: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) { VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span" const Comp = asChild ? Slot : "span";
return ( return (
<Comp <Comp
@@ -40,7 +40,7 @@ function Badge({
className={cn(badgeVariants({ variant }), className)} className={cn(badgeVariants({ variant }), className)}
{...props} {...props}
/> />
) );
} }
export { Badge, badgeVariants } export { Badge, badgeVariants };

View File

@@ -1,8 +1,8 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
@@ -33,8 +33,8 @@ const buttonVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
) );
function Button({ function Button({
className, className,
@@ -44,9 +44,9 @@ function Button({
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean asChild?: boolean;
}) { }) {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button";
return ( return (
<Comp <Comp
@@ -54,7 +54,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
) );
} }
export { Button, buttonVariants } export { Button, buttonVariants };

View File

@@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) { function Card({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
@@ -8,11 +8,11 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -21,11 +21,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
@@ -35,7 +35,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
className={cn("leading-none font-semibold", className)} className={cn("leading-none font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
@@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<"div">) {
@@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-action" data-slot="card-action"
className={cn( className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end", "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn("px-6", className)} className={cn("px-6", className)}
{...props} {...props}
/> />
) );
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex items-center px-6 [.border-t]:pt-6", className)} className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -89,4 +89,4 @@ export {
CardAction, CardAction,
CardDescription, CardDescription,
CardContent, CardContent,
} };

View File

@@ -1,10 +1,10 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox" import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react" import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Checkbox({ function Checkbox({
className, className,
@@ -15,7 +15,7 @@ function Checkbox({
data-slot="checkbox" data-slot="checkbox"
className={cn( className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className className,
)} )}
{...props} {...props}
> >
@@ -26,7 +26,7 @@ function Checkbox({
<CheckIcon className="size-3.5" /> <CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
) );
} }
export { Checkbox } export { Checkbox };

View File

@@ -0,0 +1,33 @@
"use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -1,33 +1,33 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Dialog({ function Dialog({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) { }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} /> return <DialogPrimitive.Root data-slot="dialog" {...props} />;
} }
function DialogTrigger({ function DialogTrigger({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) { }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
} }
function DialogPortal({ function DialogPortal({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) { }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
} }
function DialogClose({ function DialogClose({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) { }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
} }
function DialogOverlay({ function DialogOverlay({
@@ -39,11 +39,11 @@ function DialogOverlay({
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DialogContent({ function DialogContent({
@@ -52,7 +52,7 @@ function DialogContent({
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<DialogPortal data-slot="dialog-portal"> <DialogPortal data-slot="dialog-portal">
@@ -61,7 +61,7 @@ function DialogContent({
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className className,
)} )}
{...props} {...props}
> >
@@ -77,7 +77,7 @@ function DialogContent({
)} )}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
) );
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -87,7 +87,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} {...props}
/> />
) );
} }
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -96,11 +96,11 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DialogTitle({ function DialogTitle({
@@ -113,7 +113,7 @@ function DialogTitle({
className={cn("text-lg leading-none font-semibold", className)} className={cn("text-lg leading-none font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function DialogDescription({ function DialogDescription({
@@ -126,7 +126,7 @@ function DialogDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -140,4 +140,4 @@ export {
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} };

View File

@@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return ( return (
@@ -11,11 +11,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Input } export { Input };

View File

@@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label" import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Label({ function Label({
className, className,
@@ -14,11 +14,11 @@ function Label({
data-slot="label" data-slot="label"
className={cn( className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Label } export { Label };

View File

@@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress" import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Progress({ function Progress({
className, className,
@@ -15,7 +15,7 @@ function Progress({
data-slot="progress" data-slot="progress"
className={cn( className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", "bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className className,
)} )}
{...props} {...props}
> >
@@ -25,7 +25,7 @@ function Progress({
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/> />
</ProgressPrimitive.Root> </ProgressPrimitive.Root>
) );
} }
export { Progress } export { Progress };

View File

@@ -1,10 +1,10 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { CircleIcon } from "lucide-react" import { CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function RadioGroup({ function RadioGroup({
className, className,
@@ -16,7 +16,7 @@ function RadioGroup({
className={cn("grid gap-3", className)} className={cn("grid gap-3", className)}
{...props} {...props}
/> />
) );
} }
function RadioGroupItem({ function RadioGroupItem({
@@ -28,7 +28,7 @@ function RadioGroupItem({
data-slot="radio-group-item" data-slot="radio-group-item"
className={cn( className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className className,
)} )}
{...props} {...props}
> >
@@ -39,7 +39,7 @@ function RadioGroupItem({
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" /> <CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator> </RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item> </RadioGroupPrimitive.Item>
) );
} }
export { RadioGroup, RadioGroupItem } export { RadioGroup, RadioGroupItem };

View File

@@ -1,27 +1,27 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select" import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Select({ function Select({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) { }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} /> return <SelectPrimitive.Root data-slot="select" {...props} />;
} }
function SelectGroup({ function SelectGroup({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) { }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} /> return <SelectPrimitive.Group data-slot="select-group" {...props} />;
} }
function SelectValue({ function SelectValue({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) { }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} /> return <SelectPrimitive.Value data-slot="select-value" {...props} />;
} }
function SelectTrigger({ function SelectTrigger({
@@ -30,7 +30,7 @@ function SelectTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default" size?: "sm" | "default";
}) { }) {
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
@@ -38,7 +38,7 @@ function SelectTrigger({
data-size={size} data-size={size}
className={cn( className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
> >
@@ -47,7 +47,7 @@ function SelectTrigger({
<ChevronDownIcon className="size-4 opacity-50" /> <ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
) );
} }
function SelectContent({ function SelectContent({
@@ -65,7 +65,7 @@ function SelectContent({
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className className,
)} )}
position={position} position={position}
align={align} align={align}
@@ -76,7 +76,7 @@ function SelectContent({
className={cn( className={cn(
"p-1", "p-1",
position === "popper" && position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1" "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)} )}
> >
{children} {children}
@@ -84,7 +84,7 @@ function SelectContent({
<SelectScrollDownButton /> <SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>
) );
} }
function SelectLabel({ function SelectLabel({
@@ -97,7 +97,7 @@ function SelectLabel({
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props} {...props}
/> />
) );
} }
function SelectItem({ function SelectItem({
@@ -110,7 +110,7 @@ function SelectItem({
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className className,
)} )}
{...props} {...props}
> >
@@ -121,7 +121,7 @@ function SelectItem({
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
) );
} }
function SelectSeparator({ function SelectSeparator({
@@ -134,7 +134,7 @@ function SelectSeparator({
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
) );
} }
function SelectScrollUpButton({ function SelectScrollUpButton({
@@ -146,13 +146,13 @@ function SelectScrollUpButton({
data-slot="select-scroll-up-button" data-slot="select-scroll-up-button"
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className,
)} )}
{...props} {...props}
> >
<ChevronUpIcon className="size-4" /> <ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton> </SelectPrimitive.ScrollUpButton>
) );
} }
function SelectScrollDownButton({ function SelectScrollDownButton({
@@ -164,13 +164,13 @@ function SelectScrollDownButton({
data-slot="select-scroll-down-button" data-slot="select-scroll-down-button"
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className,
)} )}
{...props} {...props}
> >
<ChevronDownIcon className="size-4" /> <ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
) );
} }
export { export {
@@ -184,4 +184,4 @@ export {
SelectSeparator, SelectSeparator,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} };

View File

@@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator" import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Separator({ function Separator({
className, className,
@@ -18,11 +18,11 @@ function Separator({
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px", "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Separator } export { Separator };

View File

@@ -1,31 +1,31 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog" import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} /> return <SheetPrimitive.Root data-slot="sheet" {...props} />;
} }
function SheetTrigger({ function SheetTrigger({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) { }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
} }
function SheetClose({ function SheetClose({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) { }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
} }
function SheetPortal({ function SheetPortal({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) { }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
} }
function SheetOverlay({ function SheetOverlay({
@@ -37,11 +37,11 @@ function SheetOverlay({
data-slot="sheet-overlay" data-slot="sheet-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SheetContent({ function SheetContent({
@@ -50,7 +50,7 @@ function SheetContent({
side = "right", side = "right",
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & { }: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left" side?: "top" | "right" | "bottom" | "left";
}) { }) {
return ( return (
<SheetPortal> <SheetPortal>
@@ -67,7 +67,7 @@ function SheetContent({
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b", "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" && side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t", "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className className,
)} )}
{...props} {...props}
> >
@@ -78,7 +78,7 @@ function SheetContent({
</SheetPrimitive.Close> </SheetPrimitive.Close>
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
) );
} }
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -88,7 +88,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-1.5 p-4", className)} className={cn("flex flex-col gap-1.5 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -98,7 +98,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("mt-auto flex flex-col gap-2 p-4", className)} className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetTitle({ function SheetTitle({
@@ -111,7 +111,7 @@ function SheetTitle({
className={cn("text-foreground font-semibold", className)} className={cn("text-foreground font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function SheetDescription({ function SheetDescription({
@@ -124,7 +124,7 @@ function SheetDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -136,4 +136,4 @@ export {
SheetFooter, SheetFooter,
SheetTitle, SheetTitle,
SheetDescription, SheetDescription,
} };

View File

@@ -1,8 +1,8 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) { function Table({ className, ...props }: React.ComponentProps<"table">) {
return ( return (
@@ -16,7 +16,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
{...props} {...props}
/> />
</div> </div>
) );
} }
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
@@ -26,7 +26,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
className={cn("[&_tr]:border-b", className)} className={cn("[&_tr]:border-b", className)}
{...props} {...props}
/> />
) );
} }
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
@@ -36,7 +36,7 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
className={cn("[&_tr:last-child]:border-0", className)} className={cn("[&_tr:last-child]:border-0", className)}
{...props} {...props}
/> />
) );
} }
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
@@ -45,11 +45,11 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
data-slot="table-footer" data-slot="table-footer"
className={cn( className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableRow({ className, ...props }: React.ComponentProps<"tr">) { function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
@@ -58,11 +58,11 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
data-slot="table-row" data-slot="table-row"
className={cn( className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", "hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableHead({ className, ...props }: React.ComponentProps<"th">) { function TableHead({ className, ...props }: React.ComponentProps<"th">) {
@@ -71,11 +71,11 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
data-slot="table-head" data-slot="table-head"
className={cn( className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableCell({ className, ...props }: React.ComponentProps<"td">) { function TableCell({ className, ...props }: React.ComponentProps<"td">) {
@@ -84,11 +84,11 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
data-slot="table-cell" data-slot="table-cell"
className={cn( className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableCaption({ function TableCaption({
@@ -101,7 +101,7 @@ function TableCaption({
className={cn("text-muted-foreground mt-4 text-sm", className)} className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -113,4 +113,4 @@ export {
TableRow, TableRow,
TableCell, TableCell,
TableCaption, TableCaption,
} };

View File

@@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip" import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function TooltipProvider({ function TooltipProvider({
delayDuration = 0, delayDuration = 0,
@@ -15,7 +15,7 @@ function TooltipProvider({
delayDuration={delayDuration} delayDuration={delayDuration}
{...props} {...props}
/> />
) );
} }
function Tooltip({ function Tooltip({
@@ -25,13 +25,13 @@ function Tooltip({
<TooltipProvider> <TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} /> <TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider> </TooltipProvider>
) );
} }
function TooltipTrigger({ function TooltipTrigger({
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) { }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} /> return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
} }
function TooltipContent({ function TooltipContent({
@@ -47,7 +47,7 @@ function TooltipContent({
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance", "bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className className,
)} )}
{...props} {...props}
> >
@@ -55,7 +55,7 @@ function TooltipContent({
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" /> <TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) );
} }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

92
src/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,92 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import {
type User,
type UserRole,
hasRole,
getPrimaryRole,
getRedirectPath,
getTenantName,
} from "@/lib/auth";
interface UseAuthOptions {
requiredRole?: UserRole;
redirectTo?: string;
}
interface UseAuthReturn {
user: User | null;
loading: boolean;
isAuthenticated: boolean;
tenantName: string;
hasRole: (role: UserRole) => boolean;
logout: () => Promise<void>;
}
export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
const { requiredRole, redirectTo } = options;
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const checkAuth = async () => {
try {
const res = await fetch("/api/users/me", { credentials: "include" });
if (!res.ok) {
router.push(redirectTo || "/login");
return;
}
const data = await res.json();
if (!data.user) {
router.push(redirectTo || "/login");
return;
}
const fetchedUser = data.user as User;
if (requiredRole && !hasRole(fetchedUser, requiredRole)) {
const primaryRole = getPrimaryRole(fetchedUser);
router.push(getRedirectPath(primaryRole));
return;
}
setUser(fetchedUser);
} catch {
router.push(redirectTo || "/login");
} finally {
setLoading(false);
}
};
checkAuth();
}, [router, requiredRole, redirectTo]);
const logout = useCallback(async () => {
await fetch("/api/users/logout", {
method: "POST",
credentials: "include",
});
router.push("/login");
}, [router]);
const checkRole = useCallback(
(role: UserRole) => {
if (!user) return false;
return hasRole(user, role);
},
[user],
);
return {
user,
loading,
isAuthenticated: !!user,
tenantName: user ? getTenantName(user) : "Care Home",
hasRole: checkRole,
logout,
};
}

82
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,82 @@
export type UserRole = "caregiver" | "kitchen" | "admin" | "super-admin";
export interface TenantRole {
tenant: { id: number; name: string } | number;
roles?: string[];
}
export interface User {
id: number;
email: string;
name?: string;
roles?: string[];
tenants?: TenantRole[];
}
export function getUserRoles(user: User): UserRole[] {
const roles: UserRole[] = [];
if (user.roles?.includes("super-admin")) {
roles.push("super-admin", "admin", "caregiver", "kitchen");
return roles;
}
const tenantRoles = user.tenants?.flatMap((t) => t.roles || []) || [];
if (tenantRoles.includes("admin")) {
roles.push("admin", "caregiver", "kitchen");
}
if (tenantRoles.includes("caregiver") && !roles.includes("caregiver")) {
roles.push("caregiver");
}
if (tenantRoles.includes("kitchen") && !roles.includes("kitchen")) {
roles.push("kitchen");
}
return roles;
}
export function hasRole(user: User, role: UserRole): boolean {
return getUserRoles(user).includes(role);
}
export function getPrimaryRole(user: User): UserRole | null {
if (user.roles?.includes("super-admin")) {
return "admin";
}
const tenantRoles = user.tenants?.flatMap((t) => t.roles || []) || [];
if (tenantRoles.includes("admin")) {
return "admin";
}
if (tenantRoles.includes("kitchen")) {
return "kitchen";
}
if (tenantRoles.includes("caregiver")) {
return "caregiver";
}
return null;
}
export function getRedirectPath(role: UserRole | null): string {
switch (role) {
case "kitchen":
return "/kitchen/dashboard";
case "caregiver":
case "admin":
case "super-admin":
return "/caregiver/dashboard";
default:
return "/login";
}
}
export function getTenantName(user: User): string {
const firstTenant = user.tenants?.[0]?.tenant;
if (firstTenant && typeof firstTenant === "object") {
return firstTenant.name;
}
return "Care Home";
}

View File

@@ -1,63 +1,78 @@
export interface OptionItem { export interface OptionItem {
key: string key: string;
label: string label: string;
} }
export interface OptionSection { export interface OptionSection {
title: string title: string;
columns: 1 | 2 | 3 columns: 1 | 2 | 3;
options: OptionItem[] options: OptionItem[];
} }
export interface BreakfastOptions { export interface BreakfastOptions {
accordingToPlan: boolean accordingToPlan: boolean;
bread: { bread: {
breadRoll: boolean breadRoll: boolean;
wholeGrainRoll: boolean wholeGrainRoll: boolean;
greyBread: boolean greyBread: boolean;
wholeGrainBread: boolean wholeGrainBread: boolean;
whiteBread: boolean whiteBread: boolean;
crispbread: boolean crispbread: boolean;
} };
porridge: boolean porridge: boolean;
preparation: { sliced: boolean; spread: boolean } preparation: { sliced: boolean; spread: boolean };
spreads: { spreads: {
butter: boolean butter: boolean;
margarine: boolean margarine: boolean;
jam: boolean jam: boolean;
diabeticJam: boolean diabeticJam: boolean;
honey: boolean honey: boolean;
cheese: boolean cheese: boolean;
quark: boolean quark: boolean;
sausage: boolean sausage: boolean;
} };
beverages: { coffee: boolean; tea: boolean; hotMilk: boolean; coldMilk: boolean } beverages: {
additions: { sugar: boolean; sweetener: boolean; coffeeCreamer: boolean } coffee: boolean;
tea: boolean;
hotMilk: boolean;
coldMilk: boolean;
};
additions: { sugar: boolean; sweetener: boolean; coffeeCreamer: boolean };
} }
export interface LunchOptions { export interface LunchOptions {
portionSize: 'small' | 'large' | 'vegetarian' portionSize: "small" | "large" | "vegetarian";
soup: boolean soup: boolean;
dessert: boolean dessert: boolean;
specialPreparations: { specialPreparations: {
pureedFood: boolean pureedFood: boolean;
pureedMeat: boolean pureedMeat: boolean;
slicedMeat: boolean slicedMeat: boolean;
mashedPotatoes: boolean mashedPotatoes: boolean;
} };
restrictions: { noFish: boolean; fingerFood: boolean; onlySweet: boolean } restrictions: { noFish: boolean; fingerFood: boolean; onlySweet: boolean };
} }
export interface DinnerOptions { export interface DinnerOptions {
accordingToPlan: boolean accordingToPlan: boolean;
bread: { greyBread: boolean; wholeGrainBread: boolean; whiteBread: boolean; crispbread: boolean } bread: {
preparation: { spread: boolean; sliced: boolean } greyBread: boolean;
spreads: { butter: boolean; margarine: boolean } wholeGrainBread: boolean;
soup: boolean whiteBread: boolean;
porridge: boolean crispbread: boolean;
noFish: boolean };
beverages: { tea: boolean; cocoa: boolean; hotMilk: boolean; coldMilk: boolean } preparation: { spread: boolean; sliced: boolean };
additions: { sugar: boolean; sweetener: 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 };
} }
export const DEFAULT_BREAKFAST: BreakfastOptions = { export const DEFAULT_BREAKFAST: BreakfastOptions = {
@@ -84,10 +99,10 @@ export const DEFAULT_BREAKFAST: BreakfastOptions = {
}, },
beverages: { coffee: false, tea: false, hotMilk: false, coldMilk: false }, beverages: { coffee: false, tea: false, hotMilk: false, coldMilk: false },
additions: { sugar: false, sweetener: false, coffeeCreamer: false }, additions: { sugar: false, sweetener: false, coffeeCreamer: false },
} };
export const DEFAULT_LUNCH: LunchOptions = { export const DEFAULT_LUNCH: LunchOptions = {
portionSize: 'large', portionSize: "large",
soup: false, soup: false,
dessert: true, dessert: true,
specialPreparations: { specialPreparations: {
@@ -97,11 +112,16 @@ export const DEFAULT_LUNCH: LunchOptions = {
mashedPotatoes: false, mashedPotatoes: false,
}, },
restrictions: { noFish: false, fingerFood: false, onlySweet: false }, restrictions: { noFish: false, fingerFood: false, onlySweet: false },
} };
export const DEFAULT_DINNER: DinnerOptions = { export const DEFAULT_DINNER: DinnerOptions = {
accordingToPlan: false, accordingToPlan: false,
bread: { greyBread: false, wholeGrainBread: false, whiteBread: false, crispbread: false }, bread: {
greyBread: false,
wholeGrainBread: false,
whiteBread: false,
crispbread: false,
},
preparation: { spread: false, sliced: false }, preparation: { spread: false, sliced: false },
spreads: { butter: false, margarine: false }, spreads: { butter: false, margarine: false },
soup: false, soup: false,
@@ -109,163 +129,163 @@ export const DEFAULT_DINNER: DinnerOptions = {
noFish: false, noFish: false,
beverages: { tea: false, cocoa: false, hotMilk: false, coldMilk: false }, beverages: { tea: false, cocoa: false, hotMilk: false, coldMilk: false },
additions: { sugar: false, sweetener: false }, additions: { sugar: false, sweetener: false },
} };
export const BREAKFAST_CONFIG: Record<string, OptionSection> = { export const BREAKFAST_CONFIG: Record<string, OptionSection> = {
bread: { bread: {
title: 'Bread (Brot)', title: "Bread (Brot)",
columns: 2, columns: 2,
options: [ options: [
{ key: 'breadRoll', label: 'Bread Roll' }, { key: "breadRoll", label: "Bread Roll" },
{ key: 'wholeGrainRoll', label: 'Whole Grain Roll' }, { key: "wholeGrainRoll", label: "Whole Grain Roll" },
{ key: 'greyBread', label: 'Grey Bread' }, { key: "greyBread", label: "Grey Bread" },
{ key: 'wholeGrainBread', label: 'Whole Grain' }, { key: "wholeGrainBread", label: "Whole Grain" },
{ key: 'whiteBread', label: 'White Bread' }, { key: "whiteBread", label: "White Bread" },
{ key: 'crispbread', label: 'Crispbread' }, { key: "crispbread", label: "Crispbread" },
], ],
}, },
preparation: { preparation: {
title: 'Preparation', title: "Preparation",
columns: 3, columns: 3,
options: [ options: [
{ key: 'porridge', label: 'Porridge' }, { key: "porridge", label: "Porridge" },
{ key: 'sliced', label: 'Sliced' }, { key: "sliced", label: "Sliced" },
{ key: 'spread', label: 'Spread' }, { key: "spread", label: "Spread" },
], ],
}, },
spreads: { spreads: {
title: 'Spreads', title: "Spreads",
columns: 2, columns: 2,
options: [ options: [
{ key: 'butter', label: 'Butter' }, { key: "butter", label: "Butter" },
{ key: 'margarine', label: 'Margarine' }, { key: "margarine", label: "Margarine" },
{ key: 'jam', label: 'Jam' }, { key: "jam", label: "Jam" },
{ key: 'diabeticJam', label: 'Diabetic Jam' }, { key: "diabeticJam", label: "Diabetic Jam" },
{ key: 'honey', label: 'Honey' }, { key: "honey", label: "Honey" },
{ key: 'cheese', label: 'Cheese' }, { key: "cheese", label: "Cheese" },
{ key: 'quark', label: 'Quark' }, { key: "quark", label: "Quark" },
{ key: 'sausage', label: 'Sausage' }, { key: "sausage", label: "Sausage" },
], ],
}, },
beverages: { beverages: {
title: 'Beverages', title: "Beverages",
columns: 2, columns: 2,
options: [ options: [
{ key: 'coffee', label: 'Coffee' }, { key: "coffee", label: "Coffee" },
{ key: 'tea', label: 'Tea' }, { key: "tea", label: "Tea" },
{ key: 'hotMilk', label: 'Hot Milk' }, { key: "hotMilk", label: "Hot Milk" },
{ key: 'coldMilk', label: 'Cold Milk' }, { key: "coldMilk", label: "Cold Milk" },
], ],
}, },
additions: { additions: {
title: 'Additions', title: "Additions",
columns: 3, columns: 3,
options: [ options: [
{ key: 'sugar', label: 'Sugar' }, { key: "sugar", label: "Sugar" },
{ key: 'sweetener', label: 'Sweetener' }, { key: "sweetener", label: "Sweetener" },
{ key: 'coffeeCreamer', label: 'Creamer' }, { key: "coffeeCreamer", label: "Creamer" },
], ],
}, },
} };
export const LUNCH_CONFIG = { export const LUNCH_CONFIG = {
portionSizes: [ portionSizes: [
{ value: 'small', label: 'Small' }, { value: "small", label: "Small" },
{ value: 'large', label: 'Large' }, { value: "large", label: "Large" },
{ value: 'vegetarian', label: 'Vegetarian' }, { value: "vegetarian", label: "Vegetarian" },
] as const, ] as const,
mealOptions: { mealOptions: {
title: 'Meal Options', title: "Meal Options",
columns: 2 as const, columns: 2 as const,
options: [ options: [
{ key: 'soup', label: 'Soup' }, { key: "soup", label: "Soup" },
{ key: 'dessert', label: 'Dessert' }, { key: "dessert", label: "Dessert" },
], ],
}, },
specialPreparations: { specialPreparations: {
title: 'Special Preparations', title: "Special Preparations",
columns: 2 as const, columns: 2 as const,
options: [ options: [
{ key: 'pureedFood', label: 'Pureed Food' }, { key: "pureedFood", label: "Pureed Food" },
{ key: 'pureedMeat', label: 'Pureed Meat' }, { key: "pureedMeat", label: "Pureed Meat" },
{ key: 'slicedMeat', label: 'Sliced Meat' }, { key: "slicedMeat", label: "Sliced Meat" },
{ key: 'mashedPotatoes', label: 'Mashed Potatoes' }, { key: "mashedPotatoes", label: "Mashed Potatoes" },
], ],
}, },
restrictions: { restrictions: {
title: 'Restrictions', title: "Restrictions",
columns: 3 as const, columns: 3 as const,
options: [ options: [
{ key: 'noFish', label: 'No Fish' }, { key: "noFish", label: "No Fish" },
{ key: 'fingerFood', label: 'Finger Food' }, { key: "fingerFood", label: "Finger Food" },
{ key: 'onlySweet', label: 'Only Sweet' }, { key: "onlySweet", label: "Only Sweet" },
], ],
}, },
} };
export const DINNER_CONFIG: Record<string, OptionSection> = { export const DINNER_CONFIG: Record<string, OptionSection> = {
bread: { bread: {
title: 'Bread', title: "Bread",
columns: 2, columns: 2,
options: [ options: [
{ key: 'greyBread', label: 'Grey Bread' }, { key: "greyBread", label: "Grey Bread" },
{ key: 'wholeGrainBread', label: 'Whole Grain' }, { key: "wholeGrainBread", label: "Whole Grain" },
{ key: 'whiteBread', label: 'White Bread' }, { key: "whiteBread", label: "White Bread" },
{ key: 'crispbread', label: 'Crispbread' }, { key: "crispbread", label: "Crispbread" },
], ],
}, },
preparation: { preparation: {
title: 'Preparation', title: "Preparation",
columns: 2, columns: 2,
options: [ options: [
{ key: 'spread', label: 'Spread' }, { key: "spread", label: "Spread" },
{ key: 'sliced', label: 'Sliced' }, { key: "sliced", label: "Sliced" },
], ],
}, },
spreads: { spreads: {
title: 'Spreads', title: "Spreads",
columns: 2, columns: 2,
options: [ options: [
{ key: 'butter', label: 'Butter' }, { key: "butter", label: "Butter" },
{ key: 'margarine', label: 'Margarine' }, { key: "margarine", label: "Margarine" },
], ],
}, },
additionalItems: { additionalItems: {
title: 'Additional Items', title: "Additional Items",
columns: 3, columns: 3,
options: [ options: [
{ key: 'soup', label: 'Soup' }, { key: "soup", label: "Soup" },
{ key: 'porridge', label: 'Porridge' }, { key: "porridge", label: "Porridge" },
{ key: 'noFish', label: 'No Fish' }, { key: "noFish", label: "No Fish" },
], ],
}, },
beverages: { beverages: {
title: 'Beverages', title: "Beverages",
columns: 2, columns: 2,
options: [ options: [
{ key: 'tea', label: 'Tea' }, { key: "tea", label: "Tea" },
{ key: 'cocoa', label: 'Cocoa' }, { key: "cocoa", label: "Cocoa" },
{ key: 'hotMilk', label: 'Hot Milk' }, { key: "hotMilk", label: "Hot Milk" },
{ key: 'coldMilk', label: 'Cold Milk' }, { key: "coldMilk", label: "Cold Milk" },
], ],
}, },
additions: { additions: {
title: 'Additions', title: "Additions",
columns: 2, columns: 2,
options: [ options: [
{ key: 'sugar', label: 'Sugar' }, { key: "sugar", label: "Sugar" },
{ key: 'sweetener', label: 'Sweetener' }, { key: "sweetener", label: "Sweetener" },
], ],
}, },
} };
export const getGridColsClass = (cols: 1 | 2 | 3): string => { export const getGridColsClass = (cols: 1 | 2 | 3): string => {
switch (cols) { switch (cols) {
case 1: case 1:
return 'grid-cols-1' return "grid-cols-1";
case 2: case 2:
return 'grid-cols-1 sm:grid-cols-2' return "grid-cols-1 sm:grid-cols-2";
case 3: case 3:
return 'grid-cols-1 sm:grid-cols-3' return "grid-cols-1 sm:grid-cols-3";
}
} }
};

View File

@@ -1,88 +1,119 @@
import { Sunrise, Sun, Moon, Pencil, Send, ChefHat, Check, type LucideIcon } from 'lucide-react' import {
Sunrise,
Sun,
Moon,
Pencil,
Send,
ChefHat,
Check,
type LucideIcon,
} from "lucide-react";
export type MealType = 'breakfast' | 'lunch' | 'dinner' export type MealType = "breakfast" | "lunch" | "dinner";
export type OrderStatus = 'draft' | 'submitted' | 'preparing' | 'completed' export type OrderStatus = "draft" | "submitted" | "preparing" | "completed";
export interface MealTypeConfig { export interface MealTypeConfig {
value: MealType value: MealType;
label: string label: string;
sublabel?: string sublabel?: string;
icon: LucideIcon icon: LucideIcon;
color: string color: string;
} }
export interface StatusConfig { export interface StatusConfig {
value: OrderStatus value: OrderStatus;
label: string label: string;
icon: LucideIcon icon: LucideIcon;
bgColor: string bgColor: string;
textColor: string textColor: string;
borderColor: string borderColor: string;
dotColor: string dotColor: string;
description: string description: string;
} }
export const MEAL_TYPES: MealTypeConfig[] = [ export const MEAL_TYPES: MealTypeConfig[] = [
{ value: 'breakfast', label: 'Breakfast', sublabel: 'Frühstück', icon: Sunrise, color: 'text-orange-500' }, {
{ value: 'lunch', label: 'Lunch', sublabel: 'Mittagessen', icon: Sun, color: 'text-yellow-500' }, value: "breakfast",
{ value: 'dinner', label: 'Dinner', sublabel: 'Abendessen', icon: Moon, color: 'text-indigo-500' }, label: "Breakfast",
] sublabel: "Frühstück",
icon: Sunrise,
color: "text-orange-500",
},
{
value: "lunch",
label: "Lunch",
sublabel: "Mittagessen",
icon: Sun,
color: "text-yellow-500",
},
{
value: "dinner",
label: "Dinner",
sublabel: "Abendessen",
icon: Moon,
color: "text-indigo-500",
},
];
export const ORDER_STATUSES: StatusConfig[] = [ export const ORDER_STATUSES: StatusConfig[] = [
{ {
value: 'draft', value: "draft",
label: 'Draft', label: "Draft",
icon: Pencil, icon: Pencil,
bgColor: 'bg-gray-50', bgColor: "bg-gray-50",
textColor: 'text-gray-700', textColor: "text-gray-700",
borderColor: 'border-gray-200', borderColor: "border-gray-200",
dotColor: 'bg-gray-400', dotColor: "bg-gray-400",
description: 'In progress', description: "In progress",
}, },
{ {
value: 'submitted', value: "submitted",
label: 'Submitted', label: "Submitted",
icon: Send, icon: Send,
bgColor: 'bg-blue-50', bgColor: "bg-blue-50",
textColor: 'text-blue-700', textColor: "text-blue-700",
borderColor: 'border-blue-200', borderColor: "border-blue-200",
dotColor: 'bg-blue-500', dotColor: "bg-blue-500",
description: 'Sent to kitchen', description: "Sent to kitchen",
}, },
{ {
value: 'preparing', value: "preparing",
label: 'Preparing', label: "Preparing",
icon: ChefHat, icon: ChefHat,
bgColor: 'bg-yellow-50', bgColor: "bg-yellow-50",
textColor: 'text-yellow-700', textColor: "text-yellow-700",
borderColor: 'border-yellow-200', borderColor: "border-yellow-200",
dotColor: 'bg-yellow-500', dotColor: "bg-yellow-500",
description: 'Being prepared', description: "Being prepared",
}, },
{ {
value: 'completed', value: "completed",
label: 'Completed', label: "Completed",
icon: Check, icon: Check,
bgColor: 'bg-green-50', bgColor: "bg-green-50",
textColor: 'text-green-700', textColor: "text-green-700",
borderColor: 'border-green-200', borderColor: "border-green-200",
dotColor: 'bg-green-500', dotColor: "bg-green-500",
description: 'Finished', description: "Finished",
}, },
] ];
export const getMealTypeConfig = (type: MealType): MealTypeConfig | undefined => { export const getMealTypeConfig = (
return MEAL_TYPES.find((m) => m.value === type) type: MealType,
} ): MealTypeConfig | undefined => {
return MEAL_TYPES.find((m) => m.value === type);
};
export const getStatusConfig = (status: OrderStatus): StatusConfig | undefined => { export const getStatusConfig = (
return ORDER_STATUSES.find((s) => s.value === status) status: OrderStatus,
} ): StatusConfig | undefined => {
return ORDER_STATUSES.find((s) => s.value === status);
};
export const getMealTypeLabel = (type: MealType): string => { export const getMealTypeLabel = (type: MealType): string => {
return getMealTypeConfig(type)?.label ?? type return getMealTypeConfig(type)?.label ?? type;
} };
export const getStatusLabel = (status: OrderStatus): string => { export const getStatusLabel = (status: OrderStatus): string => {
return getStatusConfig(status)?.label ?? status return getStatusConfig(status)?.label ?? status;
} };

View File

@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }

View File

@@ -69,12 +69,8 @@
"name": "users_roles_parent_fk", "name": "users_roles_parent_fk",
"tableFrom": "users_roles", "tableFrom": "users_roles",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["parent_id"],
"parent_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -152,12 +148,8 @@
"name": "users_tenants_roles_parent_fk", "name": "users_tenants_roles_parent_fk",
"tableFrom": "users_tenants_roles", "tableFrom": "users_tenants_roles",
"tableTo": "users_tenants", "tableTo": "users_tenants",
"columnsFrom": [ "columnsFrom": ["parent_id"],
"parent_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -249,12 +241,8 @@
"name": "users_tenants_tenant_id_tenants_id_fk", "name": "users_tenants_tenant_id_tenants_id_fk",
"tableFrom": "users_tenants", "tableFrom": "users_tenants",
"tableTo": "tenants", "tableTo": "tenants",
"columnsFrom": [ "columnsFrom": ["tenant_id"],
"tenant_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "set null", "onDelete": "set null",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -262,12 +250,8 @@
"name": "users_tenants_parent_id_fk", "name": "users_tenants_parent_id_fk",
"tableFrom": "users_tenants", "tableFrom": "users_tenants",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["_parent_id"],
"_parent_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -350,12 +334,8 @@
"name": "users_sessions_parent_id_fk", "name": "users_sessions_parent_id_fk",
"tableFrom": "users_sessions", "tableFrom": "users_sessions",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["_parent_id"],
"_parent_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -778,12 +758,8 @@
"name": "residents_tenant_id_tenants_id_fk", "name": "residents_tenant_id_tenants_id_fk",
"tableFrom": "residents", "tableFrom": "residents",
"tableTo": "tenants", "tableTo": "tenants",
"columnsFrom": [ "columnsFrom": ["tenant_id"],
"tenant_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "set null", "onDelete": "set null",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -989,12 +965,8 @@
"name": "meal_orders_tenant_id_tenants_id_fk", "name": "meal_orders_tenant_id_tenants_id_fk",
"tableFrom": "meal_orders", "tableFrom": "meal_orders",
"tableTo": "tenants", "tableTo": "tenants",
"columnsFrom": [ "columnsFrom": ["tenant_id"],
"tenant_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "set null", "onDelete": "set null",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1002,12 +974,8 @@
"name": "meal_orders_created_by_id_users_id_fk", "name": "meal_orders_created_by_id_users_id_fk",
"tableFrom": "meal_orders", "tableFrom": "meal_orders",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["created_by_id"],
"created_by_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "set null", "onDelete": "set null",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1597,12 +1565,8 @@
"name": "meals_tenant_id_tenants_id_fk", "name": "meals_tenant_id_tenants_id_fk",
"tableFrom": "meals", "tableFrom": "meals",
"tableTo": "tenants", "tableTo": "tenants",
"columnsFrom": [ "columnsFrom": ["tenant_id"],
"tenant_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "set null", "onDelete": "set null",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1610,12 +1574,8 @@
"name": "meals_order_id_meal_orders_id_fk", "name": "meals_order_id_meal_orders_id_fk",
"tableFrom": "meals", "tableFrom": "meals",
"tableTo": "meal_orders", "tableTo": "meal_orders",
"columnsFrom": [ "columnsFrom": ["order_id"],
"order_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "set null", "onDelete": "set null",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1623,12 +1583,8 @@
"name": "meals_resident_id_residents_id_fk", "name": "meals_resident_id_residents_id_fk",
"tableFrom": "meals", "tableFrom": "meals",
"tableTo": "residents", "tableTo": "residents",
"columnsFrom": [ "columnsFrom": ["resident_id"],
"resident_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "set null", "onDelete": "set null",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1636,12 +1592,8 @@
"name": "meals_form_image_id_media_id_fk", "name": "meals_form_image_id_media_id_fk",
"tableFrom": "meals", "tableFrom": "meals",
"tableTo": "media", "tableTo": "media",
"columnsFrom": [ "columnsFrom": ["form_image_id"],
"form_image_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "set null", "onDelete": "set null",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1649,12 +1601,8 @@
"name": "meals_created_by_id_users_id_fk", "name": "meals_created_by_id_users_id_fk",
"tableFrom": "meals", "tableFrom": "meals",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["created_by_id"],
"created_by_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "set null", "onDelete": "set null",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -2251,12 +2199,8 @@
"name": "payload_locked_documents_rels_parent_fk", "name": "payload_locked_documents_rels_parent_fk",
"tableFrom": "payload_locked_documents_rels", "tableFrom": "payload_locked_documents_rels",
"tableTo": "payload_locked_documents", "tableTo": "payload_locked_documents",
"columnsFrom": [ "columnsFrom": ["parent_id"],
"parent_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -2264,12 +2208,8 @@
"name": "payload_locked_documents_rels_users_fk", "name": "payload_locked_documents_rels_users_fk",
"tableFrom": "payload_locked_documents_rels", "tableFrom": "payload_locked_documents_rels",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["users_id"],
"users_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -2277,12 +2217,8 @@
"name": "payload_locked_documents_rels_tenants_fk", "name": "payload_locked_documents_rels_tenants_fk",
"tableFrom": "payload_locked_documents_rels", "tableFrom": "payload_locked_documents_rels",
"tableTo": "tenants", "tableTo": "tenants",
"columnsFrom": [ "columnsFrom": ["tenants_id"],
"tenants_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -2290,12 +2226,8 @@
"name": "payload_locked_documents_rels_residents_fk", "name": "payload_locked_documents_rels_residents_fk",
"tableFrom": "payload_locked_documents_rels", "tableFrom": "payload_locked_documents_rels",
"tableTo": "residents", "tableTo": "residents",
"columnsFrom": [ "columnsFrom": ["residents_id"],
"residents_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -2303,12 +2235,8 @@
"name": "payload_locked_documents_rels_meal_orders_fk", "name": "payload_locked_documents_rels_meal_orders_fk",
"tableFrom": "payload_locked_documents_rels", "tableFrom": "payload_locked_documents_rels",
"tableTo": "meal_orders", "tableTo": "meal_orders",
"columnsFrom": [ "columnsFrom": ["meal_orders_id"],
"meal_orders_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -2316,12 +2244,8 @@
"name": "payload_locked_documents_rels_meals_fk", "name": "payload_locked_documents_rels_meals_fk",
"tableFrom": "payload_locked_documents_rels", "tableFrom": "payload_locked_documents_rels",
"tableTo": "meals", "tableTo": "meals",
"columnsFrom": [ "columnsFrom": ["meals_id"],
"meals_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -2329,12 +2253,8 @@
"name": "payload_locked_documents_rels_media_fk", "name": "payload_locked_documents_rels_media_fk",
"tableFrom": "payload_locked_documents_rels", "tableFrom": "payload_locked_documents_rels",
"tableTo": "media", "tableTo": "media",
"columnsFrom": [ "columnsFrom": ["media_id"],
"media_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -2538,12 +2458,8 @@
"name": "payload_preferences_rels_parent_fk", "name": "payload_preferences_rels_parent_fk",
"tableFrom": "payload_preferences_rels", "tableFrom": "payload_preferences_rels",
"tableTo": "payload_preferences", "tableTo": "payload_preferences",
"columnsFrom": [ "columnsFrom": ["parent_id"],
"parent_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -2551,12 +2467,8 @@
"name": "payload_preferences_rels_users_fk", "name": "payload_preferences_rels_users_fk",
"tableFrom": "payload_preferences_rels", "tableFrom": "payload_preferences_rels",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["users_id"],
"users_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -2648,65 +2560,37 @@
"public.enum_users_roles": { "public.enum_users_roles": {
"name": "enum_users_roles", "name": "enum_users_roles",
"schema": "public", "schema": "public",
"values": [ "values": ["super-admin", "user"]
"super-admin",
"user"
]
}, },
"public.enum_users_tenants_roles": { "public.enum_users_tenants_roles": {
"name": "enum_users_tenants_roles", "name": "enum_users_tenants_roles",
"schema": "public", "schema": "public",
"values": [ "values": ["admin", "caregiver", "kitchen"]
"admin",
"caregiver",
"kitchen"
]
}, },
"public.enum_meal_orders_meal_type": { "public.enum_meal_orders_meal_type": {
"name": "enum_meal_orders_meal_type", "name": "enum_meal_orders_meal_type",
"schema": "public", "schema": "public",
"values": [ "values": ["breakfast", "lunch", "dinner"]
"breakfast",
"lunch",
"dinner"
]
}, },
"public.enum_meal_orders_status": { "public.enum_meal_orders_status": {
"name": "enum_meal_orders_status", "name": "enum_meal_orders_status",
"schema": "public", "schema": "public",
"values": [ "values": ["draft", "submitted", "preparing", "completed"]
"draft",
"submitted",
"preparing",
"completed"
]
}, },
"public.enum_meals_meal_type": { "public.enum_meals_meal_type": {
"name": "enum_meals_meal_type", "name": "enum_meals_meal_type",
"schema": "public", "schema": "public",
"values": [ "values": ["breakfast", "lunch", "dinner"]
"breakfast",
"lunch",
"dinner"
]
}, },
"public.enum_meals_status": { "public.enum_meals_status": {
"name": "enum_meals_status", "name": "enum_meals_status",
"schema": "public", "schema": "public",
"values": [ "values": ["pending", "preparing", "prepared"]
"pending",
"preparing",
"prepared"
]
}, },
"public.enum_meals_lunch_portion_size": { "public.enum_meals_lunch_portion_size": {
"name": "enum_meals_lunch_portion_size", "name": "enum_meals_lunch_portion_size",
"schema": "public", "schema": "public",
"values": [ "values": ["small", "large", "vegetarian"]
"small",
"large",
"vegetarian"
]
} }
}, },
"schemas": {}, "schemas": {},

View File

@@ -1,6 +1,6 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> { export async function up({ db, payload: _payload, req: _req }: MigrateUpArgs): Promise<void> {
await db.execute(sql` await db.execute(sql`
CREATE TYPE "public"."enum_users_roles" AS ENUM('super-admin', 'user'); CREATE TYPE "public"."enum_users_roles" AS ENUM('super-admin', 'user');
CREATE TYPE "public"."enum_users_tenants_roles" AS ENUM('admin', 'caregiver', 'kitchen'); CREATE TYPE "public"."enum_users_tenants_roles" AS ENUM('admin', 'caregiver', 'kitchen');
@@ -330,10 +330,14 @@ export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
CREATE INDEX "payload_preferences_rels_path_idx" ON "payload_preferences_rels" USING btree ("path"); CREATE INDEX "payload_preferences_rels_path_idx" ON "payload_preferences_rels" USING btree ("path");
CREATE INDEX "payload_preferences_rels_users_id_idx" ON "payload_preferences_rels" USING btree ("users_id"); CREATE INDEX "payload_preferences_rels_users_id_idx" ON "payload_preferences_rels" USING btree ("users_id");
CREATE INDEX "payload_migrations_updated_at_idx" ON "payload_migrations" USING btree ("updated_at"); CREATE INDEX "payload_migrations_updated_at_idx" ON "payload_migrations" USING btree ("updated_at");
CREATE INDEX "payload_migrations_created_at_idx" ON "payload_migrations" USING btree ("created_at");`) CREATE INDEX "payload_migrations_created_at_idx" ON "payload_migrations" USING btree ("created_at");`);
} }
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> { export async function down({
db,
payload: _payload,
req: _req,
}: MigrateDownArgs): Promise<void> {
await db.execute(sql` await db.execute(sql`
DROP TABLE "users_roles" CASCADE; DROP TABLE "users_roles" CASCADE;
DROP TABLE "users_tenants_roles" CASCADE; DROP TABLE "users_tenants_roles" CASCADE;
@@ -357,5 +361,5 @@ export async function down({ db, payload, req }: MigrateDownArgs): Promise<void>
DROP TYPE "public"."enum_meal_orders_status"; DROP TYPE "public"."enum_meal_orders_status";
DROP TYPE "public"."enum_meals_meal_type"; DROP TYPE "public"."enum_meals_meal_type";
DROP TYPE "public"."enum_meals_status"; DROP TYPE "public"."enum_meals_status";
DROP TYPE "public"."enum_meals_lunch_portion_size";`) DROP TYPE "public"."enum_meals_lunch_portion_size";`);
} }

View File

@@ -1,9 +1,9 @@
import * as migration_20251202_123751 from './20251202_123751'; import * as migration_20251202_123751 from "./20251202_123751";
export const migrations = [ export const migrations = [
{ {
up: migration_20251202_123751.up, up: migration_20251202_123751.up,
down: migration_20251202_123751.down, down: migration_20251202_123751.down,
name: '20251202_123751' name: "20251202_123751",
}, },
]; ];

View File

@@ -1,24 +1,24 @@
import { postgresAdapter } from '@payloadcms/db-postgres' import { postgresAdapter } from "@payloadcms/db-postgres";
import { lexicalEditor } from '@payloadcms/richtext-lexical' import { lexicalEditor } from "@payloadcms/richtext-lexical";
import path from 'path' import path from "path";
import { buildConfig } from 'payload' import { buildConfig } from "payload";
import { fileURLToPath } from 'url' import { fileURLToPath } from "url";
import { s3Storage } from '@payloadcms/storage-s3' import { s3Storage } from "@payloadcms/storage-s3";
import { Tenants } from './collections/Tenants' import { Tenants } from "./collections/Tenants";
import Users from './collections/Users' import Users from "./collections/Users";
import { Residents } from './collections/Residents' import { Residents } from "./collections/Residents";
import { MealOrders } from './collections/MealOrders' import { MealOrders } from "./collections/MealOrders";
import { Meals } from './collections/Meals' import { Meals } from "./collections/Meals";
import { Media } from './collections/Media' import { Media } from "./collections/Media";
import { multiTenantPlugin } from '@payloadcms/plugin-multi-tenant' import { multiTenantPlugin } from "@payloadcms/plugin-multi-tenant";
import { isSuperAdmin } from './access/isSuperAdmin' import { isSuperAdmin } from "./access/isSuperAdmin";
import type { Config } from './payload-types' import type { Config } from "./payload-types";
import { getUserTenantIDs } from './utilities/getUserTenantIDs' import { getUserTenantIDs } from "./utilities/getUserTenantIDs";
import { seed } from './seed' import { seed } from "./seed";
const filename = fileURLToPath(import.meta.url) const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename) const dirname = path.dirname(filename);
// Use PostgreSQL when DATABASE_URI is set, SQLite only for local development // Use PostgreSQL when DATABASE_URI is set, SQLite only for local development
// Migration commands: // Migration commands:
@@ -30,41 +30,42 @@ const dirname = path.dirname(filename)
const getDatabaseAdapter = async () => { const getDatabaseAdapter = async () => {
if (process.env.DATABASE_URI) { if (process.env.DATABASE_URI) {
// Conditionally import migrations only when using PostgreSQL // Conditionally import migrations only when using PostgreSQL
const { migrations } = await import('./migrations') const { migrations } = await import("./migrations");
return postgresAdapter({ return postgresAdapter({
pool: { pool: {
connectionString: process.env.DATABASE_URI, connectionString: process.env.DATABASE_URI,
}, },
// Use migration files from src/migrations/ instead of auto-push // Use migration files from src/migrations/ instead of auto-push
push: false, push: false,
migrationDir: path.resolve(dirname, 'migrations'), migrationDir: path.resolve(dirname, "migrations"),
prodMigrations: migrations, prodMigrations: migrations,
}) });
} }
// Only load SQLite in development (no DATABASE_URI set) // Only load SQLite in development (no DATABASE_URI set)
// Dynamic import to avoid loading libsql in production/Docker // Dynamic import to avoid loading libsql in production/Docker
const { sqliteAdapter } = await import('@payloadcms/db-sqlite') const { sqliteAdapter } = await import("@payloadcms/db-sqlite");
return sqliteAdapter({ return sqliteAdapter({
client: { client: {
url: 'file:./meal-planner.db', url: "file:./meal-planner.db",
}, },
// Use push mode for SQLite in development (auto-sync schema) // Use push mode for SQLite in development (auto-sync schema)
push: true, push: true,
}) });
} };
// eslint-disable-next-line no-restricted-exports // eslint-disable-next-line no-restricted-exports
export default buildConfig({ export default buildConfig({
admin: { admin: {
user: 'users', user: "users",
meta: { meta: {
titleSuffix: '- Meal Planner', titleSuffix: "- Meal Planner",
}, },
components: { components: {
views: { views: {
kitchenDashboard: { kitchenDashboard: {
Component: '@/app/(payload)/admin/views/KitchenDashboard#KitchenDashboard', Component:
path: '/kitchen-dashboard', "@/app/(payload)/admin/views/KitchenDashboard#KitchenDashboard",
path: "/kitchen-dashboard",
}, },
}, },
}, },
@@ -73,28 +74,28 @@ export default buildConfig({
db: await getDatabaseAdapter(), db: await getDatabaseAdapter(),
onInit: async (payload) => { onInit: async (payload) => {
// Run migrations automatically on startup (for Docker/production) // Run migrations automatically on startup (for Docker/production)
payload.logger.info('Running database migrations...') payload.logger.info("Running database migrations...");
try { try {
await payload.db.migrate() await payload.db.migrate();
payload.logger.info('Migrations completed successfully') payload.logger.info("Migrations completed successfully");
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err);
payload.logger.error(`Migration failed: ${message}`) payload.logger.error(`Migration failed: ${message}`);
// Don't throw - migrations may already be applied // Don't throw - migrations may already be applied
} }
// Seed database if SEED_DB is set // Seed database if SEED_DB is set
if (process.env.SEED_DB) { if (process.env.SEED_DB) {
await seed(payload) await seed(payload);
} }
}, },
editor: lexicalEditor({}), editor: lexicalEditor({}),
graphQL: { graphQL: {
schemaOutputFile: path.resolve(dirname, 'generated-schema.graphql'), schemaOutputFile: path.resolve(dirname, "generated-schema.graphql"),
}, },
secret: process.env.PAYLOAD_SECRET as string, secret: process.env.PAYLOAD_SECRET as string,
typescript: { typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'), outputFile: path.resolve(dirname, "payload-types.ts"),
}, },
plugins: [ plugins: [
// Conditionally add S3 storage when MINIO_ENDPOINT is set (Docker environment) // Conditionally add S3 storage when MINIO_ENDPOINT is set (Docker environment)
@@ -104,13 +105,13 @@ export default buildConfig({
collections: { collections: {
media: true, media: true,
}, },
bucket: process.env.S3_BUCKET || 'meal-planner', bucket: process.env.S3_BUCKET || "meal-planner",
config: { config: {
endpoint: process.env.MINIO_ENDPOINT, endpoint: process.env.MINIO_ENDPOINT,
region: process.env.S3_REGION || 'us-east-1', region: process.env.S3_REGION || "us-east-1",
credentials: { credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID || '', accessKeyId: process.env.S3_ACCESS_KEY_ID || "",
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || '', secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || "",
}, },
forcePathStyle: true, // Required for MinIO forcePathStyle: true, // Required for MinIO
}, },
@@ -121,7 +122,7 @@ export default buildConfig({
collections: { collections: {
// Enable multi-tenancy for residents, meal orders, and meals // Enable multi-tenancy for residents, meal orders, and meals
residents: {}, residents: {},
'meal-orders': {}, "meal-orders": {},
meals: {}, meals: {},
}, },
tenantField: { tenantField: {
@@ -129,9 +130,9 @@ export default buildConfig({
read: () => true, read: () => true,
update: ({ req }) => { update: ({ req }) => {
if (isSuperAdmin(req.user)) { if (isSuperAdmin(req.user)) {
return true return true;
} }
return getUserTenantIDs(req.user).length > 0 return getUserTenantIDs(req.user).length > 0;
}, },
}, },
}, },
@@ -141,4 +142,4 @@ export default buildConfig({
userHasAccessToAllTenants: (user) => isSuperAdmin(user), userHasAccessToAllTenants: (user) => isSuperAdmin(user),
}), }),
], ],
}) });

View File

@@ -1,5 +1,5 @@
import type { Payload } from 'payload' import type { Payload } from "payload";
import { formatISO } from 'date-fns' import { formatISO } from "date-fns";
/** /**
* Seed script for the Meal Planner application * Seed script for the Meal Planner application
@@ -13,32 +13,32 @@ import { formatISO } from 'date-fns'
export const seed = async (payload: Payload): Promise<void> => { export const seed = async (payload: Payload): Promise<void> => {
// Check if already seeded // Check if already seeded
const existingResidents = await payload.find({ const existingResidents = await payload.find({
collection: 'residents', collection: "residents",
limit: 1, limit: 1,
}) });
if (existingResidents.totalDocs > 0) { if (existingResidents.totalDocs > 0) {
payload.logger.info('Database already seeded, skipping...') payload.logger.info("Database already seeded, skipping...");
return return;
} }
payload.logger.info('Seeding database...') payload.logger.info("Seeding database...");
// ============================================ // ============================================
// CREATE CARE HOME (TENANT) // CREATE CARE HOME (TENANT)
// ============================================ // ============================================
const careHome = await payload.create({ const careHome = await payload.create({
collection: 'tenants', collection: "tenants",
data: { data: {
name: 'Sunny Meadows Care Home', name: "Sunny Meadows Care Home",
slug: 'sunny-meadows', slug: "sunny-meadows",
domain: 'sunny-meadows.localhost', domain: "sunny-meadows.localhost",
address: 'Sonnenweg 123\n12345 Musterstadt\nGermany', address: "Sonnenweg 123\n12345 Musterstadt\nGermany",
phone: '+49 123 456 7890', phone: "+49 123 456 7890",
}, },
}) });
payload.logger.info(`Created care home: ${careHome.name}`) payload.logger.info(`Created care home: ${careHome.name}`);
// ============================================ // ============================================
// CREATE USERS // CREATE USERS
@@ -46,210 +46,211 @@ export const seed = async (payload: Payload): Promise<void> => {
// Super Admin (can access all care homes) // Super Admin (can access all care homes)
await payload.create({ await payload.create({
collection: 'users', collection: "users",
data: { data: {
email: 'admin@example.com', email: "admin@example.com",
password: 'test', password: "test",
name: 'System Administrator', name: "System Administrator",
roles: ['super-admin'], roles: ["super-admin"],
}, },
}) });
payload.logger.info('Created admin user: admin@example.com') payload.logger.info("Created admin user: admin@example.com");
// Caregiver (can create meal orders) // Caregiver (can create meal orders)
const caregiver = await payload.create({ const caregiver = await payload.create({
collection: 'users', collection: "users",
data: { data: {
email: 'caregiver@example.com', email: "caregiver@example.com",
password: 'test', password: "test",
name: 'Maria Schmidt', name: "Maria Schmidt",
roles: ['user'], roles: ["user"],
tenants: [ tenants: [
{ {
tenant: careHome.id, tenant: careHome.id,
roles: ['caregiver'], roles: ["caregiver"],
}, },
], ],
}, },
}) });
payload.logger.info('Created caregiver user: caregiver@example.com') payload.logger.info("Created caregiver user: caregiver@example.com");
// Kitchen Staff (can view orders and mark as prepared) // Kitchen Staff (can view orders and mark as prepared)
await payload.create({ await payload.create({
collection: 'users', collection: "users",
data: { data: {
email: 'kitchen@example.com', email: "kitchen@example.com",
password: 'test', password: "test",
name: 'Hans Weber', name: "Hans Weber",
roles: ['user'], roles: ["user"],
tenants: [ tenants: [
{ {
tenant: careHome.id, tenant: careHome.id,
roles: ['kitchen'], roles: ["kitchen"],
}, },
], ],
}, },
}) });
payload.logger.info('Created kitchen user: kitchen@example.com') payload.logger.info("Created kitchen user: kitchen@example.com");
// ============================================ // ============================================
// CREATE RESIDENTS // CREATE RESIDENTS
// ============================================ // ============================================
const residentsData = [ const residentsData = [
{ {
name: 'Hans Mueller', name: "Hans Mueller",
room: '101', room: "101",
table: '1', table: "1",
station: 'Station A', station: "Station A",
highCaloric: false, highCaloric: false,
aversions: '', aversions: "",
notes: '', notes: "",
}, },
{ {
name: 'Ingrid Schmidt', name: "Ingrid Schmidt",
room: '102', room: "102",
table: '1', table: "1",
station: 'Station A', station: "Station A",
highCaloric: true, highCaloric: true,
aversions: 'Keine Nüsse (no nuts)', aversions: "Keine Nüsse (no nuts)",
notes: 'Prefers soft foods', notes: "Prefers soft foods",
}, },
{ {
name: 'Wilhelm Bauer', name: "Wilhelm Bauer",
room: '103', room: "103",
table: '2', table: "2",
station: 'Station A', station: "Station A",
highCaloric: false, highCaloric: false,
aversions: 'Kein Fisch (no fish)', aversions: "Kein Fisch (no fish)",
notes: '', notes: "",
}, },
{ {
name: 'Gertrude Fischer', name: "Gertrude Fischer",
room: '104', room: "104",
table: '2', table: "2",
station: 'Station A', station: "Station A",
highCaloric: false, highCaloric: false,
aversions: '', aversions: "",
notes: 'Diabetic - use sugar-free options', notes: "Diabetic - use sugar-free options",
}, },
{ {
name: 'Karl Hoffmann', name: "Karl Hoffmann",
room: '105', room: "105",
table: '3', table: "3",
station: 'Station B', station: "Station B",
highCaloric: true, highCaloric: true,
aversions: 'Keine Milchprodukte (no dairy)', aversions: "Keine Milchprodukte (no dairy)",
notes: 'Lactose intolerant', notes: "Lactose intolerant",
}, },
{ {
name: 'Elisabeth Schulz', name: "Elisabeth Schulz",
room: '106', room: "106",
table: '3', table: "3",
station: 'Station B', station: "Station B",
highCaloric: false, highCaloric: false,
aversions: '', aversions: "",
notes: '', notes: "",
}, },
{ {
name: 'Friedrich Wagner', name: "Friedrich Wagner",
room: '107', room: "107",
table: '4', table: "4",
station: 'Station B', station: "Station B",
highCaloric: false, highCaloric: false,
aversions: 'Kein Schweinefleisch (no pork)', aversions: "Kein Schweinefleisch (no pork)",
notes: '', notes: "",
}, },
{ {
name: 'Helga Meyer', name: "Helga Meyer",
room: '108', room: "108",
table: '4', table: "4",
station: 'Station B', station: "Station B",
highCaloric: true, highCaloric: true,
aversions: '', aversions: "",
notes: 'Requires pureed food', notes: "Requires pureed food",
}, },
] ];
const residents: Array<{ id: number; name: string }> = [] const residents: Array<{ id: number; name: string }> = [];
for (const residentData of residentsData) { for (const residentData of residentsData) {
const resident = await payload.create({ const resident = await payload.create({
collection: 'residents', collection: "residents",
data: { data: {
...residentData, ...residentData,
active: true, active: true,
tenant: careHome.id, tenant: careHome.id,
}, },
}) });
residents.push({ id: resident.id, name: resident.name }) residents.push({ id: resident.id, name: resident.name });
} }
payload.logger.info(`Created ${residents.length} residents`) payload.logger.info(`Created ${residents.length} residents`);
// ============================================ // ============================================
// CREATE MEAL ORDERS AND MEALS // CREATE MEAL ORDERS AND MEALS
// ============================================ // ============================================
const today = new Date() const today = new Date();
const yesterday = new Date(today) const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1) yesterday.setDate(yesterday.getDate() - 1);
const tomorrow = new Date(today) const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1) tomorrow.setDate(tomorrow.getDate() + 1);
const formatDate = (date: Date) => date.toISOString().split('T')[0] const formatDate = (date: Date) => date.toISOString().split("T")[0];
type MealType = 'breakfast' | 'lunch' | 'dinner' type MealType = "breakfast" | "lunch" | "dinner";
type OrderStatus = 'draft' | 'submitted' | 'preparing' | 'completed' type OrderStatus = "draft" | "submitted" | "preparing" | "completed";
const mealOrders: Array<{ const mealOrders: Array<{
date: string date: string;
mealType: MealType mealType: MealType;
status: OrderStatus status: OrderStatus;
}> = [ }> = [
// Yesterday - all completed // Yesterday - all completed
{ date: formatDate(yesterday), mealType: 'breakfast', status: 'completed' }, { date: formatDate(yesterday), mealType: "breakfast", status: "completed" },
{ date: formatDate(yesterday), mealType: 'lunch', status: 'completed' }, { date: formatDate(yesterday), mealType: "lunch", status: "completed" },
{ date: formatDate(yesterday), mealType: 'dinner', status: 'completed' }, { date: formatDate(yesterday), mealType: "dinner", status: "completed" },
// Today - mixed statuses // Today - mixed statuses
{ date: formatDate(today), mealType: 'breakfast', status: 'completed' }, { date: formatDate(today), mealType: "breakfast", status: "completed" },
{ date: formatDate(today), mealType: 'lunch', status: 'preparing' }, { date: formatDate(today), mealType: "lunch", status: "preparing" },
{ date: formatDate(today), mealType: 'dinner', status: 'submitted' }, { date: formatDate(today), mealType: "dinner", status: "submitted" },
// Tomorrow - draft (in progress) // Tomorrow - draft (in progress)
{ date: formatDate(tomorrow), mealType: 'breakfast', status: 'draft' }, { date: formatDate(tomorrow), mealType: "breakfast", status: "draft" },
{ date: formatDate(tomorrow), mealType: 'lunch', status: 'draft' }, { date: formatDate(tomorrow), mealType: "lunch", status: "draft" },
] ];
let totalMeals = 0 let totalMeals = 0;
for (const orderData of mealOrders) { for (const orderData of mealOrders) {
// Create the meal order // Create the meal order
const order = await payload.create({ const order = await payload.create({
collection: 'meal-orders', collection: "meal-orders",
data: { data: {
date: orderData.date, date: orderData.date,
mealType: orderData.mealType, mealType: orderData.mealType,
status: orderData.status, status: orderData.status,
createdBy: caregiver.id, createdBy: caregiver.id,
tenant: careHome.id, tenant: careHome.id,
submittedAt: orderData.status !== 'draft' ? formatISO(new Date()) : undefined, submittedAt:
orderData.status !== "draft" ? formatISO(new Date()) : undefined,
}, },
}) });
// Determine meal status based on order status // Determine meal status based on order status
const mealStatus = const mealStatus =
orderData.status === 'completed' orderData.status === "completed"
? 'prepared' ? "prepared"
: orderData.status === 'preparing' : orderData.status === "preparing"
? 'preparing' ? "preparing"
: 'pending' : "pending";
// Create meals for each resident in the order // Create meals for each resident in the order
// For draft orders, only add some residents to demonstrate partial completion // For draft orders, only add some residents to demonstrate partial completion
const residentsToUse = const residentsToUse =
orderData.status === 'draft' orderData.status === "draft"
? residents.slice(0, Math.floor(residents.length / 2)) ? residents.slice(0, Math.floor(residents.length / 2))
: residents : residents;
for (let i = 0; i < residentsToUse.length; i++) { for (let i = 0; i < residentsToUse.length; i++) {
const resident = residentsToUse[i] const resident = residentsToUse[i];
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const mealData: any = { const mealData: any = {
@@ -260,10 +261,10 @@ export const seed = async (payload: Payload): Promise<void> => {
status: mealStatus, status: mealStatus,
createdBy: caregiver.id, createdBy: caregiver.id,
tenant: careHome.id, tenant: careHome.id,
} };
// Add meal-specific options based on meal type // Add meal-specific options based on meal type
if (orderData.mealType === 'breakfast') { if (orderData.mealType === "breakfast") {
mealData.breakfast = { mealData.breakfast = {
accordingToPlan: i % 3 === 0, accordingToPlan: i % 3 === 0,
bread: { bread: {
@@ -300,13 +301,13 @@ export const seed = async (payload: Payload): Promise<void> => {
sweetener: i === 3, sweetener: i === 3,
coffeeCreamer: i % 3 === 0, coffeeCreamer: i % 3 === 0,
}, },
} };
} else if (orderData.mealType === 'lunch') { } else if (orderData.mealType === "lunch") {
const portionOptions: Array<'small' | 'large' | 'vegetarian'> = [ const portionOptions: Array<"small" | "large" | "vegetarian"> = [
'small', "small",
'large', "large",
'vegetarian', "vegetarian",
] ];
mealData.lunch = { mealData.lunch = {
portionSize: portionOptions[i % 3], portionSize: portionOptions[i % 3],
soup: i % 2 === 0, soup: i % 2 === 0,
@@ -322,8 +323,8 @@ export const seed = async (payload: Payload): Promise<void> => {
fingerFood: i % 6 === 0, fingerFood: i % 6 === 0,
onlySweet: false, onlySweet: false,
}, },
} };
} else if (orderData.mealType === 'dinner') { } else if (orderData.mealType === "dinner") {
mealData.dinner = { mealData.dinner = {
accordingToPlan: i % 2 === 0, accordingToPlan: i % 2 === 0,
bread: { bread: {
@@ -353,31 +354,33 @@ export const seed = async (payload: Payload): Promise<void> => {
sugar: i !== 3, sugar: i !== 3,
sweetener: i === 3, sweetener: i === 3,
}, },
} };
} }
await payload.create({ await payload.create({
collection: 'meals', collection: "meals",
data: mealData, data: mealData,
}) });
totalMeals++ totalMeals++;
} }
// Update order meal count // Update order meal count
await payload.update({ await payload.update({
collection: 'meal-orders', collection: "meal-orders",
id: order.id, id: order.id,
data: { data: {
mealCount: residentsToUse.length, mealCount: residentsToUse.length,
}, },
}) });
} }
payload.logger.info(`Created ${mealOrders.length} meal orders with ${totalMeals} total meals`) payload.logger.info(
payload.logger.info('Database seeding complete!') `Created ${mealOrders.length} meal orders with ${totalMeals} total meals`,
payload.logger.info('') );
payload.logger.info('Login credentials:') payload.logger.info("Database seeding complete!");
payload.logger.info(' Admin: admin@example.com / test') payload.logger.info("");
payload.logger.info(' Caregiver: caregiver@example.com / test') payload.logger.info("Login credentials:");
payload.logger.info(' Kitchen: kitchen@example.com / test') payload.logger.info(" Admin: admin@example.com / test");
} payload.logger.info(" Caregiver: caregiver@example.com / test");
payload.logger.info(" Kitchen: kitchen@example.com / test");
};

View File

@@ -1,40 +1,40 @@
import { format, parseISO } from 'date-fns' import { format, parseISO } from "date-fns";
/** /**
* Format a date for display in short format (e.g., "Mon, Dec 2") * Format a date for display in short format (e.g., "Mon, Dec 2")
*/ */
export function formatDateShort(date: Date | string): string { export function formatDateShort(date: Date | string): string {
const d = typeof date === 'string' ? parseISO(date) : date const d = typeof date === "string" ? parseISO(date) : date;
return format(d, 'EEE, MMM d') return format(d, "EEE, MMM d");
} }
/** /**
* Format a date for display in long format (e.g., "Monday, December 2, 2024") * Format a date for display in long format (e.g., "Monday, December 2, 2024")
*/ */
export function formatDateLong(date: Date | string): string { export function formatDateLong(date: Date | string): string {
const d = typeof date === 'string' ? parseISO(date) : date const d = typeof date === "string" ? parseISO(date) : date;
return format(d, 'EEEE, MMMM d, yyyy') return format(d, "EEEE, MMMM d, yyyy");
} }
/** /**
* Format a date to ISO date string (YYYY-MM-DD) * Format a date to ISO date string (YYYY-MM-DD)
*/ */
export function formatDateISO(date: Date | string): string { export function formatDateISO(date: Date | string): string {
const d = typeof date === 'string' ? parseISO(date) : date const d = typeof date === "string" ? parseISO(date) : date;
return format(d, 'yyyy-MM-dd') return format(d, "yyyy-MM-dd");
} }
/** /**
* Get today's date as ISO string (YYYY-MM-DD) * Get today's date as ISO string (YYYY-MM-DD)
*/ */
export function getTodayISO(): string { export function getTodayISO(): string {
return format(new Date(), 'yyyy-MM-dd') return format(new Date(), "yyyy-MM-dd");
} }
/** /**
* Format a date with time for display (e.g., "Dec 2, 2024 at 3:45 PM") * Format a date with time for display (e.g., "Dec 2, 2024 at 3:45 PM")
*/ */
export function formatDateTime(date: Date | string): string { export function formatDateTime(date: Date | string): string {
const d = typeof date === 'string' ? parseISO(date) : date const d = typeof date === "string" ? parseISO(date) : date;
return format(d, "MMM d, yyyy 'at' h:mm a") return format(d, "MMM d, yyyy 'at' h:mm a");
} }

View File

@@ -1,10 +1,10 @@
import { Config } from '@/payload-types' import { Config } from "@/payload-types";
import type { CollectionSlug } from 'payload' import type { CollectionSlug } from "payload";
export const extractID = <T extends Config['collections'][CollectionSlug]>( export const extractID = <T extends Config["collections"][CollectionSlug]>(
objectOrID: T | T['id'], objectOrID: T | T["id"],
): T['id'] => { ): T["id"] => {
if (objectOrID && typeof objectOrID === 'object') return objectOrID.id if (objectOrID && typeof objectOrID === "object") return objectOrID.id;
return objectOrID return objectOrID;
} };

View File

@@ -1,9 +1,15 @@
import type { CollectionSlug, Payload } from 'payload' import type { CollectionSlug, Payload } from "payload";
type Args = { type Args = {
collectionSlug: CollectionSlug collectionSlug: CollectionSlug;
payload: Payload payload: Payload;
} };
export const getCollectionIDType = ({ collectionSlug, payload }: Args): 'number' | 'text' => { export const getCollectionIDType = ({
return payload.collections[collectionSlug]?.customIDType ?? payload.db.defaultIDType collectionSlug,
} payload,
}: Args): "number" | "text" => {
return (
payload.collections[collectionSlug]?.customIDType ??
payload.db.defaultIDType
);
};

View File

@@ -1,5 +1,5 @@
import type { Tenant, User } from '../payload-types' import type { Tenant, User } from "../payload-types";
import { extractID } from './extractID' import { extractID } from "./extractID";
/** /**
* Returns array of all tenant IDs assigned to a user * Returns array of all tenant IDs assigned to a user
@@ -9,23 +9,23 @@ import { extractID } from './extractID'
*/ */
export const getUserTenantIDs = ( export const getUserTenantIDs = (
user: null | User, user: null | User,
role?: NonNullable<User['tenants']>[number]['roles'][number], role?: NonNullable<User["tenants"]>[number]["roles"][number],
): Tenant['id'][] => { ): Tenant["id"][] => {
if (!user) { if (!user) {
return [] return [];
} }
return ( return (
user?.tenants?.reduce<Tenant['id'][]>((acc, { roles, tenant }) => { user?.tenants?.reduce<Tenant["id"][]>((acc, { roles, tenant }) => {
if (role && !roles.includes(role)) { if (role && !roles.includes(role)) {
return acc return acc;
} }
if (tenant) { if (tenant) {
acc.push(extractID(tenant)) acc.push(extractID(tenant));
} }
return acc return acc;
}, []) || [] }, []) || []
) );
} };