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",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.12.0",
"openai": "^4.85.3",
"pm2": "^5.3.0",
"ssh2": "^1.16.0",
"uuid": "^9.0.1"
@@ -100,6 +101,27 @@
"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": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@pm2/io/-/io-6.0.1.tgz",
@@ -238,6 +260,27 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"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": {
"version": "1.0.4",
"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==",
"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": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -298,6 +372,18 @@
"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": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/amp/-/amp-0.3.1.tgz",
@@ -401,6 +487,12 @@
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"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": {
"version": "1.1.2",
"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==",
"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": {
"version": "2.15.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz",
@@ -801,6 +905,15 @@
"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": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@@ -912,6 +1025,21 @@
"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": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -991,6 +1119,15 @@
"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": {
"version": "5.0.1",
"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": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1295,6 +1466,21 @@
"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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -1395,6 +1581,15 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"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": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -1857,6 +2052,45 @@
"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": {
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz",
@@ -1995,6 +2229,36 @@
"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": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.1.0.tgz",
@@ -2996,6 +3260,12 @@
"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": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.9.3.tgz",
@@ -3062,6 +3332,12 @@
"dev": true,
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -3132,17 +3408,44 @@
"lodash": "^4.17.14"
}
},
"node_modules/ws": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
"node_modules/web-streams-polyfill": {
"version": "4.0.0-beta.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
"integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
"license": "MIT",
"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": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {

View File

@@ -24,6 +24,7 @@
"express": "^4.18.2",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.12.0",
"openai": "^4.85.3",
"pm2": "^5.3.0",
"ssh2": "^1.16.0",
"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 testConnectionRouter = require('./routes/test-connection');
const importRouter = require('./routes/import');
const aiValidationRouter = require('./routes/ai-validation');
// Get the absolute path to the .env file
const envPath = path.resolve(process.cwd(), '.env');
@@ -93,6 +94,7 @@ async function startServer() {
app.use('/api/vendors', vendorsRouter);
app.use('/api/categories', categoriesRouter);
app.use('/api/import', importRouter);
app.use('/api/ai-validation', aiValidationRouter);
app.use('/api', testConnectionRouter);
// Basic health check route

View File

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

View File

@@ -35,7 +35,7 @@ export const UserTableColumn = <T extends string>(props: UserTableColumnProps<T>
</Button>
<div
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}
</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 type { Meta } from "./types"
import { addErrorsAndRunHooks } from "./utils/dataMutations"
import type { Data, Field, SelectOption, MultiInput } from "../../types"
import { Check, ChevronsUpDown, ArrowDown, AlertCircle } from "lucide-react"
import type { Data, SelectOption } from "../../types"
import { Check, ChevronsUpDown, ArrowDown, AlertCircle, Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Command,
@@ -56,12 +56,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import * as Select from "@radix-ui/react-select"
import {
Dialog,
DialogContent,
DialogTrigger,
} from "@/components/ui/dialog"
import config from "@/config"
type Props<T extends string> = {
initialData: (Data<T> & Meta)[]
@@ -69,19 +64,80 @@ type Props<T extends string> = {
onBack?: () => void
}
type CellProps = {
value: any,
onChange: (value: any) => void,
error?: { level: string, message: string },
field: Field<string>
type BaseFieldType = {
multiline?: boolean;
price?: boolean;
}
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 [inputValue, setInputValue] = useState(value ?? "")
const [searchQuery, setSearchQuery] = useState("")
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
useEffect(() => {
if (!isEditing) {
@@ -97,6 +153,12 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
}
}, [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) => {
// Handle non-string values
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")
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)
if (!regex.test(strVal)) {
if (!regex.test(testValue)) {
return { level: regexValidation.level || "error", message: regexValidation.errorMessage }
}
}
@@ -134,10 +202,10 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
const currentError = getValidationError()
const getDisplayValue = (value: any, fieldType: Field<string>["fieldType"]) => {
if (fieldType.type === "select") {
return fieldType.options.find((opt: SelectOption) => opt.value === value)?.label || value
}
if (fieldType.type === "multi-select") {
if (fieldType.type === "select" || fieldType.type === "multi-select") {
if (fieldType.type === "select") {
return fieldType.options.find((opt: SelectOption) => opt.value === value)?.label || value
}
if (Array.isArray(value)) {
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
}
// Handle price fields
if (isPriceField(field.fieldType)) {
newValue = formatPrice(newValue)
}
// Always commit the value
onChange(newValue)
@@ -172,27 +245,6 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
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) {
switch (field.fieldType.type) {
case "select":
@@ -353,8 +405,59 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
{currentError && <ValidationIcon error={currentError} />}
</div>
)
case "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 (
<div className="relative" id={`cell-${field.key}`}>
<Input
@@ -365,9 +468,9 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
onBlur={handleBlur}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (field.fieldType.type === "multi-input") {
const separator = (field.fieldType as MultiInput).separator || ","
const values = inputValue.split(separator).map((v: string) => v.trim()).filter(Boolean)
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)
}
@@ -381,9 +484,47 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
currentError ? "border-destructive" : ""
)}
autoFocus
placeholder={field.fieldType.type === "multi-input"
? `Enter values separated by ${(field.fieldType as MultiInput).separator || ","}`
: undefined}
placeholder={
isMultiInputType(field.fieldType)
? `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} />}
</div>
@@ -397,38 +538,79 @@ const EditableCell = ({ value, onChange, error, field }: CellProps) => {
id={`cell-${field.key}`}
onClick={(e) => {
if (field.fieldType.type !== "checkbox" && !field.disabled) {
e.stopPropagation() // Prevent event bubbling
e.stopPropagation()
setIsEditing(true)
setInputValue(Array.isArray(value) ? value.join(", ") : value ?? "")
}
}}
className={cn(
"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",
field.fieldType.type === "checkbox" ? "flex items-center" : "flex items-center justify-between",
field.disabled && "opacity-50 cursor-not-allowed bg-muted"
)}
>
<div className={cn("flex-1 overflow-hidden text-ellipsis", !value && "text-muted-foreground")}>
{value ? getDisplayValue(value, field.fieldType) : ""}
</div>
{((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")}>
{value ? getDisplayValue(value, field.fieldType) : ""}
</div>
)}
{(field.fieldType.type === "select" || field.fieldType.type === "multi-select") && (
<ChevronsUpDown className={cn("h-4 w-4 shrink-0", field.disabled ? "opacity-30" : "opacity-50")} />
)}
{currentError && <ValidationIcon error={currentError} />}
</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
const ColumnHeader = <T extends string>({
function getMultiInputSeparator(fieldType: FieldType): 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,
data,
onCopyDown
}: {
field: Field<T>,
data: (Data<T> & Meta)[],
onCopyDown: (key: T) => void
field: Field<string>
data: (Data<string> & Meta)[]
onCopyDown: (key: string) => void
}) => {
return (
<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"
onClick={(e) => {
e.stopPropagation()
onCopyDown(field.key as T)
onCopyDown(field.key)
}}
title="Copy first row's value down"
>
@@ -451,10 +633,10 @@ const ColumnHeader = <T extends string>({
)}
</div>
)
}
})
// Add this component for the copy down confirmation dialog
const CopyDownDialog = ({
const CopyDownDialog = memo(({
isOpen,
onClose,
onConfirm,
@@ -493,7 +675,7 @@ const CopyDownDialog = ({
</AlertDialogPortal>
</AlertDialog>
)
}
})
// Add type utilities at the top level
type DeepReadonlyField<T extends string> = {
@@ -523,6 +705,7 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
const [showSubmitAlert, setShowSubmitAlert] = useState(false)
const [isSubmitting, setSubmitting] = useState(false)
const [copyDownField, setCopyDownField] = useState<{key: T, label: string} | null>(null)
const [isAiValidating, setIsAiValidating] = useState(false)
// Memoize filtered data to prevent recalculation on every render
const filteredData = useMemo(() => {
@@ -596,18 +779,22 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
<div className="flex h-full items-center justify-center">
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
</div>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
<div className="flex items-start justify-center pt-2">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
enableHiding: false,
@@ -618,9 +805,9 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
header: () => (
<div className="group">
<ColumnHeader
field={field as Field<T>}
field={field as Field<string>}
data={data}
onCopyDown={(key) => copyValueDown(key, field.label)}
onCopyDown={(key: string) => copyValueDown(key as T, field.label)}
/>
</div>
),
@@ -659,14 +846,14 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
getCoreRowModel: getCoreRowModel(),
})
const deleteSelectedRows = () => {
const deleteSelectedRows = useCallback(() => {
if (Object.keys(rowSelection).length) {
const selectedRows = Object.keys(rowSelection).map(Number)
const newData = data.filter((_, index) => !selectedRows.includes(index))
updateData(newData)
setRowSelection({})
}
}
}, [rowSelection, data, updateData]);
const normalizeValue = useCallback((value: any, field: DeepReadonlyField<T>) => {
if (field.fieldType.type === "checkbox") {
@@ -694,7 +881,7 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
return value
}, [])
const submitData = async () => {
const submitData = useCallback(async () => {
const calculatedData = data.reduce(
(acc, value) => {
const { __index, __errors, ...values } = value
@@ -743,18 +930,93 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
} else {
onClose()
}
}
const onContinue = () => {
}, [data, fields, file, onClose, onSubmit, normalizeValue, toast, translations]);
const onContinue = useCallback(() => {
const invalidData = data.find((value) => {
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) {
submitData()
submitData();
} 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}
</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">
<Switch
checked={filterByErrors}
@@ -857,7 +1130,7 @@ export const ValidationStep = <T extends string>({ initialData, file, onBack }:
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="p-2"
className="p-2 align-top"
style={{
width: 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?: Validation[]
// Field entry component
fieldType: Checkbox | Select | Input | MultiInput | MultiSelect
fieldType: FieldType
// UI-facing values shown to user as field examples pre-upload phase
example?: string
width?: number
@@ -78,17 +78,22 @@ export type Field<T extends string = string> = {
onChange?: (value: string) => void
}
export type Checkbox = {
type: "checkbox"
// Alternate values to be treated as booleans, e.g. {yes: true, no: false}
booleanMatches?: { [key: string]: boolean }
}
export type Select = {
type: "select"
// Options displayed in Select component
options: SelectOption[]
}
export type FieldType =
| {
type: "input" | "multi-input";
multiline?: boolean;
price?: boolean;
separator?: string;
}
| {
type: "select" | "multi-select";
options: readonly SelectOption[];
separator?: string;
}
| {
type: "checkbox";
booleanMatches?: { readonly [key: string]: boolean };
};
export type SelectOption = {
// UI-facing option label

View File

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