From 9643cf191fd5f5188ed33bb45459d5764440d3bd Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 26 Mar 2026 10:46:24 -0400 Subject: [PATCH] Add audit log for product import, add tiff image support, add new/preorder filters on product editor, fix sorting in product editor --- .../003_create_import_audit_log.sql | 53 +++++ .../src/routes/import-audit-log.js | 193 ++++++++++++++++++ inventory-server/src/routes/import.js | 106 +++++++++- inventory-server/src/server.js | 2 + .../product-editor/ProductEditForm.tsx | 35 +++- .../src/components/product-editor/types.ts | 2 + .../components/GenericDropzone.tsx | 2 +- .../components/ProductCard/ImageDropzone.tsx | 2 +- inventory/src/pages/BulkEdit.tsx | 6 +- inventory/src/pages/Import.tsx | 50 ++++- inventory/src/pages/ProductEditor.tsx | 134 +++++++++++- inventory/src/services/importAuditLogApi.ts | 43 ++++ inventory/tsconfig.tsbuildinfo | 2 +- 13 files changed, 592 insertions(+), 38 deletions(-) create mode 100644 inventory-server/migrations/003_create_import_audit_log.sql create mode 100644 inventory-server/src/routes/import-audit-log.js create mode 100644 inventory/src/services/importAuditLogApi.ts diff --git a/inventory-server/migrations/003_create_import_audit_log.sql b/inventory-server/migrations/003_create_import_audit_log.sql new file mode 100644 index 0000000..8adb697 --- /dev/null +++ b/inventory-server/migrations/003_create_import_audit_log.sql @@ -0,0 +1,53 @@ +-- Migration: Create import_audit_log table +-- Permanent audit trail of all product import submissions sent to the API +-- Run this against your PostgreSQL database + +CREATE TABLE IF NOT EXISTS import_audit_log ( + id SERIAL PRIMARY KEY, + + -- Who initiated the import + user_id INTEGER NOT NULL, + username VARCHAR(255), + + -- What was submitted + product_count INTEGER NOT NULL, + request_payload JSONB NOT NULL, -- The exact JSON array of products sent to the API + environment VARCHAR(10) NOT NULL, -- 'dev' or 'prod' + target_endpoint VARCHAR(255), -- The API URL that was called + use_test_data_source BOOLEAN DEFAULT FALSE, + + -- What came back + success BOOLEAN NOT NULL, + response_payload JSONB, -- Full API response + error_message TEXT, -- Extracted error message on failure + created_count INTEGER DEFAULT 0, -- Number of products successfully created + errored_count INTEGER DEFAULT 0, -- Number of products that errored + + -- Metadata + session_id INTEGER, -- Optional link to the import_session used (if any) + duration_ms INTEGER, -- How long the API call took + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Index for looking up logs by user +CREATE INDEX IF NOT EXISTS idx_import_audit_log_user_id + ON import_audit_log (user_id); + +-- Index for filtering by success/failure +CREATE INDEX IF NOT EXISTS idx_import_audit_log_success + ON import_audit_log (success); + +-- Index for time-based queries +CREATE INDEX IF NOT EXISTS idx_import_audit_log_created_at + ON import_audit_log (created_at DESC); + +-- Composite index for user + time queries +CREATE INDEX IF NOT EXISTS idx_import_audit_log_user_created + ON import_audit_log (user_id, created_at DESC); + +COMMENT ON TABLE import_audit_log IS 'Permanent audit log of all product import API submissions'; +COMMENT ON COLUMN import_audit_log.request_payload IS 'Exact JSON products array sent to the external API'; +COMMENT ON COLUMN import_audit_log.response_payload IS 'Full response received from the external API'; +COMMENT ON COLUMN import_audit_log.environment IS 'dev or prod - which API endpoint was targeted'; +COMMENT ON COLUMN import_audit_log.session_id IS 'Optional reference to import_sessions.id if session was active'; +COMMENT ON COLUMN import_audit_log.duration_ms IS 'Round-trip time of the API call in milliseconds'; diff --git a/inventory-server/src/routes/import-audit-log.js b/inventory-server/src/routes/import-audit-log.js new file mode 100644 index 0000000..bd91c4a --- /dev/null +++ b/inventory-server/src/routes/import-audit-log.js @@ -0,0 +1,193 @@ +const express = require('express'); +const router = express.Router(); + +// Create a new audit log entry +router.post('/', async (req, res) => { + try { + const { + user_id, + username, + product_count, + request_payload, + environment, + target_endpoint, + use_test_data_source, + success, + response_payload, + error_message, + created_count, + errored_count, + session_id, + duration_ms, + } = req.body; + + // Validate required fields + if (!user_id) { + return res.status(400).json({ error: 'user_id is required' }); + } + if (!request_payload) { + return res.status(400).json({ error: 'request_payload is required' }); + } + if (typeof success !== 'boolean') { + return res.status(400).json({ error: 'success (boolean) is required' }); + } + + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + const result = await pool.query(` + INSERT INTO import_audit_log ( + user_id, + username, + product_count, + request_payload, + environment, + target_endpoint, + use_test_data_source, + success, + response_payload, + error_message, + created_count, + errored_count, + session_id, + duration_ms + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING id, created_at + `, [ + user_id, + username || null, + product_count || 0, + JSON.stringify(request_payload), + environment || 'prod', + target_endpoint || null, + use_test_data_source || false, + success, + response_payload ? JSON.stringify(response_payload) : null, + error_message || null, + created_count || 0, + errored_count || 0, + session_id || null, + duration_ms || null, + ]); + + res.status(201).json(result.rows[0]); + } catch (error) { + console.error('Error creating import audit log:', error); + res.status(500).json({ + error: 'Failed to create import audit log', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// List audit log entries (with pagination) +router.get('/', async (req, res) => { + try { + const { user_id, limit = 50, offset = 0, success: successFilter } = req.query; + + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + const conditions = []; + const params = []; + let paramIndex = 1; + + if (user_id) { + conditions.push(`user_id = $${paramIndex++}`); + params.push(user_id); + } + + if (successFilter !== undefined) { + conditions.push(`success = $${paramIndex++}`); + params.push(successFilter === 'true'); + } + + const whereClause = conditions.length > 0 + ? `WHERE ${conditions.join(' AND ')}` + : ''; + + // Get total count + const countResult = await pool.query( + `SELECT COUNT(*) FROM import_audit_log ${whereClause}`, + params + ); + + // Get paginated results (exclude large payload columns in list view) + const dataParams = [...params, parseInt(limit, 10), parseInt(offset, 10)]; + const result = await pool.query(` + SELECT + id, + user_id, + username, + product_count, + environment, + target_endpoint, + use_test_data_source, + success, + error_message, + created_count, + errored_count, + session_id, + duration_ms, + created_at + FROM import_audit_log + ${whereClause} + ORDER BY created_at DESC + LIMIT $${paramIndex++} OFFSET $${paramIndex++} + `, dataParams); + + res.json({ + total: parseInt(countResult.rows[0].count, 10), + entries: result.rows, + }); + } catch (error) { + console.error('Error fetching import audit log:', error); + res.status(500).json({ + error: 'Failed to fetch import audit log', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Get a single audit log entry (with full payloads) +router.get('/:id', async (req, res) => { + try { + const { id } = req.params; + const pool = req.app.locals.pool; + if (!pool) { + throw new Error('Database pool not initialized'); + } + + const result = await pool.query( + 'SELECT * FROM import_audit_log WHERE id = $1', + [id] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ error: 'Audit log entry not found' }); + } + + res.json(result.rows[0]); + } catch (error) { + console.error('Error fetching import audit log entry:', error); + res.status(500).json({ + error: 'Failed to fetch audit log entry', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } +}); + +// Error handling middleware +router.use((err, req, res, next) => { + console.error('Import audit log route error:', err); + res.status(500).json({ + error: 'Internal server error', + details: err.message + }); +}); + +module.exports = router; diff --git a/inventory-server/src/routes/import.js b/inventory-server/src/routes/import.js index 9f1d2e1..5fd50fe 100644 --- a/inventory-server/src/routes/import.js +++ b/inventory-server/src/routes/import.js @@ -194,7 +194,7 @@ const processUploadedImage = async (filePath, mimetype) => { legacyWarnings.push(message); } - const format = (baseMetadata.format || '').toLowerCase(); + let 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).`; @@ -211,6 +211,16 @@ const processUploadedImage = async (filePath, mimetype) => { return { notices, warnings: legacyWarnings, metadata, finalSize: metadata.size }; } + // TIFF: convert to JPEG (don't store TIFF files) + let convertedFromTiff = false; + if (format === 'tiff') { + convertedFromTiff = true; + format = 'jpeg'; + const message = 'Converted from TIFF to JPEG.'; + notices.push({ message, level: 'info', code: 'converted_from_tiff', source: 'server' }); + legacyWarnings.push(message); + } + const supportsQuality = ['jpeg', 'jpg', 'webp'].includes(format); let targetQuality = supportsQuality ? 90 : undefined; let finalQuality = undefined; @@ -343,8 +353,8 @@ const processUploadedImage = async (filePath, mimetype) => { }); legacyWarnings.push(message); } - } else if (shouldConvertToRgb) { - const { data, info } = await encode({ width: currentWidth, height: currentHeight }); + } else if (shouldConvertToRgb || convertedFromTiff) { + const { data, info } = await encode({ width: currentWidth, height: currentHeight, quality: targetQuality }); mutated = true; finalBuffer = data; finalInfo = info; @@ -363,6 +373,15 @@ const processUploadedImage = async (filePath, mimetype) => { metadata.optimizedSize = metadata.size; } + // Rename TIFF files to .jpg after conversion + let newFilePath = null; + if (convertedFromTiff) { + newFilePath = filePath.replace(/\.tiff?$/i, '.jpg'); + if (newFilePath !== filePath) { + await fsp.rename(filePath, newFilePath); + } + } + metadata.convertedToRgb = shouldConvertToRgb && mutated; metadata.resized = resized; if (finalQuality) { @@ -396,6 +415,7 @@ const processUploadedImage = async (filePath, mimetype) => { warnings: legacyWarnings, metadata, finalSize: finalBuffer.length, + newFilePath, }; }; @@ -419,10 +439,11 @@ const storage = multer.diskStorage({ case 'image/png': fileExt = '.png'; break; case 'image/gif': fileExt = '.gif'; break; case 'image/webp': fileExt = '.webp'; break; + case 'image/tiff': fileExt = '.tif'; break; default: fileExt = '.jpg'; // Default to jpg } } - + const fileName = `${req.body.upc || 'product'}-${uniqueSuffix}${fileExt}`; console.log(`Generated filename: ${fileName} with mimetype: ${file.mimetype}`); cb(null, fileName); @@ -436,10 +457,10 @@ const upload = multer({ }, fileFilter: function (req, file, cb) { // Accept only image files - const filetypes = /jpeg|jpg|png|gif|webp/; + const filetypes = /jpeg|jpg|png|gif|webp|tiff?/; const mimetype = filetypes.test(file.mimetype); const extname = filetypes.test(path.extname(file.originalname).toLowerCase()); - + if (mimetype && extname) { return cb(null, true); } @@ -633,13 +654,19 @@ router.post('/upload-image', upload.single('image'), async (req, res) => { const processingResult = await processUploadedImage(filePath, req.file.mimetype); req.file.size = processingResult.finalSize; + // If TIFF was converted to JPG, update filename to match the renamed file + const effectivePath = processingResult.newFilePath || filePath; + if (processingResult.newFilePath) { + req.file.filename = path.basename(processingResult.newFilePath); + } + // 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://tools.acherryontop.com'; const imageUrl = `${baseUrl}/uploads/products/${req.file.filename}`; - + // Schedule this image for deletion in 24 hours - scheduleImageDeletion(req.file.filename, filePath); + scheduleImageDeletion(req.file.filename, effectivePath); // Return success response with image URL res.status(200).json({ @@ -1308,8 +1335,11 @@ const PRODUCT_SELECT = ` pls.date_sold AS date_last_sold, IF(p.tax_code IS NULL, '', CAST(p.tax_code AS CHAR)) AS tax_code, CAST(p.size_cat AS CHAR) AS size_cat, - CAST(p.shipping_restrictions AS CHAR) AS shipping_restrictions + CAST(p.shipping_restrictions AS CHAR) AS shipping_restrictions, + IF(DATEDIFF(NOW(), p.date_ol) <= 45 AND p.notnew = 0 AND (si_feed.all IS NULL OR si_feed.all != 2), 1, 0) AS is_new, + IF(si_feed.all = 2, 1, 0) AS is_preorder FROM products p + LEFT JOIN shop_inventory si_feed ON p.pid = si_feed.pid AND si_feed.store = 0 LEFT JOIN product_current_prices pcp ON p.pid = pcp.pid AND pcp.active = 1 LEFT JOIN supplier_item_data sid ON p.pid = sid.pid LEFT JOIN suppliers s ON sid.supplier_id = s.supplierid @@ -1334,7 +1364,7 @@ router.get('/line-products', async (req, res) => { where += ' AND p.subline = ?'; params.push(Number(subline)); } - const query = `${PRODUCT_SELECT} ${where} GROUP BY p.pid ORDER BY p.description`; + const query = `${PRODUCT_SELECT} ${where} GROUP BY p.pid ORDER BY IF(p.date_ol != '0000-00-00 00:00:00', p.date_ol, p.date_created) DESC, p.description`; const [results] = await connection.query(query, params); res.json(results); } catch (error) { @@ -1501,7 +1531,7 @@ router.get('/path-products', async (req, res) => { return res.status(400).json({ error: 'No valid filters found in path' }); } - const query = `${PRODUCT_SELECT} WHERE ${whereParts.join(' AND ')} GROUP BY p.pid ORDER BY p.description`; + const query = `${PRODUCT_SELECT} WHERE ${whereParts.join(' AND ')} GROUP BY p.pid ORDER BY IF(p.date_ol != '0000-00-00 00:00:00', p.date_ol, p.date_created) DESC, p.description`; const [results] = await connection.query(query, params); res.json(results); } catch (error) { @@ -1552,6 +1582,60 @@ router.get('/product-images/:pid', async (req, res) => { } }); +// Batch fetch product images for multiple PIDs +router.get('/product-images-batch', async (req, res) => { + const { pids } = req.query; + if (!pids) { + return res.status(400).json({ error: 'pids query parameter is required' }); + } + const pidList = String(pids).split(',').map(Number).filter(n => n > 0); + if (pidList.length === 0) { + return res.json({}); + } + + try { + const { connection } = await getDbConnection(); + const placeholders = pidList.map(() => '?').join(','); + const [rows] = await connection.query( + `SELECT pid, iid, type, width, height, \`order\`, hidden FROM product_images WHERE pid IN (${placeholders}) ORDER BY \`order\` DESC, type`, + pidList + ); + + const typeMap = { 1: 'o', 2: 'l', 3: 't', 4: '100x100', 5: '175x175', 6: '300x300', 7: '600x600', 8: '500x500', 9: '150x150' }; + const result = {}; + for (const pid of pidList) { + result[pid] = {}; + } + + for (const row of rows) { + const typeName = typeMap[row.type]; + if (!typeName) continue; + const pid = row.pid; + if (!result[pid]) result[pid] = {}; + if (!result[pid][row.iid]) { + result[pid][row.iid] = { iid: row.iid, order: row.order, hidden: !!row.hidden, sizes: {} }; + } + const padded = String(pid).padStart(10, '0'); + const pathPrefix = `${padded.substring(0, 4)}/${padded.substring(4, 7)}/`; + result[pid][row.iid].sizes[typeName] = { + width: row.width, + height: row.height, + url: `https://sbing.com/i/products/${pathPrefix}${pid}-${typeName}-${row.iid}.jpg`, + }; + } + + // Convert each pid's iid map to sorted array + const output = {}; + for (const pid of pidList) { + output[pid] = Object.values(result[pid] || {}).sort((a, b) => b.order - a.order); + } + res.json(output); + } catch (error) { + console.error('Error fetching batch product images:', error); + res.status(500).json({ error: 'Failed to fetch product images' }); + } +}); + const UPC_SUPPLIER_PREFIX_LEADING_DIGIT = '4'; const UPC_MAX_SEQUENCE = 99999; const UPC_RESERVATION_TTL = 5 * 60 * 1000; // 5 minutes diff --git a/inventory-server/src/server.js b/inventory-server/src/server.js index d12d749..10f5d95 100644 --- a/inventory-server/src/server.js +++ b/inventory-server/src/server.js @@ -24,6 +24,7 @@ const vendorsAggregateRouter = require('./routes/vendorsAggregate'); const brandsAggregateRouter = require('./routes/brandsAggregate'); const htsLookupRouter = require('./routes/hts-lookup'); const importSessionsRouter = require('./routes/import-sessions'); +const importAuditLogRouter = require('./routes/import-audit-log'); const newsletterRouter = require('./routes/newsletter'); // Get the absolute path to the .env file @@ -133,6 +134,7 @@ async function startServer() { app.use('/api/reusable-images', reusableImagesRouter); app.use('/api/hts-lookup', htsLookupRouter); app.use('/api/import-sessions', importSessionsRouter); + app.use('/api/import-audit-log', importAuditLogRouter); app.use('/api/newsletter', newsletterRouter); // Basic health check route diff --git a/inventory/src/components/product-editor/ProductEditForm.tsx b/inventory/src/components/product-editor/ProductEditForm.tsx index bc156b8..e3a54f9 100644 --- a/inventory/src/components/product-editor/ProductEditForm.tsx +++ b/inventory/src/components/product-editor/ProductEditForm.tsx @@ -192,11 +192,13 @@ export function ProductEditForm({ product, fieldOptions, layoutMode, + initialImages, onClose, }: { product: SearchProduct; fieldOptions: FieldOptions; layoutMode: LayoutMode; + initialImages?: ProductImage[]; onClose: () => void; }) { const [lineOptions, setLineOptions] = useState([]); @@ -260,19 +262,24 @@ export function ProductEditForm({ originalValuesRef.current = { ...formValues }; reset(formValues); - // Fetch images and categories with abort support + // Fetch categories (and images if not pre-fetched) with abort support const controller = new AbortController(); const { signal } = controller; - setIsLoadingImages(true); - axios - .get(`/api/import/product-images/${product.pid}`, { signal }) - .then((res) => { - setProductImages(res.data); - originalImagesRef.current = res.data; - }) - .catch((e) => { if (!axios.isCancel(e)) toast.error("Failed to load product images"); }) - .finally(() => setIsLoadingImages(false)); + if (initialImages) { + setProductImages(initialImages); + originalImagesRef.current = initialImages; + } else { + setIsLoadingImages(true); + axios + .get(`/api/import/product-images/${product.pid}`, { signal }) + .then((res) => { + setProductImages(res.data); + originalImagesRef.current = res.data; + }) + .catch((e) => { if (!axios.isCancel(e)) toast.error("Failed to load product images"); }) + .finally(() => setIsLoadingImages(false)); + } axios .get(`/api/import/product-categories/${product.pid}`, { signal }) @@ -299,6 +306,14 @@ export function ProductEditForm({ return () => controller.abort(); }, [product, reset]); + // Apply batch-fetched images when they arrive after mount + useEffect(() => { + if (initialImages && productImages.length === 0 && !isLoadingImages) { + setProductImages(initialImages); + originalImagesRef.current = initialImages; + } + }, [initialImages]); + // Load lines when company changes (cached across forms) useEffect(() => { if (!watchCompany) { diff --git a/inventory/src/components/product-editor/types.ts b/inventory/src/components/product-editor/types.ts index f3b9607..9dfb3fe 100644 --- a/inventory/src/components/product-editor/types.ts +++ b/inventory/src/components/product-editor/types.ts @@ -33,6 +33,8 @@ export interface SearchProduct { tax_code?: string; size_cat?: string; shipping_restrictions?: string; + is_new?: number; + is_preorder?: number; } export interface FieldOption { diff --git a/inventory/src/components/product-import/steps/ImageUploadStep/components/GenericDropzone.tsx b/inventory/src/components/product-import/steps/ImageUploadStep/components/GenericDropzone.tsx index a9c70e1..beeb5cf 100644 --- a/inventory/src/components/product-import/steps/ImageUploadStep/components/GenericDropzone.tsx +++ b/inventory/src/components/product-import/steps/ImageUploadStep/components/GenericDropzone.tsx @@ -20,7 +20,7 @@ export const GenericDropzone = ({ }: GenericDropzoneProps) => { const { getRootProps, getInputProps, isDragActive } = useDropzone({ accept: { - 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp'] + 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.tif', '.tiff'] }, onDrop, multiple: true diff --git a/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/ImageDropzone.tsx b/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/ImageDropzone.tsx index 647a340..66d1361 100644 --- a/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/ImageDropzone.tsx +++ b/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/ImageDropzone.tsx @@ -10,7 +10,7 @@ interface ImageDropzoneProps { export const ImageDropzone = ({ onDrop }: ImageDropzoneProps) => { const { getRootProps, getInputProps, isDragActive } = useDropzone({ accept: { - 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp'] + 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.tif', '.tiff'] }, onDrop: (acceptedFiles) => { onDrop(acceptedFiles); diff --git a/inventory/src/pages/BulkEdit.tsx b/inventory/src/pages/BulkEdit.tsx index c7ea665..5f4a979 100644 --- a/inventory/src/pages/BulkEdit.tsx +++ b/inventory/src/pages/BulkEdit.tsx @@ -776,7 +776,7 @@ export default function BulkEdit() { {isLoadingProducts && (
- Loading new products... + Loading products...
)} @@ -792,7 +792,7 @@ export default function BulkEdit() { {isLoadingProducts && (
- Loading pre-order products... + Loading products...
)} @@ -801,7 +801,7 @@ export default function BulkEdit() { {isLoadingProducts && (
- Loading hidden recently-created products... + Loading products...
)} diff --git a/inventory/src/pages/Import.tsx b/inventory/src/pages/Import.tsx index 504a934..9305b2c 100644 --- a/inventory/src/pages/Import.tsx +++ b/inventory/src/pages/Import.tsx @@ -15,6 +15,7 @@ import { Separator } from "@/components/ui/separator"; 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"; +import { createImportAuditLog } from "@/services/importAuditLogApi"; import { AuthContext } from "@/contexts/AuthContext"; import { TemplateForm } from "@/components/templates/TemplateForm"; @@ -521,9 +522,18 @@ export function Import() { }; const handleData = async (data: ImportResult, _file: File, submitOptions: SubmitOptions): Promise => { + // Hoist for audit logging in catch block + const targetEnvironment = submitOptions?.targetEnvironment ?? "prod"; + const useTestDataSource = Boolean(submitOptions?.useTestDataSource); + const targetEndpoint = targetEnvironment === "dev" + ? "/apiv2-test/product/setup_new" + : "/apiv2/product/setup_new"; + let formattedRows: NormalizedProduct[] = []; + let startTime = performance.now(); + try { const rows = ((data.all?.length ? data.all : data.validData) ?? []) as Data[]; - const formattedRows: NormalizedProduct[] = rows.map((row) => { + formattedRows = rows.map((row) => { const baseValues = importFields.reduce((acc, field) => { const rawRow = row as Record; const fieldKey = field.key as ImportFieldKey; @@ -582,12 +592,14 @@ export function Import() { return true; } + startTime = performance.now(); const response = await submitNewProducts({ products: formattedRows, - environment: submitOptions?.targetEnvironment ?? "prod", - useTestDataSource: Boolean(submitOptions?.useTestDataSource), + environment: targetEnvironment, + useTestDataSource, employeeId: user?.id ?? undefined, }); + const durationMs = Math.round(performance.now() - startTime); const isSuccess = response.success; const defaultFailureMessage = "Failed to submit products. Please review and try again."; @@ -620,6 +632,24 @@ export function Import() { }; } + // Audit log — fire-and-forget, never blocks the UI + const auditPayload = extractBackendPayload(normalizedResponse.data); + createImportAuditLog({ + user_id: user?.id ?? 0, + username: user?.username, + product_count: formattedRows.length, + request_payload: formattedRows, + environment: targetEnvironment, + target_endpoint: targetEndpoint, + use_test_data_source: useTestDataSource, + success: isSuccess, + response_payload: normalizedResponse, + error_message: isSuccess ? undefined : (resolvedFailureMessage ?? defaultFailureMessage), + created_count: auditPayload.created.length, + errored_count: auditPayload.errored.length, + duration_ms: durationMs, + }); + setResumeStepState(undefined); setImportOutcome({ submittedProducts: formattedRows.map((product) => ({ ...product })), @@ -641,6 +671,20 @@ export function Import() { return isSuccess; } catch (error) { + // Audit log for thrown errors (network failures, parse errors, etc.) + createImportAuditLog({ + user_id: user?.id ?? 0, + username: user?.username, + product_count: formattedRows.length, + request_payload: formattedRows, + environment: targetEnvironment, + target_endpoint: targetEndpoint, + use_test_data_source: useTestDataSource, + success: false, + error_message: error instanceof Error ? error.message : "Unknown error", + duration_ms: Math.round(performance.now() - startTime), + }); + console.error("Import error:", error); const errorMessage = error instanceof Error ? error.message : "Failed to import data. Please try again."; diff --git a/inventory/src/pages/ProductEditor.tsx b/inventory/src/pages/ProductEditor.tsx index 7717f20..b6bd040 100644 --- a/inventory/src/pages/ProductEditor.tsx +++ b/inventory/src/pages/ProductEditor.tsx @@ -8,6 +8,8 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; import { Pagination, PaginationContent, @@ -20,7 +22,7 @@ import { import { ProductSearch } from "@/components/product-editor/ProductSearch"; import { ProductEditForm, LAYOUT_ICONS } from "@/components/product-editor/ProductEditForm"; import type { LayoutMode } from "@/components/product-editor/ProductEditForm"; -import type { SearchProduct, FieldOptions, FieldOption, LineOption, LandingExtra } from "@/components/product-editor/types"; +import type { SearchProduct, FieldOptions, FieldOption, LineOption, LandingExtra, ProductImage } from "@/components/product-editor/types"; import { ExternalLink } from "lucide-react"; const PER_PAGE = 20; @@ -169,16 +171,56 @@ export default function ProductEditor() { const [landingExtras, setLandingExtras] = useState>({}); const [isLoadingExtras, setIsLoadingExtras] = useState(false); const [activeLandingItem, setActiveLandingItem] = useState(null); + const [viewingFeaturedExtra, setViewingFeaturedExtra] = useState(null); + const [newFeedOnly, setNewFeedOnly] = useState(false); + const [preorderFeedOnly, setPreorderFeedOnly] = useState(false); + const [lineNewOnly, setLineNewOnly] = useState(false); + const [linePreorderOnly, setLinePreorderOnly] = useState(false); // Abort controller for cancelling in-flight product requests const abortRef = useRef(null); - const totalPages = Math.ceil(allProducts.length / PER_PAGE); + const filteredProducts = useMemo(() => { + if (viewingFeaturedExtra && activeTab === "new" && newFeedOnly) { + return allProducts.filter((p) => p.is_new); + } + if (viewingFeaturedExtra && activeTab === "preorder" && preorderFeedOnly) { + return allProducts.filter((p) => p.is_preorder); + } + if (activeTab === "by-line" && (lineNewOnly || linePreorderOnly)) { + return allProducts.filter((p) => + (lineNewOnly && p.is_new) || (linePreorderOnly && p.is_preorder) + ); + } + return allProducts; + }, [allProducts, viewingFeaturedExtra, activeTab, newFeedOnly, preorderFeedOnly, lineNewOnly, linePreorderOnly]); + + const totalPages = Math.ceil(filteredProducts.length / PER_PAGE); const products = useMemo( - () => allProducts.slice((page - 1) * PER_PAGE, page * PER_PAGE), - [allProducts, page] + () => filteredProducts.slice((page - 1) * PER_PAGE, page * PER_PAGE), + [filteredProducts, page] ); + // Batch-fetch images for the current page of products + const [batchImages, setBatchImages] = useState>({}); + useEffect(() => { + if (products.length === 0) return; + const pids = products.map((p) => p.pid); + const controller = new AbortController(); + axios + .get("/api/import/product-images-batch", { + params: { pids: pids.join(",") }, + signal: controller.signal, + }) + .then((res) => { + setBatchImages((prev) => ({ ...prev, ...res.data })); + }) + .catch((e) => { + if (!axios.isCancel(e)) console.error("Failed to batch-load images", e); + }); + return () => controller.abort(); + }, [products]); + useEffect(() => { axios .get("/api/import/field-options") @@ -308,6 +350,9 @@ export default function ProductEditor() { const controller = new AbortController(); abortRef.current = controller; setActiveLandingItem(extra.path); + setViewingFeaturedExtra(extra); + setNewFeedOnly(false); + setPreorderFeedOnly(false); setAllProducts([]); setIsLoadingProducts(true); try { @@ -331,6 +376,11 @@ export default function ProductEditor() { setActiveTab(tab); setQueryStatus(null); setQueryId(""); + setViewingFeaturedExtra(null); + setNewFeedOnly(false); + setPreorderFeedOnly(false); + setLineNewOnly(false); + setLinePreorderOnly(false); if (tab === "new" && loadedTab !== "new") { setLoadedTab("new"); loadFeedProducts("new-products", "new"); @@ -356,6 +406,8 @@ export default function ProductEditor() { abortRef.current = controller; setAllProducts([]); setIsLoadingProducts(true); + setLineNewOnly(false); + setLinePreorderOnly(false); try { const params: Record = { company: lineCompany, line: lineLine }; if (lineSubline) params.subline = lineSubline; @@ -634,10 +686,26 @@ export default function ProductEditor() { )} {renderLandingExtras("new")} + {viewingFeaturedExtra && activeTab === "new" && !isLoadingProducts && allProducts.length > 0 && !allProducts.every((p) => p.is_new) && ( +
+ { + setNewFeedOnly(checked); + setPage(1); + }} + /> + + {newFeedOnly && ( + {filteredProducts.length} of {allProducts.length} + )} +
+ )} {isLoadingProducts && (
- Loading new products... + Loading products...
)} @@ -650,10 +718,26 @@ export default function ProductEditor() { )} {renderLandingExtras("preorder")} + {viewingFeaturedExtra && activeTab === "preorder" && !isLoadingProducts && allProducts.length > 0 && !allProducts.every((p) => p.is_preorder) && ( +
+ { + setPreorderFeedOnly(checked); + setPage(1); + }} + /> + + {preorderFeedOnly && ( + {filteredProducts.length} of {allProducts.length} + )} +
+ )} {isLoadingProducts && (
- Loading pre-order products... + Loading products...
)} @@ -662,7 +746,7 @@ export default function ProductEditor() { {isLoadingProducts && (
- Loading hidden recently-created products... + Loading products...
)} @@ -709,7 +793,40 @@ export default function ProductEditor() { {isLoadingProducts && (
- Loading line products... + Loading products... +
+ )} + {!isLoadingProducts && allProducts.length > 0 && activeTab === "by-line" && (allProducts.some((p) => p.is_new && !p.is_preorder) || allProducts.some((p) => p.is_preorder)) && ( +
+ {!allProducts.every((p) => p.is_new) && allProducts.some((p) => p.is_new) && ( +
+ { + setLineNewOnly(checked); + setPage(1); + }} + /> + +
+ )} + {!allProducts.every((p) => p.is_preorder) && allProducts.some((p) => p.is_preorder) && ( +
+ { + setLinePreorderOnly(checked); + setPage(1); + }} + /> + +
+ )} + {(lineNewOnly || linePreorderOnly) && ( + {filteredProducts.length} of {allProducts.length} + )}
)} @@ -725,6 +842,7 @@ export default function ProductEditor() { product={product} fieldOptions={fieldOptions} layoutMode={layoutMode} + initialImages={batchImages[product.pid]} onClose={() => handleRemoveProduct(product.pid)} /> ))} diff --git a/inventory/src/services/importAuditLogApi.ts b/inventory/src/services/importAuditLogApi.ts new file mode 100644 index 0000000..2033ac6 --- /dev/null +++ b/inventory/src/services/importAuditLogApi.ts @@ -0,0 +1,43 @@ +/** + * Import Audit Log API Service + * + * Logs every product import submission to a permanent audit trail. + * Fire-and-forget by default — callers should not block on the result. + */ + +const BASE_URL = '/api/import-audit-log'; + +export interface ImportAuditLogEntry { + user_id: number; + username?: string; + product_count: number; + request_payload: unknown; + environment: 'dev' | 'prod'; + target_endpoint?: string; + use_test_data_source?: boolean; + success: boolean; + response_payload?: unknown; + error_message?: string; + created_count?: number; + errored_count?: number; + session_id?: number | null; + duration_ms?: number | null; +} + +/** + * Send an audit log entry to the backend. + * Designed to be fire-and-forget — errors are logged but never thrown + * so that a logging failure never blocks the user's import flow. + */ +export async function createImportAuditLog(entry: ImportAuditLogEntry): Promise { + try { + await fetch(BASE_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(entry), + }); + } catch (error) { + // Never throw — audit logging should not disrupt the import flow + console.error('Failed to write import audit log:', error); + } +} diff --git a/inventory/tsconfig.tsbuildinfo b/inventory/tsconfig.tsbuildinfo index b801c89..9c35d49 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/ai/aidescriptioncompare.tsx","./src/components/analytics/agingsellthrough.tsx","./src/components/analytics/capitalefficiency.tsx","./src/components/analytics/discountimpact.tsx","./src/components/analytics/growthmomentum.tsx","./src/components/analytics/inventoryflow.tsx","./src/components/analytics/inventorytrends.tsx","./src/components/analytics/inventoryvaluetrend.tsx","./src/components/analytics/portfolioanalysis.tsx","./src/components/analytics/seasonalpatterns.tsx","./src/components/analytics/stockhealth.tsx","./src/components/analytics/stockoutrisk.tsx","./src/components/auth/firstaccessiblepage.tsx","./src/components/auth/protected.tsx","./src/components/auth/requireauth.tsx","./src/components/bulk-edit/bulkeditrow.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/operationsmetrics.tsx","./src/components/dashboard/payrollmetrics.tsx","./src/components/dashboard/periodselectionpopover.tsx","./src/components/dashboard/shared/dashboardbadge.tsx","./src/components/dashboard/shared/dashboardcharttooltip.tsx","./src/components/dashboard/shared/dashboardmultistatcardmini.tsx","./src/components/dashboard/shared/dashboardsectionheader.tsx","./src/components/dashboard/shared/dashboardskeleton.tsx","./src/components/dashboard/shared/dashboardstatcard.tsx","./src/components/dashboard/shared/dashboardstatcardmini.tsx","./src/components/dashboard/shared/dashboardstates.tsx","./src/components/dashboard/shared/dashboardtable.tsx","./src/components/dashboard/shared/index.ts","./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/newsletter/campaignhistorydialog.tsx","./src/components/newsletter/newsletterstats.tsx","./src/components/newsletter/recommendationtable.tsx","./src/components/overview/bestsellers.tsx","./src/components/overview/forecastaccuracy.tsx","./src/components/overview/forecastmetrics.tsx","./src/components/overview/overstockmetrics.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/product-editor/comboboxfield.tsx","./src/components/product-editor/editablecomboboxfield.tsx","./src/components/product-editor/editableinput.tsx","./src/components/product-editor/editablemultiselect.tsx","./src/components/product-editor/imagemanager.tsx","./src/components/product-editor/producteditform.tsx","./src/components/product-editor/productsearch.tsx","./src/components/product-editor/types.ts","./src/components/product-editor/useproductsuggestions.ts","./src/components/product-import/createproductcategorydialog.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/closeconfirmationdialog.tsx","./src/components/product-import/components/modalwrapper.tsx","./src/components/product-import/components/providers.tsx","./src/components/product-import/components/savesessiondialog.tsx","./src/components/product-import/components/savedsessionslist.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/validationstep/index.tsx","./src/components/product-import/steps/validationstep/components/aisuggestionbadge.tsx","./src/components/product-import/steps/validationstep/components/copydownbanner.tsx","./src/components/product-import/steps/validationstep/components/floatingselectionbar.tsx","./src/components/product-import/steps/validationstep/components/initializingoverlay.tsx","./src/components/product-import/steps/validationstep/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstep/components/suggestionbadges.tsx","./src/components/product-import/steps/validationstep/components/validationcontainer.tsx","./src/components/product-import/steps/validationstep/components/validationfooter.tsx","./src/components/product-import/steps/validationstep/components/validationtable.tsx","./src/components/product-import/steps/validationstep/components/validationtoolbar.tsx","./src/components/product-import/steps/validationstep/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/comboboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstep/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstep/contexts/aisuggestionscontext.tsx","./src/components/product-import/steps/validationstep/dialogs/aidebugdialog.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationprogress.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationresults.tsx","./src/components/product-import/steps/validationstep/dialogs/sanitycheckdialog.tsx","./src/components/product-import/steps/validationstep/hooks/useautoinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/usecopydownvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usefieldoptions.ts","./src/components/product-import/steps/validationstep/hooks/useinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/useproductlines.ts","./src/components/product-import/steps/validationstep/hooks/usesanitycheck.ts","./src/components/product-import/steps/validationstep/hooks/usetemplatemanagement.ts","./src/components/product-import/steps/validationstep/hooks/useupcvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usevalidationactions.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/index.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiapi.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiprogress.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaitransform.ts","./src/components/product-import/steps/validationstep/store/selectors.ts","./src/components/product-import/steps/validationstep/store/types.ts","./src/components/product-import/steps/validationstep/store/validationstore.ts","./src/components/product-import/steps/validationstep/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstep/utils/countryutils.ts","./src/components/product-import/steps/validationstep/utils/datamutations.ts","./src/components/product-import/steps/validationstep/utils/inlineaipayload.ts","./src/components/product-import/steps/validationstep/utils/priceutils.ts","./src/components/product-import/steps/validationstep/utils/upcutils.ts","./src/components/product-import/steps/validationstepold/index.tsx","./src/components/product-import/steps/validationstepold/types.ts","./src/components/product-import/steps/validationstepold/components/aivalidationdialogs.tsx","./src/components/product-import/steps/validationstepold/components/basecellcontent.tsx","./src/components/product-import/steps/validationstepold/components/initializingvalidation.tsx","./src/components/product-import/steps/validationstepold/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstepold/components/upcvalidationtableadapter.tsx","./src/components/product-import/steps/validationstepold/components/validationcell.tsx","./src/components/product-import/steps/validationstepold/components/validationcontainer.tsx","./src/components/product-import/steps/validationstepold/components/validationtable.tsx","./src/components/product-import/steps/validationstepold/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstepold/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstepold/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstepold/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstepold/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstepold/hooks/useaivalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usefieldvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usefiltermanagement.tsx","./src/components/product-import/steps/validationstepold/hooks/useinitialvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/useproductlinesfetching.tsx","./src/components/product-import/steps/validationstepold/hooks/userowoperations.tsx","./src/components/product-import/steps/validationstepold/hooks/usetemplatemanagement.tsx","./src/components/product-import/steps/validationstepold/hooks/useuniqueitemnumbersvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/useuniquevalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/useupcvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usevalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usevalidationstate.tsx","./src/components/product-import/steps/validationstepold/hooks/validationtypes.ts","./src/components/product-import/steps/validationstepold/types/index.ts","./src/components/product-import/steps/validationstepold/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstepold/utils/countryutils.ts","./src/components/product-import/steps/validationstepold/utils/datamutations.ts","./src/components/product-import/steps/validationstepold/utils/priceutils.ts","./src/components/product-import/steps/validationstepold/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/productsummarycards.tsx","./src/components/products/producttable.tsx","./src/components/products/producttableskeleton.tsx","./src/components/products/productviews.tsx","./src/components/products/statusbadge.tsx","./src/components/products/columndefinitions.ts","./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/pipelinecard.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/carousel.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/contexts/importsessioncontext.tsx","./src/hooks/use-mobile.tsx","./src/hooks/use-toast.ts","./src/hooks/usedebounce.ts","./src/hooks/useimportautosave.ts","./src/lib/utils.ts","./src/lib/dashboard/chartconfig.ts","./src/lib/dashboard/designtokens.ts","./src/pages/analytics.tsx","./src/pages/blackfridaydashboard.tsx","./src/pages/brands.tsx","./src/pages/bulkedit.tsx","./src/pages/categories.tsx","./src/pages/chat.tsx","./src/pages/dashboard.tsx","./src/pages/discountsimulator.tsx","./src/pages/forecasting.tsx","./src/pages/htslookup.tsx","./src/pages/import.tsx","./src/pages/login.tsx","./src/pages/newsletter.tsx","./src/pages/overview.tsx","./src/pages/producteditor.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/services/importsessionapi.ts","./src/services/producteditor.ts","./src/types/dashboard-shims.d.ts","./src/types/dashboard.d.ts","./src/types/discount-simulator.ts","./src/types/globals.d.ts","./src/types/importsession.ts","./src/types/products.ts","./src/types/react-data-grid.d.ts","./src/types/status-codes.ts","./src/utils/emojiutils.ts","./src/utils/formatcurrency.ts","./src/utils/lifecyclephases.ts","./src/utils/naturallanguageperiod.ts","./src/utils/productutils.ts","./src/utils/transformutils.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/ai/aidescriptioncompare.tsx","./src/components/analytics/agingsellthrough.tsx","./src/components/analytics/capitalefficiency.tsx","./src/components/analytics/discountimpact.tsx","./src/components/analytics/growthmomentum.tsx","./src/components/analytics/inventoryflow.tsx","./src/components/analytics/inventorytrends.tsx","./src/components/analytics/inventoryvaluetrend.tsx","./src/components/analytics/portfolioanalysis.tsx","./src/components/analytics/seasonalpatterns.tsx","./src/components/analytics/stockhealth.tsx","./src/components/analytics/stockoutrisk.tsx","./src/components/auth/firstaccessiblepage.tsx","./src/components/auth/protected.tsx","./src/components/auth/requireauth.tsx","./src/components/bulk-edit/bulkeditrow.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/operationsmetrics.tsx","./src/components/dashboard/payrollmetrics.tsx","./src/components/dashboard/periodselectionpopover.tsx","./src/components/dashboard/shared/dashboardbadge.tsx","./src/components/dashboard/shared/dashboardcharttooltip.tsx","./src/components/dashboard/shared/dashboardmultistatcardmini.tsx","./src/components/dashboard/shared/dashboardsectionheader.tsx","./src/components/dashboard/shared/dashboardskeleton.tsx","./src/components/dashboard/shared/dashboardstatcard.tsx","./src/components/dashboard/shared/dashboardstatcardmini.tsx","./src/components/dashboard/shared/dashboardstates.tsx","./src/components/dashboard/shared/dashboardtable.tsx","./src/components/dashboard/shared/index.ts","./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/newsletter/campaignhistorydialog.tsx","./src/components/newsletter/newsletterstats.tsx","./src/components/newsletter/recommendationtable.tsx","./src/components/overview/bestsellers.tsx","./src/components/overview/forecastaccuracy.tsx","./src/components/overview/forecastmetrics.tsx","./src/components/overview/overstockmetrics.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/product-editor/comboboxfield.tsx","./src/components/product-editor/editablecomboboxfield.tsx","./src/components/product-editor/editableinput.tsx","./src/components/product-editor/editablemultiselect.tsx","./src/components/product-editor/imagemanager.tsx","./src/components/product-editor/producteditform.tsx","./src/components/product-editor/productsearch.tsx","./src/components/product-editor/types.ts","./src/components/product-editor/useproductsuggestions.ts","./src/components/product-import/createproductcategorydialog.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/closeconfirmationdialog.tsx","./src/components/product-import/components/modalwrapper.tsx","./src/components/product-import/components/providers.tsx","./src/components/product-import/components/savesessiondialog.tsx","./src/components/product-import/components/savedsessionslist.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/validationstep/index.tsx","./src/components/product-import/steps/validationstep/components/aisuggestionbadge.tsx","./src/components/product-import/steps/validationstep/components/copydownbanner.tsx","./src/components/product-import/steps/validationstep/components/floatingselectionbar.tsx","./src/components/product-import/steps/validationstep/components/initializingoverlay.tsx","./src/components/product-import/steps/validationstep/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstep/components/suggestionbadges.tsx","./src/components/product-import/steps/validationstep/components/validationcontainer.tsx","./src/components/product-import/steps/validationstep/components/validationfooter.tsx","./src/components/product-import/steps/validationstep/components/validationtable.tsx","./src/components/product-import/steps/validationstep/components/validationtoolbar.tsx","./src/components/product-import/steps/validationstep/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/comboboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstep/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstep/contexts/aisuggestionscontext.tsx","./src/components/product-import/steps/validationstep/dialogs/aidebugdialog.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationprogress.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationresults.tsx","./src/components/product-import/steps/validationstep/dialogs/sanitycheckdialog.tsx","./src/components/product-import/steps/validationstep/hooks/useautoinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/usecopydownvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usefieldoptions.ts","./src/components/product-import/steps/validationstep/hooks/useinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/useproductlines.ts","./src/components/product-import/steps/validationstep/hooks/usesanitycheck.ts","./src/components/product-import/steps/validationstep/hooks/usetemplatemanagement.ts","./src/components/product-import/steps/validationstep/hooks/useupcvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usevalidationactions.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/index.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiapi.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiprogress.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaitransform.ts","./src/components/product-import/steps/validationstep/store/selectors.ts","./src/components/product-import/steps/validationstep/store/types.ts","./src/components/product-import/steps/validationstep/store/validationstore.ts","./src/components/product-import/steps/validationstep/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstep/utils/countryutils.ts","./src/components/product-import/steps/validationstep/utils/datamutations.ts","./src/components/product-import/steps/validationstep/utils/inlineaipayload.ts","./src/components/product-import/steps/validationstep/utils/priceutils.ts","./src/components/product-import/steps/validationstep/utils/upcutils.ts","./src/components/product-import/steps/validationstepold/index.tsx","./src/components/product-import/steps/validationstepold/types.ts","./src/components/product-import/steps/validationstepold/components/aivalidationdialogs.tsx","./src/components/product-import/steps/validationstepold/components/basecellcontent.tsx","./src/components/product-import/steps/validationstepold/components/initializingvalidation.tsx","./src/components/product-import/steps/validationstepold/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstepold/components/upcvalidationtableadapter.tsx","./src/components/product-import/steps/validationstepold/components/validationcell.tsx","./src/components/product-import/steps/validationstepold/components/validationcontainer.tsx","./src/components/product-import/steps/validationstepold/components/validationtable.tsx","./src/components/product-import/steps/validationstepold/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstepold/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstepold/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstepold/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstepold/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstepold/hooks/useaivalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usefieldvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usefiltermanagement.tsx","./src/components/product-import/steps/validationstepold/hooks/useinitialvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/useproductlinesfetching.tsx","./src/components/product-import/steps/validationstepold/hooks/userowoperations.tsx","./src/components/product-import/steps/validationstepold/hooks/usetemplatemanagement.tsx","./src/components/product-import/steps/validationstepold/hooks/useuniqueitemnumbersvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/useuniquevalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/useupcvalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usevalidation.tsx","./src/components/product-import/steps/validationstepold/hooks/usevalidationstate.tsx","./src/components/product-import/steps/validationstepold/hooks/validationtypes.ts","./src/components/product-import/steps/validationstepold/types/index.ts","./src/components/product-import/steps/validationstepold/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstepold/utils/countryutils.ts","./src/components/product-import/steps/validationstepold/utils/datamutations.ts","./src/components/product-import/steps/validationstepold/utils/priceutils.ts","./src/components/product-import/steps/validationstepold/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/productsummarycards.tsx","./src/components/products/producttable.tsx","./src/components/products/producttableskeleton.tsx","./src/components/products/productviews.tsx","./src/components/products/statusbadge.tsx","./src/components/products/columndefinitions.ts","./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/pipelinecard.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/carousel.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/contexts/importsessioncontext.tsx","./src/hooks/use-mobile.tsx","./src/hooks/use-toast.ts","./src/hooks/usedebounce.ts","./src/hooks/useimportautosave.ts","./src/lib/utils.ts","./src/lib/dashboard/chartconfig.ts","./src/lib/dashboard/designtokens.ts","./src/pages/analytics.tsx","./src/pages/blackfridaydashboard.tsx","./src/pages/brands.tsx","./src/pages/bulkedit.tsx","./src/pages/categories.tsx","./src/pages/chat.tsx","./src/pages/dashboard.tsx","./src/pages/discountsimulator.tsx","./src/pages/forecasting.tsx","./src/pages/htslookup.tsx","./src/pages/import.tsx","./src/pages/login.tsx","./src/pages/newsletter.tsx","./src/pages/overview.tsx","./src/pages/producteditor.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/services/importauditlogapi.ts","./src/services/importsessionapi.ts","./src/services/producteditor.ts","./src/types/dashboard-shims.d.ts","./src/types/dashboard.d.ts","./src/types/discount-simulator.ts","./src/types/globals.d.ts","./src/types/importsession.ts","./src/types/products.ts","./src/types/react-data-grid.d.ts","./src/types/status-codes.ts","./src/utils/emojiutils.ts","./src/utils/formatcurrency.ts","./src/utils/lifecyclephases.ts","./src/utils/naturallanguageperiod.ts","./src/utils/productutils.ts","./src/utils/transformutils.ts"],"version":"5.6.3"} \ No newline at end of file