feat: add kitchen dashboard, format codebase
This commit is contained in:
@@ -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
91
pnpm-lock.yaml
generated
@@ -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)
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
730
src/app/(app)/kitchen/dashboard/page.tsx
Normal file
730
src/app/(app)/kitchen/dashboard/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
179
src/app/(app)/login/page.tsx
Normal file
179
src/app/(app)/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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')
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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
|
};
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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) || [],
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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"),
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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"),
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
33
src/components/ui/collapsible.tsx
Normal file
33
src/components/ui/collapsible.tsx
Normal 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 };
|
||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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
92
src/hooks/useAuth.ts
Normal 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
82
src/lib/auth.ts
Normal 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";
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {},
|
||||||
|
|||||||
@@ -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";`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
|
|||||||
301
src/seed.ts
301
src/seed.ts
@@ -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");
|
||||||
|
};
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}, []) || []
|
}, []) || []
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user