Validation step styles and functionality tweaks, add initial AI functionality

This commit is contained in:
2025-02-20 15:11:14 -05:00
parent bba7362641
commit 45a52cbc33
9 changed files with 800 additions and 129 deletions

View File

@@ -15,6 +15,7 @@
"express": "^4.18.2", "express": "^4.18.2",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"mysql2": "^3.12.0", "mysql2": "^3.12.0",
"openai": "^4.85.3",
"pm2": "^5.3.0", "pm2": "^5.3.0",
"ssh2": "^1.16.0", "ssh2": "^1.16.0",
"uuid": "^9.0.1" "uuid": "^9.0.1"
@@ -100,6 +101,27 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/@pm2/agent/node_modules/ws": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
"license": "MIT",
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/@pm2/io": { "node_modules/@pm2/io": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/@pm2/io/-/io-6.0.1.tgz", "resolved": "https://registry.npmjs.org/@pm2/io/-/io-6.0.1.tgz",
@@ -238,6 +260,27 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@pm2/js-api/node_modules/ws": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
"license": "MIT",
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/@pm2/pm2-version-check": { "node_modules/@pm2/pm2-version-check": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/@pm2/pm2-version-check/-/pm2-version-check-1.0.4.tgz", "resolved": "https://registry.npmjs.org/@pm2/pm2-version-check/-/pm2-version-check-1.0.4.tgz",
@@ -276,6 +319,37 @@
"integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "18.19.76",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.76.tgz",
"integrity": "sha512-yvR7Q9LdPz2vGpmpJX5LolrgRdWvB67MJKDPSgIIzpFbaf9a1j/f5DnLp5VDyHGMR0QZHlTr1afsD87QCXFHKw==",
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@types/node-fetch": {
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz",
"integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"form-data": "^4.0.0"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -298,6 +372,18 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/agentkeepalive": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
"integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
"license": "MIT",
"dependencies": {
"humanize-ms": "^1.2.1"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/amp": { "node_modules/amp": {
"version": "0.3.1", "version": "0.3.1",
"resolved": "https://registry.npmjs.org/amp/-/amp-0.3.1.tgz", "resolved": "https://registry.npmjs.org/amp/-/amp-0.3.1.tgz",
@@ -401,6 +487,12 @@
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/aws-ssl-profiles": { "node_modules/aws-ssl-profiles": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
@@ -648,6 +740,18 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commander": { "node_modules/commander": {
"version": "2.15.1", "version": "2.15.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz",
@@ -801,6 +905,15 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/denque": { "node_modules/denque": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@@ -912,6 +1025,21 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": { "node_modules/escape-html": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -991,6 +1119,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/eventemitter2": { "node_modules/eventemitter2": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-5.0.1.tgz", "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-5.0.1.tgz",
@@ -1114,6 +1251,40 @@
} }
} }
}, },
"node_modules/form-data": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/form-data-encoder": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
"integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==",
"license": "MIT"
},
"node_modules/formdata-node": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
"integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
"license": "MIT",
"dependencies": {
"node-domexception": "1.0.0",
"web-streams-polyfill": "4.0.0-beta.3"
},
"engines": {
"node": ">= 12.20"
}
},
"node_modules/forwarded": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1295,6 +1466,21 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -1395,6 +1581,15 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/humanize-ms": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
"integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.0.0"
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.4.24", "version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -1857,6 +2052,45 @@
"node": ">= 0.4.0" "node": ">= 0.4.0"
} }
}, },
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/nodemon": { "node_modules/nodemon": {
"version": "3.1.9", "version": "3.1.9",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz",
@@ -1995,6 +2229,36 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/openai": {
"version": "4.85.3",
"resolved": "https://registry.npmjs.org/openai/-/openai-4.85.3.tgz",
"integrity": "sha512-KTMXAK6FPd2IvsPtglMt0J1GyVrjMxCYzu/mVbCPabzzquSJoZlYpHtE0p0ScZPyt11XTc757xSO4j39j5g+Xw==",
"license": "Apache-2.0",
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
"abort-controller": "^3.0.0",
"agentkeepalive": "^4.2.1",
"form-data-encoder": "1.7.2",
"formdata-node": "^4.3.2",
"node-fetch": "^2.6.7"
},
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.23.8"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/pac-proxy-agent": { "node_modules/pac-proxy-agent": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.1.0.tgz", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.1.0.tgz",
@@ -2996,6 +3260,12 @@
"nodetouch": "bin/nodetouch.js" "nodetouch": "bin/nodetouch.js"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "1.9.3", "version": "1.9.3",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.9.3.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.9.3.tgz",
@@ -3062,6 +3332,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT"
},
"node_modules/unpipe": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -3132,17 +3408,44 @@
"lodash": "^4.17.14" "lodash": "^4.17.14"
} }
}, },
"node_modules/ws": { "node_modules/web-streams-polyfill": {
"version": "7.5.10", "version": "4.0.0-beta.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8.3.0" "node": ">= 14"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/ws": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=10.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"bufferutil": "^4.0.1", "bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2" "utf-8-validate": ">=5.0.2"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"bufferutil": { "bufferutil": {

View File

@@ -24,6 +24,7 @@
"express": "^4.18.2", "express": "^4.18.2",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"mysql2": "^3.12.0", "mysql2": "^3.12.0",
"openai": "^4.85.3",
"pm2": "^5.3.0", "pm2": "^5.3.0",
"ssh2": "^1.16.0", "ssh2": "^1.16.0",
"uuid": "^9.0.1" "uuid": "^9.0.1"

View File

@@ -0,0 +1,75 @@
const express = require('express');
const router = express.Router();
const OpenAI = require('openai');
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});
// Helper function to create the prompt for product validation
function createValidationPrompt(products) {
return `You are a product data validation assistant. Please review the following product data and suggest corrections or improvements. Focus on:
1. Standardizing product names and descriptions
2. Fixing any obvious errors in measurements, prices, or quantities
3. Ensuring consistency in formatting
4. Flagging any suspicious or invalid values
Here is the product data to validate:
${JSON.stringify(products, null, 2)}
Please respond with:
1. The corrected product data in the exact same JSON format
2. A list of changes made and why
3. Any warnings or suggestions for manual review
Respond in the following JSON format:
{
"correctedData": [], // Array of corrected products
"changes": [], // Array of changes made
"warnings": [] // Array of warnings or suggestions
}`;
}
router.post('/validate', async (req, res) => {
try {
const { products } = req.body;
if (!Array.isArray(products)) {
return res.status(400).json({ error: 'Products must be an array' });
}
const prompt = createValidationPrompt(products);
const completion = await openai.chat.completions.create({
model: "gpt-4-turbo-preview",
messages: [
{
role: "system",
content: "You are a product data validation assistant that helps ensure product data is accurate, consistent, and properly formatted."
},
{
role: "user",
content: prompt
}
],
temperature: 0.3, // Lower temperature for more consistent results
max_tokens: 4000,
response_format: { type: "json_object" }
});
const aiResponse = JSON.parse(completion.choices[0].message.content);
res.json({
success: true,
...aiResponse
});
} catch (error) {
console.error('AI Validation Error:', error);
res.status(500).json({
success: false,
error: error.message || 'Error during AI validation'
});
}
});
module.exports = router;

View File

@@ -18,6 +18,7 @@ const vendorsRouter = require('./routes/vendors');
const categoriesRouter = require('./routes/categories'); const categoriesRouter = require('./routes/categories');
const testConnectionRouter = require('./routes/test-connection'); const testConnectionRouter = require('./routes/test-connection');
const importRouter = require('./routes/import'); const importRouter = require('./routes/import');
const aiValidationRouter = require('./routes/ai-validation');
// Get the absolute path to the .env file // Get the absolute path to the .env file
const envPath = path.resolve(process.cwd(), '.env'); const envPath = path.resolve(process.cwd(), '.env');
@@ -93,6 +94,7 @@ async function startServer() {
app.use('/api/vendors', vendorsRouter); app.use('/api/vendors', vendorsRouter);
app.use('/api/categories', categoriesRouter); app.use('/api/categories', categoriesRouter);
app.use('/api/import', importRouter); app.use('/api/import', importRouter);
app.use('/api/ai-validation', aiValidationRouter);
app.use('/api', testConnectionRouter); app.use('/api', testConnectionRouter);
// Basic health check route // Basic health check route

View File

@@ -15,7 +15,7 @@ import { RequireAuth } from './components/auth/RequireAuth';
import Forecasting from "@/pages/Forecasting"; import Forecasting from "@/pages/Forecasting";
import { Vendors } from '@/pages/Vendors'; import { Vendors } from '@/pages/Vendors';
import { Categories } from '@/pages/Categories'; import { Categories } from '@/pages/Categories';
import { Import } from '@/pages/import/Import'; import { Import } from '@/pages/Import';
import { ChakraProvider } from '@chakra-ui/react'; import { ChakraProvider } from '@chakra-ui/react';
const queryClient = new QueryClient(); const queryClient = new QueryClient();

View File

@@ -35,7 +35,7 @@ export const UserTableColumn = <T extends string>(props: UserTableColumnProps<T>
</Button> </Button>
<div <div
className="vertical-text font-medium text-muted-foreground" className="vertical-text font-medium text-muted-foreground"
style={{ writingMode: 'vertical-rl', textOrientation: 'mixed', transform: 'rotate(180deg)' }} style={{ writingMode: 'vertical-rl', textOrientation: 'mixed', transform: 'rotate(0deg)' }}
> >
{header} {header}
</div> </div>

View File

@@ -1,9 +1,9 @@
import { useCallback, useMemo, useState, useEffect } from "react" import { useCallback, useMemo, useState, useEffect, memo } from "react"
import { useRsi } from "../../hooks/useRsi" import { useRsi } from "../../hooks/useRsi"
import type { Meta } from "./types" import type { Meta } from "./types"
import { addErrorsAndRunHooks } from "./utils/dataMutations" import { addErrorsAndRunHooks } from "./utils/dataMutations"
import type { Data, Field, SelectOption, MultiInput } from "../../types" import type { Data, SelectOption } from "../../types"
import { Check, ChevronsUpDown, ArrowDown, AlertCircle } from "lucide-react" import { Check, ChevronsUpDown, ArrowDown, AlertCircle, Loader2 } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { import {
Command, Command,
@@ -56,12 +56,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip"
import * as Select from "@radix-ui/react-select" import config from "@/config"
import {
Dialog,
DialogContent,
DialogTrigger,
} from "@/components/ui/dialog"
type Props<T extends string> = { type Props<T extends string> = {
initialData: (Data<T> & Meta)[] initialData: (Data<T> & Meta)[]
@@ -69,19 +64,80 @@ type Props<T extends string> = {
onBack?: () => void onBack?: () => void
} }
type CellProps = { type BaseFieldType = {
value: any, multiline?: boolean;
onChange: (value: any) => void, price?: boolean;
error?: { level: string, message: string },
field: Field<string>
} }
const EditableCell = ({ value, onChange, error, field }: CellProps) => { type InputFieldType = BaseFieldType & {
type: "input";
}
type MultiInputFieldType = BaseFieldType & {
type: "multi-input";
separator?: string;
}
type SelectFieldType = {
type: "select" | "multi-select";
options: SelectOption[];
}
type CheckboxFieldType = {
type: "checkbox";
booleanMatches?: { [key: string]: boolean };
}
type FieldType = InputFieldType | MultiInputFieldType | SelectFieldType | CheckboxFieldType;
type Field<T extends string> = {
label: string;
key: T;
description?: string;
alternateMatches?: string[];
validations?: ({ rule: string } & Record<string, any>)[];
fieldType: FieldType;
width?: number;
disabled?: boolean;
onChange?: (value: string) => void;
}
type CellProps = {
value: any;
onChange: (value: any) => void;
error?: { level: string; message: string };
field: Field<string>;
}
// Define ValidationIcon before EditableCell
const ValidationIcon = memo(({ error }: { error: { level: string, message: string } }) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="absolute right-2 top-2 text-destructive">
<AlertCircle className="h-4 w-4" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{error.message}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))
// Wrap EditableCell with memo to avoid unnecessary re-renders
const EditableCell = memo(({ value, onChange, error, field }: CellProps) => {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [inputValue, setInputValue] = useState(value ?? "") const [inputValue, setInputValue] = useState(value ?? "")
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("")
const [localValues, setLocalValues] = useState<string[]>([]) const [localValues, setLocalValues] = useState<string[]>([])
const handleWheel = useCallback((e: React.WheelEvent) => {
const commandList = e.currentTarget;
commandList.scrollTop += e.deltaY;
e.stopPropagation();
}, []);
// Update input value when external value changes and we're not editing // Update input value when external value changes and we're not editing
useEffect(() => { useEffect(() => {
if (!isEditing) { if (!isEditing) {
@@ -97,6 +153,12 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
} }
}, [value, field.fieldType.type]) }, [value, field.fieldType.type])
const formatPrice = (value: string) => {
if (!value) return value
// Remove dollar signs and trim
return value.replace(/^\$/, '').trim()
}
const validateRegex = (val: any) => { const validateRegex = (val: any) => {
// Handle non-string values // Handle non-string values
if (val === undefined || val === null) return undefined if (val === undefined || val === null) return undefined
@@ -115,8 +177,14 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
const regexValidation = field.validations?.find(v => v.rule === "regex") const regexValidation = field.validations?.find(v => v.rule === "regex")
if (regexValidation) { if (regexValidation) {
let testValue = strVal
// For price fields, remove dollar sign before testing
if (isPriceField(field.fieldType)) {
testValue = formatPrice(strVal)
}
const regex = new RegExp(regexValidation.value, regexValidation.flags) const regex = new RegExp(regexValidation.value, regexValidation.flags)
if (!regex.test(strVal)) { if (!regex.test(testValue)) {
return { level: regexValidation.level || "error", message: regexValidation.errorMessage } return { level: regexValidation.level || "error", message: regexValidation.errorMessage }
} }
} }
@@ -134,10 +202,10 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
const currentError = getValidationError() const currentError = getValidationError()
const getDisplayValue = (value: any, fieldType: Field<string>["fieldType"]) => { const getDisplayValue = (value: any, fieldType: Field<string>["fieldType"]) => {
if (fieldType.type === "select" || fieldType.type === "multi-select") {
if (fieldType.type === "select") { if (fieldType.type === "select") {
return fieldType.options.find((opt: SelectOption) => opt.value === value)?.label || value return fieldType.options.find((opt: SelectOption) => opt.value === value)?.label || value
} }
if (fieldType.type === "multi-select") {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value.map(v => fieldType.options.find((opt: SelectOption) => opt.value === v)?.label || v).join(", ") return value.map(v => fieldType.options.find((opt: SelectOption) => opt.value === v)?.label || v).join(", ")
} }
@@ -160,6 +228,11 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
return true return true
} }
// Handle price fields
if (isPriceField(field.fieldType)) {
newValue = formatPrice(newValue)
}
// Always commit the value // Always commit the value
onChange(newValue) onChange(newValue)
@@ -172,27 +245,6 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
setIsEditing(false) setIsEditing(false)
} }
const ValidationIcon = ({ error }: { error: { level: string, message: string } }) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="absolute right-2 top-2 text-destructive">
<AlertCircle className="h-4 w-4" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{error.message}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
const handleWheel = (e: React.WheelEvent) => {
const commandList = e.currentTarget;
commandList.scrollTop += e.deltaY;
e.stopPropagation();
};
if (isEditing) { if (isEditing) {
switch (field.fieldType.type) { switch (field.fieldType.type) {
case "select": case "select":
@@ -353,8 +405,59 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
{currentError && <ValidationIcon error={currentError} />} {currentError && <ValidationIcon error={currentError} />}
</div> </div>
) )
case "input":
case "multi-input": case "multi-input":
default: if (field.fieldType.multiline) {
return (
<div className="relative" id={`cell-${field.key}`}>
<Popover
open={isEditing}
onOpenChange={(open) => {
if (!open) {
validateAndCommit(inputValue)
setIsEditing(false)
}
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full h-[36px] justify-start font-normal"
>
<div className="truncate">
{inputValue || "Click to edit..."}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[400px] p-3"
align="start"
side="bottom"
>
<div className="space-y-2">
<h3 className="font-medium text-sm">{field.label}</h3>
<textarea
className="w-full min-h-[150px] p-2 border rounded-md"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
if (validateAndCommit(inputValue)) {
setIsEditing(false)
}
}
}}
autoFocus
placeholder={`Press ${navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+Enter to save`}
/>
{currentError && <ValidationIcon error={currentError} />}
</div>
</PopoverContent>
</Popover>
</div>
)
}
return ( return (
<div className="relative" id={`cell-${field.key}`}> <div className="relative" id={`cell-${field.key}`}>
<Input <Input
@@ -365,9 +468,9 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
onBlur={handleBlur} onBlur={handleBlur}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
if (field.fieldType.type === "multi-input") { if (isMultiInputType(field.fieldType)) {
const separator = (field.fieldType as MultiInput).separator || "," const separator = getMultiInputSeparator(field.fieldType);
const values = inputValue.split(separator).map((v: string) => v.trim()).filter(Boolean) const values = inputValue.split(separator).map((v: string) => v.trim()).filter(Boolean);
if (validateAndCommit(values.join(separator))) { if (validateAndCommit(values.join(separator))) {
setIsEditing(false) setIsEditing(false)
} }
@@ -381,9 +484,47 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
currentError ? "border-destructive" : "" currentError ? "border-destructive" : ""
)} )}
autoFocus autoFocus
placeholder={field.fieldType.type === "multi-input" placeholder={
? `Enter values separated by ${(field.fieldType as MultiInput).separator || ","}` isMultiInputType(field.fieldType)
: undefined} ? `Enter values separated by ${getMultiInputSeparator(field.fieldType)}`
: undefined
}
/>
{currentError && <ValidationIcon error={currentError} />}
</div>
)
default:
return (
<div className="relative" id={`cell-${field.key}`}>
<Input
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value)
}}
onBlur={handleBlur}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (isMultiInputType(field.fieldType)) {
const separator = getMultiInputSeparator(field.fieldType);
const values = inputValue.split(separator).map((v: string) => v.trim()).filter(Boolean);
if (validateAndCommit(values.join(separator))) {
setIsEditing(false)
}
} else if (validateAndCommit(inputValue)) {
setIsEditing(false)
}
}
}}
className={cn(
"w-full",
currentError ? "border-destructive" : ""
)}
autoFocus
placeholder={
isMultiInputType(field.fieldType)
? `Enter values separated by ${getMultiInputSeparator(field.fieldType)}`
: undefined
}
/> />
{currentError && <ValidationIcon error={currentError} />} {currentError && <ValidationIcon error={currentError} />}
</div> </div>
@@ -397,38 +538,79 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
id={`cell-${field.key}`} id={`cell-${field.key}`}
onClick={(e) => { onClick={(e) => {
if (field.fieldType.type !== "checkbox" && !field.disabled) { if (field.fieldType.type !== "checkbox" && !field.disabled) {
e.stopPropagation() // Prevent event bubbling e.stopPropagation()
setIsEditing(true) setIsEditing(true)
setInputValue(Array.isArray(value) ? value.join(", ") : value ?? "") setInputValue(Array.isArray(value) ? value.join(", ") : value ?? "")
} }
}} }}
className={cn( className={cn(
"relative min-h-[36px] cursor-text p-2 rounded-md border bg-background", "relative min-h-[36px] cursor-text p-2 rounded-md border bg-background",
(field.fieldType.type === "input" || field.fieldType.type === "multi-input") &&
field.fieldType.multiline && "max-h-[100px] overflow-y-auto",
currentError ? "border-destructive" : "border-input", currentError ? "border-destructive" : "border-input",
field.fieldType.type === "checkbox" ? "flex items-center" : "flex items-center justify-between", field.fieldType.type === "checkbox" ? "flex items-center" : "flex items-center justify-between",
field.disabled && "opacity-50 cursor-not-allowed bg-muted" field.disabled && "opacity-50 cursor-not-allowed bg-muted"
)} )}
> >
{((field.fieldType.type === "input" || field.fieldType.type === "multi-input") && field.fieldType.multiline) ? (
<div className={cn(
"flex-1 overflow-hidden",
!value && "text-muted-foreground"
)}>
<div className="line-clamp-2 whitespace-pre-wrap">
{value || ""}
</div>
{value && value.length > 100 && (
<Button
variant="link"
className="h-4 p-0 text-xs"
onClick={(e) => {
e.stopPropagation()
setIsEditing(true)
}}
>
Show more...
</Button>
)}
</div>
) : (
<div className={cn("flex-1 overflow-hidden text-ellipsis", !value && "text-muted-foreground")}> <div className={cn("flex-1 overflow-hidden text-ellipsis", !value && "text-muted-foreground")}>
{value ? getDisplayValue(value, field.fieldType) : ""} {value ? getDisplayValue(value, field.fieldType) : ""}
</div> </div>
)}
{(field.fieldType.type === "select" || field.fieldType.type === "multi-select") && ( {(field.fieldType.type === "select" || field.fieldType.type === "multi-select") && (
<ChevronsUpDown className={cn("h-4 w-4 shrink-0", field.disabled ? "opacity-30" : "opacity-50")} /> <ChevronsUpDown className={cn("h-4 w-4 shrink-0", field.disabled ? "opacity-30" : "opacity-50")} />
)} )}
{currentError && <ValidationIcon error={currentError} />} {currentError && <ValidationIcon error={currentError} />}
</div> </div>
) )
})
// Update type guard to be more specific
function isMultiInputType(fieldType: FieldType): fieldType is MultiInputFieldType {
return fieldType.type === "multi-input";
} }
// Add this component for the column header with copy down functionality function getMultiInputSeparator(fieldType: FieldType): string {
const ColumnHeader = <T extends string>({ if (isMultiInputType(fieldType)) {
return fieldType.separator || ",";
}
return ",";
}
function isPriceField(fieldType: FieldType): fieldType is (InputFieldType | MultiInputFieldType) & { price: true } {
return (fieldType.type === "input" || fieldType.type === "multi-input") && 'price' in fieldType && fieldType.price === true;
}
// Wrap ColumnHeader with memo so that it re-renders only when its props change
const ColumnHeader = memo(({
field, field,
data, data,
onCopyDown onCopyDown
}: { }: {
field: Field<T>, field: Field<string>
data: (Data<T> & Meta)[], data: (Data<string> & Meta)[]
onCopyDown: (key: T) => void onCopyDown: (key: string) => void
}) => { }) => {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -442,7 +624,7 @@ const ColumnHeader = <T extends string>({
className="h-4 w-4 p-0 opacity-0 group-hover:opacity-100 transition-opacity" className="h-4 w-4 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onCopyDown(field.key as T) onCopyDown(field.key)
}} }}
title="Copy first row's value down" title="Copy first row's value down"
> >
@@ -451,10 +633,10 @@ const ColumnHeader = <T extends string>({
)} )}
</div> </div>
) )
} })
// Add this component for the copy down confirmation dialog // Add this component for the copy down confirmation dialog
const CopyDownDialog = ({ const CopyDownDialog = memo(({
isOpen, isOpen,
onClose, onClose,
onConfirm, onConfirm,
@@ -493,7 +675,7 @@ const CopyDownDialog = ({
</AlertDialogPortal> </AlertDialogPortal>
</AlertDialog> </AlertDialog>
) )
} })
// Add type utilities at the top level // Add type utilities at the top level
type DeepReadonlyField<T extends string> = { type DeepReadonlyField<T extends string> = {
@@ -523,6 +705,7 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
const [showSubmitAlert, setShowSubmitAlert] = useState(false) const [showSubmitAlert, setShowSubmitAlert] = useState(false)
const [isSubmitting, setSubmitting] = useState(false) const [isSubmitting, setSubmitting] = useState(false)
const [copyDownField, setCopyDownField] = useState<{key: T, label: string} | null>(null) const [copyDownField, setCopyDownField] = useState<{key: T, label: string} | null>(null)
const [isAiValidating, setIsAiValidating] = useState(false)
// Memoize filtered data to prevent recalculation on every render // Memoize filtered data to prevent recalculation on every render
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
@@ -596,18 +779,22 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
{ {
id: "select", id: "select",
header: ({ table }) => ( header: ({ table }) => (
<div className="flex h-full items-center justify-center">
<Checkbox <Checkbox
checked={table.getIsAllPageRowsSelected()} checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all" aria-label="Select all"
/> />
</div>
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-start justify-center pt-2">
<Checkbox <Checkbox
checked={row.getIsSelected()} checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)} onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row" aria-label="Select row"
/> />
</div>
), ),
enableSorting: false, enableSorting: false,
enableHiding: false, enableHiding: false,
@@ -618,9 +805,9 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
header: () => ( header: () => (
<div className="group"> <div className="group">
<ColumnHeader <ColumnHeader
field={field as Field<T>} field={field as Field<string>}
data={data} data={data}
onCopyDown={(key) => copyValueDown(key, field.label)} onCopyDown={(key: string) => copyValueDown(key as T, field.label)}
/> />
</div> </div>
), ),
@@ -659,14 +846,14 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}) })
const deleteSelectedRows = () => { const deleteSelectedRows = useCallback(() => {
if (Object.keys(rowSelection).length) { if (Object.keys(rowSelection).length) {
const selectedRows = Object.keys(rowSelection).map(Number) const selectedRows = Object.keys(rowSelection).map(Number)
const newData = data.filter((_, index) => !selectedRows.includes(index)) const newData = data.filter((_, index) => !selectedRows.includes(index))
updateData(newData) updateData(newData)
setRowSelection({}) setRowSelection({})
} }
} }, [rowSelection, data, updateData]);
const normalizeValue = useCallback((value: any, field: DeepReadonlyField<T>) => { const normalizeValue = useCallback((value: any, field: DeepReadonlyField<T>) => {
if (field.fieldType.type === "checkbox") { if (field.fieldType.type === "checkbox") {
@@ -694,7 +881,7 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
return value return value
}, []) }, [])
const submitData = async () => { const submitData = useCallback(async () => {
const calculatedData = data.reduce( const calculatedData = data.reduce(
(acc, value) => { (acc, value) => {
const { __index, __errors, ...values } = value const { __index, __errors, ...values } = value
@@ -743,18 +930,93 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
} else { } else {
onClose() onClose()
} }
} }, [data, fields, file, onClose, onSubmit, normalizeValue, toast, translations]);
const onContinue = () => {
const onContinue = useCallback(() => {
const invalidData = data.find((value) => { const invalidData = data.find((value) => {
if (value?.__errors) { if (value?.__errors) {
return !!Object.values(value.__errors)?.filter((err) => err.level === "error").length return !!Object.values(value.__errors)?.filter((err) => err.level === "error").length;
} }
return false return false;
}) });
if (!invalidData) { if (!invalidData) {
submitData() submitData();
} else { } else {
setShowSubmitAlert(true) setShowSubmitAlert(true);
}
}, [data, submitData]);
// Add AI validation function
const handleAiValidation = async () => {
try {
setIsAiValidating(true)
const response = await fetch(`${config.apiUrl}/ai-validation/validate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ products: data }),
})
if (!response.ok) {
throw new Error('AI validation failed')
}
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'AI validation failed')
}
// Update the data with AI suggestions
if (result.correctedData && Array.isArray(result.correctedData)) {
// Preserve the __index and __errors from the original data
const newData = result.correctedData.map((item: any, idx: number) => ({
...item,
__index: data[idx]?.__index,
__errors: data[idx]?.__errors,
}))
// Update the data and run validations
await updateData(newData)
}
// Show changes and warnings
if (result.changes?.length) {
toast({
title: "AI Validation Changes",
description: (
<div className="mt-2 space-y-2">
{result.changes.map((change: string, i: number) => (
<div key={i} className="text-sm">• {change}</div>
))}
</div>
),
})
}
if (result.warnings?.length) {
toast({
title: "AI Validation Warnings",
description: (
<div className="mt-2 space-y-2">
{result.warnings.map((warning: string, i: number) => (
<div key={i} className="text-sm">• {warning}</div>
))}
</div>
),
variant: "destructive",
})
}
} catch (error) {
console.error('AI Validation Error:', error)
toast({
title: "AI Validation Error",
description: error instanceof Error ? error.message : "An error occurred during AI validation",
variant: "destructive",
})
} finally {
setIsAiValidating(false)
} }
} }
@@ -808,6 +1070,17 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
> >
{translations.validationStep.discardButtonTitle} {translations.validationStep.discardButtonTitle}
</Button> </Button>
<Button
variant="secondary"
size="sm"
onClick={handleAiValidation}
disabled={isAiValidating || data.length === 0}
>
{isAiValidating && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
AI Validate
</Button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Switch
checked={filterByErrors} checked={filterByErrors}
@@ -857,7 +1130,7 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<TableCell <TableCell
key={cell.id} key={cell.id}
className="p-2" className="p-2 align-top"
style={{ style={{
width: cell.column.getSize(), width: cell.column.getSize(),
minWidth: cell.column.getSize(), minWidth: cell.column.getSize(),

View File

@@ -70,7 +70,7 @@ export type Field<T extends string = string> = {
// Validations used for field entries // Validations used for field entries
validations?: Validation[] validations?: Validation[]
// Field entry component // Field entry component
fieldType: Checkbox | Select | Input | MultiInput | MultiSelect fieldType: FieldType
// UI-facing values shown to user as field examples pre-upload phase // UI-facing values shown to user as field examples pre-upload phase
example?: string example?: string
width?: number width?: number
@@ -78,17 +78,22 @@ export type Field<T extends string = string> = {
onChange?: (value: string) => void onChange?: (value: string) => void
} }
export type Checkbox = { export type FieldType =
type: "checkbox" | {
// Alternate values to be treated as booleans, e.g. {yes: true, no: false} type: "input" | "multi-input";
booleanMatches?: { [key: string]: boolean } multiline?: boolean;
price?: boolean;
separator?: string;
} }
| {
export type Select = { type: "select" | "multi-select";
type: "select" options: readonly SelectOption[];
// Options displayed in Select component separator?: string;
options: SelectOption[]
} }
| {
type: "checkbox";
booleanMatches?: { readonly [key: string]: boolean };
};
export type SelectOption = { export type SelectOption = {
// UI-facing option label // UI-facing option label

View File

@@ -20,7 +20,7 @@ const BASE_IMPORT_FIELDS = [
type: "select" as const, type: "select" as const,
options: [], // Will be populated from API options: [], // Will be populated from API
}, },
width: 200, width: 220,
validations: [{ rule: "required" as const, errorMessage: "Required", level: "error" as ErrorLevel }], validations: [{ rule: "required" as const, errorMessage: "Required", level: "error" as ErrorLevel }],
}, },
{ {
@@ -29,7 +29,7 @@ const BASE_IMPORT_FIELDS = [
description: "Universal Product Code/Barcode", description: "Universal Product Code/Barcode",
alternateMatches: ["barcode", "bar code", "JAN", "EAN"], alternateMatches: ["barcode", "bar code", "JAN", "EAN"],
fieldType: { type: "input" }, fieldType: { type: "input" },
width: 150, width: 140,
validations: [ validations: [
{ rule: "required", errorMessage: "Required", level: "error" }, { rule: "required", errorMessage: "Required", level: "error" },
{ rule: "unique", errorMessage: "Must be unique", level: "error" }, { rule: "unique", errorMessage: "Must be unique", level: "error" },
@@ -42,7 +42,7 @@ const BASE_IMPORT_FIELDS = [
description: "Supplier's product identifier", description: "Supplier's product identifier",
alternateMatches: ["sku", "item#", "mfg item #", "item"], alternateMatches: ["sku", "item#", "mfg item #", "item"],
fieldType: { type: "input" }, fieldType: { type: "input" },
width: 120, width: 180,
validations: [ validations: [
{ rule: "required", errorMessage: "Required", level: "error" }, { rule: "required", errorMessage: "Required", level: "error" },
{ rule: "unique", errorMessage: "Must be unique", level: "error" }, { rule: "unique", errorMessage: "Must be unique", level: "error" },
@@ -53,7 +53,7 @@ const BASE_IMPORT_FIELDS = [
key: "notions_no", key: "notions_no",
description: "Internal notions number", description: "Internal notions number",
fieldType: { type: "input" }, fieldType: { type: "input" },
width: 120, width: 110,
validations: [ validations: [
{ rule: "required", errorMessage: "Required", level: "error" }, { rule: "required", errorMessage: "Required", level: "error" },
{ rule: "unique", errorMessage: "Must be unique", level: "error" }, { rule: "unique", errorMessage: "Must be unique", level: "error" },
@@ -66,7 +66,7 @@ const BASE_IMPORT_FIELDS = [
description: "Product name/title", description: "Product name/title",
alternateMatches: ["sku description"], alternateMatches: ["sku description"],
fieldType: { type: "input" }, fieldType: { type: "input" },
width: 300, width: 500,
validations: [ validations: [
{ rule: "required", errorMessage: "Required", level: "error" }, { rule: "required", errorMessage: "Required", level: "error" },
{ rule: "unique", errorMessage: "Must be unique", level: "error" }, { rule: "unique", errorMessage: "Must be unique", level: "error" },
@@ -88,14 +88,17 @@ const BASE_IMPORT_FIELDS = [
key: "image_url", key: "image_url",
description: "Product image URL(s)", description: "Product image URL(s)",
fieldType: { type: "multi-input" }, fieldType: { type: "multi-input" },
width: 250, width: 300,
}, },
{ {
label: "MSRP", label: "MSRP",
key: "msrp", key: "msrp",
description: "Manufacturer's Suggested Retail Price", description: "Manufacturer's Suggested Retail Price",
alternateMatches: ["retail", "retail price", "sugg retail", "price", "sugg. Retail"], alternateMatches: ["retail", "retail price", "sugg retail", "price", "sugg. Retail"],
fieldType: { type: "input" }, fieldType: {
type: "input",
price: true
},
width: 100, width: 100,
validations: [ validations: [
{ rule: "required", errorMessage: "Required", level: "error" }, { rule: "required", errorMessage: "Required", level: "error" },
@@ -108,7 +111,7 @@ const BASE_IMPORT_FIELDS = [
description: "Quantity of items per individual unit", description: "Quantity of items per individual unit",
alternateMatches: ["inner pack", "inner", "min qty", "unit qty", "min. order qty"], alternateMatches: ["inner pack", "inner", "min qty", "unit qty", "min. order qty"],
fieldType: { type: "input" }, fieldType: { type: "input" },
width: 100, width: 90,
validations: [ validations: [
{ rule: "required", errorMessage: "Required", level: "error" }, { rule: "required", errorMessage: "Required", level: "error" },
{ rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" }, { rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" },
@@ -119,7 +122,10 @@ const BASE_IMPORT_FIELDS = [
key: "cost_each", key: "cost_each",
description: "Wholesale cost per unit", description: "Wholesale cost per unit",
alternateMatches: ["wholesale", "wholesale price"], alternateMatches: ["wholesale", "wholesale price"],
fieldType: { type: "input" }, fieldType: {
type: "input",
price: true
},
width: 100, width: 100,
validations: [ validations: [
{ rule: "required", errorMessage: "Required", level: "error" }, { rule: "required", errorMessage: "Required", level: "error" },
@@ -132,7 +138,7 @@ const BASE_IMPORT_FIELDS = [
description: "Number of units per case", description: "Number of units per case",
alternateMatches: ["mc qty"], alternateMatches: ["mc qty"],
fieldType: { type: "input" }, fieldType: { type: "input" },
width: 100, width: 50,
validations: [ validations: [
{ rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" }, { rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" },
], ],
@@ -142,10 +148,10 @@ const BASE_IMPORT_FIELDS = [
key: "tax_cat", key: "tax_cat",
description: "Product tax category", description: "Product tax category",
fieldType: { fieldType: {
type: "multi-select", type: "select",
options: [], // Will be populated from API options: [], // Will be populated from API
}, },
width: 150, width: 180,
validations: [{ rule: "required", errorMessage: "Required", level: "error" }], validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
}, },
{ {
@@ -167,7 +173,7 @@ const BASE_IMPORT_FIELDS = [
type: "select", type: "select",
options: [], // Will be populated dynamically based on company selection options: [], // Will be populated dynamically based on company selection
}, },
width: 150, width: 180,
validations: [{ rule: "required", errorMessage: "Required", level: "error" }], validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
}, },
{ {
@@ -178,7 +184,7 @@ const BASE_IMPORT_FIELDS = [
type: "select", type: "select",
options: [], // Will be populated dynamically based on line selection options: [], // Will be populated dynamically based on line selection
}, },
width: 150, width: 180,
}, },
{ {
label: "Artist", label: "Artist",
@@ -188,7 +194,7 @@ const BASE_IMPORT_FIELDS = [
type: "select", type: "select",
options: [], // Will be populated from API options: [], // Will be populated from API
}, },
width: 200, width: 180,
}, },
{ {
label: "ETA Date", label: "ETA Date",
@@ -250,7 +256,7 @@ const BASE_IMPORT_FIELDS = [
type: "select", type: "select",
options: [], // Will be populated from API options: [], // Will be populated from API
}, },
width: 150, width: 190,
validations: [{ rule: "required", errorMessage: "Required", level: "error" }], validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
}, },
{ {
@@ -270,9 +276,9 @@ const BASE_IMPORT_FIELDS = [
description: "Harmonized Tariff Schedule code", description: "Harmonized Tariff Schedule code",
alternateMatches: ["taric"], alternateMatches: ["taric"],
fieldType: { type: "input" }, fieldType: { type: "input" },
width: 120, width: 130,
validations: [ validations: [
{ rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" }, { rule: "regex", value: "^[0-9.]+$", errorMessage: "Must be a number", level: "error" },
], ],
}, },
{ {
@@ -283,13 +289,16 @@ const BASE_IMPORT_FIELDS = [
type: "select", type: "select",
options: [], // Will be populated from API options: [], // Will be populated from API
}, },
width: 150, width: 180,
}, },
{ {
label: "Description", label: "Description",
key: "description", key: "description",
description: "Detailed product description", description: "Detailed product description",
fieldType: { type: "input" }, fieldType: {
type: "input",
multiline: true
},
width: 400, width: 400,
validations: [{ rule: "required", errorMessage: "Required", level: "error" }], validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
}, },
@@ -297,7 +306,10 @@ const BASE_IMPORT_FIELDS = [
label: "Private Notes", label: "Private Notes",
key: "priv_notes", key: "priv_notes",
description: "Internal notes about the product", description: "Internal notes about the product",
fieldType: { type: "input" }, fieldType: {
type: "input",
multiline: true
},
width: 300, width: 300,
}, },
{ {
@@ -308,7 +320,7 @@ const BASE_IMPORT_FIELDS = [
type: "select", type: "select",
options: [], // Will be populated from API options: [], // Will be populated from API
}, },
width: 200, width: 350,
validations: [{ rule: "required", errorMessage: "Required", level: "error" }], validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
}, },
{ {
@@ -319,7 +331,7 @@ const BASE_IMPORT_FIELDS = [
type: "select", type: "select",
options: [], // Will be populated from API options: [], // Will be populated from API
}, },
width: 200, width: 300,
}, },
{ {
label: "Colors", label: "Colors",
@@ -329,7 +341,7 @@ const BASE_IMPORT_FIELDS = [
type: "select", type: "select",
options: [], // Will be populated from API options: [], // Will be populated from API
}, },
width: 150, width: 180,
}, },
] as const; ] as const;