diff --git a/inventory-server/package-lock.json b/inventory-server/package-lock.json index e8de9f0..20923ec 100644 --- a/inventory-server/package-lock.json +++ b/inventory-server/package-lock.json @@ -23,6 +23,7 @@ "openai": "^6.0.0", "pg": "^8.14.1", "pm2": "^5.3.0", + "sharp": "^0.33.5", "ssh2": "^1.16.0", "uuid": "^9.0.1" }, @@ -30,6 +31,384 @@ "nodemon": "^3.0.2" } }, + "node_modules/@emnapi/runtime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.6.0.tgz", + "integrity": "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@mapbox/node-pre-gyp": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", @@ -840,6 +1219,19 @@ "node": ">=8.10.0" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -858,6 +1250,16 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/color-support": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", @@ -1853,6 +2255,12 @@ "node": ">= 0.10" } }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3387,6 +3795,45 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, "node_modules/shimmer": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", @@ -3471,6 +3918,15 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", diff --git a/inventory-server/package.json b/inventory-server/package.json index 9b2b54e..d86785e 100644 --- a/inventory-server/package.json +++ b/inventory-server/package.json @@ -32,6 +32,7 @@ "openai": "^6.0.0", "pg": "^8.14.1", "pm2": "^5.3.0", + "sharp": "^0.33.5", "ssh2": "^1.16.0", "uuid": "^9.0.1" }, diff --git a/inventory-server/src/routes/import.js b/inventory-server/src/routes/import.js index b7dbee7..ce2292c 100644 --- a/inventory-server/src/routes/import.js +++ b/inventory-server/src/routes/import.js @@ -5,6 +5,8 @@ const mysql = require('mysql2/promise'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); +const fsp = fs.promises; +const sharp = require('sharp'); // Create uploads directory if it doesn't exist const uploadsDir = path.join('/var/www/html/inventory/uploads/products'); @@ -35,6 +37,9 @@ const connectionCache = { } }; +const MIN_IMAGE_DIMENSION = 1000; +const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; + // Function to schedule image deletion after 24 hours const scheduleImageDeletion = (filename, filePath) => { // Only schedule deletion for images in the products folder @@ -145,6 +150,255 @@ const cleanupImagesOnStartup = () => { // Run cleanup on server start cleanupImagesOnStartup(); +const bytesToMegabytes = (bytes) => Number((bytes / (1024 * 1024)).toFixed(2)); + +const processUploadedImage = async (filePath, mimetype) => { + const notices = []; + const legacyWarnings = []; + const metadata = {}; + + const originalBuffer = await fsp.readFile(filePath); + let baseMetadata = await sharp(originalBuffer, { failOn: 'none' }).metadata(); + + metadata.width = baseMetadata.width || 0; + metadata.height = baseMetadata.height || 0; + metadata.size = originalBuffer.length; + metadata.colorSpace = baseMetadata.space || baseMetadata.colourspace || null; + + if ( + baseMetadata.width && + baseMetadata.height && + (baseMetadata.width < MIN_IMAGE_DIMENSION || baseMetadata.height < MIN_IMAGE_DIMENSION) + ) { + const message = `Image is ${baseMetadata.width}x${baseMetadata.height}. Recommended minimum is ${MIN_IMAGE_DIMENSION}x${MIN_IMAGE_DIMENSION}.`; + notices.push({ + message, + level: 'warning', + code: 'dimensions_too_small', + source: 'server' + }); + legacyWarnings.push(message); + } + + const colorSpace = (baseMetadata.space || baseMetadata.colourspace || '').toLowerCase(); + let shouldConvertToRgb = colorSpace === 'cmyk'; + + if (shouldConvertToRgb) { + const message = 'Converted image from CMYK to RGB.'; + notices.push({ + message, + level: 'info', + code: 'converted_to_rgb', + source: 'server' + }); + legacyWarnings.push(message); + } + + const format = (baseMetadata.format || '').toLowerCase(); + if (format === 'gif') { + if (metadata.size > MAX_IMAGE_SIZE_BYTES) { + const message = `GIF optimization is limited; resulting size is ${bytesToMegabytes(metadata.size)}MB (target 5MB).`; + notices.push({ + message, + level: 'warning', + code: 'gif_size_limit', + source: 'server' + }); + legacyWarnings.push(message); + } + metadata.convertedToRgb = false; + metadata.resized = false; + return { notices, warnings: legacyWarnings, metadata, finalSize: metadata.size }; + } + + const supportsQuality = ['jpeg', 'jpg', 'webp'].includes(format); + let targetQuality = supportsQuality ? 90 : undefined; + let finalQuality = undefined; + + let currentWidth = baseMetadata.width || null; + let currentHeight = baseMetadata.height || null; + + let resized = false; + let mutated = false; + let finalBuffer = originalBuffer; + let finalInfo = baseMetadata; + + const encode = async ({ width, height, quality }) => { + let pipeline = sharp(originalBuffer, { failOn: 'none' }); + + if (shouldConvertToRgb) { + pipeline = pipeline.toColorspace('srgb'); + } + + if (width || height) { + pipeline = pipeline.resize({ + width: width ?? undefined, + height: height ?? undefined, + fit: 'inside', + withoutEnlargement: true, + }); + } + + switch (format) { + case 'png': + pipeline = pipeline.png({ + compressionLevel: 9, + adaptiveFiltering: true, + palette: true, + }); + break; + case 'webp': + pipeline = pipeline.webp({ quality: quality ?? 90 }); + break; + case 'jpeg': + case 'jpg': + default: + pipeline = pipeline.jpeg({ quality: quality ?? 90, mozjpeg: true }); + break; + } + + return pipeline.toBuffer({ resolveWithObject: true }); + }; + + const canResize = + (currentWidth && currentWidth > MIN_IMAGE_DIMENSION) || + (currentHeight && currentHeight > MIN_IMAGE_DIMENSION); + + if (metadata.size > MAX_IMAGE_SIZE_BYTES && (supportsQuality || canResize)) { + const maxAttempts = 8; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + let targetWidth = currentWidth; + let targetHeight = currentHeight; + let resizedThisAttempt = false; + + if (currentWidth && currentWidth > MIN_IMAGE_DIMENSION) { + targetWidth = Math.max(MIN_IMAGE_DIMENSION, Math.round(currentWidth * 0.85)); + } + + if (currentHeight && currentHeight > MIN_IMAGE_DIMENSION) { + targetHeight = Math.max(MIN_IMAGE_DIMENSION, Math.round(currentHeight * 0.85)); + } + + if ( + (targetWidth && currentWidth && targetWidth < currentWidth) || + (targetHeight && currentHeight && targetHeight < currentHeight) + ) { + resized = true; + resizedThisAttempt = true; + currentWidth = targetWidth; + currentHeight = targetHeight; + } else if (!supportsQuality || (targetQuality && targetQuality <= 70)) { + // Cannot resize further and quality cannot be adjusted + break; + } + + const qualityForAttempt = supportsQuality ? targetQuality : undefined; + const { data, info } = await encode({ + width: currentWidth, + height: currentHeight, + quality: qualityForAttempt, + }); + + mutated = true; + finalBuffer = data; + finalInfo = info; + metadata.optimizedSize = data.length; + if (info.width) metadata.width = info.width; + if (info.height) metadata.height = info.height; + if (info.width) currentWidth = info.width; + if (info.height) currentHeight = info.height; + + if (supportsQuality && qualityForAttempt) { + finalQuality = qualityForAttempt; + } + + if (data.length <= MAX_IMAGE_SIZE_BYTES) { + break; + } + + if (resizedThisAttempt) { + continue; + } + + if (supportsQuality && targetQuality && targetQuality > 70) { + const nextQuality = Math.max(70, targetQuality - 10); + if (nextQuality === targetQuality) { + break; + } + targetQuality = nextQuality; + continue; + } + + break; + } + + if (finalBuffer.length > MAX_IMAGE_SIZE_BYTES) { + const message = `Optimized image remains ${bytesToMegabytes(finalBuffer.length)}MB (target 5MB).`; + notices.push({ + message, + level: 'warning', + code: 'size_over_limit', + source: 'server' + }); + legacyWarnings.push(message); + } + } else if (shouldConvertToRgb) { + const { data, info } = await encode({ width: currentWidth, height: currentHeight }); + mutated = true; + finalBuffer = data; + finalInfo = info; + metadata.optimizedSize = data.length; + if (info.width) metadata.width = info.width; + if (info.height) metadata.height = info.height; + if (info.width) currentWidth = info.width; + if (info.height) currentHeight = info.height; + } + + if (mutated) { + await fsp.writeFile(filePath, finalBuffer); + metadata.optimizedSize = finalBuffer.length; + } else { + // No transformation occurred; still need to ensure we report original stats + metadata.optimizedSize = metadata.size; + } + + metadata.convertedToRgb = shouldConvertToRgb && mutated; + metadata.resized = resized; + if (finalQuality) { + metadata.quality = finalQuality; + } + + if (resized && metadata.width && metadata.height) { + const message = `Image resized to ${metadata.width}x${metadata.height} during optimization.`; + notices.push({ + message, + level: 'info', + code: 'resized', + source: 'server' + }); + legacyWarnings.push(message); + } + + if (finalQuality && finalQuality < 90) { + const message = `Image quality adjusted to ${finalQuality} to reduce file size.`; + notices.push({ + message, + level: 'info', + code: 'quality_adjusted', + source: 'server' + }); + legacyWarnings.push(message); + } + + return { + notices, + warnings: legacyWarnings, + metadata, + finalSize: finalBuffer.length, + }; +}; + // Configure multer for file uploads const storage = multer.diskStorage({ destination: function (req, file, cb) { @@ -178,7 +432,7 @@ const storage = multer.diskStorage({ const upload = multer({ storage: storage, limits: { - fileSize: 5 * 1024 * 1024, // 5MB max file size + fileSize: 15 * 1024 * 1024, // Allow bigger uploads; processing will reduce to 5MB }, fileFilter: function (req, file, cb) { // Accept only image files @@ -345,7 +599,7 @@ async function setupSshTunnel() { } // Image upload endpoint -router.post('/upload-image', upload.single('image'), (req, res) => { +router.post('/upload-image', upload.single('image'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: 'No image file provided' }); @@ -375,6 +629,10 @@ router.post('/upload-image', upload.single('image'), (req, res) => { } }); + // Process the image (resize/compress/color-space) before responding + const processingResult = await processUploadedImage(filePath, req.file.mimetype); + req.file.size = processingResult.finalSize; + // Create URL for the uploaded file - using an absolute URL with domain // This will generate a URL like: https://acot.site/uploads/products/filename.jpg const baseUrl = 'https://acot.site'; @@ -390,11 +648,24 @@ router.post('/upload-image', upload.single('image'), (req, res) => { fileName: req.file.filename, mimetype: req.file.mimetype, fullPath: filePath, + notices: processingResult.notices, + warnings: processingResult.warnings, + metadata: processingResult.metadata, message: 'Image uploaded successfully (will auto-delete after 24 hours)' }); } catch (error) { console.error('Error uploading image:', error); + if (req?.file?.filename) { + const cleanupPath = path.join(uploadsDir, req.file.filename); + if (fs.existsSync(cleanupPath)) { + try { + fs.unlinkSync(cleanupPath); + } catch (cleanupError) { + console.error('Failed to remove file after processing error:', cleanupError); + } + } + } res.status(500).json({ error: error.message || 'Failed to upload image' }); } }); diff --git a/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/SortableImage.tsx b/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/SortableImage.tsx index b1ea1e8..3132ee9 100644 --- a/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/SortableImage.tsx +++ b/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/SortableImage.tsx @@ -1,32 +1,16 @@ import { useState, useRef } from "react"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { Loader2, Trash2, Maximize2, GripVertical, X } from "lucide-react"; +import { Loader2, Trash2, Maximize2, GripVertical, X, Info } from "lucide-react"; import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import type { ProductImageSortable } from "../../types"; -// Define the ProductImage interface -interface ProductImage { - id: string; - url?: string; - imageUrl?: string; - fileName?: string; - loading?: boolean; - isLoading?: boolean; - // Optional fields from the full ProductImage type - productIndex?: number; - pid?: number; - iid?: number; - type?: number; - order?: number; - width?: number; - height?: number; - hidden?: number; -} +type SortableImage = ProductImageSortable & { url?: string }; -// Define the SortableImageProps interface interface SortableImageProps { - image: ProductImage; + image: SortableImage; productIndex: number; imgIndex: number; productName?: string; // Make this optional @@ -55,6 +39,11 @@ export const SortableImage = ({ removeImage }: SortableImageProps) => { const [dialogOpen, setDialogOpen] = useState(false); + const notices = image.notices ?? []; + const attentionNotices = notices.filter((notice) => notice.level === 'warning'); + const infoNotices = notices.filter((notice) => notice.level === 'info'); + const hasAttention = attentionNotices.length > 0; + const metadata = image.metadata; const { attributes, @@ -144,6 +133,40 @@ export const SortableImage = ({ > + + {hasAttention && ( + + + +
+ +
+
+ +
+ {attentionNotices.map((notice, idx) => ( +

{notice.message}

+ ))} + {infoNotices.map((notice, idx) => ( +

+ {notice.message} +

+ ))} + {metadata?.width && metadata?.height && ( +

+ Detected size: {metadata.width}×{metadata.height} +

+ )} + {metadata?.optimizedSize && ( +

+ Optimized size: {(metadata.optimizedSize / (1024 * 1024)).toFixed(2)}MB +

+ )} +
+
+
+
+ )} @@ -195,4 +218,4 @@ export const SortableImage = ({ )} ); -}; \ No newline at end of file +}; diff --git a/inventory/src/components/product-import/steps/ImageUploadStep/hooks/useProductImageOperations.ts b/inventory/src/components/product-import/steps/ImageUploadStep/hooks/useProductImageOperations.ts index f47775c..ff64cbb 100644 --- a/inventory/src/components/product-import/steps/ImageUploadStep/hooks/useProductImageOperations.ts +++ b/inventory/src/components/product-import/steps/ImageUploadStep/hooks/useProductImageOperations.ts @@ -1,6 +1,92 @@ import { toast } from "sonner"; import config from "@/config"; -import { Product, ProductImageSortable } from "../types"; +import { Product, ProductImageSortable, ImageMetadata, ImageNotice } from "../types"; + +const MIN_DIMENSION = 1000; +const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; + +const readImageMetadata = async (file: File): Promise => { + try { + if (typeof createImageBitmap === "function") { + const bitmap = await createImageBitmap(file); + return { + width: bitmap.width, + height: bitmap.height, + size: file.size + }; + } + } catch (error) { + console.warn("createImageBitmap failed, falling back to Image element", error); + } + + return new Promise((resolve) => { + const image = new Image(); + const objectUrl = URL.createObjectURL(file); + + image.onload = () => { + resolve({ + width: image.naturalWidth, + height: image.naturalHeight, + size: file.size + }); + URL.revokeObjectURL(objectUrl); + }; + + image.onerror = () => { + resolve({ size: file.size }); + URL.revokeObjectURL(objectUrl); + }; + + image.src = objectUrl; + }); +}; + +const analyzeImage = async (file: File) => { + const metadata = await readImageMetadata(file); + const notices: ImageNotice[] = []; + + if (metadata.width && metadata.height) { + if (metadata.width < MIN_DIMENSION || metadata.height < MIN_DIMENSION) { + notices.push({ + message: `Image is ${metadata.width}x${metadata.height}. Recommended minimum is ${MIN_DIMENSION}x${MIN_DIMENSION}.`, + level: 'warning', + code: 'dimensions_too_small', + source: 'client' + }); + } + } else { + notices.push({ + message: "Unable to verify image dimensions.", + level: 'info', + code: 'dimensions_unknown', + source: 'client' + }); + } + + if (file.size > MAX_FILE_SIZE_BYTES) { + const sizeMb = (file.size / (1024 * 1024)).toFixed(1); + notices.push({ + message: `Image is ${sizeMb}MB. Files above 5MB will be optimized automatically.`, + level: 'info', + code: 'client_large_file', + source: 'client' + }); + } + + return { metadata, notices }; +}; + +const dedupeNotices = (notices: ImageNotice[] = []) => { + const seen = new Set(); + return notices.filter((notice) => { + const key = `${notice.level}:${notice.code ?? ''}:${notice.message}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +}; interface UseProductImageOperationsProps { data: Product[]; @@ -72,10 +158,12 @@ export const useProductImageOperations = ({ for (let i = 0; i < files.length; i++) { const file = files[i]; + const imageId = `image-${productIndex}-${Date.now()}-${i}`; + const productLabel = data[productIndex].name || `Product #${productIndex + 1}`; // Add placeholder for this image const newImage: ProductImageSortable = { - id: `image-${productIndex}-${Date.now()}-${i}`, // Generate a unique ID + id: imageId, productIndex, imageUrl: '', loading: true, @@ -91,6 +179,48 @@ export const useProductImageOperations = ({ }; setProductImages(prev => [...prev, newImage]); + + let analysisNotices: ImageNotice[] | undefined; + let metadata: ImageMetadata | undefined; + + try { + const analysis = await analyzeImage(file); + metadata = analysis.metadata; + analysisNotices = analysis.notices?.length ? dedupeNotices(analysis.notices) : undefined; + + if (analysisNotices?.length) { + const warningMessages = analysisNotices + .filter((notice) => notice.level === 'warning') + .map((notice) => notice.message); + const infoMessages = analysisNotices + .filter((notice) => notice.level === 'info') + .map((notice) => notice.message); + + if (warningMessages.length) { + toast.warning(`${file.name}: ${warningMessages.join(" ")}`); + } + + if (infoMessages.length) { + if (typeof toast.info === 'function') { + toast.info(`${file.name}: ${infoMessages.join(" ")}`); + } else { + toast(`${file.name}`, { + description: infoMessages.join(" "), + }); + } + } + } + + setProductImages(prev => + prev.map(img => + img.id === imageId + ? { ...img, metadata, notices: analysisNotices } + : img + ) + ); + } catch (error) { + console.warn('Failed to analyze image before upload', error); + } // Create form data for upload const formData = new FormData(); @@ -111,29 +241,77 @@ export const useProductImageOperations = ({ } const result = await response.json(); + const serverNotices: ImageNotice[] = Array.isArray(result.notices) + ? result.notices.map((notice: any) => ({ + message: String(notice.message ?? notice), + level: notice.level === 'warning' ? 'warning' : 'info', + code: notice.code, + source: notice.source ?? 'server' + })) + : Array.isArray(result.warnings) + ? result.warnings.map((message: any) => ({ + message: String(message), + level: 'info', + source: 'server' + })) + : []; + + if (serverNotices.length) { + const warningMessages = serverNotices + .filter((notice) => notice.level === 'warning') + .map((notice) => notice.message); + const infoMessages = serverNotices + .filter((notice) => notice.level === 'info') + .map((notice) => notice.message); + + if (warningMessages.length) { + toast.warning(`${file.name}: ${warningMessages.join(" ")}`); + } + + if (infoMessages.length) { + if (typeof toast.info === 'function') { + toast.info(`${file.name}: ${infoMessages.join(" ")}`); + } else { + toast(`${file.name}`, { + description: infoMessages.join(" "), + }); + } + } + } // Update the image URL in our state setProductImages(prev => - prev.map(img => - (img.loading && img.productIndex === productIndex && img.fileName === file.name) - ? { ...img, imageUrl: result.imageUrl, loading: false } - : img - ) + prev.map(img => { + if (img.id !== imageId) return img; + + const combinedNotices = dedupeNotices([ + ...(analysisNotices || []), + ...serverNotices + ]); + + const combinedMetadata: ImageMetadata | undefined = result.metadata + ? { ...metadata, ...result.metadata } + : metadata; + + return { + ...img, + imageUrl: result.imageUrl, + loading: false, + notices: combinedNotices.length ? combinedNotices : undefined, + metadata: combinedMetadata, + }; + }) ); // Update the product data with the new image URL addImageToProduct(productIndex, result.imageUrl); - toast.success(`Image uploaded for ${data[productIndex].name || `Product #${productIndex + 1}`}`); + toast.success(`Image uploaded for ${productLabel}`); } catch (error) { console.error('Upload error:', error); // Remove the failed image from our state - setProductImages(prev => - prev.filter(img => - !(img.loading && img.productIndex === productIndex && img.fileName === file.name) - ) - ); + setProductImages(prev => prev.filter(img => img.id !== imageId)); toast.error(`Failed to upload image: ${error instanceof Error ? error.message : 'Unknown error'}`); } @@ -192,4 +370,4 @@ export const useProductImageOperations = ({ handleImageUpload, removeImage, }; -}; \ No newline at end of file +}; diff --git a/inventory/src/components/product-import/steps/ImageUploadStep/types.ts b/inventory/src/components/product-import/steps/ImageUploadStep/types.ts index eee0f3c..51f29b5 100644 --- a/inventory/src/components/product-import/steps/ImageUploadStep/types.ts +++ b/inventory/src/components/product-import/steps/ImageUploadStep/types.ts @@ -3,6 +3,8 @@ export type ProductImage = { imageUrl: string; loading: boolean; fileName: string; + notices?: ImageNotice[]; + metadata?: ImageMetadata; // Schema fields pid: number; iid: number; @@ -13,6 +15,26 @@ export type ProductImage = { hidden: number; } +export type ImageNoticeLevel = 'info' | 'warning'; + +export type ImageNotice = { + message: string; + level: ImageNoticeLevel; + code?: string; + source?: 'client' | 'server'; +}; + +export type ImageMetadata = { + width?: number; + height?: number; + size?: number; + optimizedSize?: number; + colorSpace?: string; + quality?: number; + resized?: boolean; + convertedToRgb?: boolean; +}; + export type UnassignedImage = { file: File; previewUrl: string; @@ -33,4 +55,4 @@ export interface Product { model?: string; company?: string; product_images?: string | string[]; -} \ No newline at end of file +} diff --git a/inventory/src/index.css b/inventory/src/index.css index 150883b..0199cfe 100644 --- a/inventory/src/index.css +++ b/inventory/src/index.css @@ -28,6 +28,15 @@ --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; + --info: 217.2 91.2% 59.8%; + --info-foreground: 210 40% 98%; + + --success: 142.1 76.2% 36.3%; + --success-foreground: 210 40% 98%; + + --warning: 45.4 93.4% 47.5%; + --warning-foreground: 222.2 47.4% 11.2%; + --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 222.2 84% 4.9%; @@ -76,6 +85,15 @@ --destructive: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; + --info: 217.2 91.2% 59.8%; + --info-foreground: 210 40% 98%; + + --success: 142.1 70.6% 45.3%; + --success-foreground: 210 40% 98%; + + --warning: 45.4 93.4% 47.5%; + --warning-foreground: 222.2 47.4% 11.2%; + --border: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%; --ring: 212.7 26.8% 83.9%; diff --git a/inventory/src/pages/Import.tsx b/inventory/src/pages/Import.tsx index 15aff1d..41c292f 100644 --- a/inventory/src/pages/Import.tsx +++ b/inventory/src/pages/Import.tsx @@ -2,13 +2,14 @@ import { useState, useContext } from "react"; import { ReactSpreadsheetImport, StepType } from "@/components/product-import"; import type { StepState } from "@/components/product-import/steps/UploadFlow"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Code } from "@/components/ui/code"; import { toast } from "sonner"; import { motion } from "framer-motion"; import { useQuery } from "@tanstack/react-query"; import config from "@/config"; -import { Loader2 } from "lucide-react"; +import { Loader2, AlertCircle, AlertTriangle, Info, CheckCircle } from "lucide-react"; +import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; import type { Data, DataValue, FieldType, Result, SubmitOptions } from "@/components/product-import/types"; import { BASE_IMPORT_FIELDS, type ImportFieldKey } from "@/components/product-import/config"; import { submitNewProducts, type SubmitNewProductsResponse } from "@/services/apiv2"; @@ -57,6 +58,85 @@ const extractBackendPayload = ( }; }; +const extractStringFromRecord = (record: Record): string | null => { + const candidateKeys = ["error_msg", "message", "reason", "error", "detail"]; + + for (const key of candidateKeys) { + const value = record[key]; + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed.length > 0) { + return trimmed; + } + } + } + + return null; +}; + +const extractFailureMessageFromResponse = (response: SubmitNewProductsResponse): string | null => { + if (typeof response.message === "string") { + const trimmed = response.message.trim(); + if (trimmed.length > 0) { + return trimmed; + } + } + + const { error } = response; + + if (!error) { + return null; + } + + if (typeof error === "string") { + const trimmed = error.trim(); + return trimmed.length > 0 ? trimmed : null; + } + + if (Array.isArray(error)) { + for (const entry of error) { + if (typeof entry === "string") { + const trimmed = entry.trim(); + if (trimmed.length > 0) { + return trimmed; + } + } else if (isRecord(entry)) { + const extracted = extractStringFromRecord(entry); + if (extracted) { + return extracted; + } + } + } + return null; + } + + if (isRecord(error)) { + return extractStringFromRecord(error); + } + + return null; +}; + +const extractErroredEntriesFromResponse = ( + response: SubmitNewProductsResponse, +): BackendProductResult[] => { + const { error } = response; + + if (!error) { + return []; + } + + if (Array.isArray(error)) { + return error.filter(isRecord) as BackendProductResult[]; + } + + if (isRecord(error)) { + return [error as BackendProductResult]; + } + + return []; +}; + const getFirstStringValue = (value: string | string[] | boolean | null | undefined): string | null => { if (Array.isArray(value)) { for (const entry of value) { @@ -172,7 +252,7 @@ export function Import() { const [selectedLine, setSelectedLine] = useState(null); const [startFromScratch, setStartFromScratch] = useState(false); const { user } = useContext(AuthContext); - const hasDebugPermission = user?.permissions?.includes("admin:debug") ?? false; + const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug")); // Fetch initial field options from the API const { data: fieldOptions, isLoading: isLoadingOptions } = useQuery({ @@ -452,27 +532,60 @@ export function Import() { employeeId: user?.id ?? undefined, }); - if (!response.success) { - throw new Error(response.message || "Failed to submit products"); + const isSuccess = response.success; + const defaultFailureMessage = "Failed to submit products. Please review and try again."; + const failureEntries = isSuccess ? [] : extractErroredEntriesFromResponse(response); + const resolvedFailureMessage = isSuccess + ? null + : extractFailureMessageFromResponse(response) ?? defaultFailureMessage; + + let normalizedResponse: SubmitNewProductsResponse = response; + + if (!isSuccess) { + const baseData: Record = isRecord(response.data) + ? { ...(response.data as Record) } + : {}; + + if (failureEntries.length) { + const existingErrored = baseData["errored"]; + if (!Array.isArray(existingErrored) || existingErrored.length === 0) { + baseData["errored"] = failureEntries; + } + } + + const hasDataOverrides = Object.keys(baseData).length > 0; + + normalizedResponse = { + ...response, + success: false, + message: resolvedFailureMessage ?? defaultFailureMessage, + data: hasDataOverrides ? baseData : response.data, + }; } setResumeStepState(undefined); setImportOutcome({ submittedProducts: formattedRows.map((product) => ({ ...product })), submittedRows: rows.map((row) => ({ ...row })), - response, + response: normalizedResponse, }); setIsDebugDataVisible(false); setIsOpen(false); + setStartFromScratch(false); - const successMessage = response.message - ? response.message - : `Submitted ${formattedRows.length} product${formattedRows.length === 1 ? "" : "s"} successfully`; - - toast.success(successMessage); + if (isSuccess) { + const successMessage = + normalizedResponse.message || + `Submitted ${formattedRows.length} product${formattedRows.length === 1 ? "" : "s"} successfully.`; + toast.success(successMessage); + } else { + toast.error(resolvedFailureMessage ?? defaultFailureMessage); + } } catch (error) { console.error("Import error:", error); - throw error instanceof Error ? error : new Error("Failed to import data"); + const errorMessage = + error instanceof Error ? error.message : "Failed to import data. Please try again."; + toast.error(errorMessage); } }; @@ -643,10 +756,7 @@ export function Import() { {importOutcome && ( -
Import Results - {summaryMessage && {summaryMessage}} -
{hasDebugPermission && ( )} @@ -714,7 +863,6 @@ export function Import() { {product.name} )} - {product.pid ? `PID: ${product.pid} · ` : ""} UPC: {product.upc} Item #: {product.itemNumber} @@ -728,7 +876,7 @@ export function Import() { {erroredProducts.length > 0 && (
-

Errored Products

+

Products with Errors

{erroredProducts.map((product, index) => (
@@ -757,7 +905,7 @@ export function Import() { {hasDebugPermission && isDebugDataVisible && (
-

Submitted Payload

+

Submitted Data

{JSON.stringify(importOutcome.submittedProducts, null, 2)} diff --git a/inventory/src/services/apiv2.ts b/inventory/src/services/apiv2.ts index 8c77453..446ef96 100644 --- a/inventory/src/services/apiv2.ts +++ b/inventory/src/services/apiv2.ts @@ -60,7 +60,7 @@ export async function submitNewProducts({ throw new Error("Backend authentication required. Please ensure you are logged into the backend system."); } - let parsed: SubmitNewProductsResponse | null = null; + let parsed: unknown; try { parsed = JSON.parse(rawBody); } catch { @@ -68,17 +68,22 @@ export async function submitNewProducts({ throw new Error(message); } - if (!response.ok) { - throw new Error(parsed?.message || `Request failed with status ${response.status}`); - } - - if (!parsed) { + if (!parsed || typeof parsed !== "object") { throw new Error("Empty response from backend"); } - if (!parsed.success) { - throw new Error(parsed.message || "Backend rejected product submission"); + const parsedResponse = parsed as SubmitNewProductsResponse & Record; + const extraFields = parsedResponse as Record; + const normalizedResponse: SubmitNewProductsResponse = { + success: Boolean(parsedResponse.success), + message: typeof parsedResponse.message === "string" ? parsedResponse.message : undefined, + data: parsedResponse.data, + error: parsedResponse.error ?? extraFields.errors ?? extraFields.error_msg, + }; + + if (!response.ok || !normalizedResponse.success) { + return normalizedResponse; } - return parsed; + return normalizedResponse; } diff --git a/inventory/tailwind.config.js b/inventory/tailwind.config.js index 0e14d50..2301d95 100644 --- a/inventory/tailwind.config.js +++ b/inventory/tailwind.config.js @@ -35,6 +35,18 @@ export default { DEFAULT: 'hsl(var(--destructive))', foreground: 'hsl(var(--destructive-foreground))' }, + info: { + DEFAULT: 'hsl(var(--info))', + foreground: 'hsl(var(--info-foreground))' + }, + success: { + DEFAULT: 'hsl(var(--success))', + foreground: 'hsl(var(--success-foreground))' + }, + warning: { + DEFAULT: 'hsl(var(--warning))', + foreground: 'hsl(var(--warning-foreground))' + }, muted: { DEFAULT: 'hsl(var(--muted))', foreground: 'hsl(var(--muted-foreground))' diff --git a/inventory/tsconfig.tsbuildinfo b/inventory/tsconfig.tsbuildinfo index c01be23..a76e7af 100644 --- a/inventory/tsconfig.tsbuildinfo +++ b/inventory/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/config.ts","./src/components/analytics/categoryperformance.tsx","./src/components/analytics/priceanalysis.tsx","./src/components/analytics/profitanalysis.tsx","./src/components/analytics/stockanalysis.tsx","./src/components/analytics/vendorperformance.tsx","./src/components/auth/firstaccessiblepage.tsx","./src/components/auth/protected.tsx","./src/components/auth/requireauth.tsx","./src/components/chat/chatroom.tsx","./src/components/chat/chattest.tsx","./src/components/chat/roomlist.tsx","./src/components/chat/searchresults.tsx","./src/components/dashboard/financialoverview.tsx","./src/components/dashboard/periodselectionpopover.tsx","./src/components/discount-simulator/configpanel.tsx","./src/components/discount-simulator/resultschart.tsx","./src/components/discount-simulator/resultstable.tsx","./src/components/discount-simulator/summarycard.tsx","./src/components/forecasting/daterangepickerquick.tsx","./src/components/forecasting/quickorderbuilder.tsx","./src/components/forecasting/columns.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/mainlayout.tsx","./src/components/layout/navuser.tsx","./src/components/overview/bestsellers.tsx","./src/components/overview/forecastmetrics.tsx","./src/components/overview/overstockmetrics.tsx","./src/components/overview/overview.tsx","./src/components/overview/purchasemetrics.tsx","./src/components/overview/replenishmentmetrics.tsx","./src/components/overview/salesmetrics.tsx","./src/components/overview/stockmetrics.tsx","./src/components/overview/topoverstockedproducts.tsx","./src/components/overview/topreplenishproducts.tsx","./src/components/overview/vendorperformance.tsx","./src/components/product-import/reactspreadsheetimport.tsx","./src/components/product-import/config.ts","./src/components/product-import/index.ts","./src/components/product-import/translationsrsiprops.ts","./src/components/product-import/types.ts","./src/components/product-import/components/modalwrapper.tsx","./src/components/product-import/components/providers.tsx","./src/components/product-import/components/table.tsx","./src/components/product-import/hooks/usersi.ts","./src/components/product-import/steps/steps.tsx","./src/components/product-import/steps/uploadflow.tsx","./src/components/product-import/steps/imageuploadstep/imageuploadstep.tsx","./src/components/product-import/steps/imageuploadstep/types.ts","./src/components/product-import/steps/imageuploadstep/components/droppablecontainer.tsx","./src/components/product-import/steps/imageuploadstep/components/genericdropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/copybutton.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/imagedropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/productcard.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/sortableimage.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection/unassignedimageitem.tsx","./src/components/product-import/steps/imageuploadstep/hooks/usebulkimageupload.ts","./src/components/product-import/steps/imageuploadstep/hooks/usedraganddrop.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimageoperations.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimagesinit.ts","./src/components/product-import/steps/imageuploadstep/hooks/useurlimageupload.ts","./src/components/product-import/steps/matchcolumnsstep/matchcolumnsstep.tsx","./src/components/product-import/steps/matchcolumnsstep/types.ts","./src/components/product-import/steps/matchcolumnsstep/components/matchicon.tsx","./src/components/product-import/steps/matchcolumnsstep/components/templatecolumn.tsx","./src/components/product-import/steps/matchcolumnsstep/utils/findmatch.ts","./src/components/product-import/steps/matchcolumnsstep/utils/findunmatchedrequiredfields.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getfieldoptions.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getmatchedcolumns.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizecheckboxvalue.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizetabledata.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setignorecolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setsubcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/uniqueentries.ts","./src/components/product-import/steps/selectheaderstep/selectheaderstep.tsx","./src/components/product-import/steps/selectheaderstep/components/selectheadertable.tsx","./src/components/product-import/steps/selectheaderstep/components/columns.tsx","./src/components/product-import/steps/selectsheetstep/selectsheetstep.tsx","./src/components/product-import/steps/uploadstep/uploadstep.tsx","./src/components/product-import/steps/uploadstep/components/dropzone.tsx","./src/components/product-import/steps/uploadstep/components/columns.tsx","./src/components/product-import/steps/uploadstep/utils/readfilesasync.ts","./src/components/product-import/steps/validationstepnew/index.tsx","./src/components/product-import/steps/validationstepnew/types.ts","./src/components/product-import/steps/validationstepnew/components/aivalidationdialogs.tsx","./src/components/product-import/steps/validationstepnew/components/basecellcontent.tsx","./src/components/product-import/steps/validationstepnew/components/initializingvalidation.tsx","./src/components/product-import/steps/validationstepnew/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstepnew/components/upcvalidationtableadapter.tsx","./src/components/product-import/steps/validationstepnew/components/validationcell.tsx","./src/components/product-import/steps/validationstepnew/components/validationcontainer.tsx","./src/components/product-import/steps/validationstepnew/components/validationtable.tsx","./src/components/product-import/steps/validationstepnew/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstepnew/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstepnew/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstepnew/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstepnew/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstepnew/hooks/useaivalidation.tsx","./src/components/product-import/steps/validationstepnew/hooks/usefieldvalidation.tsx","./src/components/product-import/steps/validationstepnew/hooks/usefiltermanagement.tsx","./src/components/product-import/steps/validationstepnew/hooks/useinitialvalidation.tsx","./src/components/product-import/steps/validationstepnew/hooks/useproductlinesfetching.tsx","./src/components/product-import/steps/validationstepnew/hooks/userowoperations.tsx","./src/components/product-import/steps/validationstepnew/hooks/usetemplatemanagement.tsx","./src/components/product-import/steps/validationstepnew/hooks/useuniqueitemnumbersvalidation.tsx","./src/components/product-import/steps/validationstepnew/hooks/useuniquevalidation.tsx","./src/components/product-import/steps/validationstepnew/hooks/useupcvalidation.tsx","./src/components/product-import/steps/validationstepnew/hooks/usevalidation.tsx","./src/components/product-import/steps/validationstepnew/hooks/usevalidationstate.tsx","./src/components/product-import/steps/validationstepnew/hooks/validationtypes.ts","./src/components/product-import/steps/validationstepnew/types/index.ts","./src/components/product-import/steps/validationstepnew/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstepnew/utils/countryutils.ts","./src/components/product-import/steps/validationstepnew/utils/datamutations.ts","./src/components/product-import/steps/validationstepnew/utils/priceutils.ts","./src/components/product-import/utils/exceedsmaxrecords.ts","./src/components/product-import/utils/mapdata.ts","./src/components/product-import/utils/mapworkbook.ts","./src/components/product-import/utils/steps.ts","./src/components/products/productdetail.tsx","./src/components/products/productfilters.tsx","./src/components/products/producttable.tsx","./src/components/products/producttableskeleton.tsx","./src/components/products/productviews.tsx","./src/components/products/products.tsx","./src/components/purchase-orders/categorymetricscard.tsx","./src/components/purchase-orders/filtercontrols.tsx","./src/components/purchase-orders/ordermetricscard.tsx","./src/components/purchase-orders/paginationcontrols.tsx","./src/components/purchase-orders/purchaseorderaccordion.tsx","./src/components/purchase-orders/purchaseorderstable.tsx","./src/components/purchase-orders/vendormetricscard.tsx","./src/components/settings/datamanagement.tsx","./src/components/settings/globalsettings.tsx","./src/components/settings/permissionselector.tsx","./src/components/settings/productsettings.tsx","./src/components/settings/promptmanagement.tsx","./src/components/settings/reusableimagemanagement.tsx","./src/components/settings/templatemanagement.tsx","./src/components/settings/userform.tsx","./src/components/settings/userlist.tsx","./src/components/settings/usermanagement.tsx","./src/components/settings/vendorsettings.tsx","./src/components/templates/searchproducttemplatedialog.tsx","./src/components/templates/templateform.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/code.tsx","./src/components/ui/collapsible.tsx","./src/components/ui/command.tsx","./src/components/ui/date-range-picker-narrow.tsx","./src/components/ui/date-range-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/page-loading.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/config/dashboard.ts","./src/contexts/authcontext.tsx","./src/contexts/dashboardscrollcontext.tsx","./src/hooks/use-mobile.tsx","./src/hooks/use-toast.ts","./src/hooks/usedebounce.ts","./src/lib/utils.ts","./src/pages/analytics.tsx","./src/pages/brands.tsx","./src/pages/categories.tsx","./src/pages/chat.tsx","./src/pages/dashboard.tsx","./src/pages/discountsimulator.tsx","./src/pages/forecasting.tsx","./src/pages/import.tsx","./src/pages/login.tsx","./src/pages/overview.tsx","./src/pages/products.tsx","./src/pages/purchaseorders.tsx","./src/pages/settings.tsx","./src/pages/smalldashboard.tsx","./src/pages/vendors.tsx","./src/services/apiv2.ts","./src/types/dashboard-shims.d.ts","./src/types/dashboard.d.ts","./src/types/discount-simulator.ts","./src/types/globals.d.ts","./src/types/products.ts","./src/types/react-data-grid.d.ts","./src/types/status-codes.ts","./src/utils/emojiutils.ts","./src/utils/naturallanguageperiod.ts","./src/utils/productutils.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/config.ts","./src/components/analytics/categoryperformance.tsx","./src/components/analytics/priceanalysis.tsx","./src/components/analytics/profitanalysis.tsx","./src/components/analytics/stockanalysis.tsx","./src/components/analytics/vendorperformance.tsx","./src/components/auth/firstaccessiblepage.tsx","./src/components/auth/protected.tsx","./src/components/auth/requireauth.tsx","./src/components/chat/chatroom.tsx","./src/components/chat/chattest.tsx","./src/components/chat/roomlist.tsx","./src/components/chat/searchresults.tsx","./src/components/dashboard/financialoverview.tsx","./src/components/dashboard/periodselectionpopover.tsx","./src/components/discount-simulator/configpanel.tsx","./src/components/discount-simulator/resultschart.tsx","./src/components/discount-simulator/resultstable.tsx","./src/components/discount-simulator/summarycard.tsx","./src/components/forecasting/daterangepickerquick.tsx","./src/components/forecasting/quickorderbuilder.tsx","./src/components/forecasting/columns.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/mainlayout.tsx","./src/components/layout/navuser.tsx","./src/components/overview/bestsellers.tsx","./src/components/overview/forecastmetrics.tsx","./src/components/overview/overstockmetrics.tsx","./src/components/overview/overview.tsx","./src/components/overview/purchasemetrics.tsx","./src/components/overview/replenishmentmetrics.tsx","./src/components/overview/salesmetrics.tsx","./src/components/overview/stockmetrics.tsx","./src/components/overview/topoverstockedproducts.tsx","./src/components/overview/topreplenishproducts.tsx","./src/components/overview/vendorperformance.tsx","./src/components/product-import/reactspreadsheetimport.tsx","./src/components/product-import/config.ts","./src/components/product-import/index.ts","./src/components/product-import/translationsrsiprops.ts","./src/components/product-import/types.ts","./src/components/product-import/components/modalwrapper.tsx","./src/components/product-import/components/providers.tsx","./src/components/product-import/components/table.tsx","./src/components/product-import/hooks/usersi.ts","./src/components/product-import/steps/steps.tsx","./src/components/product-import/steps/uploadflow.tsx","./src/components/product-import/steps/imageuploadstep/imageuploadstep.tsx","./src/components/product-import/steps/imageuploadstep/types.ts","./src/components/product-import/steps/imageuploadstep/components/droppablecontainer.tsx","./src/components/product-import/steps/imageuploadstep/components/genericdropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/copybutton.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/imagedropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/productcard.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/sortableimage.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection/unassignedimageitem.tsx","./src/components/product-import/steps/imageuploadstep/hooks/usebulkimageupload.ts","./src/components/product-import/steps/imageuploadstep/hooks/usedraganddrop.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimageoperations.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimagesinit.ts","./src/components/product-import/steps/imageuploadstep/hooks/useurlimageupload.ts","./src/components/product-import/steps/matchcolumnsstep/matchcolumnsstep.tsx","./src/components/product-import/steps/matchcolumnsstep/types.ts","./src/components/product-import/steps/matchcolumnsstep/components/matchicon.tsx","./src/components/product-import/steps/matchcolumnsstep/components/templatecolumn.tsx","./src/components/product-import/steps/matchcolumnsstep/utils/findmatch.ts","./src/components/product-import/steps/matchcolumnsstep/utils/findunmatchedrequiredfields.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getfieldoptions.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getmatchedcolumns.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizecheckboxvalue.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizetabledata.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setignorecolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setsubcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/uniqueentries.ts","./src/components/product-import/steps/selectheaderstep/selectheaderstep.tsx","./src/components/product-import/steps/selectheaderstep/components/selectheadertable.tsx","./src/components/product-import/steps/selectheaderstep/components/columns.tsx","./src/components/product-import/steps/selectsheetstep/selectsheetstep.tsx","./src/components/product-import/steps/uploadstep/uploadstep.tsx","./src/components/product-import/steps/uploadstep/components/dropzone.tsx","./src/components/product-import/steps/uploadstep/components/columns.tsx","./src/components/product-import/steps/uploadstep/utils/readfilesasync.ts","./src/components/product-import/steps/validationstepnew/index.tsx","./src/components/product-import/steps/validationstepnew/types.ts","./src/components/product-import/steps/validationstepnew/components/aivalidationdialogs.tsx","./src/components/product-import/steps/validationstepnew/components/basecellcontent.tsx","./src/components/product-import/steps/validationstepnew/components/initializingvalidation.tsx","./src/components/product-import/steps/validationstepnew/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstepnew/components/upcvalidationtableadapter.tsx","./src/components/product-import/steps/validationstepnew/components/validationcell.tsx","./src/components/product-import/steps/validationstepnew/components/validationcontainer.tsx","./src/components/product-import/steps/validationstepnew/components/validationtable.tsx","./src/components/product-import/steps/validationstepnew/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstepnew/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstepnew/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstepnew/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstepnew/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstepnew/hooks/useaivalidation.tsx","./src/components/product-import/steps/validationstepnew/hooks/usefieldvalidation.tsx","./src/components/product-import/steps/validationstepnew/hooks/usefiltermanagement.tsx","./src/components/product-import/steps/validationstepnew/hooks/useinitialvalidation.tsx","./src/components/product-import/steps/validationstepnew/hooks/useproductlinesfetching.tsx","./src/components/product-import/steps/validationstepnew/hooks/userowoperations.tsx","./src/components/product-import/steps/validationstepnew/hooks/usetemplatemanagement.tsx","./src/components/product-import/steps/validationstepnew/hooks/useuniqueitemnumbersvalidation.tsx","./src/components/product-import/steps/validationstepnew/hooks/useuniquevalidation.tsx","./src/components/product-import/steps/validationstepnew/hooks/useupcvalidation.tsx","./src/components/product-import/steps/validationstepnew/hooks/usevalidation.tsx","./src/components/product-import/steps/validationstepnew/hooks/usevalidationstate.tsx","./src/components/product-import/steps/validationstepnew/hooks/validationtypes.ts","./src/components/product-import/steps/validationstepnew/types/index.ts","./src/components/product-import/steps/validationstepnew/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstepnew/utils/countryutils.ts","./src/components/product-import/steps/validationstepnew/utils/datamutations.ts","./src/components/product-import/steps/validationstepnew/utils/priceutils.ts","./src/components/product-import/steps/validationstepnew/utils/upcutils.ts","./src/components/product-import/utils/exceedsmaxrecords.ts","./src/components/product-import/utils/mapdata.ts","./src/components/product-import/utils/mapworkbook.ts","./src/components/product-import/utils/steps.ts","./src/components/products/productdetail.tsx","./src/components/products/productfilters.tsx","./src/components/products/producttable.tsx","./src/components/products/producttableskeleton.tsx","./src/components/products/productviews.tsx","./src/components/products/products.tsx","./src/components/purchase-orders/categorymetricscard.tsx","./src/components/purchase-orders/filtercontrols.tsx","./src/components/purchase-orders/ordermetricscard.tsx","./src/components/purchase-orders/paginationcontrols.tsx","./src/components/purchase-orders/purchaseorderaccordion.tsx","./src/components/purchase-orders/purchaseorderstable.tsx","./src/components/purchase-orders/vendormetricscard.tsx","./src/components/settings/datamanagement.tsx","./src/components/settings/globalsettings.tsx","./src/components/settings/permissionselector.tsx","./src/components/settings/productsettings.tsx","./src/components/settings/promptmanagement.tsx","./src/components/settings/reusableimagemanagement.tsx","./src/components/settings/templatemanagement.tsx","./src/components/settings/userform.tsx","./src/components/settings/userlist.tsx","./src/components/settings/usermanagement.tsx","./src/components/settings/vendorsettings.tsx","./src/components/templates/searchproducttemplatedialog.tsx","./src/components/templates/templateform.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/code.tsx","./src/components/ui/collapsible.tsx","./src/components/ui/command.tsx","./src/components/ui/date-range-picker-narrow.tsx","./src/components/ui/date-range-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/page-loading.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/config/dashboard.ts","./src/contexts/authcontext.tsx","./src/contexts/dashboardscrollcontext.tsx","./src/hooks/use-mobile.tsx","./src/hooks/use-toast.ts","./src/hooks/usedebounce.ts","./src/lib/utils.ts","./src/pages/analytics.tsx","./src/pages/brands.tsx","./src/pages/categories.tsx","./src/pages/chat.tsx","./src/pages/dashboard.tsx","./src/pages/discountsimulator.tsx","./src/pages/forecasting.tsx","./src/pages/import.tsx","./src/pages/login.tsx","./src/pages/overview.tsx","./src/pages/products.tsx","./src/pages/purchaseorders.tsx","./src/pages/settings.tsx","./src/pages/smalldashboard.tsx","./src/pages/vendors.tsx","./src/services/apiv2.ts","./src/types/dashboard-shims.d.ts","./src/types/dashboard.d.ts","./src/types/discount-simulator.ts","./src/types/globals.d.ts","./src/types/products.ts","./src/types/react-data-grid.d.ts","./src/types/status-codes.ts","./src/utils/emojiutils.ts","./src/utils/naturallanguageperiod.ts","./src/utils/productutils.ts"],"version":"5.6.3"} \ No newline at end of file