Improve frontend 5

This commit is contained in:
Urtzi Alfaro
2025-11-20 19:14:49 +01:00
parent 29e6ddcea9
commit 4433b66f25
30 changed files with 3649 additions and 600 deletions

View File

@@ -138,6 +138,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3", "@babel/generator": "^7.28.3",
@@ -2267,7 +2268,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz",
"integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==", "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@formatjs/fast-memoize": "2.2.7", "@formatjs/fast-memoize": "2.2.7",
"@formatjs/intl-localematcher": "0.6.2", "@formatjs/intl-localematcher": "0.6.2",
@@ -2280,7 +2280,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz",
"integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.8.0" "tslib": "^2.8.0"
} }
@@ -2290,7 +2289,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz", "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz",
"integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==", "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@formatjs/ecma402-abstract": "2.3.6", "@formatjs/ecma402-abstract": "2.3.6",
"@formatjs/icu-skeleton-parser": "1.8.16", "@formatjs/icu-skeleton-parser": "1.8.16",
@@ -2302,7 +2300,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz", "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz",
"integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==", "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@formatjs/ecma402-abstract": "2.3.6", "@formatjs/ecma402-abstract": "2.3.6",
"tslib": "^2.8.0" "tslib": "^2.8.0"
@@ -2313,7 +2310,6 @@
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz", "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz",
"integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==", "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.8.0" "tslib": "^2.8.0"
} }
@@ -2742,6 +2738,7 @@
"deprecated": "Glob versions prior to v9 are no longer supported", "deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"peer": true,
"dependencies": { "dependencies": {
"fs.realpath": "^1.0.0", "fs.realpath": "^1.0.0",
"inflight": "^1.0.4", "inflight": "^1.0.4",
@@ -6044,6 +6041,7 @@
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-3.5.0.tgz", "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-3.5.0.tgz",
"integrity": "sha512-pKS3wZnJoL1iTyGBXAvCwduNNeghJHY6QSRSNNvpYnrrQrLZ6Owsazjyynu0e0ObRgks0i7Rv+pe2M7/MBTZpQ==", "integrity": "sha512-pKS3wZnJoL1iTyGBXAvCwduNNeghJHY6QSRSNNvpYnrrQrLZ6Owsazjyynu0e0ObRgks0i7Rv+pe2M7/MBTZpQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12.16" "node": ">=12.16"
} }
@@ -6133,6 +6131,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.89.0.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.89.0.tgz",
"integrity": "sha512-SXbtWSTSRXyBOe80mszPxpEbaN4XPRUp/i0EfQK1uyj3KCk/c8FuPJNIRwzOVe/OU3rzxrYtiNabsAmk1l714A==", "integrity": "sha512-SXbtWSTSRXyBOe80mszPxpEbaN4XPRUp/i0EfQK1uyj3KCk/c8FuPJNIRwzOVe/OU3rzxrYtiNabsAmk1l714A==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@tanstack/query-core": "5.89.0" "@tanstack/query-core": "5.89.0"
}, },
@@ -6625,6 +6624,7 @@
"integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==", "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.0.2" "csstype": "^3.0.2"
@@ -6636,6 +6636,7 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
} }
@@ -6777,6 +6778,7 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0", "@typescript-eslint/types": "6.21.0",
@@ -7115,6 +7117,7 @@
"integrity": "sha512-xa57bCPGuzEFqGjPs3vVLyqareG8DX0uMkr5U/v5vLv5/ZUrBrPL7gzxzTJedEyZxFMfsozwTIbbYfEQVo3kgg==", "integrity": "sha512-xa57bCPGuzEFqGjPs3vVLyqareG8DX0uMkr5U/v5vLv5/ZUrBrPL7gzxzTJedEyZxFMfsozwTIbbYfEQVo3kgg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vitest/utils": "1.6.1", "@vitest/utils": "1.6.1",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
@@ -7212,6 +7215,7 @@
"integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -7754,6 +7758,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.3", "baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741", "caniuse-lite": "^1.0.30001741",
@@ -7959,6 +7964,7 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@kurkle/color": "^0.3.0" "@kurkle/color": "^0.3.0"
}, },
@@ -8323,7 +8329,8 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/d3-array": { "node_modules/d3-array": {
"version": "3.2.4", "version": "3.2.4",
@@ -8505,6 +8512,7 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.21.0" "@babel/runtime": "^7.21.0"
}, },
@@ -8547,8 +8555,7 @@
"version": "10.6.0", "version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/decimal.js-light": { "node_modules/decimal.js-light": {
"version": "2.5.1", "version": "2.5.1",
@@ -9032,6 +9039,7 @@
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
}, },
@@ -9135,6 +9143,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1", "@eslint-community/regexpp": "^4.6.1",
@@ -10560,6 +10569,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.23.2" "@babel/runtime": "^7.23.2"
} }
@@ -10608,6 +10618,7 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz", "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
"integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==", "integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/immer" "url": "https://opencollective.com/immer"
@@ -12766,6 +12777,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -12932,6 +12944,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -13204,6 +13217,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -13276,6 +13290,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -13329,6 +13344,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.63.0.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.63.0.tgz",
"integrity": "sha512-ZwueDMvUeucovM2VjkCf7zIHcs1aAlDimZu2Hvel5C5907gUzMpm4xCrQXtRzCvsBqFjonB4m3x4LzCFI1ZKWA==", "integrity": "sha512-ZwueDMvUeucovM2VjkCf7zIHcs1aAlDimZu2Hvel5C5907gUzMpm4xCrQXtRzCvsBqFjonB4m3x4LzCFI1ZKWA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
@@ -13920,6 +13936,7 @@
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"rollup": "dist/bin/rollup" "rollup": "dist/bin/rollup"
}, },
@@ -14802,6 +14819,7 @@
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2", "arg": "^5.0.2",
@@ -15333,6 +15351,7 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -15745,6 +15764,7 @@
"integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",
@@ -16308,6 +16328,7 @@
"integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vitest/expect": "1.6.1", "@vitest/expect": "1.6.1",
"@vitest/runner": "1.6.1", "@vitest/runner": "1.6.1",
@@ -16689,6 +16710,7 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",

View File

@@ -75,11 +75,17 @@ export interface OrchestrationSummary {
userActionsRequired: number; userActionsRequired: number;
durationSeconds: number | null; durationSeconds: number | null;
aiAssisted: boolean; aiAssisted: boolean;
message?: string; message_i18n?: {
key: string;
params?: Record<string, any>;
}; // i18n data for message
} }
export interface ActionButton { export interface ActionButton {
label: string; label_i18n: {
key: string;
params?: Record<string, any>;
}; // i18n data for button label
type: 'primary' | 'secondary' | 'tertiary'; type: 'primary' | 'secondary' | 'tertiary';
action: string; action: string;
} }
@@ -88,10 +94,25 @@ export interface ActionItem {
id: string; id: string;
type: string; type: string;
urgency: 'critical' | 'important' | 'normal'; urgency: 'critical' | 'important' | 'normal';
title: string; title?: string; // Legacy field kept for alerts
subtitle: string; title_i18n?: {
reasoning?: string; // Deprecated: Use reasoning_data instead key: string;
consequence?: string; // Deprecated: Use reasoning_data instead params?: Record<string, any>;
}; // i18n data for title
subtitle?: string; // Legacy field kept for alerts
subtitle_i18n?: {
key: string;
params?: Record<string, any>;
}; // i18n data for subtitle
reasoning?: string; // Legacy field kept for alerts
reasoning_i18n?: {
key: string;
params?: Record<string, any>;
}; // i18n data for reasoning
consequence_i18n: {
key: string;
params?: Record<string, any>;
}; // i18n data for consequence
reasoning_data?: any; // Structured reasoning data for i18n translation reasoning_data?: any; // Structured reasoning data for i18n translation
amount?: number; amount?: number;
currency?: string; currency?: string;
@@ -123,6 +144,14 @@ export interface ProductionTimelineItem {
priority: string; priority: string;
reasoning?: string; // Deprecated: Use reasoning_data instead reasoning?: string; // Deprecated: Use reasoning_data instead
reasoning_data?: any; // Structured reasoning data for i18n translation reasoning_data?: any; // Structured reasoning data for i18n translation
reasoning_i18n?: {
key: string;
params?: Record<string, any>;
}; // i18n data for reasoning
status_i18n?: {
key: string;
params?: Record<string, any>;
}; // i18n data for status text
} }
export interface ProductionTimeline { export interface ProductionTimeline {
@@ -134,10 +163,21 @@ export interface ProductionTimeline {
} }
export interface InsightCard { export interface InsightCard {
label: string;
value: string;
detail: string;
color: 'green' | 'amber' | 'red'; color: 'green' | 'amber' | 'red';
i18n: {
label: {
key: string;
params?: Record<string, any>;
};
value: {
key: string;
params?: Record<string, any>;
};
detail: {
key: string;
params?: Record<string, any>;
} | null;
};
} }
export interface Insights { export interface Insights {

View File

@@ -89,7 +89,8 @@ function ActionItemCard({
const config = urgencyConfig[action.urgency as keyof typeof urgencyConfig] || urgencyConfig.normal; const config = urgencyConfig[action.urgency as keyof typeof urgencyConfig] || urgencyConfig.normal;
const UrgencyIcon = config.icon; const UrgencyIcon = config.icon;
const { formatPOAction } = useReasoningFormatter(); const { formatPOAction } = useReasoningFormatter();
const { t } = useTranslation('reasoning'); const { t: tReasoning } = useTranslation('reasoning');
const { t: tDashboard } = useTranslation('dashboard');
// Fetch PO details if this is a PO action and details are expanded // Fetch PO details if this is a PO action and details are expanded
const { data: poDetail } = usePurchaseOrder( const { data: poDetail } = usePurchaseOrder(
@@ -98,18 +99,28 @@ function ActionItemCard({
{ enabled: !!tenantId && showDetails && action.type === 'po_approval' } { enabled: !!tenantId && showDetails && action.type === 'po_approval' }
); );
// Translate reasoning_data (or fallback to deprecated text fields) // Translate i18n fields (or fallback to deprecated text fields or reasoning_data for alerts)
// Memoize to prevent undefined values from being created on each render const reasoning = useMemo(() => {
const { reasoning, consequence, severity } = useMemo(() => { if (action.reasoning_i18n) {
if (action.reasoning_data) { return tDashboard(action.reasoning_i18n.key, action.reasoning_i18n.params);
return formatPOAction(action.reasoning_data);
} }
return { if (action.reasoning_data) {
reasoning: action.reasoning || '', const formatted = formatPOAction(action.reasoning_data);
consequence: action.consequence || '', return formatted.reasoning;
severity: '' }
}; return action.reasoning || '';
}, [action.reasoning_data, action.reasoning, action.consequence, formatPOAction]); }, [action.reasoning_i18n, action.reasoning_data, action.reasoning, tDashboard, formatPOAction]);
const consequence = useMemo(() => {
if (action.consequence_i18n) {
return tDashboard(action.consequence_i18n.key, action.consequence_i18n.params);
}
if (action.reasoning_data) {
const formatted = formatPOAction(action.reasoning_data);
return formatted.consequence;
}
return '';
}, [action.consequence_i18n, action.reasoning_data, tDashboard, formatPOAction]);
return ( return (
<div <div
@@ -124,7 +135,9 @@ function ActionItemCard({
<UrgencyIcon className="w-6 h-6 flex-shrink-0" style={{ color: config.iconColor }} /> <UrgencyIcon className="w-6 h-6 flex-shrink-0" style={{ color: config.iconColor }} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1"> <div className="flex items-start justify-between gap-2 mb-1">
<h3 className="font-bold text-lg" style={{ color: 'var(--text-primary)' }}>{action.title || 'Action Required'}</h3> <h3 className="font-bold text-lg" style={{ color: 'var(--text-primary)' }}>
{action.title_i18n ? tDashboard(action.title_i18n.key, action.title_i18n.params) : (action.title || 'Action Required')}
</h3>
<span <span
className="px-2 py-1 rounded text-xs font-semibold uppercase flex-shrink-0" className="px-2 py-1 rounded text-xs font-semibold uppercase flex-shrink-0"
style={{ style={{
@@ -135,7 +148,9 @@ function ActionItemCard({
{action.urgency || 'normal'} {action.urgency || 'normal'}
</span> </span>
</div> </div>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{action.subtitle || ''}</p> <p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{action.subtitle_i18n ? tDashboard(action.subtitle_i18n.key, action.subtitle_i18n.params) : (action.subtitle || '')}
</p>
</div> </div>
</div> </div>
@@ -152,7 +167,7 @@ function ActionItemCard({
{/* Reasoning (always visible) */} {/* Reasoning (always visible) */}
<div className="rounded-md p-3 mb-3" style={{ backgroundColor: 'var(--bg-primary)' }}> <div className="rounded-md p-3 mb-3" style={{ backgroundColor: 'var(--bg-primary)' }}>
<p className="text-sm font-medium mb-1" style={{ color: 'var(--text-primary)' }}> <p className="text-sm font-medium mb-1" style={{ color: 'var(--text-primary)' }}>
{t('jtbd.action_queue.why_needed')} {tReasoning('jtbd.action_queue.why_needed')}
</p> </p>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{reasoning}</p> <p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{reasoning}</p>
</div> </div>
@@ -166,7 +181,7 @@ function ActionItemCard({
style={{ color: 'var(--text-secondary)' }} style={{ color: 'var(--text-secondary)' }}
> >
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />} {expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
<span className="font-medium">{t('jtbd.action_queue.what_if_not')}</span> <span className="font-medium">{tReasoning('jtbd.action_queue.what_if_not')}</span>
</button> </button>
{expanded && ( {expanded && (
@@ -178,11 +193,6 @@ function ActionItemCard({
}} }}
> >
<p className="text-sm" style={{ color: 'var(--color-warning-900)' }}>{consequence}</p> <p className="text-sm" style={{ color: 'var(--color-warning-900)' }}>{consequence}</p>
{severity && (
<span className="text-xs font-semibold mt-1 block" style={{ color: 'var(--color-warning-900)' }}>
{severity}
</span>
)}
</div> </div>
)} )}
</> </>
@@ -343,7 +353,7 @@ function ActionItemCard({
<div className="flex items-center gap-2 text-xs mb-4" style={{ color: 'var(--text-tertiary)' }}> <div className="flex items-center gap-2 text-xs mb-4" style={{ color: 'var(--text-tertiary)' }}>
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
<span> <span>
{t('jtbd.action_queue.estimated_time')}: {action.estimatedTimeMinutes || 5} min {tReasoning('jtbd.action_queue.estimated_time')}: {action.estimatedTimeMinutes || 5} min
</span> </span>
</div> </div>
@@ -483,7 +493,7 @@ function ActionItemCard({
{button.action === 'reject' && <X className="w-4 h-4" />} {button.action === 'reject' && <X className="w-4 h-4" />}
{button.action === 'view_details' && <Eye className="w-4 h-4" />} {button.action === 'view_details' && <Eye className="w-4 h-4" />}
{button.action === 'modify' && <Edit className="w-4 h-4" />} {button.action === 'modify' && <Edit className="w-4 h-4" />}
{button.label} {tDashboard(button.label_i18n.key, button.label_i18n.params)}
</button> </button>
); );
})} })}
@@ -502,7 +512,7 @@ export function ActionQueueCard({
tenantId, tenantId,
}: ActionQueueCardProps) { }: ActionQueueCardProps) {
const [showAll, setShowAll] = useState(false); const [showAll, setShowAll] = useState(false);
const { t } = useTranslation('reasoning'); const { t: tReasoning } = useTranslation('reasoning');
if (loading || !actionQueue) { if (loading || !actionQueue) {
return ( return (
@@ -527,9 +537,9 @@ export function ActionQueueCard({
> >
<CheckCircle2 className="w-16 h-16 mx-auto mb-4" style={{ color: 'var(--color-success-600)' }} /> <CheckCircle2 className="w-16 h-16 mx-auto mb-4" style={{ color: 'var(--color-success-600)' }} />
<h3 className="text-xl font-bold mb-2" style={{ color: 'var(--color-success-900)' }}> <h3 className="text-xl font-bold mb-2" style={{ color: 'var(--color-success-900)' }}>
{t('jtbd.action_queue.all_caught_up')} {tReasoning('jtbd.action_queue.all_caught_up')}
</h3> </h3>
<p style={{ color: 'var(--color-success-700)' }}>{t('jtbd.action_queue.no_actions')}</p> <p style={{ color: 'var(--color-success-700)' }}>{tReasoning('jtbd.action_queue.no_actions')}</p>
</div> </div>
); );
} }
@@ -540,7 +550,7 @@ export function ActionQueueCard({
<div className="rounded-xl shadow-lg p-6 border" style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border-primary)' }}> <div className="rounded-xl shadow-lg p-6 border" style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border-primary)' }}>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('jtbd.action_queue.title')}</h2> <h2 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{tReasoning('jtbd.action_queue.title')}</h2>
{(actionQueue.totalActions || 0) > 3 && ( {(actionQueue.totalActions || 0) > 3 && (
<span <span
className="px-3 py-1 rounded-full text-sm font-semibold" className="px-3 py-1 rounded-full text-sm font-semibold"
@@ -549,7 +559,7 @@ export function ActionQueueCard({
color: 'var(--color-error-800)', color: 'var(--color-error-800)',
}} }}
> >
{actionQueue.totalActions || 0} {t('jtbd.action_queue.total')} {actionQueue.totalActions || 0} {tReasoning('jtbd.action_queue.total')}
</span> </span>
)} )}
</div> </div>
@@ -565,7 +575,7 @@ export function ActionQueueCard({
color: 'var(--color-error-800)', color: 'var(--color-error-800)',
}} }}
> >
{actionQueue.criticalCount || 0} {t('jtbd.action_queue.critical')} {actionQueue.criticalCount || 0} {tReasoning('jtbd.action_queue.critical')}
</span> </span>
)} )}
{(actionQueue.importantCount || 0) > 0 && ( {(actionQueue.importantCount || 0) > 0 && (
@@ -576,7 +586,7 @@ export function ActionQueueCard({
color: 'var(--color-warning-800)', color: 'var(--color-warning-800)',
}} }}
> >
{actionQueue.importantCount || 0} {t('jtbd.action_queue.important')} {actionQueue.importantCount || 0} {tReasoning('jtbd.action_queue.important')}
</span> </span>
)} )}
</div> </div>
@@ -608,8 +618,8 @@ export function ActionQueueCard({
}} }}
> >
{showAll {showAll
? t('jtbd.action_queue.show_less') ? tReasoning('jtbd.action_queue.show_less')
: t('jtbd.action_queue.show_more', { count: (actionQueue.totalActions || 3) - 3 })} : tReasoning('jtbd.action_queue.show_more', { count: (actionQueue.totalActions || 3) - 3 })}
</button> </button>
)} )}
</div> </div>

View File

@@ -12,6 +12,7 @@ import React from 'react';
import { CheckCircle, AlertTriangle, AlertCircle, Clock, RefreshCw } from 'lucide-react'; import { CheckCircle, AlertTriangle, AlertCircle, Clock, RefreshCw } from 'lucide-react';
import { BakeryHealthStatus } from '../../api/hooks/newDashboard'; import { BakeryHealthStatus } from '../../api/hooks/newDashboard';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { es, eu, enUS } from 'date-fns/locale';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
interface HealthStatusCardProps { interface HealthStatusCardProps {
@@ -50,7 +51,10 @@ const iconMap = {
}; };
export function HealthStatusCard({ healthStatus, loading }: HealthStatusCardProps) { export function HealthStatusCard({ healthStatus, loading }: HealthStatusCardProps) {
const { t } = useTranslation('reasoning'); const { t, i18n } = useTranslation('reasoning');
// Get date-fns locale based on current language
const dateLocale = i18n.language === 'es' ? es : i18n.language === 'eu' ? eu : enUS;
if (loading || !healthStatus) { if (loading || !healthStatus) {
return ( return (
@@ -81,7 +85,7 @@ export function HealthStatusCard({ healthStatus, loading }: HealthStatusCardProp
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h2 className="text-xl md:text-2xl font-bold mb-2" style={{ color: config.textColor }}> <h2 className="text-xl md:text-2xl font-bold mb-2" style={{ color: config.textColor }}>
{typeof healthStatus.headline === 'object' && healthStatus.headline?.key {typeof healthStatus.headline === 'object' && healthStatus.headline?.key
? t(healthStatus.headline.key.replace('.', ':'), healthStatus.headline.params || {}) ? t(healthStatus.headline.key, healthStatus.headline.params || {})
: healthStatus.headline || t(`jtbd.health_status.${status}`)} : healthStatus.headline || t(`jtbd.health_status.${status}`)}
</h2> </h2>
@@ -93,6 +97,7 @@ export function HealthStatusCard({ healthStatus, loading }: HealthStatusCardProp
{healthStatus.lastOrchestrationRun {healthStatus.lastOrchestrationRun
? formatDistanceToNow(new Date(healthStatus.lastOrchestrationRun), { ? formatDistanceToNow(new Date(healthStatus.lastOrchestrationRun), {
addSuffix: true, addSuffix: true,
locale: dateLocale,
}) })
: t('jtbd.health_status.never')} : t('jtbd.health_status.never')}
</span> </span>
@@ -104,7 +109,7 @@ export function HealthStatusCard({ healthStatus, loading }: HealthStatusCardProp
<RefreshCw className="w-4 h-4" /> <RefreshCw className="w-4 h-4" />
<span> <span>
{t('jtbd.health_status.next_check')}:{' '} {t('jtbd.health_status.next_check')}:{' '}
{formatDistanceToNow(new Date(healthStatus.nextScheduledRun), { addSuffix: true })} {formatDistanceToNow(new Date(healthStatus.nextScheduledRun), { addSuffix: true, locale: dateLocale })}
</span> </span>
</div> </div>
)} )}

View File

@@ -9,6 +9,7 @@
*/ */
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
import { Insights } from '../../api/hooks/newDashboard'; import { Insights } from '../../api/hooks/newDashboard';
interface InsightsGridProps { interface InsightsGridProps {
@@ -38,18 +39,33 @@ const colorConfig = {
}; };
function InsightCard({ function InsightCard({
label,
value,
detail,
color, color,
i18n,
}: { }: {
label: string;
value: string;
detail: string;
color: 'green' | 'amber' | 'red'; color: 'green' | 'amber' | 'red';
i18n: {
label: {
key: string;
params?: Record<string, any>;
};
value: {
key: string;
params?: Record<string, any>;
};
detail: {
key: string;
params?: Record<string, any>;
} | null;
};
}) { }) {
const { t } = useTranslation('dashboard');
const config = colorConfig[color]; const config = colorConfig[color];
// Translate using i18n keys
const displayLabel = t(i18n.label.key, i18n.label.params);
const displayValue = t(i18n.value.key, i18n.value.params);
const displayDetail = i18n.detail ? t(i18n.detail.key, i18n.detail.params) : '';
return ( return (
<div <div
className="border-2 rounded-xl p-4 md:p-6 transition-all duration-200 hover:shadow-lg cursor-pointer" className="border-2 rounded-xl p-4 md:p-6 transition-all duration-200 hover:shadow-lg cursor-pointer"
@@ -59,13 +75,13 @@ function InsightCard({
}} }}
> >
{/* Label */} {/* Label */}
<div className="text-sm md:text-base font-bold mb-2" style={{ color: 'var(--text-primary)' }}>{label}</div> <div className="text-sm md:text-base font-bold mb-2" style={{ color: 'var(--text-primary)' }}>{displayLabel}</div>
{/* Value */} {/* Value */}
<div className="text-xl md:text-2xl font-bold mb-1" style={{ color: config.textColor }}>{value}</div> <div className="text-xl md:text-2xl font-bold mb-1" style={{ color: config.textColor }}>{displayValue}</div>
{/* Detail */} {/* Detail */}
<div className="text-sm font-medium" style={{ color: config.detailColor }}>{detail}</div> <div className="text-sm font-medium" style={{ color: config.detailColor }}>{displayDetail}</div>
</div> </div>
); );
} }
@@ -93,28 +109,20 @@ export function InsightsGrid({ insights, loading }: InsightsGridProps) {
return ( return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<InsightCard <InsightCard
label={insights.savings?.label || 'Savings'} color={insights.savings.color}
value={insights.savings?.value || '€0'} i18n={insights.savings.i18n}
detail={insights.savings?.detail || 'This week'}
color={insights.savings?.color || 'green'}
/> />
<InsightCard <InsightCard
label={insights.inventory?.label || 'Inventory'} color={insights.inventory.color}
value={insights.inventory?.value || '0%'} i18n={insights.inventory.i18n}
detail={insights.inventory?.detail || 'Status'}
color={insights.inventory?.color || 'green'}
/> />
<InsightCard <InsightCard
label={insights.waste?.label || 'Waste'} color={insights.waste.color}
value={insights.waste?.value || '0%'} i18n={insights.waste.i18n}
detail={insights.waste?.detail || 'Reduction'}
color={insights.waste?.color || 'green'}
/> />
<InsightCard <InsightCard
label={insights.deliveries?.label || 'Deliveries'} color={insights.deliveries.color}
value={insights.deliveries?.value || '0%'} i18n={insights.deliveries.i18n}
detail={insights.deliveries?.detail || 'On-time'}
color={insights.deliveries?.color || 'green'}
/> />
</div> </div>
); );

View File

@@ -38,16 +38,20 @@ function TimelineItemCard({
}) { }) {
const priorityColor = priorityColors[item.priority as keyof typeof priorityColors] || 'var(--text-tertiary)'; const priorityColor = priorityColors[item.priority as keyof typeof priorityColors] || 'var(--text-tertiary)';
const { formatBatchAction } = useReasoningFormatter(); const { formatBatchAction } = useReasoningFormatter();
const { t } = useTranslation('reasoning'); const { t } = useTranslation(['reasoning', 'dashboard']);
// Translate reasoning_data (or fallback to deprecated text field) // Translate reasoning_data (or use new reasoning_i18n or fallback to deprecated text field)
// Memoize to prevent undefined values from being created on each render // Memoize to prevent undefined values from being created on each render
const { reasoning } = useMemo(() => { const { reasoning } = useMemo(() => {
if (item.reasoning_data) { if (item.reasoning_i18n) {
// Use new i18n structure if available
const { key, params } = item.reasoning_i18n;
return { reasoning: t(key, params, { defaultValue: item.reasoning || '' }) };
} else if (item.reasoning_data) {
return formatBatchAction(item.reasoning_data); return formatBatchAction(item.reasoning_data);
} }
return { reasoning: item.reasoning || '' }; return { reasoning: item.reasoning || '' };
}, [item.reasoning_data, item.reasoning, formatBatchAction]); }, [item.reasoning_i18n, item.reasoning_data, item.reasoning, formatBatchAction, t]);
const startTime = item.plannedStartTime const startTime = item.plannedStartTime
? new Date(item.plannedStartTime).toLocaleTimeString('en-US', { ? new Date(item.plannedStartTime).toLocaleTimeString('en-US', {
@@ -97,7 +101,9 @@ function TimelineItemCard({
{/* Status and Progress */} {/* Status and Progress */}
<div className="mb-3"> <div className="mb-3">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{item.statusText || 'Status'}</span> <span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
{item.status_i18n ? t(item.status_i18n.key, item.status_i18n.params) : item.statusText || 'Status'}
</span>
{item.status === 'IN_PROGRESS' && ( {item.status === 'IN_PROGRESS' && (
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>{item.progress || 0}%</span> <span className="text-sm" style={{ color: 'var(--text-secondary)' }}>{item.progress || 0}%</span>
)} )}

View File

@@ -0,0 +1,186 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Plus, Trash2, FileText, Clock } from 'lucide-react';
interface RecipeInstructionsEditorProps {
value: any;
onChange: (value: any) => void;
}
export const RecipeInstructionsEditor: React.FC<RecipeInstructionsEditorProps> = ({ value, onChange }) => {
// Initialize with empty steps array if value is empty/null
const initialSteps = useMemo(() => {
if (!value) {
return [{ step: 1, title: '', description: '', duration_minutes: '' }];
}
// Handle structured format with steps
if (value && value.steps && Array.isArray(value.steps)) {
return value.steps.map((step: any, index: number) => ({
step: step.step || index + 1,
title: step.title || '',
description: step.description || '',
duration_minutes: step.duration_minutes || ''
}));
}
// Handle array format
if (Array.isArray(value)) {
return value.map((item: any, index: number) => {
if (typeof item === 'object' && item !== null) {
return {
step: item.step || index + 1,
title: item.title || '',
description: item.description || '',
duration_minutes: item.duration_minutes || ''
};
}
return {
step: index + 1,
title: '',
description: typeof item === 'string' ? item : '',
duration_minutes: ''
};
});
}
// For any other format, start with one empty step
return [{ step: 1, title: '', description: '', duration_minutes: '' }];
}, [value]);
const [steps, setSteps] = useState<any[]>(initialSteps);
// Update parent when steps change
useEffect(() => {
// Format as structured object with steps array
const formattedSteps = steps.map((step, index) => ({
...step,
step: index + 1 // Ensure steps are numbered sequentially
}));
onChange({
steps: formattedSteps
});
}, [steps, onChange]);
const addStep = () => {
const newStepNumber = steps.length + 1;
setSteps([...steps, { step: newStepNumber, title: '', description: '', duration_minutes: '' }]);
};
const removeStep = (index: number) => {
if (steps.length <= 1) return; // Don't remove the last step
const newSteps = steps.filter((_, i) => i !== index);
// Renumber the steps after removal
const renumberedSteps = newSteps.map((step, i) => ({
...step,
step: i + 1
}));
setSteps(renumberedSteps);
};
const updateStep = (index: number, field: string, newValue: string | number) => {
const newSteps = [...steps];
newSteps[index] = { ...newSteps[index], [field]: newValue };
setSteps(newSteps);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-[var(--text-primary)]">Pasos de Preparación</h4>
<button
type="button"
onClick={addStep}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-[var(--color-primary)] text-white rounded-md hover:bg-[var(--color-primary)]/90 transition-colors"
>
<Plus className="w-4 h-4" />
Agregar Paso
</button>
</div>
<div className="space-y-4 max-h-[600px] overflow-y-auto pr-2">
{steps.map((step, index) => (
<div
key={`step-editor-${step.step || index}`}
className="p-4 border-2 border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]/50 hover:border-[var(--color-primary)]/30 transition-all"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-[var(--color-primary)]/10 flex items-center justify-center text-sm font-bold text-[var(--color-primary)]">
{step.step || index + 1}
</div>
<h5 className="font-medium text-[var(--text-primary)]">Paso {step.step || index + 1}</h5>
</div>
{steps.length > 1 && (
<button
type="button"
onClick={() => removeStep(index)}
className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950 rounded transition-colors"
title="Eliminar paso"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<div className="space-y-3">
{/* Step Title */}
<div>
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
Título del paso
</label>
<input
type="text"
value={step.title || ''}
onChange={(e) => updateStep(index, 'title', e.target.value)}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
placeholder="Ej: Amasado, Fermentación, etc."
/>
</div>
{/* Step Description */}
<div>
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
Descripción
</label>
<textarea
value={step.description || ''}
onChange={(e) => updateStep(index, 'description', e.target.value)}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
placeholder="Descripción detallada del paso..."
rows={3}
/>
</div>
{/* Duration */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
Duración (minutos)
</label>
<input
type="number"
value={step.duration_minutes || ''}
onChange={(e) => updateStep(index, 'duration_minutes', e.target.value ? parseInt(e.target.value) : '')}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
placeholder="0"
min="0"
/>
</div>
</div>
</div>
</div>
))}
</div>
{steps.length === 0 && (
<div className="text-center py-8 text-[var(--text-secondary)] bg-[var(--bg-secondary)]/30 rounded-lg border-2 border-dashed border-[var(--border-secondary)]">
<FileText className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p className="text-sm">No hay pasos de preparación. Haz clic en "Agregar Paso" para comenzar.</p>
</div>
)}
</div>
);
};
export default RecipeInstructionsEditor;

View File

@@ -0,0 +1,309 @@
import React, { useState, useEffect } from 'react';
import { Plus, Trash2, CheckCircle, Clock, AlertTriangle, Settings } from 'lucide-react';
interface QualityControlEditorProps {
value: any; // The quality control configuration
onChange: (value: any) => void;
allTemplates: Array<{ id: string; name: string; category: string }>; // Available templates
}
export interface RecipeQualityConfiguration {
stages: {
[key: string]: {
template_ids: string[];
is_required?: boolean;
blocking?: boolean;
min_checks_passed?: number;
};
};
overall_quality_threshold?: number;
auto_create_quality_checks?: boolean;
quality_manager_approval_required?: boolean;
critical_stage_blocking?: boolean;
}
export const RecipeQualityControlEditor: React.FC<QualityControlEditorProps> = ({
value,
onChange,
allTemplates
}) => {
// Initialize with default structure if no value
const initialConfig: RecipeQualityConfiguration = value || {
stages: {
MIXING: { template_ids: [], is_required: false, blocking: false },
PROOFING: { template_ids: [], is_required: false, blocking: false },
SHAPING: { template_ids: [], is_required: false, blocking: false },
BAKING: { template_ids: [], is_required: false, blocking: false },
COOLING: { template_ids: [], is_required: false, blocking: false },
PACKAGING: { template_ids: [], is_required: false, blocking: false },
FINISHING: { template_ids: [], is_required: false, blocking: false },
},
overall_quality_threshold: 70,
auto_create_quality_checks: false,
quality_manager_approval_required: false,
critical_stage_blocking: false
};
const [config, setConfig] = useState<RecipeQualityConfiguration>(initialConfig);
// Update parent when config changes
useEffect(() => {
onChange(config);
}, [config, onChange]);
const stageOptions = [
{ value: 'MIXING', label: 'Mezclado', icon: Settings },
{ value: 'PROOFING', label: 'Fermentación', icon: Clock },
{ value: 'SHAPING', label: 'Formado', icon: Settings },
{ value: 'BAKING', label: 'Horneado', icon: AlertTriangle },
{ value: 'COOLING', label: 'Enfriado', icon: Clock },
{ value: 'PACKAGING', label: 'Empaquetado', icon: CheckCircle },
{ value: 'FINISHING', label: 'Acabado', icon: CheckCircle },
];
const addTemplateToStage = (stage: string, templateId: string) => {
setConfig(prev => {
const newConfig = { ...prev };
const stageConfig = newConfig.stages[stage] || { template_ids: [], is_required: false, blocking: false };
if (!stageConfig.template_ids.includes(templateId)) {
stageConfig.template_ids = [...stageConfig.template_ids, templateId];
newConfig.stages = { ...newConfig.stages, [stage]: stageConfig };
}
return newConfig;
});
};
const removeTemplateFromStage = (stage: string, templateId: string) => {
setConfig(prev => {
const newConfig = { ...prev };
const stageConfig = newConfig.stages[stage] || { template_ids: [], is_required: false, blocking: false };
stageConfig.template_ids = stageConfig.template_ids.filter(id => id !== templateId);
newConfig.stages = { ...newConfig.stages, [stage]: stageConfig };
return newConfig;
});
};
const toggleStageSetting = (stage: string, setting: 'is_required' | 'blocking', value: boolean) => {
setConfig(prev => {
const newConfig = { ...prev };
const existingStage = newConfig.stages[stage] || { template_ids: [], is_required: false, blocking: false };
newConfig.stages[stage] = {
...existingStage,
[setting]: value
};
return newConfig;
});
};
const updateGlobalSetting = (setting: keyof RecipeQualityConfiguration, value: any) => {
setConfig(prev => ({
...prev,
[setting]: value
}));
};
return (
<div className="space-y-6">
{/* Global Settings */}
<div className="p-4 rounded-lg border border-[var(--border-secondary)] bg-[var(--bg-secondary)]/30">
<h4 className="font-medium text-[var(--text-primary)] mb-3 flex items-center gap-2">
<Settings className="w-4 h-4" />
Configuración General
</h4>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-[var(--text-primary)]">Umbral de calidad general</label>
<p className="text-xs text-[var(--text-tertiary)]">Porcentaje mínimo para calidad aprobada</p>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min="0"
max="100"
value={config.overall_quality_threshold || 70}
onChange={(e) => updateGlobalSetting('overall_quality_threshold', parseInt(e.target.value) || 0)}
className="w-20 px-2 py-1 border border-[var(--border-secondary)] rounded text-sm bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
<span className="text-sm">%</span>
</div>
</div>
<div className="flex items-center justify-between pt-2 border-t border-[var(--border-secondary)]">
<div>
<label className="text-sm font-medium text-[var(--text-primary)]">Auto-crear controles</label>
<p className="text-xs text-[var(--text-tertiary)]">Crear controles automáticamente</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={config.auto_create_quality_checks || false}
onChange={(e) => updateGlobalSetting('auto_create_quality_checks', e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-[var(--border-secondary)] peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[var(--color-primary)]"></div>
</label>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-[var(--text-primary)]">Aprobación requerida</label>
<p className="text-xs text-[var(--text-tertiary)]">Requiere aprobación de responsable</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={config.quality_manager_approval_required || false}
onChange={(e) => updateGlobalSetting('quality_manager_approval_required', e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-[var(--border-secondary)] peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[var(--color-primary)]"></div>
</label>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-[var(--text-primary)]">Etapas críticas bloqueantes</label>
<p className="text-xs text-[var(--text-tertiary)]">Bloquear producción en fallo crítico</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={config.critical_stage_blocking || false}
onChange={(e) => updateGlobalSetting('critical_stage_blocking', e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-[var(--border-secondary)] peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[var(--color-primary)]"></div>
</label>
</div>
</div>
</div>
{/* Stage Configuration */}
<div className="space-y-4">
<h4 className="font-medium text-[var(--text-primary)] mb-2">Configuración por Etapas</h4>
{stageOptions.map(({ value: stageValue, label, icon: Icon }) => {
const stageConfig = config.stages[stageValue] || { template_ids: [], is_required: false, blocking: false };
const availableTemplates = allTemplates.filter(template =>
!stageConfig.template_ids.includes(template.id)
);
return (
<div
key={stageValue}
className="p-4 rounded-lg border border-[var(--border-secondary)] bg-[var(--bg-secondary)]/30"
>
<div className="flex items-center gap-2 mb-3">
<Icon className="w-4 h-4 text-[var(--color-primary)]" />
<h5 className="font-medium text-[var(--text-primary)]">{label}</h5>
</div>
{/* Add Template Dropdown */}
<div className="mb-3">
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
Añadir Plantilla de Control
</label>
<div className="flex gap-2">
<select
className="flex-1 px-3 py-2 border border-[var(--border-secondary)] rounded-md bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
onChange={(e) => {
if (e.target.value) {
addTemplateToStage(stageValue, e.target.value);
e.target.value = ''; // Reset selection
}
}}
>
<option value="">Seleccionar plantilla...</option>
{availableTemplates.map(template => (
<option key={template.id} value={template.id}>
{template.name} ({template.category})
</option>
))}
</select>
</div>
</div>
{/* Assigned Templates */}
<div className="mb-3">
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
Plantillas Asignadas
</label>
{stageConfig.template_ids.length === 0 ? (
<div className="text-sm text-[var(--text-tertiary)] italic py-2">
No hay plantillas asignadas a esta etapa
</div>
) : (
<div className="space-y-2">
{stageConfig.template_ids.map(templateId => {
const template = allTemplates.find(t => t.id === templateId);
return template ? (
<div
key={templateId}
className="flex items-center justify-between p-2 bg-[var(--bg-primary)]/70 border border-[var(--border-secondary)] rounded-md"
>
<span className="text-sm">{template.name}</span>
<button
type="button"
onClick={() => removeTemplateFromStage(stageValue, templateId)}
className="p-1 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950 rounded transition-colors"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
) : null;
})}
</div>
)}
</div>
{/* Stage Settings */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2 border-t border-[var(--border-secondary)]">
<div className="flex items-center justify-between">
<div>
<label className="text-xs font-medium text-[var(--text-primary)]">Requerido</label>
<p className="text-xs text-[var(--text-tertiary)]">Obligatorio para esta etapa</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={stageConfig.is_required || false}
onChange={(e) => toggleStageSetting(stageValue, 'is_required', e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-[var(--border-secondary)] peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-[var(--color-primary)]"></div>
</label>
</div>
<div className="flex items-center justify-between">
<div>
<label className="text-xs font-medium text-[var(--text-primary)]">Bloqueante</label>
<p className="text-xs text-[var(--text-tertiary)]">Bloquea la producción si falla</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={stageConfig.blocking || false}
onChange={(e) => toggleStageSetting(stageValue, 'blocking', e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-[var(--border-secondary)] peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-[var(--color-primary)]"></div>
</label>
</div>
</div>
</div>
);
})}
</div>
</div>
);
};
export default RecipeQualityControlEditor;

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,6 @@
export { CreateRecipeModal } from './CreateRecipeModal'; export { CreateRecipeModal } from './CreateRecipeModal';
export { QualityCheckConfigurationModal } from './QualityCheckConfigurationModal'; export { QualityCheckConfigurationModal } from './QualityCheckConfigurationModal';
export { DeleteRecipeModal } from './DeleteRecipeModal'; export { DeleteRecipeModal } from './DeleteRecipeModal';
export { default as RecipeViewEditModal } from './RecipeViewEditModal';
export { default as RecipeInstructionsEditor } from './RecipeInstructionsEditor';
export { default as RecipeQualityControlEditor } from './RecipeQualityControlEditor';

View File

@@ -2,11 +2,174 @@ import React, { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { WizardStep, WizardStepProps } from '../../../ui/WizardModal/WizardModal'; import { WizardStep, WizardStepProps } from '../../../ui/WizardModal/WizardModal';
import { AdvancedOptionsSection } from '../../../ui/AdvancedOptionsSection'; import { AdvancedOptionsSection } from '../../../ui/AdvancedOptionsSection';
import { KeyValueEditor } from '../../../ui/KeyValueEditor/KeyValueEditor';
import Tooltip from '../../../ui/Tooltip/Tooltip'; import Tooltip from '../../../ui/Tooltip/Tooltip';
import { Info } from 'lucide-react'; import {
Info,
Eye,
Ruler,
Thermometer,
Weight,
CheckSquare,
Clock,
ClipboardList
} from 'lucide-react';
// Single comprehensive step with all fields // STEP 1: Quality Check Type Selection
const QualityTemplateDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) => { const QualityCheckTypeStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) => {
const data = dataRef?.current || {};
const { t } = useTranslation('wizards');
const handleTypeSelect = (type: string) => {
onDataChange?.({ ...data, checkType: type });
};
const handleFieldChange = (field: string, value: any) => {
onDataChange?.({ ...data, [field]: value });
};
const checkTypes = [
{
value: 'visual',
icon: Eye,
titleKey: 'qualityTemplate.checkTypes.visual',
descriptionKey: 'qualityTemplate.checkTypeDescriptions.visual'
},
{
value: 'measurement',
icon: Ruler,
titleKey: 'qualityTemplate.checkTypes.measurement',
descriptionKey: 'qualityTemplate.checkTypeDescriptions.measurement'
},
{
value: 'temperature',
icon: Thermometer,
titleKey: 'qualityTemplate.checkTypes.temperature',
descriptionKey: 'qualityTemplate.checkTypeDescriptions.temperature'
},
{
value: 'weight',
icon: Weight,
titleKey: 'qualityTemplate.checkTypes.weight',
descriptionKey: 'qualityTemplate.checkTypeDescriptions.weight'
},
{
value: 'boolean',
icon: CheckSquare,
titleKey: 'qualityTemplate.checkTypes.boolean',
descriptionKey: 'qualityTemplate.checkTypeDescriptions.boolean'
},
{
value: 'timing',
icon: Clock,
titleKey: 'qualityTemplate.checkTypes.timing',
descriptionKey: 'qualityTemplate.checkTypeDescriptions.timing'
},
{
value: 'checklist',
icon: ClipboardList,
titleKey: 'qualityTemplate.checkTypes.checklist',
descriptionKey: 'qualityTemplate.checkTypeDescriptions.checklist'
}
];
return (
<div className="space-y-6">
{/* Header */}
<div className="text-center pb-4 border-b border-[var(--border-primary)]">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
{t('qualityTemplate.selectCheckType')}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{t('qualityTemplate.selectCheckTypeDescription')}
</p>
</div>
{/* Check Type Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{checkTypes.map((type) => {
const IconComponent = type.icon;
const isSelected = data.checkType === type.value;
return (
<button
key={type.value}
type="button"
onClick={() => handleTypeSelect(type.value)}
className={`p-5 border-2 rounded-lg transition-all text-left ${
isSelected
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/5 shadow-md'
: 'border-[var(--border-secondary)] hover:border-[var(--color-primary)]/50'
}`}
>
<div className="flex items-start gap-4">
<div
className={`p-3 rounded-lg ${
isSelected
? 'bg-[var(--color-primary)] text-white'
: 'bg-[var(--bg-secondary)] text-[var(--text-tertiary)]'
}`}
>
<IconComponent className="w-6 h-6" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-[var(--text-primary)] mb-1">
{t(type.titleKey)}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{t(type.descriptionKey)}
</p>
</div>
</div>
</button>
);
})}
</div>
{/* Advanced Options */}
<AdvancedOptionsSection
title={t('qualityTemplate.sections.additionalIdentifiers')}
description={t('qualityTemplate.sections.additionalIdentifiersDescription')}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Template Code */}
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
{t('qualityTemplate.fields.templateCode')}
<Tooltip content={t('qualityTemplate.fields.templateCodeTooltip')}>
<Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" />
</Tooltip>
</label>
<input
type="text"
value={data.templateCode || ''}
onChange={(e) => handleFieldChange('templateCode', e.target.value)}
placeholder={t('qualityTemplate.fields.templateCodePlaceholder')}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
{t('qualityTemplate.fields.category')}
</label>
<input
type="text"
value={data.category || ''}
onChange={(e) => handleFieldChange('category', e.target.value)}
placeholder={t('qualityTemplate.fields.categoryPlaceholder')}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
</div>
</div>
</AdvancedOptionsSection>
</div>
);
};
// STEP 2: Essential Configuration
const EssentialConfigurationStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) => {
const data = dataRef?.current || {}; const data = dataRef?.current || {};
const { t } = useTranslation('wizards'); const { t } = useTranslation('wizards');
@@ -14,51 +177,69 @@ const QualityTemplateDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onData
onDataChange?.({ ...data, [field]: value }); onDataChange?.({ ...data, [field]: value });
}; };
const handleStageToggle = (stage: string) => {
const currentStages = data.applicableStages || [];
const newStages = currentStages.includes(stage)
? currentStages.filter((s: string) => s !== stage)
: [...currentStages, stage];
handleFieldChange('applicableStages', newStages);
};
const isMeasurementType = ['measurement', 'temperature', 'weight'].includes(data.checkType);
const processStages = [
'mixing',
'proofing',
'shaping',
'baking',
'cooling',
'packaging',
'finishing'
];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */}
<div className="text-center pb-4 border-b border-[var(--border-primary)]"> <div className="text-center pb-4 border-b border-[var(--border-primary)]">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2"> <h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
{t('qualityTemplate.templateDetails')} {t('qualityTemplate.essentialConfiguration')}
</h3> </h3>
<p className="text-sm text-[var(--text-secondary)]"> <p className="text-sm text-[var(--text-secondary)]">
{t('qualityTemplate.fillRequiredInfo')} {t('qualityTemplate.essentialConfigurationDescription')}
</p> </p>
</div> </div>
{/* Required Fields */} {/* Core Fields */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="space-y-4">
<div className="md:col-span-2"> {/* Name */}
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2"> <label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
{t('qualityTemplate.fields.name')} * {t('qualityTemplate.fields.name')} *
</label> </label>
<input <input
type="text" type="text"
value={data.name} value={data.name || ''}
onChange={(e) => handleFieldChange('name', e.target.value)} onChange={(e) => handleFieldChange('name', e.target.value)}
placeholder={t('qualityTemplate.fields.namePlaceholder')} placeholder={t('qualityTemplate.fields.namePlaceholder')}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/> />
</div> </div>
{/* Description */}
<div> <div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2"> <label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
{t('qualityTemplate.fields.checkType')} * {t('qualityTemplate.fields.description')}
</label> </label>
<select <textarea
value={data.checkType} value={data.description || ''}
onChange={(e) => handleFieldChange('checkType', e.target.value)} onChange={(e) => handleFieldChange('description', e.target.value)}
placeholder={t('qualityTemplate.fields.descriptionPlaceholder')}
rows={3}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
> />
<option value="product_quality">{t('qualityTemplate.checkTypes.product_quality')}</option>
<option value="process_hygiene">{t('qualityTemplate.checkTypes.process_hygiene')}</option>
<option value="equipment">{t('qualityTemplate.checkTypes.equipment')}</option>
<option value="safety">{t('qualityTemplate.checkTypes.safety')}</option>
<option value="cleaning">{t('qualityTemplate.checkTypes.cleaning')}</option>
<option value="temperature">{t('qualityTemplate.checkTypes.temperature')}</option>
<option value="documentation">{t('qualityTemplate.checkTypes.documentation')}</option>
</select>
</div> </div>
{/* Weight */}
<div> <div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2"> <label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
{t('qualityTemplate.fields.weight')} * {t('qualityTemplate.fields.weight')} *
@@ -68,8 +249,8 @@ const QualityTemplateDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onData
</label> </label>
<input <input
type="number" type="number"
value={data.weight} value={data.weight || 5.0}
onChange={(e) => handleFieldChange('weight', e.target.value)} onChange={(e) => handleFieldChange('weight', parseFloat(e.target.value))}
placeholder="5.0" placeholder="5.0"
step="0.1" step="0.1"
min="0" min="0"
@@ -77,82 +258,189 @@ const QualityTemplateDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onData
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/> />
</div> </div>
{/* Applicable Stages */}
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
{t('qualityTemplate.fields.applicableStages')}
<Tooltip content={t('qualityTemplate.fields.applicableStagesTooltip')}>
<Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" />
</Tooltip>
</label>
<div className="flex flex-wrap gap-2">
{processStages.map((stage) => {
const isSelected = (data.applicableStages || []).includes(stage);
return (
<button
key={stage}
type="button"
onClick={() => handleStageToggle(stage)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
isSelected
? 'bg-[var(--color-primary)] text-white'
: 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:bg-[var(--color-primary)]/10'
}`}
>
{t(`qualityTemplate.processStages.${stage}`)}
</button>
);
})}
</div>
<p className="text-xs text-[var(--text-tertiary)] mt-2">
{t('qualityTemplate.fields.applicableStagesHelp')}
</p>
</div>
</div> </div>
{/* Basic Information */} {/* Measurement-Specific Fields */}
<div className="border-t border-[var(--border-primary)] pt-4"> {isMeasurementType && (
<h4 className="text-sm font-semibold text-[var(--text-primary)] mb-3">{t('qualityTemplate.sections.basicInformation')}</h4> <div className="border-t border-[var(--border-primary)] pt-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <h4 className="text-sm font-semibold text-[var(--text-primary)] mb-3">
{t('qualityTemplate.sections.measurementSpecifications')}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Min Value */}
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
{t('qualityTemplate.fields.minValue')}
</label>
<input
type="number"
value={data.minValue || ''}
onChange={(e) => handleFieldChange('minValue', e.target.value ? parseFloat(e.target.value) : null)}
placeholder="0"
step="0.01"
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
</div>
{/* Max Value */}
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
{t('qualityTemplate.fields.maxValue')}
</label>
<input
type="number"
value={data.maxValue || ''}
onChange={(e) => handleFieldChange('maxValue', e.target.value ? parseFloat(e.target.value) : null)}
placeholder="100"
step="0.01"
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
</div>
{/* Target Value */}
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
{t('qualityTemplate.fields.targetValue')}
</label>
<input
type="number"
value={data.targetValue || ''}
onChange={(e) => handleFieldChange('targetValue', e.target.value ? parseFloat(e.target.value) : null)}
placeholder="50"
step="0.01"
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
</div>
{/* Unit */}
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
{t('qualityTemplate.fields.unit')}
</label>
<input
type="text"
value={data.unit || ''}
onChange={(e) => handleFieldChange('unit', e.target.value)}
placeholder={t('qualityTemplate.fields.unitPlaceholder')}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
</div>
{/* Tolerance Percentage */}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
{t('qualityTemplate.fields.tolerancePercentage')}
<Tooltip content={t('qualityTemplate.fields.toleranceTooltip')}>
<Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" />
</Tooltip>
</label>
<input
type="number"
value={data.tolerancePercentage || ''}
onChange={(e) => handleFieldChange('tolerancePercentage', e.target.value ? parseFloat(e.target.value) : null)}
placeholder="5.0"
step="0.1"
min="0"
max="100"
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
</div>
</div>
</div>
)}
{/* Advanced Options */}
<AdvancedOptionsSection
title={t('qualityTemplate.sections.additionalDetails')}
description={t('qualityTemplate.sections.additionalDetailsDescription')}
>
<div className="space-y-4">
{/* Instructions */}
<div> <div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2"> <label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
{t('qualityTemplate.fields.templateCode')} ({t('common.optional')}) {t('qualityTemplate.fields.instructions')}
<Tooltip content={t('qualityTemplate.fields.templateCodeTooltip')}>
<Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" />
</Tooltip>
</label>
<input
type="text"
value={data.templateCode}
onChange={(e) => handleFieldChange('templateCode', e.target.value)}
placeholder={t('qualityTemplate.fields.templateCodePlaceholder')}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
{t('qualityTemplate.fields.version')}
</label>
<input
type="text"
value={data.version}
onChange={(e) => handleFieldChange('version', e.target.value)}
placeholder="1.0"
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
{t('qualityTemplate.fields.description')}
</label> </label>
<textarea <textarea
value={data.description} value={data.instructions || ''}
onChange={(e) => handleFieldChange('description', e.target.value)} onChange={(e) => handleFieldChange('instructions', e.target.value)}
placeholder={t('qualityTemplate.fields.descriptionPlaceholder')} placeholder={t('qualityTemplate.fields.instructionsPlaceholder')}
rows={2} rows={3}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
{t('qualityTemplate.fields.applicableStages')}
<Tooltip content={t('qualityTemplate.fields.applicableStagesTooltip')}>
<Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" />
</Tooltip>
</label>
<input
type="text"
value={data.applicableStages}
onChange={(e) => handleFieldChange('applicableStages', e.target.value)}
placeholder={t('qualityTemplate.fields.applicablePlaceholder')}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/> />
</div> </div>
</div> </div>
</AdvancedOptionsSection>
</div>
);
};
// STEP 3: Quality Criteria & Settings
const QualityCriteriaSettingsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) => {
const data = dataRef?.current || {};
const { t } = useTranslation('wizards');
const handleFieldChange = (field: string, value: any) => {
onDataChange?.({ ...data, [field]: value });
};
return (
<div className="space-y-6">
{/* Header */}
<div className="text-center pb-4 border-b border-[var(--border-primary)]">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
{t('qualityTemplate.criteriaAndSettings')}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{t('qualityTemplate.criteriaAndSettingsDescription')}
</p>
</div> </div>
{/* Scoring Configuration */} {/* Scoring Configuration */}
<div className="border-t border-[var(--border-primary)] pt-4"> <div className="space-y-4">
<h4 className="text-sm font-semibold text-[var(--text-primary)] mb-3">{t('qualityTemplate.sections.scoringConfiguration')}</h4> <h4 className="text-sm font-semibold text-[var(--text-primary)]">
{t('qualityTemplate.sections.scoringConfiguration')}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Scoring Method */}
<div> <div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2"> <label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
{t('qualityTemplate.scoringMethods.scoringMethod')} {t('qualityTemplate.fields.scoringMethod')}
</label> </label>
<select <select
value={data.scoringMethod} value={data.scoringMethod || 'weighted_average'}
onChange={(e) => handleFieldChange('scoringMethod', e.target.value)} onChange={(e) => handleFieldChange('scoringMethod', e.target.value)}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
> >
@@ -163,17 +451,18 @@ const QualityTemplateDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onData
</select> </select>
</div> </div>
{/* Pass Threshold */}
<div> <div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2"> <label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
{t('qualityTemplate.advancedFields.passThresholdPercent')} {t('qualityTemplate.fields.passThreshold')}
<Tooltip content={t('tooltips.passThreshold')}> <Tooltip content={t('qualityTemplate.fields.passThresholdTooltip')}>
<Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" /> <Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" />
</Tooltip> </Tooltip>
</label> </label>
<input <input
type="number" type="number"
value={data.passThreshold} value={data.passThreshold || 70}
onChange={(e) => handleFieldChange('passThreshold', e.target.value)} onChange={(e) => handleFieldChange('passThreshold', parseFloat(e.target.value))}
placeholder="70.0" placeholder="70.0"
step="0.1" step="0.1"
min="0" min="0"
@@ -182,32 +471,34 @@ const QualityTemplateDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onData
/> />
</div> </div>
{/* Frequency Days */}
<div> <div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2"> <label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
{t('qualityTemplate.advancedFields.frequencyDays')} {t('qualityTemplate.fields.frequencyDays')}
<Tooltip content={t('tooltips.frequencyDays')}> <Tooltip content={t('qualityTemplate.fields.frequencyDaysTooltip')}>
<Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" /> <Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" />
</Tooltip> </Tooltip>
</label> </label>
<input <input
type="number" type="number"
value={data.frequencyDays} value={data.frequencyDays || ''}
onChange={(e) => handleFieldChange('frequencyDays', e.target.value)} onChange={(e) => handleFieldChange('frequencyDays', e.target.value ? parseInt(e.target.value) : null)}
placeholder={t('qualityTemplate.advancedFields.frequencyPlaceholder')} placeholder={t('qualityTemplate.fields.frequencyDaysPlaceholder')}
min="1" min="1"
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/> />
</div> </div>
{/* Is Required */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
checked={data.isRequired} checked={data.isRequired || false}
onChange={(e) => handleFieldChange('isRequired', e.target.checked)} onChange={(e) => handleFieldChange('isRequired', e.target.checked)}
className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]" className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
/> />
<label className="text-sm text-[var(--text-secondary)]"> <label className="text-sm text-[var(--text-secondary)]">
{t('qualityTemplate.advancedFields.requiredCheck')} {t('qualityTemplate.fields.requiredCheck')}
</label> </label>
</div> </div>
</div> </div>
@@ -226,15 +517,15 @@ const QualityTemplateDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onData
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
<div> <div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2"> <label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
{t('qualityTemplate.advancedFields.checkPointsJsonArray')} {t('qualityTemplate.fields.checkPointsJsonArray')}
<Tooltip content={t('qualityTemplate.advancedFields.checkPointsTooltip')}> <Tooltip content={t('qualityTemplate.fields.checkPointsTooltip')}>
<Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" /> <Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" />
</Tooltip> </Tooltip>
</label> </label>
<textarea <textarea
value={data.checkPoints} value={data.checkPoints || ''}
onChange={(e) => handleFieldChange('checkPoints', e.target.value)} onChange={(e) => handleFieldChange('checkPoints', e.target.value)}
placeholder={t('qualityTemplate.advancedFields.checkPointsPlaceholder')} placeholder={t('qualityTemplate.fields.checkPointsPlaceholder')}
rows={4} rows={4}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] font-mono text-xs" className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] font-mono text-xs"
/> />
@@ -242,12 +533,12 @@ const QualityTemplateDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onData
<div> <div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2"> <label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
{t('qualityTemplate.advancedFields.acceptanceCriteria')} {t('qualityTemplate.fields.acceptanceCriteria')}
</label> </label>
<textarea <textarea
value={data.acceptanceCriteria} value={data.acceptanceCriteria || ''}
onChange={(e) => handleFieldChange('acceptanceCriteria', e.target.value)} onChange={(e) => handleFieldChange('acceptanceCriteria', e.target.value)}
placeholder={t('qualityTemplate.advancedFields.acceptanceCriteriaPlaceholder')} placeholder={t('qualityTemplate.fields.acceptanceCriteriaPlaceholder')}
rows={2} rows={2}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/> />
@@ -260,54 +551,58 @@ const QualityTemplateDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onData
<h5 className="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider"> <h5 className="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">
{t('qualityTemplate.sections.advancedConfiguration')} {t('qualityTemplate.sections.advancedConfiguration')}
</h5> </h5>
<div className="grid grid-cols-1 gap-4"> <div className="space-y-6">
<div> {/* Parameters */}
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2"> <KeyValueEditor
{t('qualityTemplate.advancedFields.parametersJson')} label={t('qualityTemplate.fields.parametersJson')}
<Tooltip content={t('qualityTemplate.advancedFields.parametersTooltip')}> tooltip={t('qualityTemplate.fields.parametersTooltip')}
<Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" /> value={data.parameters}
</Tooltip> onChange={(value) => handleFieldChange('parameters', value)}
</label> placeholder="{}"
<textarea suggestions={
value={data.parameters} data.checkType === 'temperature'
onChange={(e) => handleFieldChange('parameters', e.target.value)} ? [
placeholder={t('qualityTemplate.advancedFields.parametersPlaceholder')} { key: 'temp_min', value: '75', type: 'number' },
rows={2} { key: 'temp_max', value: '85', type: 'number' },
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] font-mono text-xs" { key: 'humidity', value: '65', type: 'number' }
/> ]
</div> : data.checkType === 'weight'
? [
{ key: 'weight_min', value: '450', type: 'number' },
{ key: 'weight_max', value: '550', type: 'number' },
{ key: 'unit', value: 'g', type: 'string' }
]
: []
}
/>
<div> {/* Thresholds */}
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2"> <KeyValueEditor
{t('qualityTemplate.advancedFields.thresholdsJson')} label={t('qualityTemplate.fields.thresholdsJson')}
<Tooltip content={t('qualityTemplate.advancedFields.thresholdsTooltip')}> tooltip={t('qualityTemplate.fields.thresholdsTooltip')}
<Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" /> value={data.thresholds}
</Tooltip> onChange={(value) => handleFieldChange('thresholds', value)}
</label> placeholder="{}"
<textarea suggestions={[
value={data.thresholds} { key: 'critical', value: '90', type: 'number' },
onChange={(e) => handleFieldChange('thresholds', e.target.value)} { key: 'warning', value: '70', type: 'number' },
placeholder={t('qualityTemplate.advancedFields.thresholdsPlaceholder')} { key: 'acceptable', value: '50', type: 'number' }
rows={2} ]}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] font-mono text-xs" />
/>
</div>
<div> {/* Scoring Criteria */}
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2"> <KeyValueEditor
{t('qualityTemplate.advancedFields.scoringCriteriaJson')} label={t('qualityTemplate.fields.scoringCriteriaJson')}
<Tooltip content={t('qualityTemplate.advancedFields.scoringCriteriaTooltip')}> tooltip={t('qualityTemplate.fields.scoringCriteriaTooltip')}
<Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" /> value={data.scoringCriteria}
</Tooltip> onChange={(value) => handleFieldChange('scoringCriteria', value)}
</label> placeholder="{}"
<textarea suggestions={[
value={data.scoringCriteria} { key: 'appearance', value: '30', type: 'number' },
onChange={(e) => handleFieldChange('scoringCriteria', e.target.value)} { key: 'texture', value: '30', type: 'number' },
placeholder={t('qualityTemplate.advancedFields.scoringCriteriaPlaceholder')} { key: 'taste', value: '40', type: 'number' }
rows={2} ]}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] font-mono text-xs" />
/>
</div>
</div> </div>
</div> </div>
@@ -319,38 +614,38 @@ const QualityTemplateDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onData
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2"> <label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
{t('qualityTemplate.advancedFields.responsibleRole')} {t('qualityTemplate.fields.responsibleRole')}
</label> </label>
<input <input
type="text" type="text"
value={data.responsibleRole} value={data.responsibleRole || ''}
onChange={(e) => handleFieldChange('responsibleRole', e.target.value)} onChange={(e) => handleFieldChange('responsibleRole', e.target.value)}
placeholder={t('qualityTemplate.advancedFields.responsibleRolePlaceholder')} placeholder={t('qualityTemplate.fields.responsibleRolePlaceholder')}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2"> <label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
{t('qualityTemplate.advancedFields.requiredEquipment')} {t('qualityTemplate.fields.requiredEquipment')}
</label> </label>
<input <input
type="text" type="text"
value={data.requiredEquipment} value={data.requiredEquipment || ''}
onChange={(e) => handleFieldChange('requiredEquipment', e.target.value)} onChange={(e) => handleFieldChange('requiredEquipment', e.target.value)}
placeholder={t('qualityTemplate.advancedFields.requiredEquipmentPlaceholder')} placeholder={t('qualityTemplate.fields.requiredEquipmentPlaceholder')}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/> />
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2"> <label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
{t('qualityTemplate.advancedFields.specificConditions')} {t('qualityTemplate.fields.specificConditions')}
</label> </label>
<textarea <textarea
value={data.specificConditions} value={data.specificConditions || ''}
onChange={(e) => handleFieldChange('specificConditions', e.target.value)} onChange={(e) => handleFieldChange('specificConditions', e.target.value)}
placeholder={t('qualityTemplate.advancedFields.specificConditionsPlaceholder')} placeholder={t('qualityTemplate.fields.specificConditionsPlaceholder')}
rows={2} rows={2}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/> />
@@ -367,48 +662,48 @@ const QualityTemplateDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onData
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
checked={data.isActive} checked={data.isActive !== false}
onChange={(e) => handleFieldChange('isActive', e.target.checked)} onChange={(e) => handleFieldChange('isActive', e.target.checked)}
className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]" className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
/> />
<label className="text-sm text-[var(--text-secondary)]"> <label className="text-sm text-[var(--text-secondary)]">
{t('qualityTemplate.advancedFields.activeTemplate')} {t('qualityTemplate.fields.activeTemplate')}
</label> </label>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
checked={data.requiresPhoto} checked={data.requiresPhoto || false}
onChange={(e) => handleFieldChange('requiresPhoto', e.target.checked)} onChange={(e) => handleFieldChange('requiresPhoto', e.target.checked)}
className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]" className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
/> />
<label className="text-sm text-[var(--text-secondary)]"> <label className="text-sm text-[var(--text-secondary)]">
{t('qualityTemplate.advancedFields.requiresPhotoEvidence')} {t('qualityTemplate.fields.requiresPhotoEvidence')}
</label> </label>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
checked={data.criticalControlPoint} checked={data.isCritical || false}
onChange={(e) => handleFieldChange('criticalControlPoint', e.target.checked)} onChange={(e) => handleFieldChange('isCritical', e.target.checked)}
className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]" className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
/> />
<label className="text-sm text-[var(--text-secondary)]"> <label className="text-sm text-[var(--text-secondary)]">
{t('qualityTemplate.advancedFields.criticalControlPoint')} {t('qualityTemplate.fields.criticalControlPoint')}
</label> </label>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
checked={data.notifyOnFail} checked={data.notifyOnFail || false}
onChange={(e) => handleFieldChange('notifyOnFail', e.target.checked)} onChange={(e) => handleFieldChange('notifyOnFail', e.target.checked)}
className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]" className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
/> />
<label className="text-sm text-[var(--text-secondary)]"> <label className="text-sm text-[var(--text-secondary)]">
{t('qualityTemplate.advancedFields.notifyOnFailure')} {t('qualityTemplate.fields.notifyOnFailure')}
</label> </label>
</div> </div>
</div> </div>
@@ -422,13 +717,39 @@ export const QualityTemplateWizardSteps = (
dataRef: React.MutableRefObject<Record<string, any>>, dataRef: React.MutableRefObject<Record<string, any>>,
setData: (data: Record<string, any>) => void setData: (data: Record<string, any>) => void
): WizardStep[] => { ): WizardStep[] => {
// New architecture: return direct component references instead of arrow functions
// dataRef and onDataChange are now passed through WizardModal props
return [ return [
{ {
id: 'template-details', id: 'check-type',
title: 'qualityTemplate.advancedFields.templateDetailsTitle', title: 'qualityTemplate.steps.checkType',
component: QualityTemplateDetailsStep, component: QualityCheckTypeStep,
validate: () => {
return !!dataRef.current.checkType;
},
},
{
id: 'essential-configuration',
title: 'qualityTemplate.steps.essentialConfiguration',
component: EssentialConfigurationStep,
validate: () => {
const name = dataRef.current.name;
const weight = dataRef.current.weight;
return !!(
name &&
name.trim().length >= 1 &&
weight !== undefined &&
weight >= 0 &&
weight <= 10
);
},
},
{
id: 'criteria-settings',
title: 'qualityTemplate.steps.criteriaSettings',
component: QualityCriteriaSettingsStep,
validate: () => {
// Optional step - all fields are optional
return true;
},
}, },
]; ];
}; };

View File

@@ -1,17 +1,19 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { WizardStep, WizardStepProps } from '../../../ui/WizardModal/WizardModal'; import { WizardStep, WizardStepProps } from '../../../ui/WizardModal/WizardModal';
import { ChefHat, Package, ClipboardCheck, CheckCircle2, Loader2, Plus, X, Search } from 'lucide-react'; import { ChefHat, Package, ClipboardCheck, CheckCircle2, Loader2, Plus, X, Search, FileText } from 'lucide-react';
import { useTenant } from '../../../../stores/tenant.store'; import { useTenant } from '../../../../stores/tenant.store';
import { recipesService } from '../../../../api/services/recipes'; import { recipesService } from '../../../../api/services/recipes';
import { inventoryService } from '../../../../api/services/inventory'; import { inventoryService } from '../../../../api/services/inventory';
import { qualityTemplateService } from '../../../../api/services/qualityTemplates'; import { qualityTemplateService } from '../../../../api/services/qualityTemplates';
import { IngredientResponse } from '../../../../api/types/inventory'; import { IngredientResponse } from '../../../../api/types/inventory';
import { RecipeCreate, RecipeIngredientCreate, MeasurementUnit, RecipeQualityConfiguration, RecipeStatus } from '../../../../api/types/recipes'; import { RecipeCreate, RecipeIngredientCreate, MeasurementUnit, RecipeQualityConfiguration, RecipeStatus } from '../../../../api/types/recipes';
import { QualityCheckTemplateResponse } from '../../../../api/types/qualityTemplates'; import { QualityCheckTemplate } from '../../../../api/types/qualityTemplates';
import { showToast } from '../../../../utils/toast'; import { showToast } from '../../../../utils/toast';
import { AdvancedOptionsSection } from '../../../ui/AdvancedOptionsSection'; import { AdvancedOptionsSection } from '../../../ui/AdvancedOptionsSection';
import Tooltip from '../../../ui/Tooltip/Tooltip'; import Tooltip from '../../../ui/Tooltip/Tooltip';
import { RecipeInstructionsEditor } from '../../recipes/RecipeInstructionsEditor';
import { RecipeQualityControlEditor } from '../../recipes/RecipeQualityControlEditor';
const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) => { const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) => {
const { t } = useTranslation('wizards'); const { t } = useTranslation('wizards');
@@ -51,7 +53,7 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
<p className="text-sm text-[var(--text-secondary)]">{t('recipe.recipeDetailsDescription')}</p> <p className="text-sm text-[var(--text-secondary)]">{t('recipe.recipeDetailsDescription')}</p>
</div> </div>
{/* Required Fields */} {/* Essential Fields Only */}
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2"> <label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
@@ -59,7 +61,7 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
</label> </label>
<input <input
type="text" type="text"
value={data.name} value={data.name || ''}
onChange={(e) => handleFieldChange('name', e.target.value)} onChange={(e) => handleFieldChange('name', e.target.value)}
placeholder={t('recipe.fields.namePlaceholder')} placeholder={t('recipe.fields.namePlaceholder')}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
@@ -72,7 +74,7 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
{t('recipe.fields.category')} * {t('recipe.fields.category')} *
</label> </label>
<select <select
value={data.category} value={data.category || 'bread'}
onChange={(e) => handleFieldChange('category', e.target.value)} onChange={(e) => handleFieldChange('category', e.target.value)}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
> >
@@ -95,7 +97,7 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
</Tooltip> </Tooltip>
</label> </label>
<select <select
value={data.finishedProductId} value={data.finishedProductId || ''}
onChange={(e) => handleFieldChange('finishedProductId', e.target.value)} onChange={(e) => handleFieldChange('finishedProductId', e.target.value)}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
disabled={loading} disabled={loading}
@@ -110,12 +112,15 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2"> <label className="block text-sm font-medium text-[var(--text-secondary)] mb-2 inline-flex items-center gap-2">
{t('recipe.fields.yieldQuantity')} * {t('recipe.fields.yieldQuantity')} *
<Tooltip content="How many units this recipe produces (e.g., 12 loaves)">
<span />
</Tooltip>
</label> </label>
<input <input
type="number" type="number"
value={data.yieldQuantity} value={data.yieldQuantity || ''}
onChange={(e) => handleFieldChange('yieldQuantity', e.target.value)} onChange={(e) => handleFieldChange('yieldQuantity', e.target.value)}
placeholder="12" placeholder="12"
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
@@ -129,7 +134,7 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
{t('recipe.fields.yieldUnit')} * {t('recipe.fields.yieldUnit')} *
</label> </label>
<select <select
value={data.yieldUnit} value={data.yieldUnit || 'units'}
onChange={(e) => handleFieldChange('yieldUnit', e.target.value)} onChange={(e) => handleFieldChange('yieldUnit', e.target.value)}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
> >
@@ -144,31 +149,21 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2"> <label className="block text-sm font-medium text-[var(--text-secondary)] mb-2 inline-flex items-center gap-2">
{t('recipe.fields.prepTime')} {t('recipe.fields.prepTime')} (minutes)
<Tooltip content="Total preparation time in minutes">
<span />
</Tooltip>
</label> </label>
<input <input
type="number" type="number"
value={data.prepTime} value={data.prepTime || ''}
onChange={(e) => handleFieldChange('prepTime', e.target.value)} onChange={(e) => handleFieldChange('prepTime', e.target.value)}
placeholder="60" placeholder="60"
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
min="0" min="0"
/> />
</div> </div>
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
{t('recipe.fields.instructions')}
</label>
<textarea
value={data.instructions}
onChange={(e) => handleFieldChange('instructions', e.target.value)}
placeholder={t('recipe.fields.instructionsPlaceholder')}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
rows={4}
/>
</div>
</div> </div>
{/* Advanced Options */} {/* Advanced Options */}
@@ -456,13 +451,16 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2"> <label className="block text-sm font-medium text-[var(--text-secondary)] mb-2 inline-flex items-center gap-2">
Preparation Notes Recipe Notes & Tips
<Tooltip content="General notes, tips, or context about this recipe (not step-by-step instructions)">
<span />
</Tooltip>
</label> </label>
<textarea <textarea
value={data.preparationNotes} value={data.preparationNotes}
onChange={(e) => handleFieldChange('preparationNotes', e.target.value)} onChange={(e) => handleFieldChange('preparationNotes', e.target.value)}
placeholder="Tips and notes for preparation..." placeholder="e.g., 'Works best in humid conditions', 'Can be prepared a day ahead', 'Traditional family recipe'..."
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
rows={3} rows={3}
/> />
@@ -511,12 +509,47 @@ const RecipeDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange })
); );
}; };
// New Step 2: Recipe Instructions
const RecipeInstructionsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) => {
const { t } = useTranslation('wizards');
const data = dataRef?.current || {};
const handleInstructionsChange = (instructions: any) => {
onDataChange?.({ ...data, instructions });
};
return (
<div className="space-y-6">
<div className="text-center pb-4 border-b border-[var(--border-primary)]">
<FileText className="w-12 h-12 mx-auto mb-3 text-[var(--color-primary)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
Recipe Instructions
</h3>
<p className="text-sm text-[var(--text-secondary)]">
Add step-by-step instructions for preparing {data.name || 'this recipe'}
</p>
</div>
<RecipeInstructionsEditor
value={data.instructions || null}
onChange={handleInstructionsChange}
/>
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-700">
<strong>Tip:</strong> Break down the recipe into clear, manageable steps. Include durations to help with production planning.
</div>
</div>
);
};
interface SelectedIngredient { interface SelectedIngredient {
id: string; id: string;
ingredientId: string; ingredientId: string;
quantity: number; quantity: number;
unit: MeasurementUnit; unit: MeasurementUnit;
notes: string; notes: string;
preparationMethod?: string;
isOptional?: boolean;
order: number; order: number;
} }
@@ -555,6 +588,8 @@ const IngredientsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) =
quantity: 0, quantity: 0,
unit: MeasurementUnit.GRAMS, unit: MeasurementUnit.GRAMS,
notes: '', notes: '',
preparationMethod: '',
isOptional: false,
order: (data.ingredients || []).length + 1, order: (data.ingredients || []).length + 1,
}; };
onDataChange?.({ ...data, ingredients: [...(data.ingredients || []), newIngredient] }); onDataChange?.({ ...data, ingredients: [...(data.ingredients || []), newIngredient] });
@@ -576,7 +611,7 @@ const IngredientsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) =
}); });
}; };
const filteredIngredients = ingredients.filter(ing => const filteredIngredients = ingredients.filter((ing: IngredientResponse) =>
ing.name.toLowerCase().includes(searchTerm.toLowerCase()) ing.name.toLowerCase().includes(searchTerm.toLowerCase())
); );
@@ -584,8 +619,8 @@ const IngredientsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) =
<div className="space-y-6"> <div className="space-y-6">
<div className="text-center pb-4 border-b border-[var(--border-primary)]"> <div className="text-center pb-4 border-b border-[var(--border-primary)]">
<Package className="w-12 h-12 mx-auto mb-3 text-[var(--color-primary)]" /> <Package className="w-12 h-12 mx-auto mb-3 text-[var(--color-primary)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">{t('recipe.ingredients')}</h3> <h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">Recipe Ingredients</h3>
<p className="text-sm text-[var(--text-secondary)]">{data.name}</p> <p className="text-sm text-[var(--text-secondary)]">Add ingredients for {data.name || 'this recipe'}</p>
</div> </div>
{error && ( {error && (
@@ -609,76 +644,109 @@ const IngredientsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) =
<p className="text-sm text-[var(--text-tertiary)]">Click "Add Ingredient" to begin</p> <p className="text-sm text-[var(--text-tertiary)]">Click "Add Ingredient" to begin</p>
</div> </div>
) : ( ) : (
(data.ingredients || []).map((selectedIng) => ( (data.ingredients || []).map((selectedIng: SelectedIngredient) => (
<div key={selectedIng.id} className="p-4 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]"> <div key={selectedIng.id} className="p-4 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]">
<div className="grid grid-cols-1 md:grid-cols-12 gap-3 items-start"> <div className="space-y-3">
<div className="md:col-span-5"> {/* Row 1: Ingredient, Quantity, Unit */}
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Ingredient *</label> <div className="grid grid-cols-1 md:grid-cols-12 gap-3 items-start">
<div className="relative"> <div className="md:col-span-6">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" /> <label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Ingredient *</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
<select
value={selectedIng.ingredientId}
onChange={(e) => handleUpdateIngredient(selectedIng.id, 'ingredientId', e.target.value)}
className="w-full pl-9 pr-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-sm"
>
<option value="">Select...</option>
{filteredIngredients.map((ing: IngredientResponse) => (
<option key={ing.id} value={ing.id}>
{ing.name} {ing.category ? `(${ing.category})` : ''}
</option>
))}
</select>
</div>
</div>
<div className="md:col-span-2">
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Quantity *</label>
<input
type="number"
value={selectedIng.quantity || ''}
onChange={(e) => handleUpdateIngredient(selectedIng.id, 'quantity', parseFloat(e.target.value) || 0)}
placeholder="0"
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-sm"
min="0"
step="0.01"
/>
</div>
<div className="md:col-span-3">
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Unit *</label>
<select <select
value={selectedIng.ingredientId} value={selectedIng.unit}
onChange={(e) => handleUpdateIngredient(selectedIng.id, 'ingredientId', e.target.value)} onChange={(e) => handleUpdateIngredient(selectedIng.id, 'unit', e.target.value as MeasurementUnit)}
className="w-full pl-9 pr-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-sm" className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-sm"
> >
<option value="">Select...</option> <option value={MeasurementUnit.GRAMS}>Grams (g)</option>
{filteredIngredients.map(ing => ( <option value={MeasurementUnit.KILOGRAMS}>Kilograms (kg)</option>
<option key={ing.id} value={ing.id}> <option value={MeasurementUnit.MILLILITERS}>Milliliters (ml)</option>
{ing.name} {ing.category ? `(${ing.category})` : ''} <option value={MeasurementUnit.LITERS}>Liters (l)</option>
</option> <option value={MeasurementUnit.UNITS}>Units</option>
))} <option value={MeasurementUnit.PIECES}>Pieces</option>
<option value={MeasurementUnit.CUPS}>Cups</option>
<option value={MeasurementUnit.TABLESPOONS}>Tablespoons</option>
<option value={MeasurementUnit.TEASPOONS}>Teaspoons</option>
</select> </select>
</div> </div>
<div className="md:col-span-1 flex items-end">
<button
type="button"
onClick={() => handleRemoveIngredient(selectedIng.id)}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Remove ingredient"
>
<X className="w-5 h-5" />
</button>
</div>
</div> </div>
<div className="md:col-span-2">
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Quantity *</label> {/* Row 2: Preparation Method and Optional Checkbox */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-3 items-start">
<div className="md:col-span-8">
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
Preparation Method
</label>
<input
type="text"
value={selectedIng.preparationMethod || ''}
onChange={(e) => handleUpdateIngredient(selectedIng.id, 'preparationMethod', e.target.value)}
placeholder="e.g., sifted, melted, chopped"
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-sm"
/>
</div>
<div className="md:col-span-4">
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Notes</label>
<input
type="text"
value={selectedIng.notes}
onChange={(e) => handleUpdateIngredient(selectedIng.id, 'notes', e.target.value)}
placeholder="Optional"
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-sm"
/>
</div>
</div>
{/* Row 3: Optional Checkbox */}
<div className="flex items-center gap-2">
<input <input
type="number" type="checkbox"
value={selectedIng.quantity || ''} id={`optional-${selectedIng.id}`}
onChange={(e) => handleUpdateIngredient(selectedIng.id, 'quantity', parseFloat(e.target.value) || 0)} checked={selectedIng.isOptional || false}
placeholder="0" onChange={(e) => handleUpdateIngredient(selectedIng.id, 'isOptional', e.target.checked)}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-sm" className="w-4 h-4 text-[var(--color-primary)] border-[var(--border-secondary)] rounded focus:ring-2 focus:ring-[var(--color-primary)]"
min="0"
step="0.01"
/> />
</div> <label htmlFor={`optional-${selectedIng.id}`} className="text-xs text-[var(--text-secondary)]">
<div className="md:col-span-2"> This ingredient is optional
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Unit *</label> </label>
<select
value={selectedIng.unit}
onChange={(e) => handleUpdateIngredient(selectedIng.id, 'unit', e.target.value as MeasurementUnit)}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-sm"
>
<option value={MeasurementUnit.GRAMS}>Grams (g)</option>
<option value={MeasurementUnit.KILOGRAMS}>Kilograms (kg)</option>
<option value={MeasurementUnit.MILLILITERS}>Milliliters (ml)</option>
<option value={MeasurementUnit.LITERS}>Liters (l)</option>
<option value={MeasurementUnit.UNITS}>Units</option>
<option value={MeasurementUnit.PIECES}>Pieces</option>
<option value={MeasurementUnit.CUPS}>Cups</option>
<option value={MeasurementUnit.TABLESPOONS}>Tablespoons</option>
<option value={MeasurementUnit.TEASPOONS}>Teaspoons</option>
</select>
</div>
<div className="md:col-span-2">
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Notes</label>
<input
type="text"
value={selectedIng.notes}
onChange={(e) => handleUpdateIngredient(selectedIng.id, 'notes', e.target.value)}
placeholder="Optional"
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-sm"
/>
</div>
<div className="md:col-span-1 flex items-end">
<button
type="button"
onClick={() => handleRemoveIngredient(selectedIng.id)}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Remove ingredient"
>
<X className="w-5 h-5" />
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -700,11 +768,12 @@ const IngredientsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) =
); );
}; };
const QualityTemplatesStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange, onComplete }) => { // New Step 4: Enhanced Quality Control Configuration
const QualityControlStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange, onComplete }) => {
const { t } = useTranslation('wizards'); const { t } = useTranslation('wizards');
const data = dataRef?.current || {}; const data = dataRef?.current || {};
const { currentTenant } = useTenant(); const { currentTenant } = useTenant();
const [templates, setTemplates] = useState<QualityCheckTemplateResponse[]>([]); const [templates, setTemplates] = useState<QualityCheckTemplate[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -727,12 +796,8 @@ const QualityTemplatesStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange
} }
}; };
const toggleTemplate = (templateId: string) => { const handleQualityConfigChange = (config: any) => {
const currentTemplates = data.selectedTemplates || []; onDataChange?.({ ...data, qualityConfiguration: config });
const newTemplates = currentTemplates.includes(templateId)
? currentTemplates.filter(id => id !== templateId)
: [...currentTemplates, templateId];
onDataChange?.({ ...data, selectedTemplates: newTemplates });
}; };
const handleCreateRecipe = async () => { const handleCreateRecipe = async () => {
@@ -750,28 +815,13 @@ const QualityTemplatesStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange
quantity: ing.quantity, quantity: ing.quantity,
unit: ing.unit, unit: ing.unit,
ingredient_notes: ing.notes || null, ingredient_notes: ing.notes || null,
is_optional: false, preparation_method: ing.preparationMethod || null,
is_optional: ing.isOptional || false,
ingredient_order: index + 1, ingredient_order: index + 1,
})); }));
let qualityConfig: RecipeQualityConfiguration | undefined; // Use the quality configuration from the editor if available
if ((data.selectedTemplates || []).length > 0) { const qualityConfig: RecipeQualityConfiguration | undefined = data.qualityConfiguration;
qualityConfig = {
stages: {
production: {
template_ids: data.selectedTemplates || [],
required_checks: [],
optional_checks: [],
blocking_on_failure: true,
min_quality_score: 7.0,
}
},
overall_quality_threshold: 7.0,
critical_stage_blocking: true,
auto_create_quality_checks: true,
quality_manager_approval_required: false,
};
}
const recipeData: RecipeCreate = { const recipeData: RecipeCreate = {
name: data.name, name: data.name,
@@ -800,7 +850,7 @@ const QualityTemplatesStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange
is_signature_item: data.isSignatureItem || false, is_signature_item: data.isSignatureItem || false,
season_start_month: data.seasonStartMonth ? parseInt(data.seasonStartMonth) : null, season_start_month: data.seasonStartMonth ? parseInt(data.seasonStartMonth) : null,
season_end_month: data.seasonEndMonth ? parseInt(data.seasonEndMonth) : null, season_end_month: data.seasonEndMonth ? parseInt(data.seasonEndMonth) : null,
instructions: data.instructions ? { steps: data.instructions } : null, instructions: data.instructions || null,
allergen_info: data.allergens ? data.allergens.split(',').map((a: string) => a.trim()) : null, allergen_info: data.allergens ? data.allergens.split(',').map((a: string) => a.trim()) : null,
dietary_tags: data.dietaryTags ? data.dietaryTags.split(',').map((t: string) => t.trim()) : null, dietary_tags: data.dietaryTags ? data.dietaryTags.split(',').map((t: string) => t.trim()) : null,
ingredients: recipeIngredients, ingredients: recipeIngredients,
@@ -820,15 +870,22 @@ const QualityTemplatesStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange
} }
}; };
// Format templates for the editor
const formattedTemplates = templates.map(t => ({
id: t.id,
name: t.name,
category: t.check_type || 'general'
}));
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="text-center pb-4 border-b border-[var(--border-primary)]"> <div className="text-center pb-4 border-b border-[var(--border-primary)]">
<ClipboardCheck className="w-12 h-12 mx-auto mb-3 text-[var(--color-primary)]" /> <ClipboardCheck className="w-12 h-12 mx-auto mb-3 text-[var(--color-primary)]" />
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2"> <h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
Quality Templates (Optional) Quality Control Configuration (Optional)
</h3> </h3>
<p className="text-sm text-[var(--text-secondary)]"> <p className="text-sm text-[var(--text-secondary)]">
Select quality control templates to apply to this recipe Configure quality control checks for each production stage
</p> </p>
</div> </div>
@@ -843,67 +900,25 @@ const QualityTemplatesStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange
<Loader2 className="w-8 h-8 animate-spin text-[var(--color-primary)]" /> <Loader2 className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
<span className="ml-3 text-[var(--text-secondary)]">Loading templates...</span> <span className="ml-3 text-[var(--text-secondary)]">Loading templates...</span>
</div> </div>
) : templates.length === 0 ? (
<div className="text-center py-12 border-2 border-dashed border-[var(--border-secondary)] rounded-lg">
<ClipboardCheck className="w-12 h-12 mx-auto mb-3 text-[var(--text-tertiary)]" />
<p className="text-[var(--text-secondary)] mb-2">No quality templates available</p>
<p className="text-sm text-[var(--text-tertiary)]">
You can create templates from the Database menu
</p>
</div>
) : ( ) : (
<> <>
{templates.length === 0 ? ( <RecipeQualityControlEditor
<div className="text-center py-12 border-2 border-dashed border-[var(--border-secondary)] rounded-lg"> value={data.qualityConfiguration || null}
<ClipboardCheck className="w-12 h-12 mx-auto mb-3 text-[var(--text-tertiary)]" /> onChange={handleQualityConfigChange}
<p className="text-[var(--text-secondary)] mb-2">No quality templates available</p> allTemplates={formattedTemplates}
<p className="text-sm text-[var(--text-tertiary)]"> />
You can create templates from the main wizard
</p>
</div>
) : (
<div className="space-y-3 max-h-96 overflow-y-auto">
{templates.map((template) => (
<button
key={template.id}
type="button"
onClick={() => toggleTemplate(template.id)}
className={`w-full p-4 rounded-lg border-2 transition-all text-left ${
(data.selectedTemplates || []).includes(template.id)
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/5'
: 'border-[var(--border-secondary)] hover:border-[var(--color-primary)]/50'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-semibold text-[var(--text-primary)]">{template.name}</h4>
{template.is_required && (
<span className="px-2 py-0.5 text-xs bg-orange-100 text-orange-700 rounded-full">
Required
</span>
)}
</div>
{template.description && (
<p className="text-sm text-[var(--text-secondary)] line-clamp-2">
{template.description}
</p>
)}
<div className="flex items-center gap-3 mt-2 text-xs text-[var(--text-tertiary)]">
<span>Type: {template.check_type}</span>
{template.frequency_days && (
<span> Every {template.frequency_days} days</span>
)}
</div>
</div>
{(data.selectedTemplates || []).includes(template.id) && (
<CheckCircle2 className="w-5 h-5 text-[var(--color-primary)] flex-shrink-0 ml-3" />
)}
</div>
</button>
))}
</div>
)}
{(data.selectedTemplates || []).length > 0 && ( <div className="p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-700">
<div className="p-4 bg-[var(--color-primary)]/5 rounded-lg border border-[var(--color-primary)]/20"> <strong>Tip:</strong> Configure which quality checks are required at each production stage. You can make certain stages blocking to halt production if quality checks fail.
<p className="text-sm text-[var(--text-primary)]"> </div>
<strong>{(data.selectedTemplates || []).length}</strong> template(s) selected
</p>
</div>
)}
</> </>
)} )}
@@ -940,18 +955,62 @@ export const RecipeWizardSteps = (dataRef: React.MutableRefObject<Record<string,
title: 'wizards:recipe.steps.recipeDetails', title: 'wizards:recipe.steps.recipeDetails',
description: 'wizards:recipe.steps.recipeDetailsDescription', description: 'wizards:recipe.steps.recipeDetailsDescription',
component: RecipeDetailsStep, component: RecipeDetailsStep,
validate: () => {
const data = dataRef.current;
// Validate required fields
if (!data.name || data.name.trim().length < 2) {
return false;
}
if (!data.category) {
return false;
}
if (!data.finishedProductId) {
return false;
}
if (!data.yieldQuantity || parseFloat(data.yieldQuantity) <= 0) {
return false;
}
if (!data.yieldUnit) {
return false;
}
return true;
},
},
{
id: 'recipe-instructions',
title: 'Recipe Instructions',
description: 'Add step-by-step preparation instructions',
component: RecipeInstructionsStep,
isOptional: true,
}, },
{ {
id: 'recipe-ingredients', id: 'recipe-ingredients',
title: 'wizards:recipe.steps.ingredients', title: 'wizards:recipe.steps.ingredients',
description: 'wizards:recipe.steps.ingredientsDescription', description: 'wizards:recipe.steps.ingredientsDescription',
component: IngredientsStep, component: IngredientsStep,
validate: () => {
const data = dataRef.current;
const ingredients = data.ingredients || [];
// Must have at least one ingredient
if (ingredients.length === 0) {
return false;
}
// Each ingredient must have required fields
return ingredients.every((ing: any) =>
ing.ingredientId &&
ing.quantity &&
parseFloat(ing.quantity) > 0 &&
ing.unit
);
},
}, },
{ {
id: 'recipe-quality-templates', id: 'recipe-quality-control',
title: 'wizards:recipe.steps.qualityTemplates', title: 'Quality Control',
description: 'wizards:recipe.steps.qualityTemplatesDescription', description: 'Configure quality checks for production stages',
component: QualityTemplatesStep, component: QualityControlStep,
isOptional: true, isOptional: true,
}, },
]; ];

View File

@@ -0,0 +1,324 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Trash2, Code } from 'lucide-react';
import Tooltip from '../Tooltip/Tooltip';
import { Info } from 'lucide-react';
interface KeyValuePair {
id: string;
key: string;
value: string;
type: 'string' | 'number' | 'boolean';
}
interface KeyValueEditorProps {
label: string;
tooltip?: string;
value?: Record<string, any> | string; // Can accept JSON object or JSON string
onChange?: (value: Record<string, any>) => void;
placeholder?: string;
suggestions?: Array<{ key: string; value: string; type?: 'string' | 'number' | 'boolean' }>;
className?: string;
}
export const KeyValueEditor: React.FC<KeyValueEditorProps> = ({
label,
tooltip,
value,
onChange,
placeholder,
suggestions = [],
className = ''
}) => {
const { t } = useTranslation('wizards');
const [pairs, setPairs] = useState<KeyValuePair[]>([]);
const [showRawJson, setShowRawJson] = useState(false);
const [rawJson, setRawJson] = useState('');
const [jsonError, setJsonError] = useState<string | null>(null);
// Initialize pairs from value
useEffect(() => {
if (!value) {
setPairs([]);
setRawJson('{}');
return;
}
try {
let jsonObj: Record<string, any>;
if (typeof value === 'string') {
if (value.trim() === '') {
setPairs([]);
setRawJson('{}');
return;
}
jsonObj = JSON.parse(value);
} else {
jsonObj = value;
}
const newPairs: KeyValuePair[] = Object.entries(jsonObj).map(([key, val], index) => ({
id: `pair-${Date.now()}-${index}`,
key,
value: String(val),
type: detectType(val)
}));
setPairs(newPairs);
setRawJson(JSON.stringify(jsonObj, null, 2));
setJsonError(null);
} catch (error) {
// If parsing fails, treat as empty
setPairs([]);
setRawJson(typeof value === 'string' ? value : '{}');
setJsonError('Invalid JSON');
}
}, [value]);
const detectType = (value: any): 'string' | 'number' | 'boolean' => {
if (typeof value === 'boolean') return 'boolean';
if (typeof value === 'number') return 'number';
return 'string';
};
const convertValue = (value: string, type: 'string' | 'number' | 'boolean'): any => {
if (type === 'boolean') {
return value === 'true' || value === '1';
}
if (type === 'number') {
const num = parseFloat(value);
return isNaN(num) ? 0 : num;
}
return value;
};
const pairsToJson = (pairs: KeyValuePair[]): Record<string, any> => {
const obj: Record<string, any> = {};
pairs.forEach(pair => {
if (pair.key.trim()) {
obj[pair.key.trim()] = convertValue(pair.value, pair.type);
}
});
return obj;
};
const handleAddPair = () => {
const newPair: KeyValuePair = {
id: `pair-${Date.now()}`,
key: '',
value: '',
type: 'string'
};
const newPairs = [...pairs, newPair];
setPairs(newPairs);
onChange?.(pairsToJson(newPairs));
};
const handleRemovePair = (id: string) => {
const newPairs = pairs.filter(p => p.id !== id);
setPairs(newPairs);
onChange?.(pairsToJson(newPairs));
};
const handlePairChange = (id: string, field: 'key' | 'value' | 'type', newValue: string) => {
const newPairs = pairs.map(pair => {
if (pair.id === id) {
return { ...pair, [field]: newValue };
}
return pair;
});
setPairs(newPairs);
onChange?.(pairsToJson(newPairs));
};
const handleApplySuggestion = (suggestion: { key: string; value: string; type?: 'string' | 'number' | 'boolean' }) => {
// Check if key already exists
const existingPair = pairs.find(p => p.key === suggestion.key);
if (existingPair) {
// Update existing
handlePairChange(existingPair.id, 'value', suggestion.value);
if (suggestion.type) {
handlePairChange(existingPair.id, 'type', suggestion.type);
}
} else {
// Add new
const newPair: KeyValuePair = {
id: `pair-${Date.now()}`,
key: suggestion.key,
value: suggestion.value,
type: suggestion.type || 'string'
};
const newPairs = [...pairs, newPair];
setPairs(newPairs);
onChange?.(pairsToJson(newPairs));
}
};
const handleRawJsonChange = (jsonString: string) => {
setRawJson(jsonString);
try {
const parsed = JSON.parse(jsonString);
setJsonError(null);
const newPairs: KeyValuePair[] = Object.entries(parsed).map(([key, val], index) => ({
id: `pair-${Date.now()}-${index}`,
key,
value: String(val),
type: detectType(val)
}));
setPairs(newPairs);
onChange?.(parsed);
} catch (error) {
setJsonError('Invalid JSON format');
}
};
return (
<div className={`space-y-3 ${className}`}>
{/* Header */}
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-[var(--text-secondary)]">
{label}
{tooltip && (
<Tooltip content={tooltip}>
<Info className="inline w-4 h-4 ml-1 text-[var(--text-tertiary)]" />
</Tooltip>
)}
</label>
<button
type="button"
onClick={() => setShowRawJson(!showRawJson)}
className="text-xs text-[var(--text-tertiary)] hover:text-[var(--text-primary)] flex items-center gap-1 transition-colors"
>
<Code className="w-3 h-3" />
{showRawJson ? t('keyValueEditor.showBuilder') : t('keyValueEditor.showJson')}
</button>
</div>
{showRawJson ? (
/* Raw JSON Editor */
<div className="space-y-2">
<textarea
value={rawJson}
onChange={(e) => handleRawJsonChange(e.target.value)}
placeholder={placeholder || '{}'}
rows={6}
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] font-mono text-xs ${
jsonError ? 'border-red-500' : 'border-[var(--border-secondary)]'
}`}
/>
{jsonError && (
<p className="text-xs text-red-500">{jsonError}</p>
)}
</div>
) : (
/* Key-Value Builder */
<div className="space-y-2">
{/* Suggestions */}
{suggestions.length > 0 && pairs.length === 0 && (
<div className="bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg p-3">
<p className="text-xs text-[var(--text-tertiary)] mb-2">
{t('keyValueEditor.suggestions')}:
</p>
<div className="flex flex-wrap gap-2">
{suggestions.map((suggestion, index) => (
<button
key={index}
type="button"
onClick={() => handleApplySuggestion(suggestion)}
className="text-xs px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded hover:border-[var(--color-primary)] transition-colors"
>
{suggestion.key}: {suggestion.value}
</button>
))}
</div>
</div>
)}
{/* Key-Value Pairs */}
{pairs.length > 0 && (
<div className="space-y-2 max-h-64 overflow-y-auto">
{pairs.map((pair) => (
<div key={pair.id} className="flex items-center gap-2">
{/* Key Input */}
<input
type="text"
value={pair.key}
onChange={(e) => handlePairChange(pair.id, 'key', e.target.value)}
placeholder={t('keyValueEditor.keyPlaceholder')}
className="flex-1 px-2 py-1.5 text-sm border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
{/* Value Input */}
{pair.type === 'boolean' ? (
<select
value={pair.value}
onChange={(e) => handlePairChange(pair.id, 'value', e.target.value)}
className="flex-1 px-2 py-1.5 text-sm border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
>
<option value="true">true</option>
<option value="false">false</option>
</select>
) : (
<input
type={pair.type === 'number' ? 'number' : 'text'}
value={pair.value}
onChange={(e) => handlePairChange(pair.id, 'value', e.target.value)}
placeholder={t('keyValueEditor.valuePlaceholder')}
step={pair.type === 'number' ? '0.01' : undefined}
className="flex-1 px-2 py-1.5 text-sm border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
)}
{/* Type Selector */}
<select
value={pair.type}
onChange={(e) => handlePairChange(pair.id, 'type', e.target.value)}
className="w-24 px-2 py-1.5 text-xs border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-secondary)]"
>
<option value="string">Text</option>
<option value="number">Number</option>
<option value="boolean">Bool</option>
</select>
{/* Delete Button */}
<button
type="button"
onClick={() => handleRemovePair(pair.id)}
className="p-1.5 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
title={t('keyValueEditor.remove')}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
{/* Add Button */}
<button
type="button"
onClick={handleAddPair}
className="w-full py-2 border-2 border-dashed border-[var(--border-secondary)] text-[var(--text-secondary)] rounded-lg hover:border-[var(--color-primary)] hover:text-[var(--color-primary)] transition-colors font-medium text-sm inline-flex items-center justify-center gap-2"
>
<Plus className="w-4 h-4" />
{t('keyValueEditor.addPair')}
</button>
{/* Empty State */}
{pairs.length === 0 && suggestions.length === 0 && (
<p className="text-xs text-center text-[var(--text-tertiary)] py-4">
{t('keyValueEditor.emptyState')}
</p>
)}
</div>
)}
</div>
);
};
export default KeyValueEditor;

View File

@@ -0,0 +1,2 @@
export { KeyValueEditor } from './KeyValueEditor';
export default from './KeyValueEditor';

View File

@@ -38,7 +38,10 @@
"savings": { "savings": {
"label": "💰 SAVINGS", "label": "💰 SAVINGS",
"this_week": "this week", "this_week": "this week",
"vs_last": "vs. last" "vs_last": "vs. last",
"value_this_week": "€{amount} this week",
"detail_vs_last_positive": "+{percentage}% vs. last",
"detail_vs_last_negative": "{percentage}% vs. last"
}, },
"inventory": { "inventory": {
"label": "📦 INVENTORY", "label": "📦 INVENTORY",
@@ -52,7 +55,9 @@
"waste": { "waste": {
"label": "♻️ WASTE", "label": "♻️ WASTE",
"this_month": "this month", "this_month": "this month",
"vs_goal": "vs. goal" "vs_goal": "vs. goal",
"value_this_month": "{percentage}% this month",
"detail_vs_goal": "{change}% vs. goal"
}, },
"deliveries": { "deliveries": {
"label": "🚚 DELIVERIES", "label": "🚚 DELIVERIES",
@@ -129,6 +134,14 @@
"remove": "Remove", "remove": "Remove",
"active_count": "{count} active alerts" "active_count": "{count} active alerts"
}, },
"production": {
"scheduled_based_on": "Scheduled based on {{type}}",
"status": {
"completed": "COMPLETED",
"in_progress": "IN PROGRESS",
"pending": "PENDING"
}
},
"messages": { "messages": {
"welcome": "Welcome back", "welcome": "Welcome back",
"good_morning": "Good morning", "good_morning": "Good morning",
@@ -203,6 +216,28 @@
"cost_analysis": "Cost Analysis" "cost_analysis": "Cost Analysis"
} }
}, },
"action_queue": {
"consequences": {
"delayed_delivery": "Delayed delivery may impact production schedule",
"immediate_action": "Immediate action required to prevent production issues",
"limited_features": "Some features are limited"
},
"titles": {
"purchase_order": "Purchase Order {po_number}",
"supplier": "Supplier: {supplier_name}",
"pending_approval": "Pending approval for {supplier_name} - {type}"
},
"buttons": {
"view_details": "View Details",
"dismiss": "Dismiss",
"approve": "Approve",
"modify": "Modify",
"complete_setup": "Complete Setup"
}
},
"orchestration": {
"no_runs_message": "No orchestration has been run yet. Click 'Run Daily Planning' to generate your first plan."
},
"errors": { "errors": {
"failed_to_load_stats": "Failed to load dashboard statistics. Please try again." "failed_to_load_stats": "Failed to load dashboard statistics. Please try again."
} }

View File

@@ -83,7 +83,7 @@
}, },
"accuracy": "Accuracy: 92% (vs 60-70% for generic systems)", "accuracy": "Accuracy: 92% (vs 60-70% for generic systems)",
"cta": "See All Features", "cta": "See All Features",
"key1": "🎯 Precision:", "key1": "🎯 Precision: ",
"key2": "(vs 60-70% of generic systems)" "key2": "(vs 60-70% of generic systems)"
}, },
"pillar2": { "pillar2": {
@@ -121,7 +121,7 @@
"cta": "See All Features" "cta": "See All Features"
}, },
"pillar3": { "pillar3": {
"title": "Your Data, Your Environmental Impact", "title": "🌱 Your Data, Your Environmental Impact",
"intro": "100% of your data belongs to you. Measure your environmental impact automatically and generate sustainability reports that comply with international standards.", "intro": "100% of your data belongs to you. Measure your environmental impact automatically and generate sustainability reports that comply with international standards.",
"data_ownership_value": "100%", "data_ownership_value": "100%",
"data_ownership": "Data ownership", "data_ownership": "Data ownership",

View File

@@ -157,5 +157,15 @@
"view_alert": "View Details", "view_alert": "View Details",
"run_planning": "Run Daily Planning" "run_planning": "Run Daily Planning"
} }
},
"types": {
"low_stock_detection": "Low stock detected for {{product_name}}. Stock will run out in {{days_until_stockout}} days.",
"stockout_prevention": "Preventing stockout for critical ingredients",
"forecast_demand": "Based on demand forecast: {{predicted_demand}} units predicted ({{confidence_score}}% confidence)",
"customer_orders": "Fulfilling confirmed customer orders",
"seasonal_demand": "Anticipated seasonal demand increase",
"inventory_replenishment": "Regular inventory replenishment",
"production_schedule": "Scheduled production batch",
"other": "Standard replenishment"
} }
} }

View File

@@ -8,6 +8,16 @@
"willBeGeneratedAutomatically": "Will be generated automatically", "willBeGeneratedAutomatically": "Will be generated automatically",
"autoGeneratedOnSave": "Auto-generated on save" "autoGeneratedOnSave": "Auto-generated on save"
}, },
"keyValueEditor": {
"showBuilder": "Show Builder",
"showJson": "Show JSON",
"suggestions": "Quick suggestions",
"keyPlaceholder": "Key",
"valuePlaceholder": "Value",
"remove": "Remove",
"addPair": "Add Parameter",
"emptyState": "No parameters yet. Click 'Add Parameter' to get started."
},
"inventory": { "inventory": {
"title": "Add Inventory", "title": "Add Inventory",
"inventoryDetails": "Inventory Item Details", "inventoryDetails": "Inventory Item Details",
@@ -168,6 +178,17 @@
"title": "Add Quality Template", "title": "Add Quality Template",
"templateDetails": "Quality Template Details", "templateDetails": "Quality Template Details",
"fillRequiredInfo": "Fill in the required information to create a quality check template", "fillRequiredInfo": "Fill in the required information to create a quality check template",
"selectCheckType": "Select Quality Check Type",
"selectCheckTypeDescription": "Choose the type of quality check you want to create",
"essentialConfiguration": "Essential Configuration",
"essentialConfigurationDescription": "Define the core properties of your quality check template",
"criteriaAndSettings": "Quality Criteria & Settings",
"criteriaAndSettingsDescription": "Configure scoring methods and advanced quality criteria",
"steps": {
"checkType": "Check Type",
"essentialConfiguration": "Configuration",
"criteriaSettings": "Criteria & Settings"
},
"fields": { "fields": {
"name": "Name", "name": "Name",
"namePlaceholder": "E.g., Bread Quality Control, Hygiene Inspection", "namePlaceholder": "E.g., Bread Quality Control, Hygiene Inspection",
@@ -177,24 +198,90 @@
"templateCode": "Template Code", "templateCode": "Template Code",
"templateCodePlaceholder": "Leave empty for auto-generation", "templateCodePlaceholder": "Leave empty for auto-generation",
"templateCodeTooltip": "Leave empty to auto-generate from backend, or enter custom code", "templateCodeTooltip": "Leave empty to auto-generate from backend, or enter custom code",
"category": "Category",
"categoryPlaceholder": "E.g., appearance, structure, texture",
"version": "Version", "version": "Version",
"description": "Description", "description": "Description",
"descriptionPlaceholder": "Detailed description of the quality check template", "descriptionPlaceholder": "Detailed description of the quality check template",
"applicableStages": "Applicable Stages", "applicableStages": "Applicable Stages",
"applicableStagesTooltip": "Comma-separated list of production stages: e.g., mixing, proofing, baking, cooling", "applicableStagesTooltip": "Select the production stages where this quality check applies",
"applicablePlaceholder": "mixing, proofing, baking, cooling" "applicableStagesHelp": "Leave empty to apply to all stages",
"applicablePlaceholder": "mixing, proofing, baking, cooling",
"instructions": "Instructions",
"instructionsPlaceholder": "Step-by-step instructions for performing this quality check",
"minValue": "Minimum Value",
"maxValue": "Maximum Value",
"targetValue": "Target Value",
"unit": "Unit",
"unitPlaceholder": "E.g., °C, g, cm, %",
"tolerancePercentage": "Tolerance Percentage",
"toleranceTooltip": "Acceptable deviation from target value (0-100%)",
"scoringMethod": "Scoring Method",
"passThreshold": "Pass Threshold (%)",
"passThresholdTooltip": "Minimum score percentage required to pass (0-100%)",
"frequencyDays": "Frequency (days)",
"frequencyDaysTooltip": "How often this check should be performed (in days)",
"frequencyDaysPlaceholder": "Leave empty for batch-based",
"requiredCheck": "Required Check",
"checkPointsJsonArray": "Check Points (JSON Array)",
"checkPointsTooltip": "Array of check points: [{\"name\": \"Visual Check\", \"description\": \"...\", \"weight\": 1.0}]",
"checkPointsPlaceholder": "[{\"name\": \"Visual Inspection\", \"description\": \"Check appearance\", \"expected_value\": \"Golden brown\", \"measurement_type\": \"visual\", \"is_critical\": false, \"weight\": 1.0}]",
"acceptanceCriteria": "Acceptance Criteria",
"acceptanceCriteriaPlaceholder": "E.g., Golden uniform color, fluffy texture, no burns...",
"parametersJson": "Parameters (JSON)",
"parametersTooltip": "Template parameters: {\"temp_min\": 75, \"temp_max\": 85, \"humidity\": 65}",
"parametersPlaceholder": "{\"temp_min\": 75, \"temp_max\": 85, \"humidity\": 65}",
"thresholdsJson": "Thresholds (JSON)",
"thresholdsTooltip": "Threshold values: {\"critical\": 90, \"warning\": 70, \"acceptable\": 50}",
"thresholdsPlaceholder": "{\"critical\": 90, \"warning\": 70, \"acceptable\": 50}",
"scoringCriteriaJson": "Scoring Criteria (JSON)",
"scoringCriteriaTooltip": "Custom scoring criteria: {\"appearance\": 30, \"texture\": 30, \"taste\": 40}",
"scoringCriteriaPlaceholder": "{\"appearance\": 30, \"texture\": 30, \"taste\": 40}",
"responsibleRole": "Responsible Role/Person",
"responsibleRolePlaceholder": "E.g., Production Manager, Baker",
"requiredEquipment": "Required Equipment/Tools",
"requiredEquipmentPlaceholder": "E.g., Thermometer, scale, timer",
"specificConditions": "Specific Conditions or Notes",
"specificConditionsPlaceholder": "E.g., Only applicable on humid days, check 30 min after baking...",
"activeTemplate": "Active Template",
"requiresPhotoEvidence": "Requires Photo Evidence",
"criticalControlPoint": "Critical Control Point (CCP)",
"notifyOnFailure": "Notify on Failure"
}, },
"checkTypes": { "checkTypes": {
"product_quality": "Product Quality", "visual": "Visual Inspection",
"process_hygiene": "Process Hygiene", "measurement": "Measurement",
"equipment": "Equipment", "temperature": "Temperature",
"safety": "Safety", "weight": "Weight",
"cleaning": "Cleaning", "boolean": "Pass/Fail Check",
"temperature": "Temperature Control", "timing": "Timing",
"documentation": "Documentation" "checklist": "Checklist"
},
"checkTypeDescriptions": {
"visual": "Inspect appearance, color, and visual quality characteristics",
"measurement": "Measure specific dimensions, sizes, or quantities",
"temperature": "Monitor and verify temperature readings",
"weight": "Check weight and mass measurements",
"boolean": "Simple yes/no or pass/fail checks",
"timing": "Track time-based quality criteria",
"checklist": "Multi-point checklist verification"
},
"processStages": {
"mixing": "Mixing",
"proofing": "Proofing",
"shaping": "Shaping",
"baking": "Baking",
"cooling": "Cooling",
"packaging": "Packaging",
"finishing": "Finishing"
}, },
"sections": { "sections": {
"basicInformation": "Basic Information", "basicInformation": "Basic Information",
"additionalIdentifiers": "Additional Identifiers",
"additionalIdentifiersDescription": "Optional identifiers for organization",
"measurementSpecifications": "Measurement Specifications",
"additionalDetails": "Additional Details",
"additionalDetailsDescription": "Optional detailed instructions",
"scoringConfiguration": "Scoring Configuration", "scoringConfiguration": "Scoring Configuration",
"advancedOptions": "Advanced Options", "advancedOptions": "Advanced Options",
"advancedOptionsDescription": "Optional fields for comprehensive quality template configuration", "advancedOptionsDescription": "Optional fields for comprehensive quality template configuration",

View File

@@ -38,7 +38,10 @@
"savings": { "savings": {
"label": "💰 AHORROS", "label": "💰 AHORROS",
"this_week": "esta semana", "this_week": "esta semana",
"vs_last": "vs. anterior" "vs_last": "vs. anterior",
"value_this_week": "€{amount} esta semana",
"detail_vs_last_positive": "+{percentage}% vs. anterior",
"detail_vs_last_negative": "{percentage}% vs. anterior"
}, },
"inventory": { "inventory": {
"label": "📦 INVENTARIO", "label": "📦 INVENTARIO",
@@ -52,7 +55,9 @@
"waste": { "waste": {
"label": "♻️ DESPERDICIO", "label": "♻️ DESPERDICIO",
"this_month": "este mes", "this_month": "este mes",
"vs_goal": "vs. objetivo" "vs_goal": "vs. objetivo",
"value_this_month": "{percentage}% este mes",
"detail_vs_goal": "{change}% vs. objetivo"
}, },
"deliveries": { "deliveries": {
"label": "🚚 ENTREGAS", "label": "🚚 ENTREGAS",
@@ -164,6 +169,14 @@
"delete": "Eliminar" "delete": "Eliminar"
} }
}, },
"production": {
"scheduled_based_on": "Programado según {{type}}",
"status": {
"completed": "COMPLETADO",
"in_progress": "EN PROGRESO",
"pending": "PENDIENTE"
}
},
"messages": { "messages": {
"welcome": "Bienvenido de vuelta", "welcome": "Bienvenido de vuelta",
"good_morning": "Buenos días", "good_morning": "Buenos días",
@@ -238,6 +251,28 @@
"cost_analysis": "Análisis de Costos" "cost_analysis": "Análisis de Costos"
} }
}, },
"action_queue": {
"consequences": {
"delayed_delivery": "La entrega retrasada puede afectar el programa de producción",
"immediate_action": "Se requiere acción inmediata para prevenir problemas de producción",
"limited_features": "Algunas funciones están limitadas"
},
"titles": {
"purchase_order": "Orden de Compra {po_number}",
"supplier": "Proveedor: {supplier_name}",
"pending_approval": "Aprobación pendiente para {supplier_name} - {type}"
},
"buttons": {
"view_details": "Ver Detalles",
"dismiss": "Descartar",
"approve": "Aprobar",
"modify": "Modificar",
"complete_setup": "Completar Configuración"
}
},
"orchestration": {
"no_runs_message": "Aún no se ha ejecutado ninguna orquestación. Haga clic en 'Ejecutar Planificación Diaria' para generar su primer plan."
},
"errors": { "errors": {
"failed_to_load_stats": "Error al cargar las estadísticas del panel. Por favor, inténtelo de nuevo." "failed_to_load_stats": "Error al cargar las estadísticas del panel. Por favor, inténtelo de nuevo."
} }

View File

@@ -83,7 +83,7 @@
}, },
"accuracy": "Precisión: 92% (vs 60-70% de sistemas genéricos)", "accuracy": "Precisión: 92% (vs 60-70% de sistemas genéricos)",
"cta": "Ver Todas las Funcionalidades", "cta": "Ver Todas las Funcionalidades",
"key1": "🎯 Precisión:", "key1": "🎯 Precisión: ",
"key2": "(vs 60-70% de sistemas genéricos)" "key2": "(vs 60-70% de sistemas genéricos)"
}, },
"pillar2": { "pillar2": {
@@ -121,12 +121,12 @@
"cta": "Ver Todas las Funcionalidades" "cta": "Ver Todas las Funcionalidades"
}, },
"pillar3": { "pillar3": {
"title": "Tus Datos, Tu Impacto Ambiental", "title": "🌱 Tus Datos, Tu Impacto Ambiental",
"intro": "100% de tus datos te pertenecen. Mide tu impacto ambiental automáticamente y genera informes de sostenibilidad que cumplen con los estándares internacionales.", "intro": "100% de tus datos te pertenecen. Mide tu impacto ambiental automáticamente y genera informes de sostenibilidad que cumplen con los estándares internacionales.",
"data_ownership_value": "100%", "data_ownership_value": "100%",
"data_ownership": "Propiedad de datos", "data_ownership": "Propiedad de datos",
"co2_metric": "Hondakinak", "co2_metric": "Residuos",
"co2": "Murrizketa automatikoa", "co2": "Reducción automática",
"sdg_value": "Verde", "sdg_value": "Verde",
"sdg": "Certificado de sostenibilidad", "sdg": "Certificado de sostenibilidad",
"sustainability_title": "🔒 Privados por defecto, sostenibles de serie.", "sustainability_title": "🔒 Privados por defecto, sostenibles de serie.",

View File

@@ -157,5 +157,15 @@
"view_alert": "Ver Detalles", "view_alert": "Ver Detalles",
"run_planning": "Ejecutar Planificación Diaria" "run_planning": "Ejecutar Planificación Diaria"
} }
},
"types": {
"low_stock_detection": "Stock bajo detectado para {{product_name}}. El stock se agotará en {{days_until_stockout}} días.",
"stockout_prevention": "Previniendo desabastecimiento de ingredientes críticos",
"forecast_demand": "Basado en pronóstico de demanda: {{predicted_demand}} unidades predichas ({{confidence_score}}% confianza)",
"customer_orders": "Cumpliendo pedidos confirmados de clientes",
"seasonal_demand": "Aumento anticipado de demanda estacional",
"inventory_replenishment": "Reposición regular de inventario",
"production_schedule": "Lote de producción programado",
"other": "Reposición estándar"
} }
} }

View File

@@ -14,6 +14,16 @@
"complete": "Completar", "complete": "Completar",
"stepOf": "Paso {{current}} de {{total}}" "stepOf": "Paso {{current}} de {{total}}"
}, },
"keyValueEditor": {
"showBuilder": "Mostrar Constructor",
"showJson": "Mostrar JSON",
"suggestions": "Sugerencias rápidas",
"keyPlaceholder": "Clave",
"valuePlaceholder": "Valor",
"remove": "Eliminar",
"addPair": "Agregar Parámetro",
"emptyState": "No hay parámetros aún. Haz clic en 'Agregar Parámetro' para comenzar."
},
"inventory": { "inventory": {
"title": "Agregar Inventario", "title": "Agregar Inventario",
"inventoryDetails": "Detalles del Artículo de Inventario", "inventoryDetails": "Detalles del Artículo de Inventario",
@@ -174,6 +184,17 @@
"title": "Agregar Plantilla de Calidad", "title": "Agregar Plantilla de Calidad",
"templateDetails": "Detalles de la Plantilla de Calidad", "templateDetails": "Detalles de la Plantilla de Calidad",
"fillRequiredInfo": "Complete la información requerida para crear una plantilla de control de calidad", "fillRequiredInfo": "Complete la información requerida para crear una plantilla de control de calidad",
"selectCheckType": "Seleccionar Tipo de Control de Calidad",
"selectCheckTypeDescription": "Elija el tipo de control de calidad que desea crear",
"essentialConfiguration": "Configuración Esencial",
"essentialConfigurationDescription": "Defina las propiedades principales de su plantilla de control de calidad",
"criteriaAndSettings": "Criterios y Configuración de Calidad",
"criteriaAndSettingsDescription": "Configure los métodos de puntuación y criterios avanzados de calidad",
"steps": {
"checkType": "Tipo de Control",
"essentialConfiguration": "Configuración",
"criteriaSettings": "Criterios y Ajustes"
},
"fields": { "fields": {
"name": "Nombre", "name": "Nombre",
"namePlaceholder": "Ej: Control de Calidad del Pan, Inspección de Higiene", "namePlaceholder": "Ej: Control de Calidad del Pan, Inspección de Higiene",
@@ -183,24 +204,90 @@
"templateCode": "Código de Plantilla", "templateCode": "Código de Plantilla",
"templateCodePlaceholder": "Dejar vacío para auto-generar", "templateCodePlaceholder": "Dejar vacío para auto-generar",
"templateCodeTooltip": "Dejar vacío para auto-generar desde el backend, o introducir código personalizado", "templateCodeTooltip": "Dejar vacío para auto-generar desde el backend, o introducir código personalizado",
"category": "Categoría",
"categoryPlaceholder": "Ej: apariencia, estructura, textura",
"version": "Versión", "version": "Versión",
"description": "Descripción", "description": "Descripción",
"descriptionPlaceholder": "Descripción detallada de la plantilla de control de calidad", "descriptionPlaceholder": "Descripción detallada de la plantilla de control de calidad",
"applicableStages": "Etapas Aplicables", "applicableStages": "Etapas Aplicables",
"applicableStagesTooltip": "Lista separada por comas de etapas de producción: ej: amasado, fermentación, horneado, enfriamiento", "applicableStagesTooltip": "Seleccione las etapas de producción donde se aplica este control de calidad",
"applicablePlaceholder": "amasado, fermentación, horneado, enfriamiento" "applicableStagesHelp": "Dejar vacío para aplicar a todas las etapas",
"applicablePlaceholder": "amasado, fermentación, horneado, enfriamiento",
"instructions": "Instrucciones",
"instructionsPlaceholder": "Instrucciones paso a paso para realizar este control de calidad",
"minValue": "Valor Mínimo",
"maxValue": "Valor Máximo",
"targetValue": "Valor Objetivo",
"unit": "Unidad",
"unitPlaceholder": "Ej: °C, g, cm, %",
"tolerancePercentage": "Porcentaje de Tolerancia",
"toleranceTooltip": "Desviación aceptable del valor objetivo (0-100%)",
"scoringMethod": "Método de Puntuación",
"passThreshold": "Umbral de Aprobación (%)",
"passThresholdTooltip": "Porcentaje de puntuación mínimo requerido para aprobar (0-100%)",
"frequencyDays": "Frecuencia (días)",
"frequencyDaysTooltip": "Con qué frecuencia debe realizarse este control (en días)",
"frequencyDaysPlaceholder": "Dejar vacío para basado en lotes",
"requiredCheck": "Verificación Requerida",
"checkPointsJsonArray": "Puntos de Control (Array JSON)",
"checkPointsTooltip": "Array de puntos de control: [{\"name\": \"Control Visual\", \"description\": \"...\", \"weight\": 1.0}]",
"checkPointsPlaceholder": "[{\"name\": \"Inspección Visual\", \"description\": \"Verificar apariencia\", \"expected_value\": \"Marrón dorado\", \"measurement_type\": \"visual\", \"is_critical\": false, \"weight\": 1.0}]",
"acceptanceCriteria": "Criterios de Aceptación",
"acceptanceCriteriaPlaceholder": "Ej: Color dorado uniforme, textura esponjosa, sin quemaduras...",
"parametersJson": "Parámetros (JSON)",
"parametersTooltip": "Parámetros de plantilla: {\"temp_min\": 75, \"temp_max\": 85, \"humidity\": 65}",
"parametersPlaceholder": "{\"temp_min\": 75, \"temp_max\": 85, \"humidity\": 65}",
"thresholdsJson": "Umbrales (JSON)",
"thresholdsTooltip": "Valores de umbral: {\"critical\": 90, \"warning\": 70, \"acceptable\": 50}",
"thresholdsPlaceholder": "{\"critical\": 90, \"warning\": 70, \"acceptable\": 50}",
"scoringCriteriaJson": "Criterios de Puntuación (JSON)",
"scoringCriteriaTooltip": "Criterios de puntuación personalizados: {\"appearance\": 30, \"texture\": 30, \"taste\": 40}",
"scoringCriteriaPlaceholder": "{\"appearance\": 30, \"texture\": 30, \"taste\": 40}",
"responsibleRole": "Rol/Persona Responsable",
"responsibleRolePlaceholder": "Ej: Gerente de Producción, Panadero",
"requiredEquipment": "Equipos/Herramientas Requeridas",
"requiredEquipmentPlaceholder": "Ej: Termómetro, báscula, temporizador",
"specificConditions": "Condiciones o Notas Específicas",
"specificConditionsPlaceholder": "Ej: Solo aplicable en días húmedos, verificar 30 min después de hornear...",
"activeTemplate": "Plantilla Activa",
"requiresPhotoEvidence": "Requiere Evidencia Fotográfica",
"criticalControlPoint": "Punto Crítico de Control (PCC)",
"notifyOnFailure": "Notificar en Falla"
}, },
"checkTypes": { "checkTypes": {
"product_quality": "Calidad del Producto", "visual": "Inspección Visual",
"process_hygiene": "Higiene del Proceso", "measurement": "Medición",
"equipment": "Equipamiento", "temperature": "Temperatura",
"safety": "Seguridad", "weight": "Peso",
"cleaning": "Limpieza", "boolean": "Control Aprobado/Reprobado",
"temperature": "Control de Temperatura", "timing": "Temporización",
"documentation": "Documentación" "checklist": "Lista de Verificación"
},
"checkTypeDescriptions": {
"visual": "Inspeccionar apariencia, color y características visuales de calidad",
"measurement": "Medir dimensiones, tamaños o cantidades específicas",
"temperature": "Monitorear y verificar lecturas de temperatura",
"weight": "Verificar mediciones de peso y masa",
"boolean": "Controles simples de sí/no o aprobado/reprobado",
"timing": "Rastrear criterios de calidad basados en tiempo",
"checklist": "Verificación de lista de puntos múltiples"
},
"processStages": {
"mixing": "Amasado",
"proofing": "Fermentación",
"shaping": "Formado",
"baking": "Horneado",
"cooling": "Enfriamiento",
"packaging": "Empaquetado",
"finishing": "Acabado"
}, },
"sections": { "sections": {
"basicInformation": "Información Básica", "basicInformation": "Información Básica",
"additionalIdentifiers": "Identificadores Adicionales",
"additionalIdentifiersDescription": "Identificadores opcionales para organización",
"measurementSpecifications": "Especificaciones de Medición",
"additionalDetails": "Detalles Adicionales",
"additionalDetailsDescription": "Instrucciones detalladas opcionales",
"scoringConfiguration": "Configuración de Puntuación", "scoringConfiguration": "Configuración de Puntuación",
"advancedOptions": "Opciones Avanzadas", "advancedOptions": "Opciones Avanzadas",
"advancedOptionsDescription": "Campos opcionales para configuración completa de plantilla de calidad", "advancedOptionsDescription": "Campos opcionales para configuración completa de plantilla de calidad",

View File

@@ -36,7 +36,10 @@
"savings": { "savings": {
"label": "💰 AURREZKIAK", "label": "💰 AURREZKIAK",
"this_week": "aste honetan", "this_week": "aste honetan",
"vs_last": "vs. aurrekoa" "vs_last": "vs. aurrekoa",
"value_this_week": "€{amount} aste honetan",
"detail_vs_last_positive": "+{percentage}% vs. aurrekoa",
"detail_vs_last_negative": "{percentage}% vs. aurrekoa"
}, },
"inventory": { "inventory": {
"label": "📦 INBENTARIOA", "label": "📦 INBENTARIOA",
@@ -50,7 +53,9 @@
"waste": { "waste": {
"label": "♻️ HONDAKINAK", "label": "♻️ HONDAKINAK",
"this_month": "hilabete honetan", "this_month": "hilabete honetan",
"vs_goal": "vs. helburua" "vs_goal": "vs. helburua",
"value_this_month": "{percentage}% hilabete honetan",
"detail_vs_goal": "{change}% vs. helburua"
}, },
"deliveries": { "deliveries": {
"label": "🚚 BIDALKETA", "label": "🚚 BIDALKETA",
@@ -127,6 +132,14 @@
"remove": "Kendu", "remove": "Kendu",
"active_count": "{count} alerta aktibo" "active_count": "{count} alerta aktibo"
}, },
"production": {
"scheduled_based_on": "{{type}} arabera programatuta",
"status": {
"completed": "OSATUTA",
"in_progress": "MARTXAN",
"pending": "ITXAROTEAN"
}
},
"messages": { "messages": {
"welcome": "Ongi etorri berriro", "welcome": "Ongi etorri berriro",
"good_morning": "Egun on", "good_morning": "Egun on",
@@ -200,5 +213,30 @@
"production_planning": "Ekoizpen Plangintza", "production_planning": "Ekoizpen Plangintza",
"cost_analysis": "Kostu Analisia" "cost_analysis": "Kostu Analisia"
} }
},
"action_queue": {
"consequences": {
"delayed_delivery": "Entrega atzeratuak ekoizpen-egutegian eragina izan dezake",
"immediate_action": "Berehalako ekintza beharrezkoa da ekoizpen-arazoak saihesteko",
"limited_features": "Funtzio batzuk mugatuta daude"
},
"titles": {
"purchase_order": "Erosketa Agindua {po_number}",
"supplier": "Hornitzailea: {supplier_name}",
"pending_approval": "{supplier_name} - {type} onarpenaren zai"
},
"buttons": {
"view_details": "Xehetasunak Ikusi",
"dismiss": "Baztertu",
"approve": "Onartu",
"modify": "Aldatu",
"complete_setup": "Konfigurazioa Osatu"
}
},
"orchestration": {
"no_runs_message": "Oraindik ez da orkestraziorik exekutatu. Sakatu 'Eguneko Plangintza Exekutatu' zure lehen plana sortzeko."
},
"errors": {
"failed_to_load_stats": "Huts egin du aginte-paneleko estatistikak kargatzean. Saiatu berriro mesedez."
} }
} }

View File

@@ -83,7 +83,7 @@
}, },
"accuracy": "Zehaztasuna: %92 (vs %60-70 sistema generikoetan)", "accuracy": "Zehaztasuna: %92 (vs %60-70 sistema generikoetan)",
"cta": "Ikusi Ezaugarri Guztiak", "cta": "Ikusi Ezaugarri Guztiak",
"key1": "🎯 Zehatasuna:", "key1": "🎯 Zehatasuna: ",
"key2": "(sistema generikoen %60-70aren aldean)" "key2": "(sistema generikoen %60-70aren aldean)"
}, },
"pillar2": { "pillar2": {
@@ -121,7 +121,7 @@
"cta": "Ikusi Ezaugarri Guztiak" "cta": "Ikusi Ezaugarri Guztiak"
}, },
"pillar3": { "pillar3": {
"title": "Zure Datuak, Zure Ingurumen Inpaktua", "title": "🌱 Zure Datuak, Zure Ingurumen Inpaktua",
"intro": "Zure datuen %100 zureak dira. Neurtu zure ingurumen-inpaktua automatikoki eta sortu nazioarteko estandarrak betetzen dituzten iraunkortasun-txostenak.", "intro": "Zure datuen %100 zureak dira. Neurtu zure ingurumen-inpaktua automatikoki eta sortu nazioarteko estandarrak betetzen dituzten iraunkortasun-txostenak.",
"data_ownership_value": "100%", "data_ownership_value": "100%",
"data_ownership": "Datuen jabetza", "data_ownership": "Datuen jabetza",

View File

@@ -157,5 +157,15 @@
"view_alert": "Ikusi Xehetasunak", "view_alert": "Ikusi Xehetasunak",
"run_planning": "Exekutatu Eguneko Plangintza" "run_planning": "Exekutatu Eguneko Plangintza"
} }
},
"types": {
"low_stock_detection": "Stock baxua detektatu da {{product_name}}-(e)rako. Stocka {{days_until_stockout}} egunetan agortuko da.",
"stockout_prevention": "Osagai kritikoen desabastetzea saihestea",
"forecast_demand": "Eskari aurreikuspenean oinarrituta: {{predicted_demand}} unitate aurreikusita ({{confidence_score}}% konfiantza)",
"customer_orders": "Bezeroen eskaera bermatuen betetze",
"seasonal_demand": "Aurreikusitako sasoiko eskariaren igoera",
"inventory_replenishment": "Inbentario berritze erregularra",
"production_schedule": "Ekoizpen sorta programatua",
"other": "Berritze estandarra"
} }
} }

View File

@@ -8,6 +8,16 @@
"willBeGeneratedAutomatically": "Automatikoki sortuko da", "willBeGeneratedAutomatically": "Automatikoki sortuko da",
"autoGeneratedOnSave": "Automatikoki sortua gordetzean" "autoGeneratedOnSave": "Automatikoki sortua gordetzean"
}, },
"keyValueEditor": {
"showBuilder": "Eraikitzailea Erakutsi",
"showJson": "JSON Erakutsi",
"suggestions": "Iradokizun azkarrak",
"keyPlaceholder": "Gakoa",
"valuePlaceholder": "Balioa",
"remove": "Kendu",
"addPair": "Parametroa Gehitu",
"emptyState": "Oraindik ez dago parametrorik. Egin klik 'Parametroa Gehitu'-n hasteko."
},
"inventory": { "inventory": {
"title": "Inbentarioa Gehitu", "title": "Inbentarioa Gehitu",
"inventoryDetails": "Inbentario Elementuaren Xehetasunak", "inventoryDetails": "Inbentario Elementuaren Xehetasunak",
@@ -168,6 +178,17 @@
"title": "Kalitate Txantiloia Gehitu", "title": "Kalitate Txantiloia Gehitu",
"templateDetails": "Kalitate Txantiloiaren Xehetasunak", "templateDetails": "Kalitate Txantiloiaren Xehetasunak",
"fillRequiredInfo": "Bete beharrezko informazioa kalitate kontrol txantiloi bat sortzeko", "fillRequiredInfo": "Bete beharrezko informazioa kalitate kontrol txantiloi bat sortzeko",
"selectCheckType": "Kalitate Kontrol Mota Hautatu",
"selectCheckTypeDescription": "Hautatu sortu nahi duzun kalitate kontrol mota",
"essentialConfiguration": "Oinarrizko Konfigurazioa",
"essentialConfigurationDescription": "Zehaztu zure kalitate kontrol txantiloiaren oinarrizko ezaugarriak",
"criteriaAndSettings": "Kalitate Irizpideak eta Ezarpenak",
"criteriaAndSettingsDescription": "Konfiguratu puntuazio metodoak eta kalitate irizpide aurreratuak",
"steps": {
"checkType": "Kontrol Mota",
"essentialConfiguration": "Konfigurazioa",
"criteriaSettings": "Irizpideak eta Ezarpenak"
},
"fields": { "fields": {
"name": "Izena", "name": "Izena",
"namePlaceholder": "Adib: Ogiaren Kalitate Kontrola, Higiene Ikuskatzea", "namePlaceholder": "Adib: Ogiaren Kalitate Kontrola, Higiene Ikuskatzea",
@@ -177,24 +198,90 @@
"templateCode": "Txantiloi Kodea", "templateCode": "Txantiloi Kodea",
"templateCodePlaceholder": "Utzi hutsik automatikoki sortzeko", "templateCodePlaceholder": "Utzi hutsik automatikoki sortzeko",
"templateCodeTooltip": "Utzi hutsik backend-etik automatikoki sortzeko, edo sartu kode pertsonalizatua", "templateCodeTooltip": "Utzi hutsik backend-etik automatikoki sortzeko, edo sartu kode pertsonalizatua",
"category": "Kategoria",
"categoryPlaceholder": "Adib: itxura, egitura, ehundura",
"version": "Bertsioa", "version": "Bertsioa",
"description": "Deskribapena", "description": "Deskribapena",
"descriptionPlaceholder": "Kalitate kontrol txantiloiaren deskribapen zehatza", "descriptionPlaceholder": "Kalitate kontrol txantiloiaren deskribapen zehatza",
"applicableStages": "Aplikagarriak Diren Faseak", "applicableStages": "Aplikagarriak Diren Faseak",
"applicableStagesTooltip": "Komaz bereizitako ekoizpen faseen zerrenda: adib: nahasketaNahasketa, hartzidura, labean, hoztetanHozte", "applicableStagesTooltip": "Hautatu kalitate kontrol hau aplikatzen den ekoizpen faseak",
"applicablePlaceholder": "nahasketa, hartzidura, labea, hozte" "applicableStagesHelp": "Utzi hutsik fase guztietan aplikatzeko",
"applicablePlaceholder": "nahasketa, hartzidura, labea, hozte",
"instructions": "Jarraibideak",
"instructionsPlaceholder": "Kalitate kontrol hau egiteko urrats-urratseko jarraibideak",
"minValue": "Balio Minimoa",
"maxValue": "Balio Maximoa",
"targetValue": "Helburu Balioa",
"unit": "Unitatea",
"unitPlaceholder": "Adib: °C, g, cm, %",
"tolerancePercentage": "Tolerantzia Ehunekoa",
"toleranceTooltip": "Helburu baliotik onartutako desbideratzea (0-100%)",
"scoringMethod": "Puntuazio Metodoa",
"passThreshold": "Gainditzeko Atalasea (%)",
"passThresholdTooltip": "Gaindit zeko beharrezko gutxieneko puntuazio ehunekoa (0-100%)",
"frequencyDays": "Maiztasuna (egunak)",
"frequencyDaysTooltip": "Kontrol hau zenbat denboratan egin behar den (egunetan)",
"frequencyDaysPlaceholder": "Utzi hutsik lote oinarritua izateko",
"requiredCheck": "Beharrezko Egiaztapena",
"checkPointsJsonArray": "Kontrol Puntuak (JSON Array)",
"checkPointsTooltip": "Kontrol puntuen array-a: [{\"name\": \"Ikusizko Kontrola\", \"description\": \"...\", \"weight\": 1.0}]",
"checkPointsPlaceholder": "[{\"name\": \"Ikusizko Ikuskatzea\", \"description\": \"Itxura egiaztatu\", \"expected_value\": \"Urre marroia\", \"measurement_type\": \"visual\", \"is_critical\": false, \"weight\": 1.0}]",
"acceptanceCriteria": "Onarpenerako Irizpideak",
"acceptanceCriteriaPlaceholder": "Adib: Kolore urre uniformea, ehundura puzgatua, erreadurak gabe...",
"parametersJson": "Parametroak (JSON)",
"parametersTooltip": "Txantiloiaren parametroak: {\"temp_min\": 75, \"temp_max\": 85, \"humidity\": 65}",
"parametersPlaceholder": "{\"temp_min\": 75, \"temp_max\": 85, \"humidity\": 65}",
"thresholdsJson": "Atalaseak (JSON)",
"thresholdsTooltip": "Atalase balioak: {\"critical\": 90, \"warning\": 70, \"acceptable\": 50}",
"thresholdsPlaceholder": "{\"critical\": 90, \"warning\": 70, \"acceptable\": 50}",
"scoringCriteriaJson": "Puntuazio Irizpideak (JSON)",
"scoringCriteriaTooltip": "Puntuazio irizpide pertsonalizatuak: {\"appearance\": 30, \"texture\": 30, \"taste\": 40}",
"scoringCriteriaPlaceholder": "{\"appearance\": 30, \"texture\": 30, \"taste\": 40}",
"responsibleRole": "Arduradunaren Rola/Pertsona",
"responsibleRolePlaceholder": "Adib: Ekoizpen Kudeatzailea, Okindegilea",
"requiredEquipment": "Beharrezko Ekipamendua/Tresnak",
"requiredEquipmentPlaceholder": "Adib: Termometroa, balantza, kronometroa",
"specificConditions": "Baldintza Espezifikoak edo Oharrak",
"specificConditionsPlaceholder": "Adib: Egun hezetan soilik aplikagarria, labean 30 minutu geroago egiaztatu...",
"activeTemplate": "Txantiloi Aktiboa",
"requiresPhotoEvidence": "Argazki Frogak Behar Ditu",
"criticalControlPoint": "Kontrol Puntu Kritikoa (KPK)",
"notifyOnFailure": "Jakinarazi Hutsegitean"
}, },
"checkTypes": { "checkTypes": {
"product_quality": "Produktuaren Kalitatea", "visual": "Ikusizko Ikuskatzea",
"process_hygiene": "Prozesuaren Higienea", "measurement": "Neurketa",
"equipment": "Ekipamendua", "temperature": "Tenperatura",
"safety": "Segurtasuna", "weight": "Pisua",
"cleaning": "Garbiketa", "boolean": "Gainditu/Huts Egin Kontrola",
"temperature": "Tenperatura Kontrola", "timing": "Denboratzea",
"documentation": "Dokumentazioa" "checklist": "Egiaztapen Zerrenda"
},
"checkTypeDescriptions": {
"visual": "Itxura, kolorea eta kalitate ezaugarri bisualak ikuskatu",
"measurement": "Dimentsio, tamaina edo kopuru zehatzak neurtu",
"temperature": "Tenperatura irakurketak kontrolatu eta egiaztatu",
"weight": "Pisu eta masa neurketak egiaztatu",
"boolean": "Bai/ez edo gainditu/huts egin kontrol sinpleak",
"timing": "Denboran oinarritutako kalitate irizpideak jarraitu",
"checklist": "Puntu anitzeko egiaztapen zerrenda egiaztapena"
},
"processStages": {
"mixing": "Nahasketa",
"proofing": "Hartzidura",
"shaping": "Moldatzea",
"baking": "Labea",
"cooling": "Hoztea",
"packaging": "Ontziratzea",
"finishing": "Amaiera"
}, },
"sections": { "sections": {
"basicInformation": "Oinarrizko Informazioa", "basicInformation": "Oinarrizko Informazioa",
"additionalIdentifiers": "Identifikatzaile Gehigarriak",
"additionalIdentifiersDescription": "Antolakuntza rako identifikatzaile aukerazkoak",
"measurementSpecifications": "Neurketa Zehaztapenak",
"additionalDetails": "Xehetasun Gehigarriak",
"additionalDetailsDescription": "Jarraibide zehatz aukerazkoak",
"scoringConfiguration": "Puntuazio Konfigurazioa", "scoringConfiguration": "Puntuazio Konfigurazioa",
"advancedOptions": "Aukera Aurreratuak", "advancedOptions": "Aukera Aurreratuak",
"advancedOptionsDescription": "Kalitate txantiloi konfigurazio osoa egiteko eremu aukerazkoak", "advancedOptionsDescription": "Kalitate txantiloi konfigurazio osoa egiteko eremu aukerazkoak",

View File

@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { Plus, Star, Clock, Euro, Package, Eye, ChefHat, Timer, CheckCircle, Trash2, Settings, FileText } from 'lucide-react'; import { Plus, Star, Clock, Euro, Package, Eye, ChefHat, Timer, CheckCircle, Trash2, Settings, FileText } from 'lucide-react';
import { StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, type FilterConfig, EmptyState } from '../../../../components/ui'; import { StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, type FilterConfig, EmptyState, type EditViewModalSection } from '../../../../components/ui';
import { QualityPromptDialog } from '../../../../components/ui/QualityPromptDialog'; import { QualityPromptDialog } from '../../../../components/ui/QualityPromptDialog';
import { formatters } from '../../../../components/ui/Stats/StatsPresets'; import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { PageHeader } from '../../../../components/layout'; import { PageHeader } from '../../../../components/layout';
@@ -11,7 +11,7 @@ import type { RecipeResponse, RecipeCreate } from '../../../../api/types/recipes
import { MeasurementUnit } from '../../../../api/types/recipes'; import { MeasurementUnit } from '../../../../api/types/recipes';
import { useIngredients } from '../../../../api/hooks/inventory'; import { useIngredients } from '../../../../api/hooks/inventory';
import { ProcessStage, type RecipeQualityConfiguration } from '../../../../api/types/qualityTemplates'; import { ProcessStage, type RecipeQualityConfiguration } from '../../../../api/types/qualityTemplates';
import { CreateRecipeModal, DeleteRecipeModal } from '../../../../components/domain/recipes'; import { CreateRecipeModal, DeleteRecipeModal, RecipeViewEditModal } from '../../../../components/domain/recipes';
import { QualityCheckConfigurationModal } from '../../../../components/domain/recipes/QualityCheckConfigurationModal'; import { QualityCheckConfigurationModal } from '../../../../components/domain/recipes/QualityCheckConfigurationModal';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import type { RecipeIngredientResponse } from '../../../../api/types/recipes'; import type { RecipeIngredientResponse } from '../../../../api/types/recipes';
@@ -408,7 +408,7 @@ const RecipesPage: React.FC = () => {
); );
const totalTemplates = Object.values(stages).reduce( const totalTemplates = Object.values(stages).reduce(
(sum, stage) => sum + (stage.template_ids?.length || 0), (sum, stage) => sum + (stage?.template_ids?.length || 0),
0 0
); );
@@ -785,7 +785,7 @@ const RecipesPage: React.FC = () => {
}; };
// Get modal sections with editable fields // Get modal sections with editable fields
const getModalSections = () => { const getModalSections = (): EditViewModalSection[] => {
if (!selectedRecipe) return []; if (!selectedRecipe) return [];
return [ return [
@@ -965,8 +965,8 @@ const RecipesPage: React.FC = () => {
: (selectedRecipe.is_seasonal ? 'Sí' : 'No'), : (selectedRecipe.is_seasonal ? 'Sí' : 'No'),
type: modalMode === 'edit' ? 'select' : 'text', type: modalMode === 'edit' ? 'select' : 'text',
options: modalMode === 'edit' ? [ options: modalMode === 'edit' ? [
{ value: false, label: 'No' }, { value: 'false', label: 'No' },
{ value: true, label: 'Sí' } { value: 'true', label: 'Sí' }
] : undefined, ] : undefined,
editable: modalMode === 'edit' editable: modalMode === 'edit'
}, },
@@ -989,8 +989,8 @@ const RecipesPage: React.FC = () => {
: (selectedRecipe.is_signature_item ? 'Sí' : 'No'), : (selectedRecipe.is_signature_item ? 'Sí' : 'No'),
type: modalMode === 'edit' ? 'select' : 'text', type: modalMode === 'edit' ? 'select' : 'text',
options: modalMode === 'edit' ? [ options: modalMode === 'edit' ? [
{ value: false, label: 'No' }, { value: 'false', label: 'No' },
{ value: true, label: 'Sí' } { value: 'true', label: 'Sí' }
] : undefined, ] : undefined,
editable: modalMode === 'edit', editable: modalMode === 'edit',
highlight: selectedRecipe.is_signature_item highlight: selectedRecipe.is_signature_item
@@ -1059,10 +1059,96 @@ const RecipesPage: React.FC = () => {
label: 'Instrucciones de preparación', label: 'Instrucciones de preparación',
value: modalMode === 'edit' value: modalMode === 'edit'
? formatJsonField(getFieldValue(selectedRecipe.instructions, 'instructions')) ? formatJsonField(getFieldValue(selectedRecipe.instructions, 'instructions'))
: formatJsonField(selectedRecipe.instructions), : '', // Empty string when using customRenderer in view mode
type: modalMode === 'edit' ? 'textarea' : 'text', type: modalMode === 'edit' ? 'textarea' : 'custom',
editable: modalMode === 'edit', editable: modalMode === 'edit',
span: 2 span: 2,
customRenderer: modalMode === 'view' ? () => {
const instructions = selectedRecipe.instructions;
if (!instructions) {
return (
<div className="text-sm text-[var(--text-secondary)] italic">
No especificado
</div>
);
}
// Handle array of instruction objects
if (Array.isArray(instructions)) {
return (
<div className="space-y-2">
{instructions.map((item: any, index: number) => {
// Handle different item types
let stepTitle = '';
let stepDescription = '';
if (typeof item === 'string') {
stepDescription = item;
} else if (typeof item === 'object' && item !== null) {
stepTitle = item.step || item.title || '';
stepDescription = item.description || '';
// If no description but has other fields, stringify them
if (!stepDescription && !stepTitle) {
stepDescription = JSON.stringify(item);
}
} else {
stepDescription = String(item);
}
return (
<div key={index} className="flex gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-[var(--color-primary)]/10 flex items-center justify-center text-xs font-semibold text-[var(--color-primary)]">
{index + 1}
</div>
<div className="flex-1">
{stepTitle && stepDescription ? (
<>
<div className="font-medium text-[var(--text-primary)]">{stepTitle}</div>
<div className="text-sm text-[var(--text-secondary)] mt-1">{stepDescription}</div>
</>
) : stepDescription ? (
<div className="text-sm text-[var(--text-primary)]">{stepDescription}</div>
) : stepTitle ? (
<div className="text-sm text-[var(--text-primary)]">{stepTitle}</div>
) : (
<div className="text-sm text-[var(--text-secondary)] italic">
Sin información
</div>
)}
</div>
</div>
);
})}
</div>
);
}
// Handle string
if (typeof instructions === 'string') {
return (
<div className="text-sm text-[var(--text-primary)] whitespace-pre-wrap">
{instructions}
</div>
);
}
// Handle object
if (typeof instructions === 'object') {
return (
<div className="text-sm text-[var(--text-primary)] whitespace-pre-wrap">
{formatJsonField(instructions)}
</div>
);
}
return (
<div className="text-sm text-[var(--text-secondary)] italic">
No especificado
</div>
);
} : undefined
} }
] ]
}, },
@@ -1105,43 +1191,11 @@ const RecipesPage: React.FC = () => {
fields: [ fields: [
{ {
label: '', label: '',
value: modalMode === 'edit' value: '',
? (editedIngredients.length > 0 ? editedIngredients : selectedRecipe.ingredients || []) type: modalMode === 'edit' ? ('component' as const) : ('custom' as const),
: (selectedRecipe.ingredients
?.sort((a, b) => a.ingredient_order - b.ingredient_order)
?.map(ing => {
const ingredientName = ingredientLookup[ing.ingredient_id] || 'Ingrediente desconocido';
const parts = [];
// Main ingredient line with quantity
parts.push(`${ing.quantity} ${ing.unit}`);
parts.push(ingredientName);
// Add optional indicator
if (ing.is_optional) {
parts.push('(opcional)');
}
// Add preparation method on new line if exists
if (ing.preparation_method) {
parts.push(`\n → ${ing.preparation_method}`);
}
// Add notes on new line if exists
if (ing.ingredient_notes) {
parts.push(`\n 💡 ${ing.ingredient_notes}`);
}
// Add substitution info if exists
if (ing.substitution_options) {
parts.push(`\n 🔄 Sustituto: ${ing.substitution_options}`);
}
return parts.join(' ');
}) || ['No especificados']),
type: modalMode === 'edit' ? 'component' as const : 'custom' as const,
component: modalMode === 'edit' ? IngredientsEditComponent : undefined, component: modalMode === 'edit' ? IngredientsEditComponent : undefined,
componentProps: modalMode === 'edit' ? { componentProps: modalMode === 'edit' ? {
value: editedIngredients.length > 0 ? editedIngredients : (selectedRecipe.ingredients || []),
availableIngredients, availableIngredients,
unitOptions, unitOptions,
onChange: (newIngredients: RecipeIngredientResponse[]) => { onChange: (newIngredients: RecipeIngredientResponse[]) => {
@@ -1208,7 +1262,7 @@ const RecipesPage: React.FC = () => {
<div className="flex items-start gap-2 text-[var(--text-secondary)]"> <div className="flex items-start gap-2 text-[var(--text-secondary)]">
<span className="flex-shrink-0">🔄</span> <span className="flex-shrink-0">🔄</span>
<span> <span>
<strong>Sustituto:</strong> {ing.substitution_options} <strong>Sustituto:</strong> {typeof ing.substitution_options === 'string' ? ing.substitution_options : JSON.stringify(ing.substitution_options)}
{ing.substitution_ratio && ` (ratio: ${ing.substitution_ratio})`} {ing.substitution_ratio && ` (ratio: ${ing.substitution_ratio})`}
</span> </span>
</div> </div>
@@ -1519,37 +1573,20 @@ const RecipesPage: React.FC = () => {
{/* Recipe Details Modal */} {/* Recipe Details Modal */}
{showForm && selectedRecipe && ( {showForm && selectedRecipe && (
<EditViewModal <RecipeViewEditModal
isOpen={showForm} isOpen={showForm}
onClose={() => { onClose={() => {
setShowForm(false); setShowForm(false);
setSelectedRecipe(null); setSelectedRecipe(null);
setModalMode('view'); setModalMode('view');
setEditedRecipe({});
setEditedIngredients([]);
}} }}
recipe={selectedRecipe}
mode={modalMode} mode={modalMode}
onModeChange={(newMode) => { onModeChange={(newMode) => {
setModalMode(newMode); setModalMode(newMode);
if (newMode === 'view') {
setEditedRecipe({});
setEditedIngredients([]);
} else if (newMode === 'edit' && selectedRecipe) {
// Initialize edited ingredients when entering edit mode
setEditedIngredients(selectedRecipe.ingredients || []);
}
}} }}
title={selectedRecipe.name}
subtitle={selectedRecipe.description || ''}
statusIndicator={getRecipeStatusConfig(selectedRecipe)}
size="xl"
sections={getModalSections()}
onFieldChange={handleFieldChange}
showDefaultActions={true}
onSave={handleSaveRecipe} onSave={handleSaveRecipe}
waitForRefetch={true} isSaving={updateRecipeMutation.isPending}
isRefetching={isRefetchingRecipes}
onSaveComplete={handleRecipeSaveComplete}
/> />
)} )}

View File

@@ -61,9 +61,9 @@ const LandingPage: React.FC = () => {
</div> </div>
{/* Scarcity Badge */} {/* Scarcity Badge */}
<div className="mb-8 inline-block"> <div className="mb-8 inline-flex">
<div className="bg-gradient-to-r from-amber-50 via-orange-50 to-amber-50 dark:from-amber-900/20 dark:via-orange-900/20 dark:to-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-full px-6 py-3 shadow-lg hover:shadow-xl transition-shadow"> <div className="bg-gradient-to-r from-amber-50 via-orange-50 to-amber-50 dark:from-amber-900/20 dark:via-orange-900/20 dark:to-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-full px-5 py-2 shadow-lg hover:shadow-xl transition-all hover:scale-105">
<p className="text-sm font-bold text-amber-700 dark:text-amber-300"> <p className="text-sm font-bold text-amber-700 dark:text-amber-300 leading-tight">
🔥 {t('landing:hero.scarcity', 'Solo 12 plazas restantes de 20 • 3 meses GRATIS')} 🔥 {t('landing:hero.scarcity', 'Solo 12 plazas restantes de 20 • 3 meses GRATIS')}
</p> </p>
</div> </div>

View File

@@ -42,6 +42,12 @@ router = APIRouter(prefix="/api/v1/tenants/{tenant_id}/dashboard", tags=["dashbo
# Response Models # Response Models
# ============================================================ # ============================================================
class I18nData(BaseModel):
"""i18n translation data"""
key: str = Field(..., description="i18n translation key")
params: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Parameters for translation")
class HeadlineData(BaseModel): class HeadlineData(BaseModel):
"""i18n-ready headline data""" """i18n-ready headline data"""
key: str = Field(..., description="i18n translation key") key: str = Field(..., description="i18n translation key")
@@ -103,12 +109,12 @@ class OrchestrationSummaryResponse(BaseModel):
userActionsRequired: int = Field(..., description="Number of actions needing approval") userActionsRequired: int = Field(..., description="Number of actions needing approval")
durationSeconds: Optional[int] = Field(None, description="How long orchestration took") durationSeconds: Optional[int] = Field(None, description="How long orchestration took")
aiAssisted: bool = Field(False, description="Whether AI insights were used") aiAssisted: bool = Field(False, description="Whether AI insights were used")
message: Optional[str] = Field(None, description="User-friendly message") message_i18n: Optional[I18nData] = Field(None, description="i18n data for message")
class ActionButton(BaseModel): class ActionButton(BaseModel):
"""Action button configuration""" """Action button configuration"""
label: str label_i18n: I18nData = Field(..., description="i18n data for button label")
type: str = Field(..., description="Button type: primary, secondary, tertiary") type: str = Field(..., description="Button type: primary, secondary, tertiary")
action: str = Field(..., description="Action identifier") action: str = Field(..., description="Action identifier")
@@ -118,10 +124,14 @@ class ActionItem(BaseModel):
id: str id: str
type: str = Field(..., description="Action type") type: str = Field(..., description="Action type")
urgency: str = Field(..., description="Urgency: critical, important, normal") urgency: str = Field(..., description="Urgency: critical, important, normal")
title: str title: Optional[str] = Field(None, description="Legacy field for alerts")
subtitle: str title_i18n: Optional[I18nData] = Field(None, description="i18n data for title")
reasoning: str = Field(..., description="Why this action is needed") subtitle: Optional[str] = Field(None, description="Legacy field for alerts")
consequence: str = Field(..., description="What happens if not done") subtitle_i18n: Optional[I18nData] = Field(None, description="i18n data for subtitle")
reasoning: Optional[str] = Field(None, description="Legacy field for alerts")
reasoning_i18n: Optional[I18nData] = Field(None, description="i18n data for reasoning")
consequence_i18n: I18nData = Field(..., description="i18n data for consequence")
reasoning_data: Optional[Dict[str, Any]] = Field(None, description="Structured reasoning data")
amount: Optional[float] = Field(None, description="Amount for financial actions") amount: Optional[float] = Field(None, description="Amount for financial actions")
currency: Optional[str] = Field(None, description="Currency code") currency: Optional[str] = Field(None, description="Currency code")
actions: List[ActionButton] actions: List[ActionButton]
@@ -152,7 +162,9 @@ class ProductionTimelineItem(BaseModel):
progress: int = Field(..., ge=0, le=100, description="Progress percentage") progress: int = Field(..., ge=0, le=100, description="Progress percentage")
readyBy: Optional[str] readyBy: Optional[str]
priority: str priority: str
reasoning: str reasoning_data: Optional[Dict[str, Any]] = Field(None, description="Structured reasoning data")
reasoning_i18n: Optional[I18nData] = Field(None, description="i18n data for reasoning")
status_i18n: Optional[I18nData] = Field(None, description="i18n data for status")
class ProductionTimelineResponse(BaseModel): class ProductionTimelineResponse(BaseModel):
@@ -164,12 +176,17 @@ class ProductionTimelineResponse(BaseModel):
pendingBatches: int pendingBatches: int
class InsightCardI18n(BaseModel):
"""i18n data for insight card"""
label: I18nData = Field(..., description="i18n data for label")
value: I18nData = Field(..., description="i18n data for value")
detail: Optional[I18nData] = Field(None, description="i18n data for detail")
class InsightCard(BaseModel): class InsightCard(BaseModel):
"""Individual insight card""" """Individual insight card"""
label: str
value: str
detail: str
color: str = Field(..., description="Color: green, amber, red") color: str = Field(..., description="Color: green, amber, red")
i18n: InsightCardI18n = Field(..., description="i18n translation data")
class InsightsResponse(BaseModel): class InsightsResponse(BaseModel):

View File

@@ -201,28 +201,28 @@ class DashboardService:
"""Generate i18n-ready headline based on status""" """Generate i18n-ready headline based on status"""
if status == HealthStatus.GREEN: if status == HealthStatus.GREEN:
return { return {
"key": "dashboard.health.headline_green", "key": "health.headline_green",
"params": {} "params": {}
} }
elif status == HealthStatus.YELLOW: elif status == HealthStatus.YELLOW:
if pending_approvals > 0: if pending_approvals > 0:
return { return {
"key": "dashboard.health.headline_yellow_approvals", "key": "health.headline_yellow_approvals",
"params": {"count": pending_approvals} "params": {"count": pending_approvals}
} }
elif critical_alerts > 0: elif critical_alerts > 0:
return { return {
"key": "dashboard.health.headline_yellow_alerts", "key": "health.headline_yellow_alerts",
"params": {"count": critical_alerts} "params": {"count": critical_alerts}
} }
else: else:
return { return {
"key": "dashboard.health.headline_yellow_general", "key": "health.headline_yellow_general",
"params": {} "params": {}
} }
else: # RED else: # RED
return { return {
"key": "dashboard.health.headline_red", "key": "health.headline_red",
"params": {} "params": {}
} }
@@ -302,7 +302,10 @@ class DashboardService:
}, },
"userActionsRequired": 0, "userActionsRequired": 0,
"status": "no_runs", "status": "no_runs",
"message": "No orchestration has been run yet. Click 'Run Daily Planning' to generate your first plan." "message_i18n": {
"key": "orchestration.no_runs_message",
"params": {}
}
} }
# Use actual model columns instead of non-existent results attribute # Use actual model columns instead of non-existent results attribute
@@ -371,10 +374,13 @@ class DashboardService:
"title": alert["title"], "title": alert["title"],
"subtitle": alert.get("source") or "System Alert", "subtitle": alert.get("source") or "System Alert",
"reasoning": alert.get("description") or "System alert requires attention", "reasoning": alert.get("description") or "System alert requires attention",
"consequence": "Immediate action required to prevent production issues", "consequence_i18n": {
"key": "action_queue.consequences.immediate_action",
"params": {}
},
"actions": [ "actions": [
{"label": "View Details", "type": "primary", "action": "view_alert"}, {"label_i18n": {"key": "action_queue.buttons.view_details", "params": {}}, "type": "primary", "action": "view_alert"},
{"label": "Dismiss", "type": "secondary", "action": "dismiss"} {"label_i18n": {"key": "action_queue.buttons.dismiss", "params": {}}, "type": "secondary", "action": "dismiss"}
], ],
"estimatedTimeMinutes": 5 "estimatedTimeMinutes": 5
}) })
@@ -394,20 +400,36 @@ class DashboardService:
} }
} }
# Get reasoning type and convert to i18n key
reasoning_type = reasoning_data.get('type', 'inventory_replenishment')
reasoning_type_i18n_key = self._get_reasoning_type_i18n_key(reasoning_type)
actions.append({ actions.append({
"id": po["id"], "id": po["id"],
"type": ActionType.APPROVE_PO, "type": ActionType.APPROVE_PO,
"urgency": urgency, "urgency": urgency,
"title": f"Purchase Order {po.get('po_number', 'N/A')}", "title_i18n": {
"subtitle": f"Supplier: {po.get('supplier_name', 'Unknown')}", "key": "action_queue.titles.purchase_order",
"reasoning": f"Pending approval for {po.get('supplier_name', 'supplier')} - {reasoning_data.get('type', 'inventory replenishment')}", "params": {"po_number": po.get('po_number', 'N/A')}
"consequence": "Delayed delivery may impact production schedule", },
"subtitle_i18n": {
"key": "action_queue.titles.supplier",
"params": {"supplier_name": po.get('supplier_name', 'Unknown')}
},
"reasoning_i18n": {
"key": reasoning_type_i18n_key,
"params": reasoning_data.get('parameters', {})
},
"consequence_i18n": {
"key": "action_queue.consequences.delayed_delivery",
"params": {}
},
"amount": po.get("total_amount", 0), "amount": po.get("total_amount", 0),
"currency": po.get("currency", "EUR"), "currency": po.get("currency", "EUR"),
"actions": [ "actions": [
{"label": "Approve", "type": "primary", "action": "approve"}, {"label_i18n": {"key": "action_queue.buttons.approve", "params": {}}, "type": "primary", "action": "approve"},
{"label": "View Details", "type": "secondary", "action": "view_details"}, {"label_i18n": {"key": "action_queue.buttons.view_details", "params": {}}, "type": "secondary", "action": "view_details"},
{"label": "Modify", "type": "tertiary", "action": "modify"} {"label_i18n": {"key": "action_queue.buttons.modify", "params": {}}, "type": "tertiary", "action": "modify"}
], ],
"estimatedTimeMinutes": 2 "estimatedTimeMinutes": 2
}) })
@@ -423,9 +445,12 @@ class DashboardService:
"title": step.get("title") or "Complete onboarding step", "title": step.get("title") or "Complete onboarding step",
"subtitle": "Setup incomplete", "subtitle": "Setup incomplete",
"reasoning": "Required to unlock full automation", "reasoning": "Required to unlock full automation",
"consequence": step.get("consequence") or "Some features are limited", "consequence_i18n": step.get("consequence_i18n") or {
"key": "action_queue.consequences.limited_features",
"params": {}
},
"actions": [ "actions": [
{"label": "Complete Setup", "type": "primary", "action": "complete_onboarding"} {"label_i18n": {"key": "action_queue.buttons.complete_setup", "params": {}}, "type": "primary", "action": "complete_onboarding"}
], ],
"estimatedTimeMinutes": step.get("estimated_minutes") or 10 "estimatedTimeMinutes": step.get("estimated_minutes") or 10
}) })
@@ -440,6 +465,19 @@ class DashboardService:
return actions return actions
def _get_reasoning_type_i18n_key(self, reasoning_type: str) -> str:
"""Map reasoning type identifiers to i18n keys"""
reasoning_type_map = {
"low_stock_detection": "reasoning.types.low_stock_detection",
"stockout_prevention": "reasoning.types.stockout_prevention",
"forecast_demand": "reasoning.types.forecast_demand",
"customer_orders": "reasoning.types.customer_orders",
"seasonal_demand": "reasoning.types.seasonal_demand",
"inventory_replenishment": "reasoning.types.inventory_replenishment",
"production_schedule": "reasoning.types.production_schedule",
}
return reasoning_type_map.get(reasoning_type, "reasoning.types.other")
def _calculate_po_urgency(self, po: Dict[str, Any]) -> str: def _calculate_po_urgency(self, po: Dict[str, Any]) -> str:
"""Calculate urgency of PO approval based on delivery date""" """Calculate urgency of PO approval based on delivery date"""
required_date = po.get("required_delivery_date") required_date = po.get("required_delivery_date")
@@ -503,6 +541,10 @@ class DashboardService:
progress = 100 progress = 100
status_icon = "" status_icon = ""
status_text = "COMPLETED" status_text = "COMPLETED"
status_i18n = {
"key": "production.status.completed",
"params": {}
}
elif status == "IN_PROGRESS": elif status == "IN_PROGRESS":
# Calculate progress based on time elapsed # Calculate progress based on time elapsed
if actual_start and planned_end: if actual_start and planned_end:
@@ -513,9 +555,17 @@ class DashboardService:
progress = 50 progress = 50
status_icon = "🔄" status_icon = "🔄"
status_text = "IN PROGRESS" status_text = "IN PROGRESS"
status_i18n = {
"key": "production.status.in_progress",
"params": {}
}
else: else:
status_icon = "" status_icon = ""
status_text = "PENDING" status_text = "PENDING"
status_i18n = {
"key": "production.status.pending",
"params": {}
}
# Get reasoning_data or create default # Get reasoning_data or create default
reasoning_data = batch.get("reasoning_data") or { reasoning_data = batch.get("reasoning_data") or {
@@ -527,6 +577,10 @@ class DashboardService:
} }
} }
# Get reasoning type and convert to i18n key
reasoning_type = reasoning_data.get('type', 'forecast_demand')
reasoning_type_i18n_key = self._get_reasoning_type_i18n_key(reasoning_type)
timeline.append({ timeline.append({
"id": batch["id"], "id": batch["id"],
"batchNumber": batch.get("batch_number"), "batchNumber": batch.get("batch_number"),
@@ -542,8 +596,12 @@ class DashboardService:
"progress": progress, "progress": progress,
"readyBy": planned_end.isoformat() if planned_end else None, "readyBy": planned_end.isoformat() if planned_end else None,
"priority": batch.get("priority", "MEDIUM"), "priority": batch.get("priority", "MEDIUM"),
"reasoning": batch.get("reasoning") or f"Scheduled based on {reasoning_data.get('type', 'forecast demand')}", # Fallback reasoning text "reasoning_data": reasoning_data, # Structured data for i18n
"reasoning_data": reasoning_data # NEW: Structured data for i18n "reasoning_i18n": {
"key": reasoning_type_i18n_key,
"params": reasoning_data.get('parameters', {})
},
"status_i18n": status_i18n # i18n for status text
}) })
# Sort by planned start time # Sort by planned start time
@@ -578,19 +636,28 @@ class DashboardService:
low_stock_count = inventory_data.get("low_stock_count", 0) low_stock_count = inventory_data.get("low_stock_count", 0)
out_of_stock_count = inventory_data.get("out_of_stock_count", 0) out_of_stock_count = inventory_data.get("out_of_stock_count", 0)
# Determine inventory color
if out_of_stock_count > 0: if out_of_stock_count > 0:
inventory_status = "⚠️ Stock issues"
inventory_detail = f"{out_of_stock_count} out of stock"
inventory_color = "red" inventory_color = "red"
elif low_stock_count > 0: elif low_stock_count > 0:
inventory_status = "Low stock"
inventory_detail = f"{low_stock_count} alert{'s' if low_stock_count != 1 else ''}"
inventory_color = "amber" inventory_color = "amber"
else: else:
inventory_status = "All stocked"
inventory_detail = "No alerts"
inventory_color = "green" inventory_color = "green"
# Create i18n objects for inventory data
inventory_i18n = {
"status_key": "insights.inventory.stock_issues" if out_of_stock_count > 0 else
"insights.inventory.low_stock" if low_stock_count > 0 else
"insights.inventory.all_stocked",
"status_params": {"count": out_of_stock_count} if out_of_stock_count > 0 else
{"count": low_stock_count} if low_stock_count > 0 else {},
"detail_key": "insights.inventory.out_of_stock" if out_of_stock_count > 0 else
"insights.inventory.alerts" if low_stock_count > 0 else
"insights.inventory.no_alerts",
"detail_params": {"count": out_of_stock_count} if out_of_stock_count > 0 else
{"count": low_stock_count} if low_stock_count > 0 else {}
}
# Waste insight # Waste insight
waste_percentage = sustainability_data.get("waste_percentage", 0) waste_percentage = sustainability_data.get("waste_percentage", 0)
waste_target = sustainability_data.get("target_percentage", 5.0) waste_target = sustainability_data.get("target_percentage", 5.0)
@@ -602,27 +669,71 @@ class DashboardService:
return { return {
"savings": { "savings": {
"label": "💰 SAVINGS", "color": "green" if savings_trend > 0 else "amber",
"value": f"{weekly_savings:.0f} this week", "i18n": {
"detail": f"+{savings_trend:.0f}% vs. last" if savings_trend > 0 else f"{savings_trend:.0f}% vs. last", "label": {
"color": "green" if savings_trend > 0 else "amber" "key": "insights.savings.label",
"params": {}
},
"value": {
"key": "insights.savings.value_this_week",
"params": {"amount": f"{weekly_savings:.0f}"}
},
"detail": {
"key": "insights.savings.detail_vs_last_positive" if savings_trend > 0 else "insights.savings.detail_vs_last_negative",
"params": {"percentage": f"{abs(savings_trend):.0f}"}
}
}
}, },
"inventory": { "inventory": {
"label": "📦 INVENTORY", "color": inventory_color,
"value": inventory_status, "i18n": {
"detail": inventory_detail, "label": {
"color": inventory_color "key": "insights.inventory.label",
"params": {}
},
"value": {
"key": inventory_i18n["status_key"],
"params": inventory_i18n["status_params"]
},
"detail": {
"key": inventory_i18n["detail_key"],
"params": inventory_i18n["detail_params"]
}
}
}, },
"waste": { "waste": {
"label": "♻️ WASTE", "color": "green" if waste_trend <= 0 else "amber",
"value": f"{waste_percentage:.1f}% this month", "i18n": {
"detail": f"{waste_trend:+.1f}% vs. goal", "label": {
"color": "green" if waste_trend <= 0 else "amber" "key": "insights.waste.label",
"params": {}
},
"value": {
"key": "insights.waste.value_this_month",
"params": {"percentage": f"{waste_percentage:.1f}"}
},
"detail": {
"key": "insights.waste.detail_vs_goal",
"params": {"change": f"{waste_trend:+.1f}"}
}
}
}, },
"deliveries": { "deliveries": {
"label": "🚚 DELIVERIES", "color": "green",
"value": f"{deliveries_today} arriving today", "i18n": {
"detail": next_delivery or "None scheduled", "label": {
"color": "green" "key": "insights.deliveries.label",
"params": {}
},
"value": {
"key": "insights.deliveries.arriving_today",
"params": {"count": deliveries_today}
},
"detail": {
"key": "insights.deliveries.none_scheduled" if not next_delivery else None,
"params": {}
} if not next_delivery else {"key": None, "params": {"next_delivery": next_delivery}}
}
} }
} }