diff --git a/inventory-server/db/templates.sql b/inventory-server/db/templates.sql new file mode 100644 index 0000000..4242fa2 --- /dev/null +++ b/inventory-server/db/templates.sql @@ -0,0 +1,39 @@ +-- Templates table for storing import templates +CREATE TABLE IF NOT EXISTS templates ( + id SERIAL PRIMARY KEY, + company TEXT NOT NULL, + product_type TEXT NOT NULL, + supplier TEXT, + msrp DECIMAL(10,2), + cost_each DECIMAL(10,2), + qty_per_unit INTEGER, + case_qty INTEGER, + hts_code TEXT, + description TEXT, + weight DECIMAL(10,2), + length DECIMAL(10,2), + width DECIMAL(10,2), + height DECIMAL(10,2), + tax_cat TEXT, + size_cat TEXT, + categories TEXT[], + ship_restrictions TEXT[], + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(company, product_type) +); + +-- Function to update the updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Trigger to automatically update the updated_at column +CREATE TRIGGER update_templates_updated_at + BEFORE UPDATE ON templates + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file diff --git a/inventory-server/package-lock.json b/inventory-server/package-lock.json index f71d63b..c667c9f 100755 --- a/inventory-server/package-lock.json +++ b/inventory-server/package-lock.json @@ -16,6 +16,7 @@ "multer": "^1.4.5-lts.1", "mysql2": "^3.12.0", "openai": "^4.85.3", + "pg": "^8.13.3", "pm2": "^5.3.0", "ssh2": "^1.16.0", "uuid": "^9.0.1" @@ -2341,6 +2342,95 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.13.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.3.tgz", + "integrity": "sha512-P6tPt9jXbL9HVu/SSRERNYaYG++MjnscnegFh9pPHihfoBSujsrka0hyuymMzeJKFWrcG8wvCKy8rCe8e5nDUQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.1", + "pg-protocol": "^1.7.1", + "pg-types": "^2.1.0", + "pgpass": "1.x" + }, + "engines": { + "node": ">= 8.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.1.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", + "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.1.tgz", + "integrity": "sha512-xIOsFoh7Vdhojas6q3596mXFsR8nwBQBXX5JiV7p9buEVAGqYL4yFzclON5P9vFrpu1u7Zwl2oriyDa89n0wbw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.1.tgz", + "integrity": "sha512-gjTHWGYWsEgy9MsY0Gp6ZJxV24IjDqdpTW7Eh0x+WfJLFsm/TJx1MzL6T0D88mBvkpxotCQ6TwW6N+Kko7lhgQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -2584,6 +2674,45 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -3114,6 +3243,15 @@ "source-map": "^0.6.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", diff --git a/inventory-server/package.json b/inventory-server/package.json index fb36c53..bd1451a 100755 --- a/inventory-server/package.json +++ b/inventory-server/package.json @@ -25,6 +25,7 @@ "multer": "^1.4.5-lts.1", "mysql2": "^3.12.0", "openai": "^4.85.3", + "pg": "^8.13.3", "pm2": "^5.3.0", "ssh2": "^1.16.0", "uuid": "^9.0.1" diff --git a/inventory-server/src/prompts/product-validation.txt b/inventory-server/src/prompts/product-validation.txt index 052d7f8..ead8ff2 100644 --- a/inventory-server/src/prompts/product-validation.txt +++ b/inventory-server/src/prompts/product-validation.txt @@ -1,10 +1,10 @@ -I will provide a JSON array with product data. Process the array by combining all products from validData and invalidData arrays into a single array, excluding any fields starting with “__”, such as “__index” or “__errors”. Process each product according to the reference guidelines below. If a field is not included in the data, do not include it in your response unless the specific field guidelines below say otherwise. +I will provide a JSON array with product data. Process the array by combining all products from validData and invalidData arrays into a single array, excluding any fields starting with “__”, such as “__index” or “__errors”. Process each product according to the reference guidelines below. If a field is not included in the data, do not include it in your response (e.g. do not include its key or any value) unless the specific field guidelines below say otherwise. If a product appears to be from an empty or entirely invalid line, do not include it in your response. -Respond in the following JSON structure in minified format (single line, no whitespace): +Your response should be a JSON object with the following structure: { "correctedData": [], // Array of corrected products "changes": [], // Array of strings describing each change made - "warnings": [] // Array of strings with warnings or suggestions for manual review + "warnings": [] // Array of strings with warnings or suggestions for manual review (see below for details) } Using the provided guidelines, focus on: @@ -14,94 +14,101 @@ Using the provided guidelines, focus on: 4. Fixing any obvious errors or inconsistencies between similar products in measurements, prices, or quantities 5. Adding correct categories, themes, and colors -Use only the provided data and your own knowledge to make changes. Do not make assumptions or make up information that you're not sure about. If you're unable to make a change you're confident about, leave the field as is. All data passed in should be validated, corrected, and returned. All values should be strings. Do not leave out any fields that were present in the original data. +Use only the provided data and your own knowledge to make changes. Do not make assumptions or make up information that you're not sure about. If you're unable to make a change you're confident about, leave the field as is. All data passed in should be validated, corrected, and returned. All values returned should be strings, not numbers. Do not leave out any fields that were present in the original data. + +Possible reasons for including a warning in the warnings array: +- If you're unable to make a change you're confident about but you believe one needs to be made +- If there are inconsistencies in the data that could be valid but need to be reviewed +- If not enough information is provided to make a change that you believe is needed +- If you infer a value for a required field based on context + ----------PRODUCT FIELD GUIDELINES---------- Fields: supplier, private_notes, company, line, subline, artist Changes: Not allowed -Required: Return if present in the original data +Required: Return if present in the original data. Do not return if not present. Instructions: If present, return these fields exactly as provided with no changes Fields: upc, supplier_no, notions_no, item_number Changes: Formatting only -Required: Return if present in the original data +Required: Return if present in the original data. Do not return if not present. Instructions: If present, trim outside white space and return these fields exactly as provided with no other changes Fields: hts_code Changes: Minimal, you can correct formatting, obvious errors or inconsistencies -Required: Return if present in the original data -Instructions: If present, trim white space and any characters that are not a number or decimal point and return as a string +Required: Return if present in the original data. Do not return if not present. +Instructions: If present, trim white space and any non-numeric characters, then return as a string. Do not validate in any other way. Fields: image_url Changes: Formatting only -Required: Return if present in the original data +Required: Return if present in the original data. Do not return if not present. Instructions: If present, convert all comma-separated values to valid https:// URLs and return Fields: msrp, cost_each Changes: Minimal, you can correct formatting, obvious errors or inconsistencies -Required: Return if present in the original data +Required: Return if present in the original data. Do not return if not present. Instructions: If present, strip any currency symbols and return as a string with exactly two decimal places, even if the last place is a 0. Fields: qty_per_unit, case_qty Changes: Minimal, you can correct formatting, obvious errors or inconsistencies -Required: Return if present in the original data +Required: Return if present in the original data. Do not return if not present. Instructions: If present, strip non-numeric characters and return Fields: ship_restrictions Changes: Only add a value if it's not already present -Required: You must always return a value for this field, even if it's not provided in the original data -Instructions: Always return a value exactly as provided, or return 0 if no value is provided. Do not leave this field out even if it's not provided. +Required: You must always return a value for this field, even if it's not provided in the original data. If no value is provided, return 0. +Instructions: Always return a value exactly as provided, or return 0 if no value is provided. Fields: eta Changes: Minimal, you can correct formatting, obvious errors or inconsistencies -Required: Return if present in the original data +Required: Return if present in the original data. Do not return if not present. Instructions: If present, return a full month name, day is optional, no year ever (e.g. “January” or “March 3”). This value is not required if not provided. Fields: name Changes: Allowed to conform to guidelines, to fix typos or formatting -Required: You must always return a value for this field, even if it's not provided in the original data +Required: You must always return a value for this field, even if it's not provided in the original data. If no value is provided, return the most reasonable value possible based on the naming guidelines and the other information you have. Instructions: Always return a value that is corrected and enhanced per additional guidelines below Fields: description Changes: Full creative control allowed within guidelines -Required: You must always return a value for this field, even if it's not provided in the original data +Required: You must always return a value for this field, even if it's not provided in the original data. If no value is provided, return the most accurate description possible based on the description guidelines and the other information you have. Instructions: Always return a value that is corrected and enhanced per additional guidelines below Fields: weight, length, width, height Changes: Allowed to correct obvious errors or inconsistencies or to add missing values -Required: You must always return a value for this field, even if it's not provided in the original data -Instructions: Always return a reasonable value (weights in ounces and dimensions in inches) that is validated against similar provided products and your knowledge of general object measurements (e.g. a sheet of paper is not going to be 3 inches thick, a pack of stickers is not going to be 250 ounces, this sheet of paper is very likely going to be the same size as that other sheet of paper from the same line). If a value is unusual or unreasonable, change it to match similar products or to be more reasonable. Do not return 0 or null for any of these fields. +Required: You must always return a value for this field, even if it's not provided in the original data. If no value is provided, return your best guess based on the other information you have or the dimensions for similar products. +Instructions: Always return a reasonable value (weights in ounces and dimensions in inches) that is validated against similar provided products and your knowledge of general object measurements (e.g. a sheet of paper is not going to be 3 inches thick, a pack of stickers is not going to be 250 ounces, this sheet of paper is very likely going to be the same size as that other sheet of paper from the same line). If a value is unusual or unreasonable, even wildly so, change it to match similar products or to be more reasonable. When correcting unreasonable weights or dimensions, prioritize comparisons to products from the same company and product line first, then broader category matches or common knowledge if necessary.Do not return 0 or null for any of these fields. Fields: coo Changes: Formatting only -Required: Return if present in the original data -Instructions: If present, return a valid two character country code, using capital letters +Required: Return if present in the original data. Do not return if not present. +Instructions: If present, convert all country names and abbreviations to the official ISO 3166-1 alpha-2 two-character country code. Convert any value with more than two characters to two characters only (e.g. "United States" or "USA" should both return "US"). Fields: tax_cat Changes: Allowed to correct obvious errors or inconsistencies or to add missing values -Required: You must always return a value for this field, even if it's not provided in the original data +Required: You must always return a value for this field, even if it's not provided in the original data. If no value is provided, return 0. Instructions: Always return a valid numerical tax code ID from the Available Tax Codes array below. Give preference to the value provided, but correct it if another value is more accurate. You must return a value for this field. 0 should be the default value in most cases. Fields: size_cat Changes: Allowed to correct obvious errors or inconsistencies or to add missing values -Required: Return if present in the original data or if not present and applicable -Instructions: If present or if applicable, return a valid numerical size category ID from the Available Size Categories array below. Give preference to the value provided, but correct it if another value is more accurate. A value is not required if none of the size categories apply. +Required: Return if present in the original data or if not present and applicable. Do not return if not applicable (e.g. if no size categories apply based on what you know about the product). +Instructions: If present or if applicable, return one valid numerical size category ID from the Available Size Categories array below. Give preference to the value provided, but correct it if another value is more accurate. A value is not required if none of the size categories apply, but it's important to include if one clearly applies, such as if the name contains 12x12, 6x8, 2oz, etc. Fields: themes Changes: Allowed to correct obvious errors or inconsistencies or to add missing values -Required: Return if present in the original data or if not present and applicable +Required: Return if present in the original data or if not present and applicable. Do not return any value if not applicable (e.g. if no themes apply based on what you know about the product). Instructions: If present, confirm that each provided theme matches what you understand to be a theme of the product. Remove any themes that do not match and add any themes that are missing. Most products will have zero or one theme. Return a comma-separated list of numerical theme IDs from the Available Themes array below. If you choose a sub-theme, you do not need to include its parent theme in the list. Fields: colors Changes: Allowed to correct obvious errors or inconsistencies or to add missing values -Required: Return if present in the original data or if not present and applicable -Instructions: If present or if applicable, return a comma-separated list of numerical color IDs from the Available Colors array below, using the product name as the primary guide. A value is not required if none of the colors apply. Most products will have zero colors. +Required: Return if present in the original data or if not present and applicable. Do not return any value if not applicable (e.g. if no colors apply based on what you know about the product). +Instructions: If present or if applicable, return a comma-separated list of numerical color IDs from the Available Colors array below, using the product name as the primary guide (e.g. if the name contains Blue or a blue variant, you should return the blue color ID). A value is not required if none of the colors apply. Most products will have zero colors. Fields: categories Changes: Allowed to correct obvious errors or inconsistencies or to add missing values -Required: You must always return at least one value for this field, even if it's not provided in the original data -Instructions: Always return a comma-separated list of one or more valid numerical category IDs from the Available Categories array below. Give preference to the values provided, particularly if the other information isn't enough to determine a category, but correct them or add new categories if another value is more accurate. Do not return categories in the Deals or Black Friday categories, and strip these from the list if present. If you choose a subcategory at any level, you do not need to include its parent categories in the list. You must return at least one category. +Required: You must always return at least one value for this field, even if it's not provided in the original data. If no value is provided, return the most appropriate category or categories based on the other information you have. +Instructions: Always return a comma-separated list of one or more valid numerical category IDs from the Available Categories array below. Give preference to the values provided, particularly if the other information isn't enough to determine a category, but correct them or add new categories if another value is more accurate. Do not return categories in the Deals or Black Friday categories, and strip these from the list if present. If you choose a subcategory at any level, you do not need to include its parent categories in the list. You must return at least one category and you can return multiple categories if applicable. All categories have equal value so their order is not important. Always try to return the most specific categories possible (e.g. one in the third level of the category hierarchy is better than one in the second level). ----------PRODUCT NAMING GUIDELINES---------- If there's only one of this type of product in a line: [Line Name] [Product Name] - [Company] @@ -167,6 +174,10 @@ Important Notes - Use the minimum amount of information needed to uniquely identify the product - Put detailed specifications in the product description, not its name +Edge Cases +- If the product is missing a company name, infer one from the other products included in the data +- If the product is missing a clear differentiator and needs one to be unique, infer and add one from the other data provided (e.g. the description, existing size categories, etc.) + Incorrect example: MVP Rugby - Collection Pack - Photoplay Notes: there should be no dash between the line and the product @@ -202,7 +213,7 @@ If no description is provided, make one up using the product name, the informati Important Notes: - Every description should state exactly what's included in the product (e.g. "Includes one 12x12 sheet of patterned cardstock." or "Includes one 6x12 sheet with 27 unique stickers." or "Includes 55 pieces." or "Package includes machine, power cord, 12 sheets of cardstock, 3 dies, and project instructions.") - Do not use the word "our" in the description (this usually shows up when we copy a description from the manufacturer). Instead use "these" or "[Company name] [product]" or similar. (e.g. don't use "Our journals are hand-made in the USA", instead use "These journals are hand made..." or "Archer & Olive journals are handmade...") -- Don't include fluff like “this is perfect for all your paper crafts” most of the time. If the product helps to solve a unique problem or has a unique feature, by all means describe it, but if it’s just a normal sheet of paper or pack of stickers, you don’t have to pretend like it’s the best thing ever. +- Don't include statements that add no value like “this is perfect for all your paper crafts”. If the product helps to solve a unique problem or has a unique feature, by all means describe it, but if it’s just a normal sheet of paper or pack of stickers, you don’t have to pretend like it’s the best thing ever. At the same time, ensure that you add enough copy to ensure good SEO. - State as many facts as you can about the product, considering the viewpoint of the customer and what they would want to know when looking at it. They probably want to know dimensions, what products it’s compatible with, how thick the paper is, how many sheets are included, whether the sheets are double-sided or not, which items are in the kit, etc. Say as much as you possibly can with the information that you have. - !!DO NOT make up information if you aren't sure about it. A minimal correct description is better than a long incorrect one!! diff --git a/inventory-server/src/routes/ai-validation.js b/inventory-server/src/routes/ai-validation.js index 16d8a7d..8afaff9 100644 --- a/inventory-server/src/routes/ai-validation.js +++ b/inventory-server/src/routes/ai-validation.js @@ -32,32 +32,7 @@ function clearCache() { }; } -// Debug endpoint to view prompt and cache status -router.get('/debug', async (req, res) => { - try { - console.log('Debug endpoint called'); - const pool = req.app.locals.pool; - - // Get a real supplier, company, and artist ID from the database - const [suppliers] = await pool.query('SELECT supplierid FROM suppliers LIMIT 1'); - const [companies] = await pool.query('SELECT cat_id FROM product_categories WHERE type = 1 LIMIT 1'); - const [artists] = await pool.query('SELECT cat_id FROM product_categories WHERE type = 40 LIMIT 1'); - - // Create a sample product with real IDs - const productsToUse = [{ - supplierid: suppliers[0]?.supplierid || 1234, - company: companies[0]?.cat_id || 567, - artist: artists[0]?.cat_id || 890 - }]; - - return await generateDebugResponse(pool, productsToUse, res); - } catch (error) { - console.error('Debug endpoint error:', error); - res.status(500).json({ error: error.message }); - } -}); - -// New POST endpoint for debug with products +// Debug endpoint for viewing prompt and cache status router.post('/debug', async (req, res) => { try { console.log('Debug POST endpoint called'); @@ -490,8 +465,7 @@ router.post('/validate', async (req, res) => { content: fullPrompt } ], - temperature: 0.3, - max_tokens: 4000, + temperature: 0.2, response_format: { type: "json_object" } }); diff --git a/inventory-server/src/routes/templates.ts b/inventory-server/src/routes/templates.ts new file mode 100644 index 0000000..91d6442 --- /dev/null +++ b/inventory-server/src/routes/templates.ts @@ -0,0 +1,272 @@ +import { Router } from 'express'; +import { Pool } from 'pg'; +import dotenv from 'dotenv'; +import path from 'path'; + +dotenv.config({ path: path.join(__dirname, "../../.env") }); + +const router = Router(); + +// Initialize PostgreSQL connection pool +const pool = new Pool({ + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + // Add SSL if needed (based on your environment) + ...(process.env.NODE_ENV === 'production' && { + ssl: { + rejectUnauthorized: false + } + }) +}); + +// Get all templates +router.get('/', async (req, res) => { + try { + const result = await pool.query(` + SELECT * FROM templates + ORDER BY company ASC, product_type ASC + `); + res.json(result.rows); + } catch (error) { + console.error('Error fetching templates:', error); + res.status(500).json({ + error: 'Failed to fetch templates', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Get template by company and product type +router.get('/:company/:productType', async (req, res) => { + try { + const { company, productType } = req.params; + const result = await pool.query(` + SELECT * FROM templates + WHERE company = $1 AND product_type = $2 + `, [company, productType]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Template not found' }); + } + + res.json(result.rows[0]); + } catch (error) { + console.error('Error fetching template:', error); + res.status(500).json({ + error: 'Failed to fetch template', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Create new template +router.post('/', async (req, res) => { + try { + const { + company, + product_type, + supplier, + msrp, + cost_each, + qty_per_unit, + case_qty, + hts_code, + description, + weight, + length, + width, + height, + tax_cat, + size_cat, + categories, + ship_restrictions + } = req.body; + + // Validate required fields + if (!company || !product_type) { + return res.status(400).json({ error: 'Company and Product Type are required' }); + } + + const result = await pool.query(` + INSERT INTO templates ( + company, + product_type, + supplier, + msrp, + cost_each, + qty_per_unit, + case_qty, + hts_code, + description, + weight, + length, + width, + height, + tax_cat, + size_cat, + categories, + ship_restrictions + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + RETURNING * + `, [ + company, + product_type, + supplier, + msrp, + cost_each, + qty_per_unit, + case_qty, + hts_code, + description, + weight, + length, + width, + height, + tax_cat, + size_cat, + categories, + ship_restrictions + ]); + + res.status(201).json(result.rows[0]); + } catch (error) { + console.error('Error creating template:', error); + // Check for unique constraint violation + if (error instanceof Error && error.message.includes('unique constraint')) { + return res.status(409).json({ + error: 'Template already exists for this company and product type', + details: error.message + }); + } + res.status(500).json({ + error: 'Failed to create template', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Update template +router.put('/:id', async (req, res) => { + try { + const { id } = req.params; + const { + company, + product_type, + supplier, + msrp, + cost_each, + qty_per_unit, + case_qty, + hts_code, + description, + weight, + length, + width, + height, + tax_cat, + size_cat, + categories, + ship_restrictions + } = req.body; + + // Validate required fields + if (!company || !product_type) { + return res.status(400).json({ error: 'Company and Product Type are required' }); + } + + const result = await pool.query(` + UPDATE templates + SET + company = $1, + product_type = $2, + supplier = $3, + msrp = $4, + cost_each = $5, + qty_per_unit = $6, + case_qty = $7, + hts_code = $8, + description = $9, + weight = $10, + length = $11, + width = $12, + height = $13, + tax_cat = $14, + size_cat = $15, + categories = $16, + ship_restrictions = $17 + WHERE id = $18 + RETURNING * + `, [ + company, + product_type, + supplier, + msrp, + cost_each, + qty_per_unit, + case_qty, + hts_code, + description, + weight, + length, + width, + height, + tax_cat, + size_cat, + categories, + ship_restrictions, + id + ]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Template not found' }); + } + + res.json(result.rows[0]); + } catch (error) { + console.error('Error updating template:', error); + // Check for unique constraint violation + if (error instanceof Error && error.message.includes('unique constraint')) { + return res.status(409).json({ + error: 'Template already exists for this company and product type', + details: error.message + }); + } + res.status(500).json({ + error: 'Failed to update template', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Delete template +router.delete('/:id', async (req, res) => { + try { + const { id } = req.params; + const result = await pool.query('DELETE FROM templates WHERE id = $1 RETURNING *', [id]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Template not found' }); + } + + res.json({ message: 'Template deleted successfully' }); + } catch (error) { + console.error('Error deleting template:', error); + res.status(500).json({ + error: 'Failed to delete template', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Error handling middleware +router.use((err: Error, req: any, res: any, next: any) => { + console.error('Template route error:', err); + res.status(500).json({ + error: 'Internal server error', + details: err.message + }); +}); + +export default router; \ No newline at end of file diff --git a/inventory-server/src/server.js b/inventory-server/src/server.js index f167683..075f2bf 100755 --- a/inventory-server/src/server.js +++ b/inventory-server/src/server.js @@ -19,6 +19,7 @@ const categoriesRouter = require('./routes/categories'); const testConnectionRouter = require('./routes/test-connection'); const importRouter = require('./routes/import'); const aiValidationRouter = require('./routes/ai-validation'); +const templatesRouter = require('./routes/templates'); // Get the absolute path to the .env file const envPath = path.resolve(process.cwd(), '.env'); @@ -64,8 +65,8 @@ app.use((req, res, next) => { app.use(corsMiddleware); // Body parser middleware -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); // Initialize database pool and start server async function startServer() { @@ -95,6 +96,7 @@ async function startServer() { app.use('/api/categories', categoriesRouter); app.use('/api/import', importRouter); app.use('/api/ai-validation', aiValidationRouter); + app.use('/api/templates', templatesRouter); app.use('/api', testConnectionRouter); // Basic health check route diff --git a/inventory/src/components/settings/DataManagement.tsx b/inventory/src/components/settings/DataManagement.tsx index 4e97413..1108948 100644 --- a/inventory/src/components/settings/DataManagement.tsx +++ b/inventory/src/components/settings/DataManagement.tsx @@ -660,75 +660,83 @@ export function DataManagement() { - {importHistory.slice(0, 20).map((record) => ( - - - - - -
- - #{record.id} - - - {formatDate(record.start_time)} - - - {formatDurationWithSeconds( - record.duration_minutes, - record.status === "running", - record.start_time - )} - - - {record.status} - -
-
- -
-
- End Time: - - {record.end_time - ? formatDate(record.end_time) - : "N/A"} + {importHistory.length > 0 ? ( + importHistory.slice(0, 20).map((record) => ( + + + + + +
+ + #{record.id} + + + {formatDate(record.start_time)} + + + {formatDurationWithSeconds( + record.duration_minutes, + record.status === "running", + record.start_time + )} + + + {record.status}
-
- Records: - - {record.records_added} added,{" "} - {record.records_updated} updated - -
- {record.error_message && ( -
- {record.error_message} + + +
+
+ End Time: + + {record.end_time + ? formatDate(record.end_time) + : "N/A"} +
- )} - {record.additional_info && - formatJsonData(record.additional_info)} -
-
- - +
+ Records: + + {record.records_added} added,{" "} + {record.records_updated} updated + +
+ {record.error_message && ( +
+ {record.error_message} +
+ )} + {record.additional_info && + formatJsonData(record.additional_info)} +
+ +
+
+
+
+ )) + ) : ( + + + No import history available - ))} + )}
@@ -742,90 +750,98 @@ export function DataManagement() { - {calculateHistory.slice(0, 20).map((record) => ( - - - - - -
- - #{record.id} - - - {formatDate(record.start_time)} - - - {formatDurationWithSeconds( - record.duration_minutes, - record.status === "running", - record.start_time - )} - + {calculateHistory.length > 0 ? ( + calculateHistory.slice(0, 20).map((record) => ( + + + + + +
+ + #{record.id} + + + {formatDate(record.start_time)} + + + {formatDurationWithSeconds( + record.duration_minutes, + record.status === "running", + record.start_time + )} + - - {record.status} - -
-
- -
-
- End Time: - - {record.end_time - ? formatDate(record.end_time) - : "N/A"} + + {record.status}
-
- - Processed Products: - - {record.processed_products} + + +
+
+ End Time: + + {record.end_time + ? formatDate(record.end_time) + : "N/A"} + +
+
+ + Processed Products: + + {record.processed_products} +
+
+ + Processed Orders: + + {record.processed_orders} +
+
+ + Processed Purchase Orders: + + {record.processed_purchase_orders} +
+ {record.error_message && ( +
+ {record.error_message} +
+ )} + {record.additional_info && + formatJsonData(record.additional_info)}
-
- - Processed Orders: - - {record.processed_orders} -
-
- - Processed Purchase Orders: - - {record.processed_purchase_orders} -
- {record.error_message && ( -
- {record.error_message} -
- )} - {record.additional_info && - formatJsonData(record.additional_info)} -
- - - + + + + + + )) + ) : ( + + + No calculation history available - ))} + )}
-
+ diff --git a/inventory/src/components/settings/TemplateManagement.tsx b/inventory/src/components/settings/TemplateManagement.tsx new file mode 100644 index 0000000..17144ab --- /dev/null +++ b/inventory/src/components/settings/TemplateManagement.tsx @@ -0,0 +1,422 @@ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { toast } from "sonner"; +import config from "@/config"; + +interface Template { + id: number; + company: string; + product_type: string; + supplier?: string; + msrp?: number; + cost_each?: number; + qty_per_unit?: number; + case_qty?: number; + hts_code?: string; + description?: string; + weight?: number; + length?: number; + width?: number; + height?: number; + tax_cat?: string; + size_cat?: string; + categories?: string[]; + ship_restrictions?: string[]; + created_at: string; + updated_at: string; +} + +interface TemplateFormData extends Omit {} + +export function TemplateManagement() { + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [formData, setFormData] = useState({ + company: "", + product_type: "", + }); + + const queryClient = useQueryClient(); + + const { data: templates, isLoading } = useQuery({ + queryKey: ["templates"], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/templates`); + if (!response.ok) { + throw new Error("Failed to fetch templates"); + } + return response.json(); + }, + }); + + const createMutation = useMutation({ + mutationFn: async (data: TemplateFormData) => { + const response = await fetch(`${config.apiUrl}/templates`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + if (!response.ok) { + throw new Error("Failed to create template"); + } + return response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["templates"] }); + setIsCreateOpen(false); + setFormData({ company: "", product_type: "" }); + toast.success("Template created successfully"); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Failed to create template"); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async (id: number) => { + const response = await fetch(`${config.apiUrl}/templates/${id}`, { + method: "DELETE", + }); + if (!response.ok) { + throw new Error("Failed to delete template"); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["templates"] }); + toast.success("Template deleted successfully"); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Failed to delete template"); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + createMutation.mutate(formData); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: value, + })); + }; + + const handleArrayInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: value.split(",").map((item) => item.trim()), + })); + }; + + const handleNumberInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: value === "" ? undefined : Number(value), + })); + }; + + return ( +
+
+

Import Templates

+ + + + + + + Create Import Template + + Create a new template for importing products. Company and Product Type combination must be unique. + + +
+ +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+
+
+ + + +
+
+
+
+ + {isLoading ? ( +
Loading templates...
+ ) : ( +
+ + + + Company + Product Type + Supplier + Last Updated + Actions + + + + {templates?.map((template) => ( + + {template.company} + {template.product_type} + {template.supplier || "-"} + + {new Date(template.updated_at).toLocaleDateString()} + + + + + + ))} + {templates?.length === 0 && ( + + + No templates found + + + )} + +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStep/ValidationStep.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStep/ValidationStep.tsx index 7b10678..f2d8457 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStep/ValidationStep.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStep/ValidationStep.tsx @@ -744,6 +744,7 @@ export const ValidationStep = ({ initialData, file, onBack }: prompt: null, isLoading: false, }); + // Memoize filtered data to prevent recalculation on every render const filteredData = useMemo(() => { @@ -893,6 +894,59 @@ export const ValidationStep = ({ initialData, file, onBack }: } }, [rowSelection, data, updateData]); + const discardEmptyAndDuplicateRows = useCallback(() => { + // Helper function to count non-empty values in a row + const countNonEmptyValues = (values: Record): number => { + return Object.values(values).filter(val => + val !== undefined && + val !== null && + (typeof val === 'string' ? val.trim() !== '' : true) + ).length; + }; + + // First, analyze all rows to determine if we have rows with multiple values + const rowsWithValues = data.map(row => { + const { __index, __errors, ...values } = row; + return countNonEmptyValues(values); + }); + + // Check if we have any rows with more than one value + const hasMultiValueRows = rowsWithValues.some(count => count > 1); + + // Filter out empty rows and rows with single values (if we have multi-value rows) + const nonEmptyRows = data.filter((row, index) => { + const nonEmptyCount = rowsWithValues[index]; + + // Keep the row if: + // 1. It has more than one value, OR + // 2. It has exactly one value AND we don't have any rows with multiple values + return nonEmptyCount > 0 && (!hasMultiValueRows || nonEmptyCount > 1); + }); + + // Then, remove duplicates by creating a unique string representation of each row + const seen = new Set(); + const uniqueRows = nonEmptyRows.filter(row => { + const { __index, __errors, ...values } = row; + const rowStr = JSON.stringify(Object.entries(values).sort()); + if (seen.has(rowStr)) { + return false; + } + seen.add(rowStr); + return true; + }); + + // Only update if we actually removed any rows + if (uniqueRows.length < data.length) { + updateData(uniqueRows); + setRowSelection({}); + toast({ + title: "Rows removed", + description: `Removed ${data.length - uniqueRows.length} empty, single-value, or duplicate rows`, + variant: "default" + }); + } + }, [data, updateData, toast]); + const normalizeValue = useCallback((value: any, field: DeepReadonlyField) => { if (field.fieldType.type === "checkbox") { if (typeof value === "boolean") return value @@ -1218,7 +1272,12 @@ export const ValidationStep = ({ initialData, file, onBack }: - {}}> + { + // Only allow closing if validation failed + if (!open && aiValidationProgress.step === -1) { + setAiValidationProgress(prev => ({ ...prev, isOpen: false })); + } + }}> AI Validation Progress @@ -1302,6 +1361,13 @@ export const ValidationStep = ({ initialData, file, onBack }: > {translations.validationStep.discardButtonTitle} +